mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-18 21:36:12 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24da7363c3 | |||
| a19b6b50e0 | |||
| fd2112cbcd | |||
| 0b57c9d3da | |||
| 1958a59f4e | |||
| f7ed6df35b | |||
| a18569c690 | |||
| 4ff4dead20 | |||
| 5a7d46e900 | |||
| 92f34bcc0d | |||
| 7955a43a9e | |||
| fa0ec62d71 | |||
| 3b94f86303 | |||
| 05b2aca92b | |||
| e4b15caf74 | |||
| 82096dcd89 | |||
| 66d096e963 | |||
| 50ffa5b100 | |||
| 8e20bd182f | |||
| 53b4b4d4d3 | |||
| decbc4ce7f | |||
| 4e31a33599 |
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: heterogeneous-agent
|
||||
description: Guide for implementing and debugging LobeHub heterogeneous agent integrations such as Claude Code, Codex, and future external CLI agents. Use when working on adapter event mapping, Electron IPC transport, renderer persistence, tool-call chaining, subagent threads, resume/session handling, or regressions like mixed multi-tool messages, broken step boundaries, stuck tool loading, and orphan tool messages. Triggers on 'heterogeneous agent', 'hetero agent', '异构 agent', 'claude code adapter', 'codex adapter', 'external agent CLI', '孤立 tool 消息', 'raw Codex trace', or adapter/executor bugs.
|
||||
---
|
||||
|
||||
# Heterogeneous Agent Development
|
||||
|
||||
Use this skill when the bug or feature lives in the external CLI agent pipeline, not the normal server-side agent runtime.
|
||||
|
||||
## Use This Skill For
|
||||
|
||||
- Adding or changing a driver under `apps/desktop/src/main/modules/heterogeneousAgent/drivers/`
|
||||
- Editing an adapter under `packages/heterogeneous-agents/src/adapters/`
|
||||
- Debugging `heteroAgentRawLine` transport, `window.__HETERO_AGENT_TRACE`, or `executeHeterogeneousAgent`
|
||||
- Fixing Claude Code stream-json bugs such as duplicate partial/full chunks, broken `message.id` boundaries, missing `tool_result`, TodoWrite state drift, or subagent thread routing
|
||||
- Fixing Codex JSONL bugs such as mixed multi-tool messages, broken turn boundaries, or missing tool-result mapping
|
||||
- Fixing step-boundary, tool persistence, subagent thread, or resume bugs in Claude Code / Codex flows
|
||||
- Reproducing multi-tool mixing, orphan tool messages, or stuck tool-result loading
|
||||
|
||||
## Pipeline Map
|
||||
|
||||
1. CLI raw stdout / JSONL
|
||||
2. Electron main spawns the CLI and broadcasts `heteroAgentRawLine`
|
||||
3. Adapter maps raw provider events into `HeterogeneousAgentEvent`
|
||||
4. `executeHeterogeneousAgent` persists assistant/tool messages and forwards stream events
|
||||
5. `createGatewayEventHandler` hydrates the UI
|
||||
6. Only after this path looks correct should you move on to `agent-tracing` or context-engine debugging
|
||||
|
||||
## Read These Files First
|
||||
|
||||
- `apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts`
|
||||
- `apps/desktop/src/main/modules/heterogeneousAgent/drivers/claudeCode.ts`
|
||||
- `apps/desktop/src/main/modules/heterogeneousAgent/drivers/codex.ts`
|
||||
- `packages/heterogeneous-agents/src/adapters/claudeCode.ts`
|
||||
- `packages/heterogeneous-agents/src/adapters/codex.ts`
|
||||
- `src/store/chat/slices/aiChat/actions/heterogeneousAgentExecutor.ts`
|
||||
- `src/store/chat/slices/aiChat/actions/__tests__/heterogeneousAgentExecutor.test.ts`
|
||||
|
||||
## Default Debug Order
|
||||
|
||||
1. Prove whether the raw CLI output is correct before touching UI code.
|
||||
2. If raw output is correct, compare it with adapter output. In dev, `executeHeterogeneousAgent` exposes `window.__HETERO_AGENT_TRACE`.
|
||||
3. If adapted events look correct, inspect `persistToolBatch`, `persistToolResult`, step transitions, and subagent routing.
|
||||
4. Turn the repro into a focused test before fixing.
|
||||
5. Only after the transport/adapter/executor path looks sound should you debug later-stage message processing.
|
||||
|
||||
## Critical Invariants
|
||||
|
||||
- One raw tool item must map to one stable `ToolCallPayload.id`.
|
||||
- A new main-agent step must emit a boundary signal before events are forwarded to the new assistant.
|
||||
- In Claude Code, multiple assistant events with the same `message.id` are one turn, not multiple turns.
|
||||
- In Claude Code, `tool_result` lives in `type: 'user'` events, not assistant events.
|
||||
- In Claude Code partial mode, `message_delta.usage` is authoritative; do not trust echoed usage on every assistant block.
|
||||
- `persistToolBatch` must pre-register assistant `tools[]` before creating tool messages.
|
||||
- Every tool message must keep `parentId` equal to the owning assistant and `tool_call_id` equal to the tool id.
|
||||
- `tool_result` must resolve an existing `toolMsgIdByCallId`.
|
||||
- Subagent chunks must stay in thread scope and must not be forwarded into the main assistant stream.
|
||||
- Never clear the global `toolMsgIdByCallId` map at main step boundaries.
|
||||
|
||||
## Common Bug Patterns
|
||||
|
||||
- Claude Code duplicates text or thinking:
|
||||
check whether partial deltas and the later full assistant block are both being emitted.
|
||||
- Claude Code opens too many assistant messages:
|
||||
check whether the adapter is cutting steps on every assistant event instead of only on `message.id` changes.
|
||||
- Claude Code tool results never land:
|
||||
check whether `type: 'user'` `tool_result` blocks are being ignored because the code only inspects assistant events.
|
||||
- Claude Code TodoWrite cards look stale:
|
||||
check whether synthesized `pluginState.todos` is being attached at tool-result time.
|
||||
- Claude Code subagent transcript leaks into the main bubble:
|
||||
check `parent_tool_use_id` handling and whether subagent chunks are being forwarded to the main gateway handler.
|
||||
- Multiple Codex tools collapse into one assistant message:
|
||||
first check whether the adapter emits a usable step boundary such as `newStep` or an equivalent turn-change signal.
|
||||
- Orphan tool messages:
|
||||
first check step-transition ordering and whether `persistToolBatch` Phase 1 ran before tool message creation.
|
||||
- Tool bubble stays loading:
|
||||
look for `tool_result for unknown toolCallId` and missing `result_msg_id` backfill.
|
||||
- Subagent tools show up in the main bubble:
|
||||
check for subagent chunks reaching the main gateway handler.
|
||||
|
||||
## References
|
||||
|
||||
- For commands, trace capture, invariants, and focused test commands, read [references/debug-workflow.md](./references/debug-workflow.md).
|
||||
@@ -0,0 +1,246 @@
|
||||
# Heterogeneous Agent Debug Workflow
|
||||
|
||||
## Contents
|
||||
|
||||
1. Pipeline map
|
||||
2. Capture raw CLI traces first
|
||||
3. Compare raw and adapted events
|
||||
4. Check step boundaries before persistence
|
||||
5. Check tool persistence invariants
|
||||
6. Focused tests
|
||||
7. Repro-to-fix workflow
|
||||
|
||||
## 1. Pipeline Map
|
||||
|
||||
```
|
||||
CLI raw stdout
|
||||
-> HeterogeneousAgentCtr (Electron main)
|
||||
-> heteroAgentRawLine broadcast
|
||||
-> createAdapter(...)
|
||||
-> executeHeterogeneousAgent(...)
|
||||
-> persistToolBatch / persistToolResult
|
||||
-> createGatewayEventHandler(...)
|
||||
-> UI hydration
|
||||
```
|
||||
|
||||
Start at the leftmost broken layer. Do not jump straight to UI rendering unless raw and adapted events already look correct.
|
||||
|
||||
## 2. Capture Raw CLI Traces First
|
||||
|
||||
### Codex raw JSONL
|
||||
|
||||
Use a read-only prompt and save traces under the repo-local scratch directory `.heerogeneous-tracing/`.
|
||||
|
||||
```bash
|
||||
ts=$(date +%Y%m%d-%H%M%S)
|
||||
out=".heerogeneous-tracing/codex-${ts}.jsonl"
|
||||
last=".heerogeneous-tracing/codex-${ts}.last.txt"
|
||||
|
||||
cat << 'EOF' | codex exec --json --skip-git-repo-check --sandbox read-only -C "$PWD" -o "$last" - > "$out"
|
||||
You are being run only to collect a raw Codex JSON event trace.
|
||||
Do not modify any files.
|
||||
Use at least 4 separate shell tool invocations, one invocation per command.
|
||||
Run a short sequence of read-only repo checks and then reply with a one-sentence summary.
|
||||
EOF
|
||||
```
|
||||
|
||||
What to look for in the JSONL:
|
||||
|
||||
- `thread.started`
|
||||
- `turn.started`
|
||||
- `item.started` / `item.completed`
|
||||
- `item.type === 'command_execution'`
|
||||
- `item.type === 'agent_message'`
|
||||
- `turn.completed`
|
||||
|
||||
If raw Codex already merges tools into one item, the adapter is innocent. If raw Codex emits independent items but UI collapses them, the bug is downstream.
|
||||
|
||||
If the repo already contains useful traces under `.heerogeneous-tracing/`, inspect them before reproducing.
|
||||
|
||||
### Claude Code raw NDJSON
|
||||
|
||||
Mirror the arguments from `apps/desktop/src/main/modules/heterogeneousAgent/drivers/claudeCode.ts`.
|
||||
|
||||
- `-p`
|
||||
- `--input-format stream-json`
|
||||
- `--output-format stream-json`
|
||||
- `--verbose`
|
||||
- `--include-partial-messages`
|
||||
- `--permission-mode bypassPermissions`
|
||||
|
||||
You can capture a local raw trace like this:
|
||||
|
||||
```bash
|
||||
ts=$(date +%Y%m%d-%H%M%S)
|
||||
out=".heerogeneous-tracing/claude-${ts}.ndjson"
|
||||
|
||||
cat << 'EOF' | claude -p \
|
||||
--input-format stream-json \
|
||||
--output-format stream-json \
|
||||
--verbose \
|
||||
--include-partial-messages \
|
||||
--permission-mode bypassPermissions \
|
||||
> "$out"
|
||||
{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Do a few read-only repo checks, use several tool calls, and then summarize briefly."}]}}
|
||||
EOF
|
||||
```
|
||||
|
||||
What to look for in Claude Code raw traces:
|
||||
|
||||
- `type: 'system', subtype: 'init'`
|
||||
- `type: 'assistant'` blocks for `thinking`, `tool_use`, and `text`
|
||||
- `type: 'user'` blocks containing `tool_result`
|
||||
- `type: 'stream_event'` with `message_start`, `content_block_delta`, and `message_delta`
|
||||
- `type: 'result'`
|
||||
- `type: 'rate_limit_event'`
|
||||
|
||||
Important Claude Code semantics:
|
||||
|
||||
- Each content block often arrives as its own assistant event.
|
||||
- Multiple assistant events can share the same `message.id`; that is still one turn.
|
||||
- `message.id` change is the main-step boundary.
|
||||
- Partial deltas arrive before the later full assistant block.
|
||||
- `message_delta.usage` is the authoritative per-turn usage.
|
||||
- Subagent events are tagged with `parent_tool_use_id`.
|
||||
|
||||
If the repo already contains useful references, inspect these first:
|
||||
|
||||
- `.heerogeneous-tracing/cc-monitor-real-trace.jsonl`
|
||||
- `.heerogeneous-tracing/cc-stream-chain-reference.md`
|
||||
|
||||
If you only need boundary semantics or tool persistence behavior, prefer existing adapter tests under:
|
||||
|
||||
- `packages/heterogeneous-agents/src/adapters/claudeCode.test.ts`
|
||||
- `packages/heterogeneous-agents/src/adapters/claudeCode.e2e.test.ts`
|
||||
|
||||
## 3. Compare Raw And Adapted Events
|
||||
|
||||
In dev builds, `executeHeterogeneousAgent` stores raw lines plus adapted events on:
|
||||
|
||||
- `window.__HETERO_AGENT_TRACE`
|
||||
|
||||
Use that trace to compare:
|
||||
|
||||
- raw `item.started` / `item.completed`
|
||||
- adapted `stream_chunk { chunkType: 'tools_calling' }`
|
||||
- adapted `tool_result`
|
||||
- adapted `tool_end`
|
||||
|
||||
For Codex, the usual mapping is:
|
||||
|
||||
- raw `item.started(command_execution)` -> `tools_calling` + `tool_start`
|
||||
- raw `item.completed(command_execution)` -> `tool_result` + `tool_end`
|
||||
- raw `item.completed(agent_message)` -> `stream_chunk(text)`
|
||||
|
||||
If the raw trace is right but adapted events are wrong, fix the adapter before touching persistence.
|
||||
|
||||
## 4. Check Step Boundaries Before Persistence
|
||||
|
||||
This is the first thing to verify for "mixed tools in one assistant" bugs.
|
||||
|
||||
### Claude Code
|
||||
|
||||
Claude Code step boundaries are keyed off assistant `message.id` changes. The adapter should emit:
|
||||
|
||||
- `stream_end`
|
||||
- `stream_start { newStep: true }`
|
||||
|
||||
Also verify these Claude-specific invariants:
|
||||
|
||||
- the first assistant after init does not open a new step
|
||||
- repeated assistant events with the same `message.id` do not open a new step
|
||||
- partial `content_block_delta` text/thinking does not get duplicated by the later full assistant event
|
||||
- `tool_result` from `type: 'user'` updates the matching tool row
|
||||
- `parent_tool_use_id` creates thread-scoped subagent chunks instead of main-stream chunks
|
||||
- TodoWrite `tool_use.input` is converted into synthesized `pluginState.todos` on `tool_result`
|
||||
|
||||
Good references:
|
||||
|
||||
- `packages/heterogeneous-agents/src/adapters/claudeCode.ts`
|
||||
- `packages/heterogeneous-agents/src/adapters/claudeCode.test.ts`
|
||||
|
||||
### Codex
|
||||
|
||||
Codex raw traces usually provide turn-level boundaries through:
|
||||
|
||||
- `turn.started`
|
||||
- `turn.completed`
|
||||
|
||||
The executor only cuts a new assistant message when it receives a step-boundary signal it understands. If the adapter emits `stream_start` without `newStep`, multiple Codex tools and text chunks can accumulate under the same assistant longer than intended.
|
||||
|
||||
Relevant files:
|
||||
|
||||
- `packages/heterogeneous-agents/src/adapters/codex.ts`
|
||||
- `src/store/chat/slices/aiChat/actions/heterogeneousAgentExecutor.ts`
|
||||
|
||||
## 5. Check Tool Persistence Invariants
|
||||
|
||||
Read `persistToolBatch` and `persistToolResult` before changing UI code.
|
||||
|
||||
### `persistToolBatch`
|
||||
|
||||
The expected order is:
|
||||
|
||||
1. Pre-register assistant `tools[]`
|
||||
2. Create `role: 'tool'` messages
|
||||
3. Backfill `result_msg_id` onto assistant `tools[]`
|
||||
|
||||
If tool rows are created before assistant `tools[]` are registered, orphan tool messages are likely.
|
||||
|
||||
### `persistToolResult`
|
||||
|
||||
`tool_result` must resolve the tool row through `toolMsgIdByCallId`.
|
||||
|
||||
Warning signs:
|
||||
|
||||
- `tool_result for unknown toolCallId`
|
||||
- tool rows with empty content forever
|
||||
- missing `result_msg_id`
|
||||
|
||||
For Claude Code, remember that tool results originate from raw `type: 'user'` events.
|
||||
|
||||
### Main vs subagent scope
|
||||
|
||||
- Main-agent tool state is per-step.
|
||||
- `toolMsgIdByCallId` is global across main and subagent scopes.
|
||||
- Subagent chunks must not be forwarded into the main gateway handler.
|
||||
|
||||
If subagent events leak to the main handler, the main bubble can inherit the wrong `tools[]` and content.
|
||||
|
||||
## 6. Focused Tests
|
||||
|
||||
Run the smallest useful test set first.
|
||||
|
||||
```bash
|
||||
bunx vitest run --silent='passed-only' 'packages/heterogeneous-agents/src/adapters/codex.test.ts'
|
||||
bunx vitest run --silent='passed-only' 'packages/heterogeneous-agents/src/adapters/claudeCode.test.ts'
|
||||
bunx vitest run --silent='passed-only' 'src/store/chat/slices/aiChat/actions/__tests__/heterogeneousAgentExecutor.test.ts'
|
||||
```
|
||||
|
||||
Especially useful places:
|
||||
|
||||
- `packages/heterogeneous-agents/src/adapters/codex.test.ts`
|
||||
- `packages/heterogeneous-agents/src/adapters/claudeCode.test.ts`
|
||||
- `src/store/chat/slices/aiChat/actions/__tests__/heterogeneousAgentExecutor.test.ts`
|
||||
|
||||
Claude Code-specific assertions worth adding when fixing bugs:
|
||||
|
||||
- same `message.id` does not emit `newStep`
|
||||
- changed `message.id` does emit `stream_end` plus `stream_start { newStep: true }`
|
||||
- partial text/thinking is emitted once
|
||||
- `tool_result` from `user` events reaches the right tool row
|
||||
- subagent chunks carry `subagent.parentToolCallId`
|
||||
- TodoWrite result synthesizes `pluginState.todos`
|
||||
|
||||
When the bug comes from a real trace, distill it into the closest existing test file instead of relying on manual UI-only repros.
|
||||
|
||||
## 7. Repro-To-Fix Workflow
|
||||
|
||||
1. Capture a raw trace and save it under `.heerogeneous-tracing/`.
|
||||
2. Confirm whether the bug appears in raw events, adapted events, or persistence.
|
||||
3. Add or update the narrowest failing test near the broken layer.
|
||||
4. Fix the smallest layer that can explain the symptom.
|
||||
5. Re-run focused tests.
|
||||
6. Only then do an Electron smoke test with the `local-testing` skill if UI confirmation is still needed.
|
||||
|
||||
Do not start with a broad Electron repro if a raw trace or adapter test can prove the fault zone faster.
|
||||
@@ -188,6 +188,7 @@ export default defineConfig({
|
||||
],
|
||||
resolve: {
|
||||
dedupe: ['react', 'react-dom'],
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "^7.16.0",
|
||||
"uuid": "^13.0.0",
|
||||
"uuid": "^14.0.0",
|
||||
"vite": "^8.0.9",
|
||||
"vitest": "^3.2.4",
|
||||
"zod": "^3.25.76"
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
"fullDiskAccess.openSettings": "Open Settings",
|
||||
"fullDiskAccess.skip": "Later",
|
||||
"fullDiskAccess.title": "Full Disk Access Required",
|
||||
"screenCaptureAccess.cancel": "Later",
|
||||
"screenCaptureAccess.detail": "Open System Settings, enable Screen Recording for LobeHub, then try Quick Composer again.",
|
||||
"screenCaptureAccess.message": "Quick Composer needs Screen Recording permission before it can capture screenshots.",
|
||||
"screenCaptureAccess.openSettings": "Open Settings",
|
||||
"screenCaptureAccess.title": "Screen Recording Permission Required",
|
||||
"update.downloadAndInstall": "Download and Install",
|
||||
"update.downloadComplete": "Download Complete",
|
||||
"update.downloadCompleteMessage": "Update downloaded. Install now?",
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
"fullDiskAccess.openSettings": "打开设置",
|
||||
"fullDiskAccess.skip": "稍后",
|
||||
"fullDiskAccess.title": "需要完全磁盘访问权限",
|
||||
"screenCaptureAccess.cancel": "稍后",
|
||||
"screenCaptureAccess.detail": "请打开系统设置,为 LobeHub 开启“屏幕录制”权限,然后再次尝试 Quick Composer。",
|
||||
"screenCaptureAccess.message": "Quick Composer 需要“屏幕录制”权限后才能进行截图。",
|
||||
"screenCaptureAccess.openSettings": "打开设置",
|
||||
"screenCaptureAccess.title": "需要屏幕录制权限",
|
||||
"update.downloadAndInstall": "下载并安装",
|
||||
"update.downloadComplete": "下载完成",
|
||||
"update.downloadCompleteMessage": "已下载更新。现在安装吗?",
|
||||
|
||||
@@ -1,53 +1,57 @@
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { Readable, Writable } from 'node:stream';
|
||||
|
||||
import type { HeterogeneousAgentSessionError } from '@lobechat/electron-client-ipc';
|
||||
import {
|
||||
CLAUDE_CODE_CLI_INSTALL_COMMANDS,
|
||||
CLAUDE_CODE_CLI_INSTALL_DOCS_URL,
|
||||
CODEX_CLI_INSTALL_COMMANDS,
|
||||
CODEX_CLI_INSTALL_DOCS_URL,
|
||||
HeterogeneousAgentSessionErrorCode,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { app as electronApp, BrowserWindow } from 'electron';
|
||||
|
||||
import { getHeterogeneousAgentDriver } from '@/modules/heterogeneousAgent';
|
||||
import { CodexFileChangeTracker } from '@/modules/heterogeneousAgent/codexFileChangeTracker';
|
||||
import type {
|
||||
HeterogeneousAgentImageAttachment,
|
||||
HeterogeneousAgentParsedOutput,
|
||||
} from '@/modules/heterogeneousAgent/types';
|
||||
import { buildProxyEnv } from '@/modules/networkProxy/envBuilder';
|
||||
import { detectHeterogeneousCliCommand } from '@/modules/toolDetectors';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
const logger = createLogger('controllers:HeterogeneousAgentCtr');
|
||||
const CODEX_RESUME_THREAD_NOT_FOUND_PATTERNS = [
|
||||
/no conversation found/i,
|
||||
/thread .*not found/i,
|
||||
/conversation .*not found/i,
|
||||
/resume.*not found/i,
|
||||
] as const;
|
||||
const CLI_AUTH_REQUIRED_PATTERNS = [
|
||||
/failed to authenticate/i,
|
||||
/invalid authentication credentials/i,
|
||||
/authentication[_ ]error/i,
|
||||
/not authenticated/i,
|
||||
/\bunauthorized\b/i,
|
||||
/\b401\b/,
|
||||
] as const;
|
||||
const CODEX_RESUME_CWD_MISMATCH_PATTERNS = [
|
||||
/working directory/i,
|
||||
/\bcwd\b/i,
|
||||
/different directory/i,
|
||||
/directory.*mismatch/i,
|
||||
] as const;
|
||||
|
||||
/** 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',
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--include-partial-messages',
|
||||
'--permission-mode',
|
||||
'bypassPermissions',
|
||||
],
|
||||
promptMode: 'stdin',
|
||||
resumeArgs: (sid) => ['--resume', sid],
|
||||
},
|
||||
// Future presets:
|
||||
// 'codex': { baseArgs: [...], promptMode: 'positional' },
|
||||
// 'kimi-cli': { baseArgs: [...], promptMode: 'positional' },
|
||||
};
|
||||
|
||||
// ─── IPC types ───
|
||||
|
||||
interface StartSessionParams {
|
||||
@@ -69,14 +73,9 @@ 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[];
|
||||
imageList?: HeterogeneousAgentImageAttachment[];
|
||||
prompt: string;
|
||||
sessionId: string;
|
||||
}
|
||||
@@ -115,15 +114,19 @@ interface AgentSession {
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
process?: ChildProcess;
|
||||
resumeSessionId?: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
type SessionErrorPayload = HeterogeneousAgentSessionError | 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.
|
||||
* Agent-agnostic: delegates spawn-plan construction and stdout framing to a
|
||||
* per-agent driver so Claude Code, Codex, and future CLIs can differ in
|
||||
* prompt transport, resume semantics, and raw stream shape without turning
|
||||
* this controller into a giant `switch`.
|
||||
*
|
||||
* Lifecycle: startSession → sendPrompt → (heteroAgentRawLine broadcasts) → stopSession
|
||||
*/
|
||||
@@ -132,6 +135,203 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
|
||||
private sessions = new Map<string, AgentSession>();
|
||||
|
||||
private resolveSessionCommand(session: AgentSession): string {
|
||||
const resolvedCommand = session.command.trim();
|
||||
if (resolvedCommand) return resolvedCommand;
|
||||
|
||||
return session.agentType === 'codex' ? 'codex' : 'claude';
|
||||
}
|
||||
|
||||
private buildCodexCliMissingError(session: AgentSession): HeterogeneousAgentSessionError {
|
||||
const command = this.resolveSessionCommand(session);
|
||||
|
||||
return {
|
||||
agentType: 'codex',
|
||||
code: HeterogeneousAgentSessionErrorCode.CliNotFound,
|
||||
command,
|
||||
docsUrl: CODEX_CLI_INSTALL_DOCS_URL,
|
||||
installCommands: CODEX_CLI_INSTALL_COMMANDS,
|
||||
message: `Codex CLI was not found. Install it and make sure \`${command}\` can be executed.`,
|
||||
};
|
||||
}
|
||||
|
||||
private buildClaudeCodeCliMissingError(session: AgentSession): HeterogeneousAgentSessionError {
|
||||
const command = this.resolveSessionCommand(session);
|
||||
|
||||
return {
|
||||
agentType: 'claude-code',
|
||||
code: HeterogeneousAgentSessionErrorCode.CliNotFound,
|
||||
command,
|
||||
docsUrl: CLAUDE_CODE_CLI_INSTALL_DOCS_URL,
|
||||
installCommands: CLAUDE_CODE_CLI_INSTALL_COMMANDS,
|
||||
message: `Claude Code CLI was not found. Install it and make sure \`${command}\` can be executed.`,
|
||||
};
|
||||
}
|
||||
|
||||
private buildCliMissingError(session: AgentSession): HeterogeneousAgentSessionError | undefined {
|
||||
switch (session.agentType) {
|
||||
case 'claude-code': {
|
||||
return this.buildClaudeCodeCliMissingError(session);
|
||||
}
|
||||
case 'codex': {
|
||||
return this.buildCodexCliMissingError(session);
|
||||
}
|
||||
default: {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildCliAuthRequiredError(
|
||||
session: AgentSession,
|
||||
stderr: string,
|
||||
): HeterogeneousAgentSessionError | undefined {
|
||||
const command = this.resolveSessionCommand(session);
|
||||
|
||||
switch (session.agentType) {
|
||||
case 'claude-code': {
|
||||
return {
|
||||
agentType: 'claude-code',
|
||||
code: HeterogeneousAgentSessionErrorCode.AuthRequired,
|
||||
command,
|
||||
docsUrl: CLAUDE_CODE_CLI_INSTALL_DOCS_URL,
|
||||
message:
|
||||
'Claude Code could not authenticate. Sign in again or refresh its credentials, then retry.',
|
||||
stderr,
|
||||
};
|
||||
}
|
||||
case 'codex': {
|
||||
return {
|
||||
agentType: 'codex',
|
||||
code: HeterogeneousAgentSessionErrorCode.AuthRequired,
|
||||
command,
|
||||
docsUrl: CODEX_CLI_INSTALL_DOCS_URL,
|
||||
message:
|
||||
'Codex could not authenticate. Sign in again or refresh its credentials, then retry.',
|
||||
stderr,
|
||||
};
|
||||
}
|
||||
default: {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getErrorMessage(error: unknown): string | undefined {
|
||||
return typeof error === 'string'
|
||||
? error
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: typeof error === 'object' &&
|
||||
error &&
|
||||
'message' in error &&
|
||||
typeof error.message === 'string'
|
||||
? error.message
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private buildCodexResumeError(
|
||||
code:
|
||||
| typeof HeterogeneousAgentSessionErrorCode.ResumeCwdMismatch
|
||||
| typeof HeterogeneousAgentSessionErrorCode.ResumeThreadNotFound,
|
||||
stderr: string,
|
||||
session: AgentSession,
|
||||
): HeterogeneousAgentSessionError {
|
||||
const message =
|
||||
code === HeterogeneousAgentSessionErrorCode.ResumeCwdMismatch
|
||||
? 'The saved Codex thread can only be resumed from its original working directory.'
|
||||
: 'The saved Codex thread could not be found, so it can no longer be resumed.';
|
||||
|
||||
return {
|
||||
agentType: 'codex',
|
||||
code,
|
||||
command: session.command,
|
||||
message,
|
||||
resumeSessionId: session.resumeSessionId,
|
||||
stderr,
|
||||
workingDirectory: session.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
private getCodexResumeError(
|
||||
error: unknown,
|
||||
session: AgentSession,
|
||||
): HeterogeneousAgentSessionError | undefined {
|
||||
if (session.agentType !== 'codex' || !session.resumeSessionId) return;
|
||||
|
||||
const message = this.getErrorMessage(error);
|
||||
|
||||
if (!message) return;
|
||||
|
||||
if (CODEX_RESUME_CWD_MISMATCH_PATTERNS.some((pattern) => pattern.test(message))) {
|
||||
return this.buildCodexResumeError(
|
||||
HeterogeneousAgentSessionErrorCode.ResumeCwdMismatch,
|
||||
message,
|
||||
session,
|
||||
);
|
||||
}
|
||||
|
||||
if (CODEX_RESUME_THREAD_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(message))) {
|
||||
return this.buildCodexResumeError(
|
||||
HeterogeneousAgentSessionErrorCode.ResumeThreadNotFound,
|
||||
message,
|
||||
session,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private getCliAuthRequiredError(
|
||||
error: unknown,
|
||||
session: AgentSession,
|
||||
): HeterogeneousAgentSessionError | undefined {
|
||||
const message = this.getErrorMessage(error);
|
||||
|
||||
if (!message || !CLI_AUTH_REQUIRED_PATTERNS.some((pattern) => pattern.test(message))) return;
|
||||
|
||||
return this.buildCliAuthRequiredError(session, message);
|
||||
}
|
||||
|
||||
private getSessionErrorPayload(error: unknown, session: AgentSession): SessionErrorPayload {
|
||||
if (typeof error === 'object' && error && 'code' in error && error.code === 'ENOENT') {
|
||||
const cliMissingError = this.buildCliMissingError(session);
|
||||
if (cliMissingError) return cliMissingError;
|
||||
}
|
||||
|
||||
const resumeError = this.getCodexResumeError(error, session);
|
||||
if (resumeError) return resumeError;
|
||||
|
||||
const authRequiredError = this.getCliAuthRequiredError(error, session);
|
||||
if (authRequiredError) return authRequiredError;
|
||||
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
private async getSpawnPreflightError(
|
||||
session: AgentSession,
|
||||
): Promise<HeterogeneousAgentSessionError | undefined> {
|
||||
const defaultCommand =
|
||||
session.agentType === 'claude-code'
|
||||
? 'claude'
|
||||
: session.agentType === 'codex'
|
||||
? 'codex'
|
||||
: undefined;
|
||||
if (!defaultCommand) return;
|
||||
|
||||
const command = this.resolveSessionCommand(session);
|
||||
const status =
|
||||
command === defaultCommand
|
||||
? await this.app.toolDetectorManager?.detect?.(defaultCommand, true)
|
||||
: await detectHeterogeneousCliCommand(
|
||||
session.agentType === 'claude-code' ? 'claude-code' : 'codex',
|
||||
command,
|
||||
);
|
||||
const cliMissingError = this.buildCliMissingError(session);
|
||||
|
||||
if (!status || status.available || !cliMissingError) return;
|
||||
|
||||
return cliMissingError;
|
||||
}
|
||||
|
||||
// ─── Broadcast ───
|
||||
|
||||
private broadcast<T>(channel: string, data: T) {
|
||||
@@ -164,7 +364,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
* Download an image by URL, with local disk cache keyed by id.
|
||||
*/
|
||||
private async resolveImage(
|
||||
image: ImageAttachment,
|
||||
image: HeterogeneousAgentImageAttachment,
|
||||
): Promise<{ buffer: Buffer; mimeType: string }> {
|
||||
const cacheDir = this.fileCacheDir;
|
||||
const cacheKey = this.getImageCacheKey(image.id);
|
||||
@@ -201,12 +401,71 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
return { buffer, mimeType };
|
||||
}
|
||||
|
||||
private guessImageExtension(
|
||||
mimeType: string,
|
||||
image: HeterogeneousAgentImageAttachment,
|
||||
): string | undefined {
|
||||
const knownByMime: Record<string, string> = {
|
||||
'image/gif': '.gif',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/webp': '.webp',
|
||||
};
|
||||
|
||||
if (knownByMime[mimeType]) return knownByMime[mimeType];
|
||||
|
||||
try {
|
||||
const pathname = new URL(image.url).pathname;
|
||||
const ext = path.extname(pathname);
|
||||
return ext || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize an image attachment into a stable local file path so CLIs like
|
||||
* Codex can consume it through `--image <file>`.
|
||||
*/
|
||||
private async resolveCliImagePath(image: HeterogeneousAgentImageAttachment): Promise<string> {
|
||||
const { buffer, mimeType } = await this.resolveImage(image);
|
||||
const cacheKey = this.getImageCacheKey(image.id);
|
||||
const ext = this.guessImageExtension(mimeType, image) || '';
|
||||
const filePath = path.join(this.fileCacheDir, `${cacheKey}${ext}`);
|
||||
|
||||
try {
|
||||
await access(filePath);
|
||||
} catch {
|
||||
await mkdir(this.fileCacheDir, { recursive: true });
|
||||
await writeFile(filePath, buffer);
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
private async resolveCliImagePaths(
|
||||
imageList: HeterogeneousAgentImageAttachment[] = [],
|
||||
): Promise<string[]> {
|
||||
const resolved = await Promise.all(
|
||||
imageList.map(async (image) => {
|
||||
try {
|
||||
return await this.resolveCliImagePath(image);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to materialize image ${image.id} for CLI:`, err);
|
||||
return undefined;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return resolved.filter(Boolean) as string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stream-json user message with text + optional image content blocks.
|
||||
*/
|
||||
private async buildStreamJsonInput(
|
||||
prompt: string,
|
||||
imageList: ImageAttachment[] = [],
|
||||
imageList: HeterogeneousAgentImageAttachment[] = [],
|
||||
): Promise<string> {
|
||||
const content: any[] = [{ text: prompt, type: 'text' }];
|
||||
|
||||
@@ -226,10 +485,10 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
return `${JSON.stringify({
|
||||
message: { content, role: 'user' },
|
||||
type: 'user',
|
||||
});
|
||||
})}\n`;
|
||||
}
|
||||
|
||||
// ─── IPC methods ───
|
||||
@@ -241,6 +500,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
async startSession(params: StartSessionParams): Promise<StartSessionResult> {
|
||||
const sessionId = randomUUID();
|
||||
const agentType = params.agentType || 'claude-code';
|
||||
getHeterogeneousAgentDriver(agentType);
|
||||
|
||||
this.sessions.set(sessionId, {
|
||||
// If resuming, pre-set the agent session ID so sendPrompt adds --resume
|
||||
@@ -251,6 +511,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
sessionId,
|
||||
resumeSessionId: params.resumeSessionId,
|
||||
});
|
||||
|
||||
logger.info('Session created:', { agentType, sessionId });
|
||||
@@ -268,32 +529,31 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
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 useStdin = preset.promptMode === 'stdin';
|
||||
|
||||
// Build stream-json payload up-front so any image download errors
|
||||
// surface before the process is spawned.
|
||||
let stdinPayload: string | undefined;
|
||||
if (useStdin) {
|
||||
stdinPayload = await this.buildStreamJsonInput(params.prompt, params.imageList ?? []);
|
||||
const preflightError = await this.getSpawnPreflightError(session);
|
||||
if (preflightError) {
|
||||
this.broadcast('heteroAgentSessionError', {
|
||||
error: preflightError,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
throw new Error(preflightError.message);
|
||||
}
|
||||
|
||||
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,
|
||||
];
|
||||
const driver = getHeterogeneousAgentDriver(session.agentType);
|
||||
const spawnPlan = await driver.buildSpawnPlan({
|
||||
args: session.args,
|
||||
helpers: {
|
||||
buildClaudeStreamJsonInput: (prompt, imageList) =>
|
||||
this.buildStreamJsonInput(prompt, imageList),
|
||||
resolveCliImagePaths: (imageList) => this.resolveCliImagePaths(imageList),
|
||||
},
|
||||
imageList: params.imageList ?? [],
|
||||
prompt: params.prompt,
|
||||
resumeSessionId: session.agentSessionId,
|
||||
});
|
||||
const useStdin = spawnPlan.stdinPayload !== undefined;
|
||||
|
||||
if (!useStdin && preset.promptMode === 'positional') {
|
||||
// Positional mode: append prompt as a CLI arg (legacy / non-CC presets).
|
||||
cliArgs.push(params.prompt);
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const cliArgs = spawnPlan.args;
|
||||
|
||||
// Fall back to the user's Desktop so the process never inherits
|
||||
// the Electron parent's cwd (which is `/` when launched from Finder).
|
||||
@@ -318,45 +578,50 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
// In stdin mode, write the stream-json message and close stdin.
|
||||
if (useStdin && stdinPayload && proc.stdin) {
|
||||
// In stdin mode, write the prepared payload and close stdin.
|
||||
if (useStdin && spawnPlan.stdinPayload !== undefined && proc.stdin) {
|
||||
const stdin = proc.stdin as Writable;
|
||||
stdin.write(stdinPayload + '\n', () => {
|
||||
stdin.write(spawnPlan.stdinPayload, () => {
|
||||
stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
session.process = proc;
|
||||
let buffer = '';
|
||||
const streamProcessor = driver.createStreamProcessor();
|
||||
const codexFileChangeTracker =
|
||||
session.agentType === 'codex' ? new CodexFileChangeTracker() : undefined;
|
||||
let stdoutBroadcastQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
// Stream stdout lines as raw events to Renderer
|
||||
const broadcastParsedOutputs = (parsedOutputs: HeterogeneousAgentParsedOutput[]) => {
|
||||
stdoutBroadcastQueue = stdoutBroadcastQueue
|
||||
.then(async () => {
|
||||
for (const parsedOutput of parsedOutputs) {
|
||||
if (parsedOutput.agentSessionId) {
|
||||
session.agentSessionId = parsedOutput.agentSessionId;
|
||||
}
|
||||
|
||||
const line = codexFileChangeTracker
|
||||
? await codexFileChangeTracker.track(parsedOutput.payload)
|
||||
: parsedOutput.payload;
|
||||
|
||||
this.broadcast('heteroAgentRawLine', {
|
||||
line,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Failed to broadcast parsed agent output:', error);
|
||||
});
|
||||
};
|
||||
|
||||
// Stream stdout events as raw provider payloads 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
|
||||
}
|
||||
}
|
||||
broadcastParsedOutputs(streamProcessor.push(chunk));
|
||||
});
|
||||
stdout.on('end', () => {
|
||||
broadcastParsedOutputs(streamProcessor.flush());
|
||||
});
|
||||
|
||||
// Capture stderr
|
||||
@@ -368,40 +633,46 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
|
||||
proc.on('error', (err) => {
|
||||
logger.error('Agent process error:', err);
|
||||
const sessionError = this.getSessionErrorPayload(err, session);
|
||||
this.broadcast('heteroAgentSessionError', {
|
||||
error: err.message,
|
||||
error: sessionError,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
reject(err);
|
||||
reject(new Error(typeof sessionError === 'string' ? sessionError : sessionError.message));
|
||||
});
|
||||
|
||||
proc.on('exit', (code, signal) => {
|
||||
logger.info('Agent process exited:', { code, sessionId: session.sessionId, signal });
|
||||
session.process = undefined;
|
||||
void stdoutBroadcastQueue.finally(() => {
|
||||
logger.info('Agent process exited:', { code, sessionId: session.sessionId, signal });
|
||||
session.process = undefined;
|
||||
|
||||
// If *we* killed it (cancel / stop / before-quit), treat the non-zero
|
||||
// exit as a clean shutdown — surfacing it as an error would make a
|
||||
// user-initiated cancel look like an agent failure, and an Electron
|
||||
// shutdown affecting OTHER running CC sessions would pollute their
|
||||
// topics with a misleading "Agent exited with code 143" message.
|
||||
if (session.cancelledByUs) {
|
||||
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
// If *we* killed it (cancel / stop / before-quit), treat the non-zero
|
||||
// exit as a clean shutdown — surfacing it as an error would make a
|
||||
// user-initiated cancel look like an agent failure, and an Electron
|
||||
// shutdown affecting OTHER running CC sessions would pollute their
|
||||
// topics with a misleading "Agent exited with code 143" message.
|
||||
if (session.cancelledByUs) {
|
||||
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
if (code === 0) {
|
||||
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
|
||||
resolve();
|
||||
} else {
|
||||
const stderrOutput = stderrChunks.join('').trim();
|
||||
const errorMsg = stderrOutput || `Agent exited with code ${code}`;
|
||||
const sessionError = this.getSessionErrorPayload(errorMsg, session);
|
||||
this.broadcast('heteroAgentSessionError', {
|
||||
error: sessionError,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
reject(
|
||||
new Error(typeof sessionError === 'string' ? sessionError : sessionError.message),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,7 +99,9 @@ export default class NotificationCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Show system desktop notification (only when window is hidden)
|
||||
* Show system desktop notification.
|
||||
* By default notifications only appear when the main window is hidden or unfocused.
|
||||
* High-priority callers can pass `force` to surface a banner even while focused.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async showDesktopNotification(
|
||||
@@ -117,12 +119,16 @@ export default class NotificationCtr extends ControllerModule {
|
||||
// Check if window is hidden
|
||||
const isWindowHidden = this.isMainWindowHidden();
|
||||
|
||||
if (!isWindowHidden) {
|
||||
if (!params.force && !isWindowHidden) {
|
||||
logger.debug('Main window is visible, skipping desktop notification');
|
||||
return { reason: 'Window is visible', skipped: true, success: true };
|
||||
}
|
||||
|
||||
logger.info('Window is hidden, showing desktop notification:', params.title);
|
||||
if (params.requestAttention && isWindowHidden) {
|
||||
this.requestUserAttention();
|
||||
}
|
||||
|
||||
logger.info('Showing desktop notification:', params.title);
|
||||
|
||||
const notification = new Notification({
|
||||
body: params.body,
|
||||
@@ -178,6 +184,23 @@ export default class NotificationCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
private requestUserAttention(): void {
|
||||
try {
|
||||
const mainWindow = this.app.browserManager.getMainWindow().browserWindow;
|
||||
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
|
||||
if (electronIs.macOS()) {
|
||||
app.dock?.bounce?.('informational');
|
||||
return;
|
||||
}
|
||||
|
||||
mainWindow.flashFrame(true);
|
||||
} catch (error) {
|
||||
logger.error('Failed to request user attention:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the app-level badge count (dock red dot on macOS, Unity counter on Linux,
|
||||
* overlay icon on Windows). Pass 0 to clear.
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { exec } from 'node:child_process';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import type { ClaudeAuthStatus } from '@lobechat/electron-client-ipc';
|
||||
import type {
|
||||
ClaudeAuthStatus,
|
||||
DetectHeterogeneousAgentCommandParams,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
|
||||
import type { ToolCategory, ToolStatus } from '@/core/infrastructure/ToolDetectorManager';
|
||||
import { detectHeterogeneousCliCommand } from '@/modules/toolDetectors';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
const execFilePromise = promisify(execFile);
|
||||
|
||||
const logger = createLogger('controllers:ToolDetectorCtr');
|
||||
|
||||
@@ -34,6 +38,14 @@ export default class ToolDetectorCtr extends ControllerModule {
|
||||
return this.manager.detect(name, force);
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async detectHeterogeneousAgentCommand(
|
||||
params: DetectHeterogeneousAgentCommandParams,
|
||||
): Promise<ToolStatus> {
|
||||
logger.debug('Detecting heterogeneous agent command:', params);
|
||||
return detectHeterogeneousCliCommand(params.agentType, params.command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect all registered tools
|
||||
*/
|
||||
@@ -125,9 +137,14 @@ export default class ToolDetectorCtr extends ControllerModule {
|
||||
* Returns null if the CLI is unavailable or the command fails.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async getClaudeAuthStatus(): Promise<ClaudeAuthStatus | null> {
|
||||
async getClaudeAuthStatus(command = 'claude'): Promise<ClaudeAuthStatus | null> {
|
||||
const resolvedCommand = command.trim() || 'claude';
|
||||
|
||||
try {
|
||||
const { stdout } = await execPromise('claude auth status --json', { timeout: 5000 });
|
||||
const { stdout } = await execFilePromise(resolvedCommand, ['auth', 'status', '--json'], {
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
});
|
||||
return JSON.parse(stdout.trim()) as ClaudeAuthStatus;
|
||||
} catch (error) {
|
||||
logger.debug('Failed to get claude auth status:', error);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import { HeterogeneousAgentSessionErrorCode } from '@lobechat/electron-client-ipc';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import HeterogeneousAgentCtr from '../HeterogeneousAgentCtr';
|
||||
@@ -32,18 +33,34 @@ vi.mock('@/utils/logger', () => ({
|
||||
// Captures the most recent spawn() call so sendPrompt tests can assert on argv.
|
||||
const spawnCalls: Array<{ args: string[]; command: string; options: any }> = [];
|
||||
let nextFakeProc: any = null;
|
||||
vi.mock('node:child_process', () => ({
|
||||
spawn: (command: string, args: string[], options: any) => {
|
||||
spawnCalls.push({ args, command, options });
|
||||
return nextFakeProc;
|
||||
},
|
||||
const { execFileMock } = vi.hoisted(() => ({
|
||||
execFileMock: vi.fn(),
|
||||
}));
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
...actual,
|
||||
execFile: execFileMock,
|
||||
spawn: (command: string, args: string[], options: any) => {
|
||||
spawnCalls.push({ args, command, options });
|
||||
nextFakeProc?.__start?.();
|
||||
return nextFakeProc;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Build a fake ChildProcess that immediately exits cleanly. Records every
|
||||
* stdin write on the returned `writes` array so tests can inspect the payload.
|
||||
*/
|
||||
const createFakeProc = () => {
|
||||
const createFakeProc = ({
|
||||
exitCode = 0,
|
||||
stdoutLines = [],
|
||||
}: {
|
||||
exitCode?: number;
|
||||
stdoutLines?: string[];
|
||||
} = {}) => {
|
||||
const proc = new EventEmitter() as any;
|
||||
const stdout = new PassThrough();
|
||||
const stderr = new PassThrough();
|
||||
@@ -60,15 +77,26 @@ const createFakeProc = () => {
|
||||
};
|
||||
proc.kill = vi.fn();
|
||||
proc.killed = false;
|
||||
// Exit asynchronously so the Promise returned by sendPrompt resolves cleanly.
|
||||
setImmediate(() => {
|
||||
stdout.end();
|
||||
stderr.end();
|
||||
proc.emit('exit', 0);
|
||||
});
|
||||
let started = false;
|
||||
proc.__start = () => {
|
||||
if (started) return;
|
||||
started = true;
|
||||
// Exit asynchronously so the Promise returned by sendPrompt resolves cleanly.
|
||||
setImmediate(() => {
|
||||
for (const line of stdoutLines) {
|
||||
stdout.write(line);
|
||||
}
|
||||
stdout.end();
|
||||
stderr.end();
|
||||
proc.emit('exit', exitCode);
|
||||
});
|
||||
};
|
||||
return { proc, writes };
|
||||
};
|
||||
|
||||
const getFlagValues = (args: string[], flag: string) =>
|
||||
args.flatMap((arg, index) => (arg === flag ? [args[index + 1]] : []));
|
||||
|
||||
describe('HeterogeneousAgentCtr', () => {
|
||||
let appStoragePath: string;
|
||||
|
||||
@@ -144,10 +172,15 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
describe('sendPrompt (claude-code)', () => {
|
||||
beforeEach(() => {
|
||||
spawnCalls.length = 0;
|
||||
execFileMock.mockReset();
|
||||
});
|
||||
|
||||
const runSendPrompt = async (prompt: string, sessionOverrides: Record<string, any> = {}) => {
|
||||
const { proc, writes } = createFakeProc();
|
||||
const runSendPrompt = async (
|
||||
prompt: string,
|
||||
sessionOverrides: Record<string, any> = {},
|
||||
stdoutLines: string[] = [],
|
||||
) => {
|
||||
const { proc, writes } = createFakeProc({ stdoutLines });
|
||||
nextFakeProc = proc;
|
||||
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
@@ -162,7 +195,7 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
await ctr.sendPrompt({ prompt, sessionId });
|
||||
|
||||
const { args: cliArgs, command, options } = spawnCalls[0];
|
||||
return { cliArgs, command, options, writes };
|
||||
return { cliArgs, command, ctr, options, sessionId, writes };
|
||||
};
|
||||
|
||||
it('passes prompt via stdin stream-json — never as a positional arg', async () => {
|
||||
@@ -221,5 +254,258 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
|
||||
expect(options.cwd).toBe(explicitCwd);
|
||||
});
|
||||
|
||||
it('captures the Claude Code session id from stream-json init events', async () => {
|
||||
const { ctr, sessionId } = await runSendPrompt('hello', {}, [
|
||||
`${JSON.stringify({ session_id: 'sess_cc_123', subtype: 'init', type: 'system' })}\n`,
|
||||
]);
|
||||
|
||||
await expect(ctr.getSessionInfo({ sessionId })).resolves.toEqual({
|
||||
agentSessionId: 'sess_cc_123',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendPrompt (codex)', () => {
|
||||
beforeEach(() => {
|
||||
spawnCalls.length = 0;
|
||||
execFileMock.mockReset();
|
||||
});
|
||||
|
||||
const runSendPrompt = async (
|
||||
prompt: string,
|
||||
sessionOverrides: Record<string, any> = {},
|
||||
stdoutLines: string[] = [],
|
||||
sendPromptOverrides: Partial<{ imageList: Array<{ id: string; url: string }> }> = {},
|
||||
) => {
|
||||
const { proc, writes } = createFakeProc({ stdoutLines });
|
||||
nextFakeProc = proc;
|
||||
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
} as any);
|
||||
const { sessionId } = await ctr.startSession({
|
||||
agentType: 'codex',
|
||||
command: 'codex',
|
||||
...sessionOverrides,
|
||||
});
|
||||
await ctr.sendPrompt({ prompt, sessionId, ...sendPromptOverrides });
|
||||
|
||||
const { args: cliArgs, command, options } = spawnCalls[0];
|
||||
return { cliArgs, command, ctr, options, sessionId, writes };
|
||||
};
|
||||
|
||||
it('fails fast when Codex CLI is unavailable instead of attempting spawn', async () => {
|
||||
const detect = vi.fn().mockResolvedValue({ available: false });
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
toolDetectorManager: { detect },
|
||||
} as any);
|
||||
const { sessionId } = await ctr.startSession({
|
||||
agentType: 'codex',
|
||||
command: 'codex',
|
||||
});
|
||||
|
||||
await expect(ctr.sendPrompt({ prompt: 'hello', sessionId })).rejects.toThrow(
|
||||
'Codex CLI was not found',
|
||||
);
|
||||
|
||||
expect(detect).toHaveBeenCalledWith('codex', true);
|
||||
expect(spawnCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('fails fast when Claude Code CLI is unavailable instead of attempting spawn', async () => {
|
||||
const detect = vi.fn().mockResolvedValue({ available: false });
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
toolDetectorManager: { detect },
|
||||
} as any);
|
||||
const { sessionId } = await ctr.startSession({
|
||||
agentType: 'claude-code',
|
||||
command: 'claude',
|
||||
});
|
||||
|
||||
await expect(ctr.sendPrompt({ prompt: 'hello', sessionId })).rejects.toThrow(
|
||||
'Claude Code CLI was not found',
|
||||
);
|
||||
|
||||
expect(detect).toHaveBeenCalledWith('claude', true);
|
||||
expect(spawnCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('fails fast when a customized Claude command is unavailable instead of checking the default detector', async () => {
|
||||
execFileMock.mockImplementation(
|
||||
(
|
||||
file: string,
|
||||
_args: string[],
|
||||
optionsOrCallback: unknown,
|
||||
callback?: (error: Error | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
const resolvedCallback =
|
||||
typeof optionsOrCallback === 'function' ? optionsOrCallback : callback;
|
||||
|
||||
resolvedCallback?.(
|
||||
Object.assign(new Error(`${file} not found`), { code: 'ENOENT' }),
|
||||
'',
|
||||
'',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const detect = vi.fn().mockResolvedValue({ available: true });
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
toolDetectorManager: { detect },
|
||||
} as any);
|
||||
const { sessionId } = await ctr.startSession({
|
||||
agentType: 'claude-code',
|
||||
command: 'claude-alt',
|
||||
});
|
||||
|
||||
await expect(ctr.sendPrompt({ prompt: 'hello', sessionId })).rejects.toThrow(
|
||||
'Claude Code CLI was not found',
|
||||
);
|
||||
|
||||
expect(detect).not.toHaveBeenCalled();
|
||||
expect(spawnCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('passes prompt via stdin to codex exec instead of argv', async () => {
|
||||
const prompt = '--run a shell-like prompt safely';
|
||||
const { cliArgs, command, writes } = await runSendPrompt(prompt);
|
||||
|
||||
expect(command).toBe('codex');
|
||||
expect(cliArgs).not.toContain(prompt);
|
||||
expect(cliArgs).toEqual(
|
||||
expect.arrayContaining(['exec', '--json', '--skip-git-repo-check', '--full-auto', '-']),
|
||||
);
|
||||
expect(writes).toEqual([prompt]);
|
||||
});
|
||||
|
||||
it('materializes image attachments into local files and forwards them via --image', async () => {
|
||||
const imageList = [
|
||||
{ id: 'image-1', url: 'data:image/png;base64,UE5HX1RFU1Q=' },
|
||||
{ id: 'image-2', url: 'data:image/jpeg;base64,SlBFR19URVNU' },
|
||||
];
|
||||
const { cliArgs, writes } = await runSendPrompt('describe these screenshots', {}, [], {
|
||||
imageList,
|
||||
});
|
||||
|
||||
const imagePaths = getFlagValues(cliArgs, '--image');
|
||||
|
||||
expect(cliArgs).not.toContain('describe these screenshots');
|
||||
expect(cliArgs.filter((arg) => arg === '--image')).toHaveLength(2);
|
||||
expect(imagePaths).toHaveLength(2);
|
||||
expect(imagePaths[0]).toMatch(/\.png$/);
|
||||
expect(imagePaths[1]).toMatch(/\.jpg$/);
|
||||
expect(
|
||||
imagePaths.every((filePath) =>
|
||||
filePath.startsWith(path.join(appStoragePath, 'heteroAgent/files')),
|
||||
),
|
||||
).toBe(true);
|
||||
await expect(
|
||||
Promise.all(imagePaths.map((filePath) => readFile(filePath, 'utf8'))),
|
||||
).resolves.toEqual(['PNG_TEST', 'JPEG_TEST']);
|
||||
expect(writes).toEqual(['describe these screenshots']);
|
||||
});
|
||||
|
||||
it('skips images that fail to materialize and still forwards the remaining --image args', async () => {
|
||||
const imageList = [
|
||||
{ id: 'good-image', url: 'data:image/png;base64,VkFMSURfSU1BR0U=' },
|
||||
{ id: 'bad-image', url: 'bad://broken-image' },
|
||||
];
|
||||
const { cliArgs, writes } = await runSendPrompt('inspect the valid screenshot only', {}, [], {
|
||||
imageList,
|
||||
});
|
||||
|
||||
const imagePaths = getFlagValues(cliArgs, '--image');
|
||||
|
||||
expect(cliArgs.filter((arg) => arg === '--image')).toHaveLength(1);
|
||||
expect(imagePaths).toHaveLength(1);
|
||||
expect(imagePaths[0]).toMatch(/\.png$/);
|
||||
await expect(readFile(imagePaths[0], 'utf8')).resolves.toBe('VALID_IMAGE');
|
||||
expect(writes).toEqual(['inspect the valid screenshot only']);
|
||||
});
|
||||
|
||||
it('uses codex exec resume syntax when continuing an existing thread', async () => {
|
||||
const { cliArgs } = await runSendPrompt('continue', { resumeSessionId: 'thread_abc' });
|
||||
|
||||
expect(cliArgs.slice(0, 2)).toEqual(['exec', 'resume']);
|
||||
expect(cliArgs).toContain('thread_abc');
|
||||
expect(cliArgs).not.toContain('--resume');
|
||||
expect(cliArgs.at(-1)).toBe('-');
|
||||
});
|
||||
|
||||
it('captures the Codex thread id from json output for later resume', async () => {
|
||||
const { ctr, sessionId } = await runSendPrompt('hello', {}, [
|
||||
`${JSON.stringify({ thread_id: 'thread_codex_123', type: 'thread.started' })}\n`,
|
||||
]);
|
||||
|
||||
await expect(ctr.getSessionInfo({ sessionId })).resolves.toEqual({
|
||||
agentSessionId: 'thread_codex_123',
|
||||
});
|
||||
});
|
||||
|
||||
it('classifies stale Codex resume stderr as a structured resume error', () => {
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
} as any);
|
||||
|
||||
const payload = (ctr as any).getSessionErrorPayload(
|
||||
'No conversation found for thread thread_stale_123',
|
||||
{
|
||||
agentSessionId: 'thread_stale_123',
|
||||
agentType: 'codex',
|
||||
args: [],
|
||||
command: 'codex',
|
||||
cwd: '/Users/fake/projects/repo',
|
||||
resumeSessionId: 'thread_stale_123',
|
||||
sessionId: 'session-1',
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload).toEqual({
|
||||
agentType: 'codex',
|
||||
code: HeterogeneousAgentSessionErrorCode.ResumeThreadNotFound,
|
||||
command: 'codex',
|
||||
message: 'The saved Codex thread could not be found, so it can no longer be resumed.',
|
||||
resumeSessionId: 'thread_stale_123',
|
||||
stderr: 'No conversation found for thread thread_stale_123',
|
||||
workingDirectory: '/Users/fake/projects/repo',
|
||||
});
|
||||
});
|
||||
|
||||
it('classifies CLI authentication failures as auth-required errors', () => {
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
} as any);
|
||||
|
||||
const payload = (ctr as any).getSessionErrorPayload(
|
||||
'Failed to authenticate. API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"}}',
|
||||
{
|
||||
agentType: 'claude-code',
|
||||
args: [],
|
||||
command: 'claude',
|
||||
sessionId: 'session-1',
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload).toEqual({
|
||||
agentType: 'claude-code',
|
||||
code: HeterogeneousAgentSessionErrorCode.AuthRequired,
|
||||
command: 'claude',
|
||||
docsUrl: 'https://docs.anthropic.com/en/docs/claude-code/setup',
|
||||
message:
|
||||
'Claude Code could not authenticate. Sign in again or refresh its credentials, then retry.',
|
||||
stderr:
|
||||
'Failed to authenticate. API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"}}',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,9 @@ vi.mock('electron', () => {
|
||||
},
|
||||
Notification: MockNotification,
|
||||
app: {
|
||||
dock: {
|
||||
bounce: vi.fn(),
|
||||
},
|
||||
setAppUserModelId: vi.fn(),
|
||||
},
|
||||
};
|
||||
@@ -48,6 +51,7 @@ vi.mock('electron-is', () => ({
|
||||
|
||||
// Mock browserManager
|
||||
const mockBrowserWindow = {
|
||||
flashFrame: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
isDestroyed: vi.fn(() => false),
|
||||
isFocused: vi.fn(() => true),
|
||||
@@ -181,6 +185,24 @@ describe('NotificationCtr', () => {
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should show notification when force is true even if window is visible and focused', async () => {
|
||||
const { Notification } = await import('electron');
|
||||
vi.mocked(Notification.isSupported).mockReturnValue(true);
|
||||
mockBrowserWindow.isVisible.mockReturnValue(true);
|
||||
mockBrowserWindow.isFocused.mockReturnValue(true);
|
||||
mockBrowserWindow.isMinimized.mockReturnValue(false);
|
||||
|
||||
const promise = controller.showDesktopNotification({
|
||||
...params,
|
||||
force: true,
|
||||
});
|
||||
vi.advanceTimersByTime(100);
|
||||
const result = await promise;
|
||||
|
||||
expect(Notification).toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should use low urgency on Linux to prevent GNOME Shell freeze', async () => {
|
||||
const { linux } = await import('electron-is');
|
||||
const { Notification } = await import('electron');
|
||||
@@ -252,6 +274,40 @@ describe('NotificationCtr', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should request window attention when requested and window is hidden', async () => {
|
||||
const { Notification } = await import('electron');
|
||||
vi.mocked(Notification.isSupported).mockReturnValue(true);
|
||||
mockBrowserWindow.isVisible.mockReturnValue(false);
|
||||
|
||||
const promise = controller.showDesktopNotification({
|
||||
...params,
|
||||
requestAttention: true,
|
||||
});
|
||||
vi.advanceTimersByTime(100);
|
||||
await promise;
|
||||
|
||||
expect(mockBrowserWindow.flashFrame).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should bounce dock on macOS when attention is requested', async () => {
|
||||
const { app, Notification } = await import('electron');
|
||||
const { macOS } = await import('electron-is');
|
||||
vi.mocked(macOS).mockReturnValue(true);
|
||||
vi.mocked(Notification.isSupported).mockReturnValue(true);
|
||||
mockBrowserWindow.isVisible.mockReturnValue(false);
|
||||
|
||||
const promise = controller.showDesktopNotification({
|
||||
...params,
|
||||
requestAttention: true,
|
||||
});
|
||||
vi.advanceTimersByTime(100);
|
||||
await promise;
|
||||
|
||||
expect(app.dock.bounce).toHaveBeenCalledWith('informational');
|
||||
|
||||
vi.mocked(macOS).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('should register click handler to show main window', async () => {
|
||||
const { Notification } = await import('electron');
|
||||
vi.mocked(Notification.isSupported).mockReturnValue(true);
|
||||
|
||||
@@ -16,6 +16,13 @@ const dialog = {
|
||||
'fullDiskAccess.openSettings': 'Open Settings',
|
||||
'fullDiskAccess.skip': 'Later',
|
||||
'fullDiskAccess.title': 'Full Disk Access Required',
|
||||
'screenCaptureAccess.cancel': 'Later',
|
||||
'screenCaptureAccess.detail':
|
||||
'Open System Settings, enable Screen Recording for LobeHub, then try Quick Composer again.',
|
||||
'screenCaptureAccess.message':
|
||||
'Quick Composer needs Screen Recording permission before it can capture screenshots.',
|
||||
'screenCaptureAccess.openSettings': 'Open Settings',
|
||||
'screenCaptureAccess.title': 'Screen Recording Permission Required',
|
||||
'update.downloadAndInstall': 'Download and Install',
|
||||
'update.downloadComplete': 'Download Complete',
|
||||
'update.downloadCompleteMessage': 'Update downloaded. Install now?',
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { CodexFileChangeTracker } from './codexFileChangeTracker';
|
||||
|
||||
describe('CodexFileChangeTracker', () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.map((dir) => rm(dir, { force: true, recursive: true })));
|
||||
tempDirs.length = 0;
|
||||
});
|
||||
|
||||
it('enriches completed file_change payloads with per-file and total line stats', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'codex-file-change-tracker-'));
|
||||
tempDirs.push(dir);
|
||||
|
||||
const updatePath = path.join(dir, 'a.txt');
|
||||
const addPath = path.join(dir, 'b.txt');
|
||||
|
||||
await writeFile(updatePath, 'hello\n', 'utf8');
|
||||
|
||||
const tracker = new CodexFileChangeTracker();
|
||||
|
||||
await tracker.track({
|
||||
item: {
|
||||
changes: [
|
||||
{ kind: 'update', path: updatePath },
|
||||
{ kind: 'add', path: addPath },
|
||||
],
|
||||
id: 'item_1',
|
||||
type: 'file_change',
|
||||
},
|
||||
type: 'item.started',
|
||||
});
|
||||
|
||||
await writeFile(updatePath, 'hello\nappended line\n', 'utf8');
|
||||
await writeFile(addPath, 'line one\nline two\n', 'utf8');
|
||||
|
||||
const enriched = await tracker.track({
|
||||
item: {
|
||||
changes: [
|
||||
{ kind: 'update', path: updatePath },
|
||||
{ kind: 'add', path: addPath },
|
||||
],
|
||||
id: 'item_1',
|
||||
type: 'file_change',
|
||||
},
|
||||
type: 'item.completed',
|
||||
});
|
||||
|
||||
expect(enriched.item).toMatchObject({
|
||||
changes: [
|
||||
{
|
||||
kind: 'update',
|
||||
linesAdded: 1,
|
||||
linesDeleted: 0,
|
||||
path: updatePath,
|
||||
},
|
||||
{
|
||||
kind: 'add',
|
||||
linesAdded: 2,
|
||||
linesDeleted: 0,
|
||||
path: addPath,
|
||||
},
|
||||
],
|
||||
linesAdded: 3,
|
||||
linesDeleted: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats rename changes as metadata-only and keeps line stats at zero', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'codex-file-change-tracker-'));
|
||||
tempDirs.push(dir);
|
||||
|
||||
const beforePath = path.join(dir, 'before.txt');
|
||||
const afterPath = path.join(dir, 'after.txt');
|
||||
|
||||
await writeFile(beforePath, 'content\n', 'utf8');
|
||||
|
||||
const tracker = new CodexFileChangeTracker();
|
||||
|
||||
await tracker.track({
|
||||
item: {
|
||||
changes: [{ kind: 'rename', path: afterPath }],
|
||||
id: 'item_rename',
|
||||
type: 'file_change',
|
||||
},
|
||||
type: 'item.started',
|
||||
});
|
||||
|
||||
await rename(beforePath, afterPath);
|
||||
|
||||
const enriched = await tracker.track({
|
||||
item: {
|
||||
changes: [{ kind: 'rename', path: afterPath }],
|
||||
id: 'item_rename',
|
||||
type: 'file_change',
|
||||
},
|
||||
type: 'item.completed',
|
||||
});
|
||||
|
||||
expect(enriched.item).toMatchObject({
|
||||
changes: [{ kind: 'rename', linesAdded: 0, linesDeleted: 0, path: afterPath }],
|
||||
linesAdded: 0,
|
||||
linesDeleted: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts added lines even when file content begins with repeated plus markers', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'codex-file-change-tracker-'));
|
||||
tempDirs.push(dir);
|
||||
|
||||
const addPath = path.join(dir, 'plus-prefixed.txt');
|
||||
const tracker = new CodexFileChangeTracker();
|
||||
|
||||
await tracker.track({
|
||||
item: {
|
||||
changes: [{ kind: 'add', path: addPath }],
|
||||
id: 'item_plus_prefix',
|
||||
type: 'file_change',
|
||||
},
|
||||
type: 'item.started',
|
||||
});
|
||||
|
||||
await writeFile(addPath, '++leading content\n+++header lookalike\n', 'utf8');
|
||||
|
||||
const enriched = await tracker.track({
|
||||
item: {
|
||||
changes: [{ kind: 'add', path: addPath }],
|
||||
id: 'item_plus_prefix',
|
||||
type: 'file_change',
|
||||
},
|
||||
type: 'item.completed',
|
||||
});
|
||||
|
||||
expect(enriched.item).toMatchObject({
|
||||
changes: [{ kind: 'add', linesAdded: 2, linesDeleted: 0, path: addPath }],
|
||||
linesAdded: 2,
|
||||
linesDeleted: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { access, readFile } from 'node:fs/promises';
|
||||
|
||||
import { createPatch } from 'diff';
|
||||
|
||||
interface CodexFileChangeEntry {
|
||||
kind?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface CodexFileChangeSnapshot {
|
||||
content?: string;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
interface CodexFileChangeItem {
|
||||
changes?: CodexFileChangeEntry[];
|
||||
id?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface CodexFileChangePayload {
|
||||
item?: CodexFileChangeItem;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface CodexFileChangeLineStats {
|
||||
linesAdded: number;
|
||||
linesDeleted: number;
|
||||
}
|
||||
|
||||
interface CodexTrackedFileChangeEntry extends CodexFileChangeEntry, CodexFileChangeLineStats {}
|
||||
|
||||
interface CodexTrackedFileChangeItem extends CodexFileChangeItem, CodexFileChangeLineStats {
|
||||
changes?: CodexTrackedFileChangeEntry[];
|
||||
}
|
||||
|
||||
const isCodexFileChangePayload = (
|
||||
payload: CodexFileChangePayload,
|
||||
): payload is Required<CodexFileChangePayload> =>
|
||||
payload?.item?.type === 'file_change' && !!payload.item.id;
|
||||
|
||||
const readTextFileSnapshot = async (filePath: string): Promise<CodexFileChangeSnapshot> => {
|
||||
try {
|
||||
await access(filePath);
|
||||
} catch {
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
content: await readFile(filePath, 'utf8'),
|
||||
exists: true,
|
||||
};
|
||||
} catch {
|
||||
return { exists: true };
|
||||
}
|
||||
};
|
||||
|
||||
const countPatchLines = (
|
||||
previousContent: string,
|
||||
nextContent: string,
|
||||
): CodexFileChangeLineStats => {
|
||||
if (previousContent === nextContent) return { linesAdded: 0, linesDeleted: 0 };
|
||||
|
||||
const patch = createPatch('codex-file-change', previousContent, nextContent, '', '');
|
||||
let insideHunk = false;
|
||||
let linesAdded = 0;
|
||||
let linesDeleted = 0;
|
||||
|
||||
for (const line of patch.split('\n')) {
|
||||
if (line.startsWith('@@')) {
|
||||
insideHunk = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!insideHunk) continue;
|
||||
|
||||
if (line.startsWith('+')) {
|
||||
linesAdded += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('-')) {
|
||||
linesDeleted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { linesAdded, linesDeleted };
|
||||
};
|
||||
|
||||
const computeLineStats = async (
|
||||
change: CodexFileChangeEntry,
|
||||
snapshot?: CodexFileChangeSnapshot,
|
||||
): Promise<CodexFileChangeLineStats> => {
|
||||
const filePath = change.path;
|
||||
if (!filePath) return { linesAdded: 0, linesDeleted: 0 };
|
||||
|
||||
const kind = change.kind ?? 'update';
|
||||
if (kind === 'rename') return { linesAdded: 0, linesDeleted: 0 };
|
||||
|
||||
const previousContent = snapshot?.content ?? '';
|
||||
const current = await readTextFileSnapshot(filePath);
|
||||
const nextContent = current.content ?? '';
|
||||
|
||||
if (kind === 'add') {
|
||||
if (!current.exists) return { linesAdded: 0, linesDeleted: 0 };
|
||||
return countPatchLines('', nextContent);
|
||||
}
|
||||
|
||||
if (kind === 'delete' || kind === 'remove') {
|
||||
if (!snapshot?.exists) return { linesAdded: 0, linesDeleted: 0 };
|
||||
return countPatchLines(previousContent, '');
|
||||
}
|
||||
|
||||
if (!snapshot?.exists && !current.exists) return { linesAdded: 0, linesDeleted: 0 };
|
||||
|
||||
return countPatchLines(previousContent, nextContent);
|
||||
};
|
||||
|
||||
export class CodexFileChangeTracker {
|
||||
private snapshots = new Map<string, Map<string, CodexFileChangeSnapshot>>();
|
||||
|
||||
async track<T extends CodexFileChangePayload>(payload: T): Promise<T> {
|
||||
if (!isCodexFileChangePayload(payload)) return payload;
|
||||
|
||||
const itemId = payload.item.id;
|
||||
const changes = payload.item.changes ?? [];
|
||||
|
||||
if (payload.type === 'item.started') {
|
||||
const snapshots = new Map<string, CodexFileChangeSnapshot>();
|
||||
|
||||
await Promise.all(
|
||||
changes.map(async (change) => {
|
||||
if (!change.path || snapshots.has(change.path)) return;
|
||||
snapshots.set(change.path, await readTextFileSnapshot(change.path));
|
||||
}),
|
||||
);
|
||||
|
||||
this.snapshots.set(itemId, snapshots);
|
||||
return payload;
|
||||
}
|
||||
|
||||
if (payload.type !== 'item.completed') return payload;
|
||||
|
||||
const snapshots = this.snapshots.get(itemId);
|
||||
this.snapshots.delete(itemId);
|
||||
|
||||
if (!snapshots) return payload;
|
||||
|
||||
const trackedChanges = await Promise.all(
|
||||
changes.map(async (change) => {
|
||||
const stats = await computeLineStats(
|
||||
change,
|
||||
change.path ? snapshots.get(change.path) : undefined,
|
||||
);
|
||||
|
||||
return {
|
||||
...change,
|
||||
...stats,
|
||||
} satisfies CodexTrackedFileChangeEntry;
|
||||
}),
|
||||
);
|
||||
|
||||
const totals = trackedChanges.reduce<CodexFileChangeLineStats>(
|
||||
(acc, change) => ({
|
||||
linesAdded: acc.linesAdded + change.linesAdded,
|
||||
linesDeleted: acc.linesDeleted + change.linesDeleted,
|
||||
}),
|
||||
{ linesAdded: 0, linesDeleted: 0 },
|
||||
);
|
||||
|
||||
return {
|
||||
...payload,
|
||||
item: {
|
||||
...payload.item,
|
||||
...totals,
|
||||
changes: trackedChanges,
|
||||
} satisfies CodexTrackedFileChangeItem,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { JsonlStreamProcessor } from '../jsonlProcessor';
|
||||
import type { HeterogeneousAgentBuildPlanParams, HeterogeneousAgentDriver } from '../types';
|
||||
|
||||
const CLAUDE_CODE_BASE_ARGS = [
|
||||
'-p',
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--include-partial-messages',
|
||||
'--permission-mode',
|
||||
'bypassPermissions',
|
||||
] as const;
|
||||
|
||||
export const claudeCodeDriver: HeterogeneousAgentDriver = {
|
||||
async buildSpawnPlan({
|
||||
args,
|
||||
helpers,
|
||||
imageList,
|
||||
prompt,
|
||||
resumeSessionId,
|
||||
}: HeterogeneousAgentBuildPlanParams) {
|
||||
const stdinPayload = await helpers.buildClaudeStreamJsonInput(prompt, imageList);
|
||||
|
||||
return {
|
||||
args: [
|
||||
...CLAUDE_CODE_BASE_ARGS,
|
||||
...(resumeSessionId ? ['--resume', resumeSessionId] : []),
|
||||
...args,
|
||||
],
|
||||
stdinPayload,
|
||||
};
|
||||
},
|
||||
createStreamProcessor() {
|
||||
return new JsonlStreamProcessor({
|
||||
extractSessionId: (payload) =>
|
||||
payload?.type === 'system' && payload?.subtype === 'init' ? payload?.session_id : undefined,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { JsonlStreamProcessor } from '../jsonlProcessor';
|
||||
import type { HeterogeneousAgentBuildPlanParams, HeterogeneousAgentDriver } from '../types';
|
||||
|
||||
const CODEX_REQUIRED_ARGS = ['--json', '--skip-git-repo-check'] as const;
|
||||
const CODEX_AUTO_EXECUTION_FLAGS = [
|
||||
'--full-auto',
|
||||
'--dangerously-bypass-approvals-and-sandbox',
|
||||
'--sandbox',
|
||||
'-s',
|
||||
] as const;
|
||||
|
||||
const hasAnyFlag = (args: string[], flags: readonly string[]) =>
|
||||
args.some((arg) => flags.includes(arg as (typeof flags)[number]));
|
||||
|
||||
const buildCodexOptionArgs = async ({
|
||||
args,
|
||||
helpers,
|
||||
imageList,
|
||||
}: Pick<HeterogeneousAgentBuildPlanParams, 'args' | 'helpers' | 'imageList'>) => {
|
||||
const imagePaths = await helpers.resolveCliImagePaths(imageList);
|
||||
const imageArgs = imagePaths.flatMap((filePath) => ['--image', filePath]);
|
||||
const autoExecutionArgs = hasAnyFlag(args, CODEX_AUTO_EXECUTION_FLAGS) ? [] : ['--full-auto'];
|
||||
|
||||
return [...CODEX_REQUIRED_ARGS, ...autoExecutionArgs, ...imageArgs, ...args];
|
||||
};
|
||||
|
||||
export const codexDriver: HeterogeneousAgentDriver = {
|
||||
async buildSpawnPlan({
|
||||
args,
|
||||
helpers,
|
||||
imageList,
|
||||
prompt,
|
||||
resumeSessionId,
|
||||
}: HeterogeneousAgentBuildPlanParams) {
|
||||
const optionArgs = await buildCodexOptionArgs({ args, helpers, imageList });
|
||||
|
||||
return {
|
||||
args: resumeSessionId
|
||||
? ['exec', 'resume', ...optionArgs, resumeSessionId, '-']
|
||||
: ['exec', ...optionArgs, '-'],
|
||||
stdinPayload: prompt,
|
||||
};
|
||||
},
|
||||
createStreamProcessor() {
|
||||
return new JsonlStreamProcessor({
|
||||
extractSessionId: (payload) =>
|
||||
payload?.type === 'thread.started' ? payload?.thread_id : undefined,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { claudeCodeDriver } from './drivers/claudeCode';
|
||||
import { codexDriver } from './drivers/codex';
|
||||
import type { HeterogeneousAgentDriver } from './types';
|
||||
|
||||
const heterogeneousAgentDrivers: Record<string, HeterogeneousAgentDriver> = {
|
||||
'claude-code': claudeCodeDriver,
|
||||
'codex': codexDriver,
|
||||
};
|
||||
|
||||
export const getHeterogeneousAgentDriver = (agentType: string): HeterogeneousAgentDriver => {
|
||||
const driver = heterogeneousAgentDrivers[agentType];
|
||||
|
||||
if (!driver) {
|
||||
throw new Error(`Unknown heterogeneous agent type: ${agentType}`);
|
||||
}
|
||||
|
||||
return driver;
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { HeterogeneousAgentParsedOutput, HeterogeneousAgentStreamProcessor } from './types';
|
||||
|
||||
export interface JsonlProcessorOptions {
|
||||
extractSessionId?: (payload: any) => string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses stdout as JSONL / NDJSON while tolerating non-JSON noise lines.
|
||||
* Different CLIs still end up sharing this framing logic even when the
|
||||
* payload schema differs.
|
||||
*/
|
||||
export class JsonlStreamProcessor implements HeterogeneousAgentStreamProcessor {
|
||||
private buffer = '';
|
||||
|
||||
constructor(private readonly options: JsonlProcessorOptions = {}) {}
|
||||
|
||||
push(chunk: Buffer | string): HeterogeneousAgentParsedOutput[] {
|
||||
this.buffer += chunk instanceof Buffer ? chunk.toString('utf8') : chunk;
|
||||
return this.drainCompleteLines();
|
||||
}
|
||||
|
||||
flush(): HeterogeneousAgentParsedOutput[] {
|
||||
const trailing = this.buffer.trim();
|
||||
this.buffer = '';
|
||||
|
||||
if (!trailing) return [];
|
||||
|
||||
try {
|
||||
return [this.toParsedOutput(JSON.parse(trailing))];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private drainCompleteLines(): HeterogeneousAgentParsedOutput[] {
|
||||
const lines = this.buffer.split('\n');
|
||||
this.buffer = lines.pop() || '';
|
||||
|
||||
const parsed: HeterogeneousAgentParsedOutput[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
try {
|
||||
parsed.push(this.toParsedOutput(JSON.parse(trimmed)));
|
||||
} catch {
|
||||
// Ignore non-JSON stdout noise.
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private toParsedOutput(payload: any): HeterogeneousAgentParsedOutput {
|
||||
return {
|
||||
agentSessionId: this.options.extractSessionId?.(payload),
|
||||
payload,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export interface HeterogeneousAgentImageAttachment {
|
||||
id: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface HeterogeneousAgentBuildPlan {
|
||||
args: string[];
|
||||
stdinPayload?: string;
|
||||
}
|
||||
|
||||
export interface HeterogeneousAgentBuildPlanHelpers {
|
||||
buildClaudeStreamJsonInput: (
|
||||
prompt: string,
|
||||
imageList: HeterogeneousAgentImageAttachment[],
|
||||
) => Promise<string>;
|
||||
resolveCliImagePaths: (imageList: HeterogeneousAgentImageAttachment[]) => Promise<string[]>;
|
||||
}
|
||||
|
||||
export interface HeterogeneousAgentBuildPlanParams {
|
||||
args: string[];
|
||||
helpers: HeterogeneousAgentBuildPlanHelpers;
|
||||
imageList: HeterogeneousAgentImageAttachment[];
|
||||
prompt: string;
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
|
||||
export interface HeterogeneousAgentParsedOutput {
|
||||
agentSessionId?: string;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
export interface HeterogeneousAgentStreamProcessor {
|
||||
flush: () => HeterogeneousAgentParsedOutput[];
|
||||
push: (chunk: Buffer | string) => HeterogeneousAgentParsedOutput[];
|
||||
}
|
||||
|
||||
export interface HeterogeneousAgentDriver {
|
||||
buildSpawnPlan: (
|
||||
params: HeterogeneousAgentBuildPlanParams,
|
||||
) => Promise<HeterogeneousAgentBuildPlan>;
|
||||
createStreamProcessor: () => HeterogeneousAgentStreamProcessor;
|
||||
}
|
||||
@@ -5,11 +5,14 @@ import { ScreenCaptureManager } from './ScreenCaptureManager';
|
||||
const {
|
||||
mockBrowserWindow,
|
||||
MockBrowserWindow,
|
||||
mockDialogShowMessageBox,
|
||||
mockScreen,
|
||||
mockEnumerateWindows,
|
||||
mockIsMac,
|
||||
mockCaptureWindow,
|
||||
mockCaptureRect,
|
||||
mockGetScreenCaptureStatus,
|
||||
mockRequestScreenCaptureAccess,
|
||||
} = vi.hoisted(() => {
|
||||
const mockBrowserWindow = {
|
||||
destroy: vi.fn(),
|
||||
@@ -37,8 +40,11 @@ const {
|
||||
MockBrowserWindow: vi.fn(() => mockBrowserWindow),
|
||||
mockCaptureRect: vi.fn(),
|
||||
mockCaptureWindow: vi.fn(),
|
||||
mockDialogShowMessageBox: vi.fn(async () => ({ response: 0 })),
|
||||
mockEnumerateWindows: vi.fn().mockResolvedValue([]),
|
||||
mockGetScreenCaptureStatus: vi.fn(() => 'granted'),
|
||||
mockIsMac: { value: true },
|
||||
mockRequestScreenCaptureAccess: vi.fn(async () => false),
|
||||
mockScreen: {
|
||||
getCursorScreenPoint: vi.fn(() => ({ x: 10, y: 10 })),
|
||||
getDisplayNearestPoint: vi.fn(() => ({
|
||||
@@ -52,6 +58,9 @@ const {
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: MockBrowserWindow,
|
||||
dialog: {
|
||||
showMessageBox: mockDialogShowMessageBox,
|
||||
},
|
||||
screen: mockScreen,
|
||||
}));
|
||||
|
||||
@@ -74,6 +83,11 @@ vi.mock('@/utils/logger', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/permissions', () => ({
|
||||
getScreenCaptureStatus: mockGetScreenCaptureStatus,
|
||||
requestScreenCaptureAccess: mockRequestScreenCaptureAccess,
|
||||
}));
|
||||
|
||||
vi.mock('./WindowSourceService', () => ({
|
||||
enumerateWindows: mockEnumerateWindows,
|
||||
}));
|
||||
@@ -84,21 +98,36 @@ vi.mock('./CaptureService', () => ({
|
||||
}));
|
||||
|
||||
describe('ScreenCaptureManager', () => {
|
||||
const createApp = () =>
|
||||
({
|
||||
const createApp = ({ mainWindowVisible = true }: { mainWindowVisible?: boolean } = {}) => {
|
||||
const mainWindow = {
|
||||
browserWindow: {
|
||||
id: 1,
|
||||
isVisible: vi.fn(() => mainWindowVisible),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
browserManager: {
|
||||
broadcastToAllWindows: vi.fn(),
|
||||
broadcastToWindow: vi.fn(),
|
||||
getMainWindow: vi.fn(() => mainWindow),
|
||||
showMainWindow: vi.fn(),
|
||||
},
|
||||
buildRendererUrl: vi.fn().mockResolvedValue('http://localhost:5173/overlay'),
|
||||
}) as any;
|
||||
i18n: {
|
||||
ns: vi.fn(() => (key: string) => key),
|
||||
},
|
||||
} as any;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockBrowserWindow.isDestroyed.mockReturnValue(false);
|
||||
mockDialogShowMessageBox.mockResolvedValue({ response: 0 });
|
||||
mockEnumerateWindows.mockResolvedValue([]);
|
||||
mockGetScreenCaptureStatus.mockReturnValue('granted');
|
||||
mockIsMac.value = true;
|
||||
mockRequestScreenCaptureAccess.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it('keeps the app in regular mode when showing overlay on macOS', async () => {
|
||||
@@ -122,6 +151,56 @@ describe('ScreenCaptureManager', () => {
|
||||
expect(mockBrowserWindow.moveTop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks quick composer and prompts for permission when screen recording is unavailable', async () => {
|
||||
mockGetScreenCaptureStatus.mockReturnValue('denied');
|
||||
mockDialogShowMessageBox.mockResolvedValue({ response: 0 });
|
||||
const app = createApp();
|
||||
const manager = new ScreenCaptureManager(app);
|
||||
|
||||
await manager.startSession();
|
||||
|
||||
expect(mockDialogShowMessageBox).toHaveBeenCalledWith(
|
||||
app.browserManager.getMainWindow().browserWindow,
|
||||
expect.objectContaining({
|
||||
message: 'screenCaptureAccess.message',
|
||||
title: 'screenCaptureAccess.title',
|
||||
}),
|
||||
);
|
||||
expect(mockRequestScreenCaptureAccess).toHaveBeenCalled();
|
||||
expect(mockEnumerateWindows).not.toHaveBeenCalled();
|
||||
expect(MockBrowserWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not open settings when permission prompt is dismissed', async () => {
|
||||
mockGetScreenCaptureStatus.mockReturnValue('denied');
|
||||
mockDialogShowMessageBox.mockResolvedValue({ response: 1 });
|
||||
const manager = new ScreenCaptureManager(createApp());
|
||||
|
||||
await manager.startSession();
|
||||
|
||||
expect(mockRequestScreenCaptureAccess).not.toHaveBeenCalled();
|
||||
expect(mockEnumerateWindows).not.toHaveBeenCalled();
|
||||
expect(MockBrowserWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an app-modal prompt when the main window is hidden', async () => {
|
||||
mockGetScreenCaptureStatus.mockReturnValue('denied');
|
||||
const manager = new ScreenCaptureManager(createApp({ mainWindowVisible: false }));
|
||||
|
||||
await manager.startSession();
|
||||
|
||||
expect(mockDialogShowMessageBox).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'screenCaptureAccess.message',
|
||||
title: 'screenCaptureAccess.title',
|
||||
}),
|
||||
);
|
||||
expect(mockDialogShowMessageBox).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 1 }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
describe('preview handlers', () => {
|
||||
it('hides overlay via opacity while capturing rect and restores after', async () => {
|
||||
const app = createApp();
|
||||
|
||||
@@ -11,13 +11,14 @@ import type {
|
||||
ScreenCaptureSession,
|
||||
ScreenCaptureSubmitParams,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { BrowserWindow, screen } from 'electron';
|
||||
import { BrowserWindow, dialog, screen } from 'electron';
|
||||
|
||||
import { BrowsersIdentifiers } from '@/appBrowsers';
|
||||
import { preloadDir } from '@/const/dir';
|
||||
import { isMac } from '@/const/env';
|
||||
import type { App } from '@/core/App';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
import { getScreenCaptureStatus, requestScreenCaptureAccess } from '@/utils/permissions';
|
||||
|
||||
import { captureRect, captureWindow } from './CaptureService';
|
||||
import { enumerateWindows } from './WindowSourceService';
|
||||
@@ -77,6 +78,10 @@ export class ScreenCaptureManager {
|
||||
}
|
||||
|
||||
async startSession(): Promise<void> {
|
||||
if (!(await this.ensureScreenCaptureAccess())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isActive) {
|
||||
logger.warn('Capture session already active');
|
||||
this.close();
|
||||
@@ -267,6 +272,45 @@ export class ScreenCaptureManager {
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureScreenCaptureAccess(): Promise<boolean> {
|
||||
if (!isMac) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const status = getScreenCaptureStatus();
|
||||
if (status === 'granted') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const t = this.app.i18n.ns('dialog');
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
const parentWindow = mainWindow?.browserWindow?.isVisible?.() ? mainWindow.browserWindow : null;
|
||||
const options = {
|
||||
buttons: [t('screenCaptureAccess.openSettings'), t('screenCaptureAccess.cancel')],
|
||||
cancelId: 1,
|
||||
defaultId: 0,
|
||||
detail: t('screenCaptureAccess.detail'),
|
||||
message: t('screenCaptureAccess.message'),
|
||||
noLink: true,
|
||||
title: t('screenCaptureAccess.title'),
|
||||
type: 'warning' as const,
|
||||
};
|
||||
|
||||
const result = parentWindow
|
||||
? await dialog.showMessageBox(parentWindow, options)
|
||||
: await dialog.showMessageBox(options);
|
||||
|
||||
if (result.response !== 0) {
|
||||
logger.info(`Screen capture permission prompt dismissed; status=${status}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info(`Opening screen capture permission settings; status=${status}`);
|
||||
await requestScreenCaptureAccess();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async createOverlayWindow(bounds: Electron.Rectangle): Promise<void> {
|
||||
const win = new BrowserWindow({
|
||||
...(isMac ? { type: 'panel' } : {}),
|
||||
|
||||
@@ -1,58 +1,107 @@
|
||||
import { exec } from 'node:child_process';
|
||||
import { execFile } 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);
|
||||
const execFilePromise = promisify(execFile);
|
||||
|
||||
type HeterogeneousCliAgentType = 'claude-code' | 'codex';
|
||||
|
||||
interface ValidatedDetectorOptions {
|
||||
description: string;
|
||||
name: string;
|
||||
priority: number;
|
||||
validateFlag?: string;
|
||||
validateKeywords: string[];
|
||||
}
|
||||
|
||||
const resolveCommandPath = async (command: string): Promise<string | undefined> => {
|
||||
const trimmedCommand = command.trim();
|
||||
if (!trimmedCommand) return;
|
||||
|
||||
const whichCommand = platform() === 'win32' ? 'where' : 'which';
|
||||
|
||||
try {
|
||||
const { stdout } = await execFilePromise(whichCommand, [trimmedCommand], { timeout: 3000 });
|
||||
return stdout.trim().split(/\r?\n/)[0] || trimmedCommand;
|
||||
} catch {
|
||||
return trimmedCommand;
|
||||
}
|
||||
};
|
||||
|
||||
const detectValidatedCommand = async (
|
||||
command: string,
|
||||
options: Pick<ValidatedDetectorOptions, 'validateFlag' | 'validateKeywords'>,
|
||||
): Promise<ToolStatus> => {
|
||||
const trimmedCommand = command.trim();
|
||||
if (!trimmedCommand) return { available: false };
|
||||
|
||||
const { validateFlag = '--version', validateKeywords } = options;
|
||||
|
||||
try {
|
||||
const { stderr, stdout } = await execFilePromise(trimmedCommand, [validateFlag], {
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
});
|
||||
const output = `${stdout}\n${stderr}`.trim();
|
||||
const loweredOutput = output.toLowerCase();
|
||||
|
||||
if (!validateKeywords.some((keyword) => loweredOutput.includes(keyword.toLowerCase()))) {
|
||||
return { available: false };
|
||||
}
|
||||
|
||||
return {
|
||||
available: true,
|
||||
path: await resolveCommandPath(trimmedCommand),
|
||||
version: output.split(/\r?\n/)[0],
|
||||
};
|
||||
} catch {
|
||||
return { available: false };
|
||||
}
|
||||
};
|
||||
|
||||
const HETEROGENEOUS_CLI_AGENT_OPTIONS = {
|
||||
'claude-code': {
|
||||
validateKeywords: ['claude code'],
|
||||
},
|
||||
'codex': {
|
||||
validateKeywords: ['codex'],
|
||||
},
|
||||
} as const satisfies Record<
|
||||
HeterogeneousCliAgentType,
|
||||
Pick<ValidatedDetectorOptions, 'validateKeywords'>
|
||||
>;
|
||||
|
||||
export const detectHeterogeneousCliCommand = async (
|
||||
agentType: HeterogeneousCliAgentType,
|
||||
command: string,
|
||||
): Promise<ToolStatus> => {
|
||||
const validator = HETEROGENEOUS_CLI_AGENT_OPTIONS[agentType];
|
||||
if (!validator) return { available: false };
|
||||
|
||||
return detectValidatedCommand(command, validator);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
const createValidatedDetector = (
|
||||
options: ValidatedDetectorOptions & {
|
||||
candidates: string[];
|
||||
},
|
||||
): IToolDetector => {
|
||||
const { candidates, description, name, priority, ...validation } = 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;
|
||||
}
|
||||
const status = await detectValidatedCommand(cmd, validation);
|
||||
if (status.available) return status;
|
||||
}
|
||||
|
||||
return { available: false };
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
export { browserAutomationDetectors } from './agentBrowserDetectors';
|
||||
export { cliAgentDetectors } from './cliAgentDetectors';
|
||||
export { cliAgentDetectors, detectHeterogeneousCliCommand } from './cliAgentDetectors';
|
||||
export { astSearchDetectors, contentSearchDetectors } from './contentSearchDetectors';
|
||||
export { fileSearchDetectors } from './fileSearchDetectors';
|
||||
export { runtimeEnvironmentDetectors } from './runtimeEnvironmentDetectors';
|
||||
|
||||
@@ -252,7 +252,7 @@ const ChatPanel = memo<ChatPanelProps>(
|
||||
}, [theme]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (selected && !hidden && textareaRef.current) {
|
||||
if (!hidden && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, [hidden, selected]);
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import ChatPanel, { type ChatPanelSelection, type ChatPanelSubmitPayload } from './ChatPanel';
|
||||
import { OVERLAY_COPY, OVERLAY_LAYOUT } from './constants';
|
||||
import { OVERLAY_COPY, OVERLAY_LAYOUT, OVERLAY_SHORTCUTS } from './constants';
|
||||
import * as styles from './overlay.css.ts';
|
||||
import { resolveCommittedSelectionRect, shouldHideChatPanel } from './overlaySelectionState';
|
||||
import { useDragSelection } from './useDragSelection';
|
||||
@@ -375,6 +375,7 @@ const ScreenCaptureOverlay = memo(() => {
|
||||
});
|
||||
const showHover = hoveredWindow && !hasSelections && !isDragging && !committedSelectionRect;
|
||||
const showDrag = isDragging && dragRect;
|
||||
const showHint = !hasSelections && !isDragging && !pendingSelectionRect;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -446,6 +447,22 @@ const ScreenCaptureOverlay = memo(() => {
|
||||
onRemoveSelection={removeSelection}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
|
||||
{showHint && (
|
||||
<div className={styles.hintPill} role="note">
|
||||
<span className={styles.hintPillKey}>{OVERLAY_COPY.hintHoverTrigger}</span>
|
||||
<span className={styles.hintPillDivider}>•</span>
|
||||
<span className={styles.hintPillLabel}>{OVERLAY_COPY.hintSelectWindow}</span>
|
||||
<span className={styles.hintPillGroupDivider} />
|
||||
<span className={styles.hintPillKey}>{OVERLAY_COPY.hintDragTrigger}</span>
|
||||
<span className={styles.hintPillDivider}>•</span>
|
||||
<span className={styles.hintPillLabel}>{OVERLAY_COPY.hintDragRegion}</span>
|
||||
<span className={styles.hintPillGroupDivider} />
|
||||
<span className={styles.hintPillKey}>{OVERLAY_SHORTCUTS.close}</span>
|
||||
<span className={styles.hintPillDivider}>•</span>
|
||||
<span className={styles.hintPillLabel}>{OVERLAY_COPY.hintExit}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,11 @@ export const OVERLAY_COPY = {
|
||||
clearSelectionLabel: 'Clear selection',
|
||||
closeLabel: 'Close',
|
||||
customRegionLabel: 'Custom region',
|
||||
hintDragRegion: 'Capture a region',
|
||||
hintDragTrigger: 'Drag',
|
||||
hintExit: 'Exit',
|
||||
hintHoverTrigger: 'Hover',
|
||||
hintSelectWindow: 'Select active window',
|
||||
idlePlaceholder: 'Select a window or drag a region to start asking…',
|
||||
latestSelectionLabel: 'Latest',
|
||||
modelSelectLabel: 'Model',
|
||||
|
||||
@@ -15,8 +15,7 @@ const overlayTheme = {
|
||||
tagText: 'rgba(248, 250, 252, 0.96)',
|
||||
},
|
||||
font: {
|
||||
system:
|
||||
"'SF Pro Display', 'SF Pro Text', 'Segoe UI Variable Text', 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
system: 'system-ui, sans-serif',
|
||||
},
|
||||
radius: {
|
||||
highlight: '14px',
|
||||
@@ -160,3 +159,47 @@ export const selection = style([
|
||||
boxShadow: vars.shadow.selection,
|
||||
},
|
||||
]);
|
||||
|
||||
export const hintPill = style([
|
||||
windowTag,
|
||||
{
|
||||
bottom: 24,
|
||||
left: 24,
|
||||
minHeight: 28,
|
||||
padding: '4px 14px',
|
||||
position: 'fixed',
|
||||
userSelect: 'none',
|
||||
zIndex: 20,
|
||||
},
|
||||
]);
|
||||
|
||||
export const hintPillKey = style({
|
||||
color: vars.color.tagText,
|
||||
flex: 'none',
|
||||
fontSize: 12,
|
||||
fontWeight: 650,
|
||||
letterSpacing: '0.01em',
|
||||
});
|
||||
|
||||
export const hintPillDivider = style({
|
||||
color: vars.color.tagDivider,
|
||||
flex: 'none',
|
||||
fontSize: 10,
|
||||
});
|
||||
|
||||
export const hintPillLabel = style({
|
||||
color: vars.color.tagMuted,
|
||||
flex: 'none',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
export const hintPillGroupDivider = style({
|
||||
background: vars.color.tagDivider,
|
||||
flex: 'none',
|
||||
height: 12,
|
||||
margin: '0 4px',
|
||||
opacity: 0.4,
|
||||
width: 1,
|
||||
});
|
||||
|
||||
@@ -36,7 +36,47 @@
|
||||
"builtinCopilot": "Built-in Copilot",
|
||||
"chatList.expandMessage": "Expand Message",
|
||||
"chatList.longMessageDetail": "View Details",
|
||||
"claudeCodeInstallGuide.actions.openDocs": "Open Install Guide",
|
||||
"claudeCodeInstallGuide.actions.openSystemTools": "Open System Tools",
|
||||
"claudeCodeInstallGuide.afterInstall": "After installing, run Claude Code once to sign in, then retry your message or click Re-detect in System Tools.",
|
||||
"claudeCodeInstallGuide.desc": "Claude Code needs the Claude Code CLI to run locally. Install it and make sure the `claude` command is available in your PATH.",
|
||||
"claudeCodeInstallGuide.installWithBrew": "Homebrew",
|
||||
"claudeCodeInstallGuide.installWithNpm": "Recommended install",
|
||||
"claudeCodeInstallGuide.menuNotification.title": "Claude Code CLI not found",
|
||||
"claudeCodeInstallGuide.reason": "LobeHub could not start Claude Code: {{message}}",
|
||||
"claudeCodeInstallGuide.title": "Install Claude Code CLI",
|
||||
"clearCurrentMessages": "Clear current session messages",
|
||||
"cliAuthGuide.actions.openDocs": "Open Sign-in Guide",
|
||||
"cliAuthGuide.actions.openSystemTools": "Open System Tools",
|
||||
"cliAuthGuide.afterLogin": "After signing in again or refreshing credentials, retry your message. You can also re-detect in System Tools.",
|
||||
"cliAuthGuide.desc": "{{name}} could not continue because its sign-in session expired or the credentials are invalid.",
|
||||
"cliAuthGuide.errorDetails": "Error details",
|
||||
"cliAuthGuide.runCommand": "Run this in Terminal",
|
||||
"cliAuthGuide.title": "Sign in to {{name}}",
|
||||
"cliRateLimitGuide.actions.openSystemTools": "Open System Tools",
|
||||
"cliRateLimitGuide.afterReset": "Wait until the reset time, then retry your message. If you are using API authorization, you can also check your provider quota and billing status.",
|
||||
"cliRateLimitGuide.desc": "{{name}} has reached its current usage limit and cannot continue this run right now.",
|
||||
"cliRateLimitGuide.limitType": "Limit window",
|
||||
"cliRateLimitGuide.limitTypes.weekCycle": "Week cycle",
|
||||
"cliRateLimitGuide.relative.day_one": "{{count}} day",
|
||||
"cliRateLimitGuide.relative.day_other": "{{count}} days",
|
||||
"cliRateLimitGuide.relative.hour_one": "{{count}} hour",
|
||||
"cliRateLimitGuide.relative.hour_other": "{{count}} hours",
|
||||
"cliRateLimitGuide.relative.minute_one": "{{count}} minute",
|
||||
"cliRateLimitGuide.relative.minute_other": "{{count}} minutes",
|
||||
"cliRateLimitGuide.relative.soon": "Resets soon",
|
||||
"cliRateLimitGuide.resetAt": "Resets at",
|
||||
"cliRateLimitGuide.resetInApprox": "Resets in about {{duration}}",
|
||||
"cliRateLimitGuide.title": "{{name}} usage limit reached",
|
||||
"codexInstallGuide.actions.openDocs": "Open Install Guide",
|
||||
"codexInstallGuide.actions.openSystemTools": "Open System Tools",
|
||||
"codexInstallGuide.afterInstall": "After installing, run Codex once to sign in, then retry your message or click Re-detect in System Tools.",
|
||||
"codexInstallGuide.desc": "Codex Agent needs the Codex CLI to run locally. Install it and make sure the `codex` command is available in your PATH.",
|
||||
"codexInstallGuide.installWithBrew": "Homebrew (macOS)",
|
||||
"codexInstallGuide.installWithNpm": "Recommended install",
|
||||
"codexInstallGuide.menuNotification.title": "Codex CLI not found",
|
||||
"codexInstallGuide.reason": "LobeHub could not start Codex: {{message}}",
|
||||
"codexInstallGuide.title": "Install Codex CLI",
|
||||
"compressedHistory": "Compressed History",
|
||||
"compression.cancel": "Uncompress",
|
||||
"compression.cancelConfirm": "Are you sure you want to uncompress? This will restore the original messages.",
|
||||
@@ -65,6 +105,8 @@
|
||||
"defaultSession": "Default Agent",
|
||||
"desktopNotification.aiReplyCompleted.body": "Agent reply is ready",
|
||||
"desktopNotification.aiReplyCompleted.title": "Reply completed",
|
||||
"desktopNotification.humanApprovalRequired.body": "An Agent needs your approval to continue",
|
||||
"desktopNotification.humanApprovalRequired.title": "Approval required",
|
||||
"dm.placeholder": "Your private messages with {{agentTitle}} will appear here.",
|
||||
"dm.tooltip": "Send a private message",
|
||||
"dm.visibleTo": "Visible to {{target}} only",
|
||||
@@ -139,6 +181,7 @@
|
||||
"heteroAgent.fullAccess.label": "Full access",
|
||||
"heteroAgent.fullAccess.tooltip": "Claude Code runs locally with full read/write access to the working directory. Switching permission modes is not available yet.",
|
||||
"heteroAgent.resumeReset.cwdChanged": "Working directory changed. Previous Claude Code session can only be resumed from its original directory, so a new conversation has started.",
|
||||
"heteroAgent.resumeReset.resumeFailed": "The saved Codex thread could not be resumed safely, so a new conversation has started for this topic.",
|
||||
"heteroAgent.switchCwd.cancel": "Cancel",
|
||||
"heteroAgent.switchCwd.content": "Claude Code sessions are pinned to a working directory. Switching will start a new session for this topic — chat messages stay, but the previous session context cannot be resumed.",
|
||||
"heteroAgent.switchCwd.ok": "Switch and start new session",
|
||||
@@ -247,6 +290,7 @@
|
||||
"minimap.senderUser": "You",
|
||||
"newAgent": "Create Agent",
|
||||
"newClaudeCodeAgent": "Add Claude Code",
|
||||
"newCodexAgent": "Add Codex",
|
||||
"newGroupChat": "Create Group",
|
||||
"newPage": "Create Page",
|
||||
"noAgentsYet": "This group has no members yet. Click the + button to invite agents.",
|
||||
|
||||
@@ -191,11 +191,6 @@
|
||||
"analytics.telemetry.desc": "Help us improve {{appName}} with anonymous usage data",
|
||||
"analytics.telemetry.title": "Send Anonymous Usage Data",
|
||||
"analytics.title": "Analytics",
|
||||
"ccStatus.account.label": "Account",
|
||||
"ccStatus.detecting": "Detecting Claude Code CLI...",
|
||||
"ccStatus.redetect": "Re-detect",
|
||||
"ccStatus.title": "Claude Code CLI",
|
||||
"ccStatus.unavailable": "Claude Code CLI not found. Please install or configure it.",
|
||||
"checking": "Checking...",
|
||||
"checkingPermissions": "Checking permissions...",
|
||||
"creds.actions.delete": "Delete",
|
||||
@@ -292,6 +287,10 @@
|
||||
"header.sessionDesc": "Agent Profile and session preferences",
|
||||
"header.sessionWithName": "Session Settings · {{name}}",
|
||||
"header.title": "Settings",
|
||||
"heterogeneousStatus.account.label": "Account",
|
||||
"heterogeneousStatus.detecting": "Detecting {{name}} CLI...",
|
||||
"heterogeneousStatus.redetect": "Re-detect",
|
||||
"heterogeneousStatus.unavailable": "{{name}} CLI not found. Please install or configure it.",
|
||||
"hotkey.clearBinding": "Clear binding",
|
||||
"hotkey.conflicts": "Conflicts with existing hotkeys",
|
||||
"hotkey.errors.CONFLICT": "Hotkey conflict: This hotkey is already assigned to another function",
|
||||
|
||||
@@ -36,7 +36,47 @@
|
||||
"builtinCopilot": "内置 Copilot",
|
||||
"chatList.expandMessage": "展开消息",
|
||||
"chatList.longMessageDetail": "查看详情",
|
||||
"claudeCodeInstallGuide.actions.openDocs": "打开安装指南",
|
||||
"claudeCodeInstallGuide.actions.openSystemTools": "打开系统工具",
|
||||
"claudeCodeInstallGuide.afterInstall": "安装完成后,请先运行一次 Claude Code 完成登录,然后重试刚才的消息,或在系统工具中点击“重新检测”。",
|
||||
"claudeCodeInstallGuide.desc": "Claude Code 需要本地安装 Claude Code CLI 才能运行。请先安装,并确保 `claude` 命令已加入 PATH。",
|
||||
"claudeCodeInstallGuide.installWithBrew": "Homebrew 安装",
|
||||
"claudeCodeInstallGuide.installWithNpm": "推荐安装方式",
|
||||
"claudeCodeInstallGuide.menuNotification.title": "未找到 Claude Code CLI",
|
||||
"claudeCodeInstallGuide.reason": "LobeHub 无法启动 Claude Code:{{message}}",
|
||||
"claudeCodeInstallGuide.title": "安装 Claude Code CLI",
|
||||
"clearCurrentMessages": "清空当前会话消息",
|
||||
"cliAuthGuide.actions.openDocs": "打开登录指南",
|
||||
"cliAuthGuide.actions.openSystemTools": "打开系统工具",
|
||||
"cliAuthGuide.afterLogin": "重新登录或更新凭证后,请重试刚才的消息。你也可以去系统工具里重新检测。",
|
||||
"cliAuthGuide.desc": "{{name}} 当前无法继续运行,可能是登录态已失效,或凭证已经不可用。",
|
||||
"cliAuthGuide.errorDetails": "错误详情",
|
||||
"cliAuthGuide.runCommand": "在终端执行",
|
||||
"cliAuthGuide.title": "登录 {{name}}",
|
||||
"cliRateLimitGuide.actions.openSystemTools": "打开系统工具",
|
||||
"cliRateLimitGuide.afterReset": "请等到重置时间后再重试这条消息。如果你当前使用的是 API 授权,也可以检查对应提供商的额度和计费状态。",
|
||||
"cliRateLimitGuide.desc": "{{name}} 已达到当前使用上限,这次运行暂时无法继续。",
|
||||
"cliRateLimitGuide.limitType": "限制周期",
|
||||
"cliRateLimitGuide.limitTypes.weekCycle": "Week 周期",
|
||||
"cliRateLimitGuide.relative.day_one": "{{count}} 天",
|
||||
"cliRateLimitGuide.relative.day_other": "{{count}} 天",
|
||||
"cliRateLimitGuide.relative.hour_one": "{{count}} 小时",
|
||||
"cliRateLimitGuide.relative.hour_other": "{{count}} 小时",
|
||||
"cliRateLimitGuide.relative.minute_one": "{{count}} 分钟",
|
||||
"cliRateLimitGuide.relative.minute_other": "{{count}} 分钟",
|
||||
"cliRateLimitGuide.relative.soon": "即将重置",
|
||||
"cliRateLimitGuide.resetAt": "重置时间",
|
||||
"cliRateLimitGuide.resetInApprox": "约 {{duration}} 后重置",
|
||||
"cliRateLimitGuide.title": "{{name}} 已达到使用上限",
|
||||
"codexInstallGuide.actions.openDocs": "打开安装指南",
|
||||
"codexInstallGuide.actions.openSystemTools": "打开系统工具",
|
||||
"codexInstallGuide.afterInstall": "安装完成后,请先运行一次 Codex 完成登录,然后重试刚才的消息,或在系统工具中点击“重新检测”。",
|
||||
"codexInstallGuide.desc": "Codex Agent 需要本地安装 Codex CLI 才能运行。请先安装,并确保 `codex` 命令已加入 PATH。",
|
||||
"codexInstallGuide.installWithBrew": "Homebrew 安装(macOS)",
|
||||
"codexInstallGuide.installWithNpm": "推荐安装方式",
|
||||
"codexInstallGuide.menuNotification.title": "未找到 Codex CLI",
|
||||
"codexInstallGuide.reason": "LobeHub 无法启动 Codex:{{message}}",
|
||||
"codexInstallGuide.title": "安装 Codex CLI",
|
||||
"compressedHistory": "压缩历史",
|
||||
"compression.cancel": "取消压缩",
|
||||
"compression.cancelConfirm": "确定要取消压缩吗?这将恢复原始消息。",
|
||||
@@ -65,6 +105,8 @@
|
||||
"defaultSession": "自定义助理",
|
||||
"desktopNotification.aiReplyCompleted.body": "AI 回复生成完成",
|
||||
"desktopNotification.aiReplyCompleted.title": "AI 回复完成",
|
||||
"desktopNotification.humanApprovalRequired.body": "Agent 需要你的批准后才能继续",
|
||||
"desktopNotification.humanApprovalRequired.title": "需要你的批准",
|
||||
"dm.placeholder": "你与 {{agentTitle}} 的私信会显示在这里。",
|
||||
"dm.tooltip": "发送私信",
|
||||
"dm.visibleTo": "仅 {{target}} 可见",
|
||||
@@ -139,6 +181,7 @@
|
||||
"heteroAgent.fullAccess.label": "完全访问权限",
|
||||
"heteroAgent.fullAccess.tooltip": "Claude Code 在本地运行,对工作目录拥有完全的读写权限。当前暂不支持切换权限模式。",
|
||||
"heteroAgent.resumeReset.cwdChanged": "工作目录已切换,之前的 Claude Code 会话只能在原目录下继续,已开始新对话。",
|
||||
"heteroAgent.resumeReset.resumeFailed": "已保存的 Codex 线程无法安全恢复,当前话题已自动开启新会话。",
|
||||
"heteroAgent.switchCwd.cancel": "取消",
|
||||
"heteroAgent.switchCwd.content": "Claude Code 会话会绑定到具体的工作目录。切换后将为当前话题开启一个新会话——消息记录会保留,但之前会话的上下文无法恢复。",
|
||||
"heteroAgent.switchCwd.ok": "切换并开启新会话",
|
||||
@@ -247,6 +290,7 @@
|
||||
"minimap.senderUser": "你",
|
||||
"newAgent": "创建助理",
|
||||
"newClaudeCodeAgent": "添加 Claude Code",
|
||||
"newCodexAgent": "添加 Codex",
|
||||
"newGroupChat": "创建群组",
|
||||
"newPage": "创建文稿",
|
||||
"noAgentsYet": "这个群组还没有成员。点击「+」邀请助理加入",
|
||||
|
||||
@@ -191,11 +191,6 @@
|
||||
"analytics.telemetry.desc": "通过匿名使用数据帮助我们改进 {{appName}}",
|
||||
"analytics.telemetry.title": "发送匿名使用数据",
|
||||
"analytics.title": "数据统计",
|
||||
"ccStatus.account.label": "账号",
|
||||
"ccStatus.detecting": "正在检测 Claude Code CLI…",
|
||||
"ccStatus.redetect": "重新检测",
|
||||
"ccStatus.title": "Claude Code CLI",
|
||||
"ccStatus.unavailable": "未检测到 Claude Code CLI,请先安装或配置",
|
||||
"checking": "检查中…",
|
||||
"checkingPermissions": "检查权限中…",
|
||||
"creds.actions.delete": "删除",
|
||||
@@ -292,6 +287,17 @@
|
||||
"header.sessionDesc": "助理档案与会话偏好",
|
||||
"header.sessionWithName": "会话设置 · {{name}}",
|
||||
"header.title": "设置",
|
||||
"heterogeneousStatus.account.label": "账号",
|
||||
"heterogeneousStatus.auth.api": "API",
|
||||
"heterogeneousStatus.auth.label": "授权方式",
|
||||
"heterogeneousStatus.auth.subscription": "订阅",
|
||||
"heterogeneousStatus.command.edit": "编辑启动命令",
|
||||
"heterogeneousStatus.command.label": "启动命令",
|
||||
"heterogeneousStatus.command.placeholder": "命令名或绝对路径",
|
||||
"heterogeneousStatus.detecting": "正在检测 {{name}} CLI…",
|
||||
"heterogeneousStatus.plan.label": "套餐",
|
||||
"heterogeneousStatus.redetect": "重新检测",
|
||||
"heterogeneousStatus.unavailable": "未检测到 {{name}} CLI,请先安装或配置",
|
||||
"hotkey.clearBinding": "清除绑定",
|
||||
"hotkey.conflicts": "与现有快捷键冲突",
|
||||
"hotkey.errors.CONFLICT": "快捷键冲突:该快捷键已被其他功能占用",
|
||||
|
||||
+2
-2
@@ -313,7 +313,7 @@
|
||||
"better-auth-harmony": "^1.2.5",
|
||||
"better-call": "1.1.8",
|
||||
"brotli-wasm": "^3.0.1",
|
||||
"buffer": "^6.0.3",
|
||||
"buffer.js": "npm:buffer@^6.0.3",
|
||||
"chat": "^4.23.0",
|
||||
"chroma-js": "^3.2.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -424,7 +424,7 @@
|
||||
"unist-builder": "^4.0.0",
|
||||
"url-join": "^5.0.0",
|
||||
"use-merge-value": "^1.2.0",
|
||||
"uuid": "^13.0.0",
|
||||
"uuid": "^14.0.0",
|
||||
"virtua": "^0.48.3",
|
||||
"word-extractor": "^1.0.4",
|
||||
"ws": "^8.19.0",
|
||||
|
||||
@@ -1,24 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { type BuiltinInspectorProps } from '@lobechat/types';
|
||||
import { cx } from 'antd-style';
|
||||
import { SkillsIcon } from '@lobehub/ui/icons';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
|
||||
import { inspectorTextStyles, shinyTextStyles } from '@/styles';
|
||||
|
||||
import type { ActivateSkillParams, ActivateSkillState } from '../../../types';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
chip: css`
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
flex-shrink: 1;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
margin-inline-start: 6px;
|
||||
padding-block: 3px;
|
||||
padding-inline: 10px;
|
||||
border: 1px solid ${cssVar.colorBorderSecondary};
|
||||
border-radius: 999px;
|
||||
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
skillIcon: css`
|
||||
flex-shrink: 0;
|
||||
color: ${cssVar.colorTextDescription};
|
||||
`,
|
||||
skillName: css`
|
||||
overflow: hidden;
|
||||
|
||||
min-width: 0;
|
||||
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorText};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
}));
|
||||
|
||||
export const ActivateSkillInspector = memo<
|
||||
BuiltinInspectorProps<ActivateSkillParams, ActivateSkillState>
|
||||
>(({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
|
||||
const name = args?.name || partialArgs?.name || '';
|
||||
const activatedName = pluginState?.name;
|
||||
const name = args?.name || partialArgs?.name;
|
||||
const displayName = pluginState?.name || name;
|
||||
|
||||
if (isArgumentsStreaming) {
|
||||
if (!name)
|
||||
if (!displayName)
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-skills.apiName.activateSkill')}</span>
|
||||
@@ -28,17 +63,23 @@ export const ActivateSkillInspector = memo<
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-skills.apiName.activateSkill')}:</span>
|
||||
<span>{name}</span>
|
||||
<span className={styles.chip}>
|
||||
<SkillsIcon className={styles.skillIcon} size={12} />
|
||||
<span className={styles.skillName}>{displayName}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
|
||||
<span>
|
||||
<span>{t('builtins.lobe-skills.apiName.activateSkill')}:</span>
|
||||
<span className={highlightTextStyles.primary}>{activatedName || name}</span>
|
||||
</span>
|
||||
<span>{t('builtins.lobe-skills.apiName.activateSkill')}:</span>
|
||||
{displayName && (
|
||||
<span className={styles.chip}>
|
||||
<SkillsIcon className={styles.skillIcon} size={12} />
|
||||
<span className={styles.skillName}>{displayName}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { getLobehubSkillProviderById } from '@lobechat/const';
|
||||
import { getKlavisServerByServerIdentifier, getLobehubSkillProviderById } from '@lobechat/const';
|
||||
import type { BuiltinServerRuntimeOutput } from '@lobechat/types';
|
||||
|
||||
import type {
|
||||
ConnectKlavisServiceParams,
|
||||
GetPlaintextCredParams,
|
||||
InitiateOAuthConnectParams,
|
||||
InjectCredsToSandboxParams,
|
||||
@@ -102,6 +103,42 @@ export class CredsExecutionRuntime {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a Klavis integration service
|
||||
* In server-side context, Klavis OAuth requires browser interaction,
|
||||
* so we return a message guiding the user to connect via the UI.
|
||||
*/
|
||||
async connectKlavisService(
|
||||
args: ConnectKlavisServiceParams,
|
||||
): Promise<BuiltinServerRuntimeOutput> {
|
||||
const { service } = args;
|
||||
|
||||
const serverType = getKlavisServerByServerIdentifier(service);
|
||||
if (!serverType) {
|
||||
return {
|
||||
content: `Unknown Klavis service: "${service}". Check the available Klavis services list in the credentials context.`,
|
||||
error: {
|
||||
message: `Unknown Klavis service: ${service}`,
|
||||
type: 'UnknownService',
|
||||
},
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Server-side cannot open OAuth popups or access browser stores.
|
||||
// Guide the user to connect via the frontend UI.
|
||||
return {
|
||||
content: `To connect ${serverType.label}, please use the LobeHub app UI to initiate the Klavis OAuth flow. Server-side execution cannot open OAuth popups. Go to Settings or the onboarding page to connect ${serverType.label}.`,
|
||||
state: {
|
||||
connected: false,
|
||||
identifier: service,
|
||||
requiresUserAction: true,
|
||||
serviceName: serverType.label,
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate OAuth connection flow
|
||||
* In server-side context, returns authorization URL for the user to click
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { getLobehubSkillProviderById } from '@lobechat/const';
|
||||
import { getKlavisServerByServerIdentifier, getLobehubSkillProviderById } from '@lobechat/const';
|
||||
import type { BuiltinToolContext, BuiltinToolResult } from '@lobechat/types';
|
||||
import { BaseExecutor } from '@lobechat/types';
|
||||
import debug from 'debug';
|
||||
|
||||
import { lambdaClient, toolsClient } from '@/libs/trpc/client';
|
||||
import { getToolStoreState, useToolStore } from '@/store/tool';
|
||||
import { klavisStoreSelectors } from '@/store/tool/selectors';
|
||||
import { KlavisServerStatus } from '@/store/tool/slices/klavisStore/types';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { userProfileSelectors } from '@/store/user/slices/auth/selectors';
|
||||
|
||||
import { CredsIdentifier } from '../manifest';
|
||||
import {
|
||||
type ConnectKlavisServiceParams,
|
||||
CredsApiName,
|
||||
type GetPlaintextCredParams,
|
||||
type InitiateOAuthConnectParams,
|
||||
@@ -22,6 +26,134 @@ class CredsExecutor extends BaseExecutor<typeof CredsApiName> {
|
||||
readonly identifier = CredsIdentifier;
|
||||
protected readonly apiEnum = CredsApiName;
|
||||
|
||||
/**
|
||||
* Connect a Klavis integration service via OAuth
|
||||
* Creates a Klavis server instance and initiates the OAuth flow
|
||||
*/
|
||||
connectKlavisService = async (
|
||||
params: ConnectKlavisServiceParams,
|
||||
_ctx?: BuiltinToolContext,
|
||||
): Promise<BuiltinToolResult> => {
|
||||
try {
|
||||
const { service } = params;
|
||||
|
||||
// Validate service identifier
|
||||
const serverType = getKlavisServerByServerIdentifier(service);
|
||||
if (!serverType) {
|
||||
return {
|
||||
error: {
|
||||
message: `Unknown Klavis service: "${service}". Check the available Klavis services list in the credentials context.`,
|
||||
type: 'UnknownService',
|
||||
},
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if already connected via store
|
||||
const toolState = getToolStoreState();
|
||||
const existingServer = klavisStoreSelectors.getServerByIdentifier(service)(toolState);
|
||||
if (existingServer?.status === KlavisServerStatus.CONNECTED) {
|
||||
return {
|
||||
content: `Already connected to ${serverType.label}. You can use ${serverType.label} tools directly.`,
|
||||
state: {
|
||||
connected: true,
|
||||
identifier: service,
|
||||
serviceName: serverType.label,
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Get userId
|
||||
const userId = userProfileSelectors.userId(useUserStore.getState());
|
||||
if (!userId) {
|
||||
return {
|
||||
error: {
|
||||
message: 'User is not authenticated',
|
||||
type: 'MissingUserId',
|
||||
},
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
log('[CredsExecutor] connectKlavisService - creating server for:', service);
|
||||
|
||||
// Create Klavis server instance
|
||||
const server = await useToolStore.getState().createKlavisServer({
|
||||
identifier: serverType.identifier,
|
||||
serverName: serverType.serverName,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (!server) {
|
||||
return {
|
||||
error: {
|
||||
message: `Failed to create Klavis server instance for ${serverType.label}`,
|
||||
type: 'CreateServerFailed',
|
||||
},
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
// If already authenticated (no OAuth needed)
|
||||
if (server.isAuthenticated) {
|
||||
return {
|
||||
content: `Successfully connected to ${serverType.label}! You can now use ${serverType.label} tools.`,
|
||||
state: {
|
||||
connected: true,
|
||||
identifier: service,
|
||||
serviceName: serverType.label,
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
// OAuth needed — open popup and poll for completion
|
||||
if (server.oauthUrl) {
|
||||
const result = await this.openKlavisOAuthAndWait(server.oauthUrl, server.identifier);
|
||||
|
||||
if (result.success) {
|
||||
return {
|
||||
content: `Successfully connected to ${serverType.label}! You can now use ${serverType.label} tools.`,
|
||||
state: {
|
||||
connected: true,
|
||||
identifier: service,
|
||||
serviceName: serverType.label,
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: `Authorization was cancelled or timed out for ${serverType.label}. You can try again later.`,
|
||||
state: {
|
||||
connected: false,
|
||||
identifier: service,
|
||||
serviceName: serverType.label,
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
error: {
|
||||
message: 'Unexpected server state: no oauthUrl and not authenticated',
|
||||
type: 'UnexpectedState',
|
||||
},
|
||||
success: false,
|
||||
};
|
||||
} catch (error) {
|
||||
log('[CredsExecutor] connectKlavisService - error:', error);
|
||||
return {
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : 'Failed to connect Klavis service',
|
||||
type: 'ConnectKlavisFailed',
|
||||
},
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Initiate OAuth connection flow
|
||||
* Opens authorization popup and waits for user to complete authorization
|
||||
@@ -174,6 +306,91 @@ class CredsExecutor extends BaseExecutor<typeof CredsApiName> {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Open Klavis OAuth popup and poll for authorization completion
|
||||
* Unlike Market OAuth which uses postMessage, Klavis OAuth uses polling
|
||||
*/
|
||||
private openKlavisOAuthAndWait = (
|
||||
oauthUrl: string,
|
||||
identifier: string,
|
||||
): Promise<{ success: boolean }> => {
|
||||
return new Promise((resolve) => {
|
||||
const popup = window.open(oauthUrl, '_blank', 'width=600,height=700');
|
||||
|
||||
if (!popup) {
|
||||
resolve({ success: false });
|
||||
return;
|
||||
}
|
||||
|
||||
let resolved = false;
|
||||
// eslint-disable-next-line prefer-const
|
||||
let pollInterval: ReturnType<typeof setInterval>;
|
||||
// eslint-disable-next-line prefer-const
|
||||
let windowCheckInterval: ReturnType<typeof setInterval>;
|
||||
|
||||
const checkConnected = async (): Promise<boolean> => {
|
||||
try {
|
||||
await useToolStore.getState().refreshKlavisServerTools(identifier);
|
||||
const toolState = getToolStoreState();
|
||||
const server = klavisStoreSelectors.getServerByIdentifier(identifier)(toolState);
|
||||
return server?.status === KlavisServerStatus.CONNECTED;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
clearInterval(pollInterval);
|
||||
clearInterval(windowCheckInterval);
|
||||
};
|
||||
|
||||
// Poll for authentication completion every 1s
|
||||
pollInterval = setInterval(async () => {
|
||||
if (resolved) return;
|
||||
if (await checkConnected()) {
|
||||
cleanup();
|
||||
resolve({ success: true });
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Monitor popup closure — give a short grace period then treat as cancelled
|
||||
windowCheckInterval = setInterval(() => {
|
||||
if (popup.closed) {
|
||||
clearInterval(windowCheckInterval);
|
||||
if (resolved) return;
|
||||
|
||||
// Grace period: check a few more times after popup closes (4s)
|
||||
// User may have authorized right before closing
|
||||
setTimeout(async () => {
|
||||
if (resolved) return;
|
||||
// One final check
|
||||
if (await checkConnected()) {
|
||||
cleanup();
|
||||
resolve({ success: true });
|
||||
} else {
|
||||
cleanup();
|
||||
resolve({ success: false });
|
||||
}
|
||||
}, 4000);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Hard timeout after 2 minutes
|
||||
setTimeout(
|
||||
() => {
|
||||
if (!resolved) {
|
||||
cleanup();
|
||||
if (!popup.closed) popup.close();
|
||||
resolve({ success: false });
|
||||
}
|
||||
},
|
||||
2 * 60 * 1000,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get plaintext credential value by key
|
||||
*/
|
||||
|
||||
@@ -86,6 +86,53 @@ export const injectCredsContext = (content: string, context: UserCredsContext):
|
||||
.replaceAll('{{SETTINGS_URL}}', context.settingsUrl);
|
||||
};
|
||||
|
||||
// ==================== Klavis Services ====================
|
||||
|
||||
/**
|
||||
* Summary of a Klavis service for display in the tool prompt
|
||||
*/
|
||||
export interface KlavisServiceSummary {
|
||||
description?: string;
|
||||
identifier: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the Klavis services list string for injection into the prompt
|
||||
*/
|
||||
export const generateKlavisServicesList = (
|
||||
connected: KlavisServiceSummary[],
|
||||
available: KlavisServiceSummary[],
|
||||
): string => {
|
||||
if (connected.length === 0 && available.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const sections: string[] = [];
|
||||
|
||||
if (connected.length > 0) {
|
||||
const items = connected
|
||||
.map(
|
||||
(s) =>
|
||||
` - ${s.name} (identifier: ${s.identifier}) — Authorized via Klavis OAuth. Use ${s.identifier} tools directly.`,
|
||||
)
|
||||
.join('\n');
|
||||
sections.push(`**Connected Klavis Services (authorized, use tools directly):**\n${items}`);
|
||||
}
|
||||
|
||||
if (available.length > 0) {
|
||||
const items = available
|
||||
.map(
|
||||
(s) =>
|
||||
` - ${s.name} (identifier: ${s.identifier}) — Use \`connectKlavisService\` to connect.`,
|
||||
)
|
||||
.join('\n');
|
||||
sections.push(`**Available Klavis Services (not yet connected):**\n${items}`);
|
||||
}
|
||||
|
||||
return sections.join('\n\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a skill's required credentials are satisfied
|
||||
*/
|
||||
|
||||
@@ -4,13 +4,17 @@ export {
|
||||
type CredRequirement,
|
||||
type CredSummary,
|
||||
generateCredsList,
|
||||
generateKlavisServicesList,
|
||||
groupCredsByType,
|
||||
injectCredsContext,
|
||||
type KlavisServiceSummary,
|
||||
type UserCredsContext,
|
||||
} from './helpers';
|
||||
export { CredsIdentifier, CredsManifest } from './manifest';
|
||||
export { systemPrompt } from './systemRole';
|
||||
export {
|
||||
type ConnectKlavisServiceParams,
|
||||
type ConnectKlavisServiceState,
|
||||
CredsApiName,
|
||||
type CredsApiNameType,
|
||||
type CredSummaryForContext,
|
||||
|
||||
@@ -8,6 +8,23 @@ export const CredsIdentifier = 'lobe-creds';
|
||||
|
||||
export const CredsManifest: BuiltinToolManifest = {
|
||||
api: [
|
||||
{
|
||||
description:
|
||||
'Connect a Klavis integration service via OAuth. Use this to authorize access to third-party services managed by the Klavis platform (e.g., Notion, Gmail, Google Calendar, Slack). Check the available Klavis services in the credentials context before calling this.',
|
||||
name: CredsApiName.connectKlavisService,
|
||||
parameters: {
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
service: {
|
||||
description:
|
||||
'The Klavis service identifier to connect (e.g., "notion", "gmail", "google-calendar"). See the available Klavis services list in the credentials context.',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['service'],
|
||||
type: 'object',
|
||||
} satisfies JSONSchema7,
|
||||
},
|
||||
{
|
||||
description:
|
||||
'Initiate OAuth connection flow for a third-party service (e.g., Linear, Microsoft Outlook, Twitter/X). Returns an authorization URL that the user must click to authorize. After authorization, the credential will be automatically saved.',
|
||||
|
||||
@@ -93,6 +93,18 @@ When sandbox mode is enabled and you need to run code that requires credentials:
|
||||
- Use the file path directly in your code (e.g., \`GOOGLE_APPLICATION_CREDENTIALS=~/.creds/files/gcp-service-account/credentials.json\`)
|
||||
</sandbox_integration>
|
||||
|
||||
<klavis_integrations>
|
||||
{{KLAVIS_SERVICES_LIST}}
|
||||
</klavis_integrations>
|
||||
|
||||
<klavis_guidelines>
|
||||
- **Klavis integrations** are OAuth connections managed by the Klavis platform for third-party services (e.g., Notion, Gmail, Google Calendar, Slack).
|
||||
- For **connected** Klavis services: Use the corresponding tools directly. Do NOT ask users for API keys, tokens, or credentials — the authorization is already handled by Klavis.
|
||||
- For **available but not connected** services: Use \`connectKlavisService\` to initiate the OAuth connection flow via Klavis.
|
||||
- Klavis credentials **CANNOT** be retrieved via \`getPlaintextCred\` or injected via \`injectCredsToSandbox\` — they are tool-only authorizations managed externally by Klavis.
|
||||
- If a user asks about a service that matches a connected Klavis integration, always prefer using the Klavis tools over asking the user for manual credentials.
|
||||
</klavis_guidelines>
|
||||
|
||||
<response_expectations>
|
||||
- When credentials are relevant, mention which ones are available and how they can be used.
|
||||
- When accessing credentials, briefly explain why access is needed.
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { CredType } from '@lobechat/types';
|
||||
|
||||
export const CredsApiName = {
|
||||
/**
|
||||
* Connect a Klavis integration service via OAuth
|
||||
* Initiates Klavis OAuth flow for third-party services like Notion, Gmail, etc.
|
||||
*/
|
||||
connectKlavisService: 'connectKlavisService',
|
||||
|
||||
/**
|
||||
* Get plaintext value of a credential
|
||||
* Use when AI needs to access credential value for API calls
|
||||
@@ -138,6 +144,34 @@ export interface SaveCredsState {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
// ==================== Klavis Service Types ====================
|
||||
|
||||
export interface ConnectKlavisServiceParams {
|
||||
/**
|
||||
* The Klavis service identifier to connect (e.g., 'notion', 'gmail', 'google-calendar')
|
||||
*/
|
||||
service: string;
|
||||
}
|
||||
|
||||
export interface ConnectKlavisServiceState {
|
||||
/**
|
||||
* Whether the service is now connected
|
||||
*/
|
||||
connected: boolean;
|
||||
/**
|
||||
* The service identifier
|
||||
*/
|
||||
identifier: string;
|
||||
/**
|
||||
* OAuth URL (only present when authorization is needed)
|
||||
*/
|
||||
oauthUrl?: string;
|
||||
/**
|
||||
* The service display name
|
||||
*/
|
||||
serviceName: string;
|
||||
}
|
||||
|
||||
// ==================== Context Types ====================
|
||||
|
||||
export interface CredSummaryForContext {
|
||||
|
||||
@@ -1,24 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { type BuiltinInspectorProps } from '@lobechat/types';
|
||||
import { cx } from 'antd-style';
|
||||
import { SkillsIcon } from '@lobehub/ui/icons';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
|
||||
import { inspectorTextStyles, shinyTextStyles } from '@/styles';
|
||||
|
||||
import type { ActivateSkillParams, ActivateSkillState } from '../../../types';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
chip: css`
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
flex-shrink: 1;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
margin-inline-start: 6px;
|
||||
padding-block: 3px;
|
||||
padding-inline: 10px;
|
||||
border: 1px solid ${cssVar.colorBorderSecondary};
|
||||
border-radius: 999px;
|
||||
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
skillIcon: css`
|
||||
flex-shrink: 0;
|
||||
color: ${cssVar.colorTextDescription};
|
||||
`,
|
||||
skillName: css`
|
||||
overflow: hidden;
|
||||
|
||||
min-width: 0;
|
||||
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorText};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
}));
|
||||
|
||||
export const RunSkillInspector = memo<
|
||||
BuiltinInspectorProps<ActivateSkillParams, ActivateSkillState>
|
||||
>(({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
|
||||
const name = args?.name || partialArgs?.name || '';
|
||||
const activatedName = pluginState?.name;
|
||||
const name = args?.name || partialArgs?.name;
|
||||
const displayName = pluginState?.name || name;
|
||||
|
||||
if (isArgumentsStreaming) {
|
||||
if (!name)
|
||||
if (!displayName)
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-skills.apiName.activateSkill')}</span>
|
||||
@@ -28,17 +63,23 @@ export const RunSkillInspector = memo<
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-skills.apiName.activateSkill')}:</span>
|
||||
<span>{name}</span>
|
||||
<span className={styles.chip}>
|
||||
<SkillsIcon className={styles.skillIcon} size={12} />
|
||||
<span className={styles.skillName}>{displayName}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
|
||||
<span>
|
||||
<span>{t('builtins.lobe-skills.apiName.activateSkill')}:</span>
|
||||
<span className={highlightTextStyles.primary}>{activatedName || name}</span>
|
||||
</span>
|
||||
<span>{t('builtins.lobe-skills.apiName.activateSkill')}:</span>
|
||||
{displayName && (
|
||||
<span className={styles.chip}>
|
||||
<SkillsIcon className={styles.skillIcon} size={12} />
|
||||
<span className={styles.skillName}>{displayName}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { FilePathDisplay } from '@lobechat/shared-tool-ui/components';
|
||||
import { inspectorTextStyles, shinyTextStyles } from '@lobechat/shared-tool-ui/styles';
|
||||
import type { BuiltinInspectorProps } from '@lobechat/types';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { type CodexFileChangeArgs, type CodexFileChangeState, getFileChangeStats } from './utils';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
chip: css`
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
flex-shrink: 1;
|
||||
align-items: center;
|
||||
|
||||
min-width: 0;
|
||||
margin-inline-start: 6px;
|
||||
padding-block: 2px;
|
||||
padding-inline: 10px;
|
||||
border-radius: 999px;
|
||||
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
count: css`
|
||||
margin-inline-start: 4px;
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
lineAdded: css`
|
||||
margin-inline-start: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: ${cssVar.colorSuccess};
|
||||
`,
|
||||
lineDeleted: css`
|
||||
margin-inline-start: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: ${cssVar.colorError};
|
||||
`,
|
||||
}));
|
||||
|
||||
const FileChangeInspector = memo<BuiltinInspectorProps<CodexFileChangeArgs, CodexFileChangeState>>(
|
||||
({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => {
|
||||
const stats = getFileChangeStats(args || partialArgs, pluginState);
|
||||
const hasLineStats = stats.linesAdded > 0 || stats.linesDeleted > 0;
|
||||
|
||||
if (isArgumentsStreaming && !stats.firstPath) {
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>File changes</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
inspectorTextStyles.root,
|
||||
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
|
||||
)}
|
||||
>
|
||||
<span>File changes:</span>
|
||||
{stats.firstPath && (
|
||||
<span className={styles.chip}>
|
||||
<FilePathDisplay filePath={stats.firstPath} />
|
||||
</span>
|
||||
)}
|
||||
{stats.total > 1 && <span className={styles.count}>+{stats.total - 1}</span>}
|
||||
{hasLineStats && (
|
||||
<>
|
||||
<span className={styles.lineAdded}>+{stats.linesAdded}</span>
|
||||
<span className={styles.lineDeleted}>-{stats.linesDeleted}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
FileChangeInspector.displayName = 'CodexFileChangeInspector';
|
||||
|
||||
export default FileChangeInspector;
|
||||
@@ -0,0 +1,253 @@
|
||||
'use client';
|
||||
|
||||
import { FilePathDisplay, ToolResultCard } from '@lobechat/shared-tool-ui/components';
|
||||
import type { BuiltinRenderProps } from '@lobechat/types';
|
||||
import { Flexbox, Icon, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { Files, FileText } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import {
|
||||
type CodexFileChangeArgs,
|
||||
type CodexFileChangeKind,
|
||||
type CodexFileChangeState,
|
||||
getFileChangeData,
|
||||
getFileChangeKind,
|
||||
getFileChangeStats,
|
||||
} from './utils';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
emptyState: css`
|
||||
padding: 4px;
|
||||
font-size: 13px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
header: css`
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
`,
|
||||
headerChip: css`
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
flex: 0 1 auto;
|
||||
align-items: center;
|
||||
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding-block: 4px;
|
||||
padding-inline: 10px;
|
||||
border-radius: 999px;
|
||||
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
headerCount: css`
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
headerLabel: css`
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
`,
|
||||
kindAdded: css`
|
||||
background: ${cssVar.colorSuccess};
|
||||
`,
|
||||
kindDeleted: css`
|
||||
background: ${cssVar.colorError};
|
||||
`,
|
||||
kindDot: css`
|
||||
flex-shrink: 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
`,
|
||||
kindModified: css`
|
||||
background: ${cssVar.colorInfo};
|
||||
`,
|
||||
kindRenamed: css`
|
||||
background: ${cssVar.colorWarning};
|
||||
`,
|
||||
lineAdded: css`
|
||||
font-weight: 600;
|
||||
color: ${cssVar.colorSuccess};
|
||||
`,
|
||||
lineDeleted: css`
|
||||
font-weight: 600;
|
||||
color: ${cssVar.colorError};
|
||||
`,
|
||||
lineStats: css`
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
|
||||
font-size: 12px;
|
||||
`,
|
||||
list: css`
|
||||
gap: 0;
|
||||
`,
|
||||
panel: css`
|
||||
overflow: hidden;
|
||||
border: 1px solid ${cssVar.colorBorderSecondary};
|
||||
border-radius: 12px;
|
||||
background: ${cssVar.colorFillQuaternary};
|
||||
`,
|
||||
panelHeader: css`
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
padding: 12px;
|
||||
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
|
||||
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
panelMeta: css`
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
panelTitle: css`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
`,
|
||||
rowMain: css`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
|
||||
min-width: 0;
|
||||
`,
|
||||
path: css`
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
`,
|
||||
row: css`
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
|
||||
& + & {
|
||||
border-block-start: 1px solid ${cssVar.colorBorderSecondary};
|
||||
}
|
||||
`,
|
||||
unknownPath: css`
|
||||
font-size: 13px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
}));
|
||||
|
||||
const getKindClassName = (kind: CodexFileChangeKind) => {
|
||||
switch (kind) {
|
||||
case 'added': {
|
||||
return styles.kindAdded;
|
||||
}
|
||||
case 'deleted': {
|
||||
return styles.kindDeleted;
|
||||
}
|
||||
case 'renamed': {
|
||||
return styles.kindRenamed;
|
||||
}
|
||||
default: {
|
||||
return styles.kindModified;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const LineStats = memo<{ className?: string; linesAdded?: number; linesDeleted?: number }>(
|
||||
({ className, linesAdded = 0, linesDeleted = 0 }) => {
|
||||
if (linesAdded === 0 && linesDeleted === 0) return null;
|
||||
|
||||
return (
|
||||
<span className={cx(styles.lineStats, className)}>
|
||||
<span className={styles.lineAdded}>+{linesAdded}</span>
|
||||
<span className={styles.lineDeleted}>-{linesDeleted}</span>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
);
|
||||
LineStats.displayName = 'CodexFileChangeLineStats';
|
||||
|
||||
const FileChangeRender = memo<BuiltinRenderProps<CodexFileChangeArgs, CodexFileChangeState>>(
|
||||
({ args, pluginState }) => {
|
||||
const stats = getFileChangeStats(args, pluginState);
|
||||
const data = getFileChangeData(args, pluginState);
|
||||
const summary = stats.total === 1 ? '1 file change' : `${stats.total} file changes`;
|
||||
const detail = [
|
||||
stats.byKind.added > 0 ? `${stats.byKind.added} added` : null,
|
||||
stats.byKind.modified > 0 ? `${stats.byKind.modified} modified` : null,
|
||||
stats.byKind.deleted > 0 ? `${stats.byKind.deleted} deleted` : null,
|
||||
stats.byKind.renamed > 0 ? `${stats.byKind.renamed} renamed` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
return (
|
||||
<ToolResultCard
|
||||
wrapHeader
|
||||
icon={FileText}
|
||||
header={
|
||||
<Flexbox horizontal align={'center'} className={styles.header} wrap={'wrap'}>
|
||||
<Text className={styles.headerLabel}>File changes:</Text>
|
||||
{stats.firstPath && (
|
||||
<span className={styles.headerChip}>
|
||||
<FilePathDisplay filePath={stats.firstPath} />
|
||||
</span>
|
||||
)}
|
||||
{stats.total > 1 && <Text className={styles.headerCount}>+{stats.total - 1}</Text>}
|
||||
<LineStats linesAdded={stats.linesAdded} linesDeleted={stats.linesDeleted} />
|
||||
</Flexbox>
|
||||
}
|
||||
>
|
||||
{stats.total > 0 ? (
|
||||
<div className={styles.panel}>
|
||||
<Flexbox horizontal align={'center'} className={styles.panelHeader} wrap={'wrap'}>
|
||||
<div className={styles.panelTitle}>
|
||||
<Icon icon={Files} size={16} />
|
||||
<Text strong>{summary}</Text>
|
||||
</div>
|
||||
{detail && <Text className={styles.panelMeta}>{detail}</Text>}
|
||||
<LineStats linesAdded={stats.linesAdded} linesDeleted={stats.linesDeleted} />
|
||||
</Flexbox>
|
||||
<Flexbox className={styles.list}>
|
||||
{data.changes.map((change, index) => {
|
||||
const kind = getFileChangeKind(change.kind);
|
||||
const path = change.path || '';
|
||||
|
||||
return (
|
||||
<Flexbox horizontal className={styles.row} key={`${path}-${index}`}>
|
||||
<span className={cx(styles.kindDot, getKindClassName(kind))} />
|
||||
<div className={styles.rowMain}>
|
||||
<div className={styles.path}>
|
||||
{path ? (
|
||||
<FilePathDisplay filePath={path} />
|
||||
) : (
|
||||
<Text className={styles.unknownPath}>Unknown file</Text>
|
||||
)}
|
||||
</div>
|
||||
<LineStats
|
||||
linesAdded={change.linesAdded}
|
||||
linesDeleted={change.linesDeleted}
|
||||
/>
|
||||
</div>
|
||||
</Flexbox>
|
||||
);
|
||||
})}
|
||||
</Flexbox>
|
||||
</div>
|
||||
) : (
|
||||
<Text className={styles.emptyState}>No file changes</Text>
|
||||
)}
|
||||
</ToolResultCard>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
FileChangeRender.displayName = 'CodexFileChangeRender';
|
||||
|
||||
export default FileChangeRender;
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import type { TodoWriteArgs } from '@lobechat/builtin-tool-claude-code';
|
||||
import { ClaudeCodeApiName } from '@lobechat/builtin-tool-claude-code';
|
||||
import { ClaudeCodeInspectors } from '@lobechat/builtin-tool-claude-code/client';
|
||||
import type { BuiltinInspectorProps } from '@lobechat/types';
|
||||
import { type ComponentType, memo } from 'react';
|
||||
|
||||
import { type CodexTodoListArgs, toTodoWriteArgs } from './utils';
|
||||
|
||||
const TodoListInspector = memo<BuiltinInspectorProps<CodexTodoListArgs>>(
|
||||
({ args, partialArgs, ...rest }) => {
|
||||
const TodoWriteInspector = ClaudeCodeInspectors[ClaudeCodeApiName.TodoWrite] as ComponentType<
|
||||
BuiltinInspectorProps<TodoWriteArgs>
|
||||
>;
|
||||
|
||||
return (
|
||||
<TodoWriteInspector
|
||||
{...rest}
|
||||
args={toTodoWriteArgs(args)}
|
||||
partialArgs={toTodoWriteArgs(partialArgs)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TodoListInspector.displayName = 'CodexTodoListInspector';
|
||||
|
||||
export default TodoListInspector;
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import type { TodoWriteArgs } from '@lobechat/builtin-tool-claude-code';
|
||||
import { ClaudeCodeApiName } from '@lobechat/builtin-tool-claude-code';
|
||||
import { ClaudeCodeRenders } from '@lobechat/builtin-tool-claude-code/client';
|
||||
import type { BuiltinRenderProps } from '@lobechat/types';
|
||||
import { type ComponentType, memo } from 'react';
|
||||
|
||||
import { type CodexTodoListArgs, toTodoWriteArgs } from './utils';
|
||||
|
||||
const TodoListRender = memo<BuiltinRenderProps<CodexTodoListArgs>>(({ args, ...rest }) => {
|
||||
const TodoWriteRender = ClaudeCodeRenders[ClaudeCodeApiName.TodoWrite] as ComponentType<
|
||||
BuiltinRenderProps<TodoWriteArgs>
|
||||
>;
|
||||
|
||||
return <TodoWriteRender {...rest} args={toTodoWriteArgs(args)} />;
|
||||
});
|
||||
|
||||
TodoListRender.displayName = 'CodexTodoListRender';
|
||||
|
||||
export default TodoListRender;
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
type BuiltinInspector,
|
||||
type BuiltinRender,
|
||||
type RenderDisplayControl,
|
||||
} from '@lobechat/types';
|
||||
|
||||
import FileChangeInspector from './FileChangeInspector';
|
||||
import FileChangeRender from './FileChangeRender';
|
||||
import TodoListInspector from './TodoListInspector';
|
||||
import TodoListRender from './TodoListRender';
|
||||
|
||||
export const CodexInspectors: Record<string, BuiltinInspector> = {
|
||||
file_change: FileChangeInspector as BuiltinInspector,
|
||||
todo_list: TodoListInspector as BuiltinInspector,
|
||||
};
|
||||
|
||||
export const CodexRenders: Record<string, BuiltinRender> = {
|
||||
file_change: FileChangeRender as BuiltinRender,
|
||||
todo_list: TodoListRender as BuiltinRender,
|
||||
};
|
||||
|
||||
export const CodexRenderDisplayControls: Record<string, RenderDisplayControl> = {
|
||||
file_change: 'expand',
|
||||
todo_list: 'expand',
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import type { ClaudeCodeTodoItem, TodoWriteArgs } from '@lobechat/builtin-tool-claude-code';
|
||||
|
||||
export interface CodexTodoListEntry {
|
||||
completed?: boolean;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface CodexTodoListArgs {
|
||||
items?: CodexTodoListEntry[];
|
||||
}
|
||||
|
||||
export type CodexFileChangeKind = 'added' | 'deleted' | 'modified' | 'renamed';
|
||||
|
||||
export interface CodexFileChangeEntry {
|
||||
kind?: string;
|
||||
linesAdded?: number;
|
||||
linesDeleted?: number;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface CodexFileChangeArgs {
|
||||
changes?: CodexFileChangeEntry[];
|
||||
}
|
||||
|
||||
export interface CodexFileChangeState {
|
||||
changes?: CodexFileChangeEntry[];
|
||||
linesAdded?: number;
|
||||
linesDeleted?: number;
|
||||
}
|
||||
|
||||
export interface CodexFileChangeStats {
|
||||
byKind: Record<CodexFileChangeKind, number>;
|
||||
firstPath?: string;
|
||||
linesAdded: number;
|
||||
linesDeleted: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const normalizeTodoEntries = (args?: CodexTodoListArgs) =>
|
||||
(args?.items || [])
|
||||
.map((item) => ({
|
||||
completed: !!item.completed,
|
||||
text: typeof item.text === 'string' ? item.text.trim() : '',
|
||||
}))
|
||||
.filter((item) => item.text.length > 0);
|
||||
|
||||
export const toTodoWriteArgs = (args?: CodexTodoListArgs): TodoWriteArgs => {
|
||||
let assignedProcessing = false;
|
||||
|
||||
const todos = normalizeTodoEntries(args).map((item): ClaudeCodeTodoItem => {
|
||||
if (item.completed) {
|
||||
return {
|
||||
activeForm: item.text,
|
||||
content: item.text,
|
||||
status: 'completed',
|
||||
};
|
||||
}
|
||||
|
||||
if (!assignedProcessing) {
|
||||
assignedProcessing = true;
|
||||
return {
|
||||
activeForm: item.text,
|
||||
content: item.text,
|
||||
status: 'in_progress',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
activeForm: item.text,
|
||||
content: item.text,
|
||||
status: 'pending',
|
||||
};
|
||||
});
|
||||
|
||||
return { todos };
|
||||
};
|
||||
|
||||
export const getFileChangeKind = (kind?: string): CodexFileChangeKind => {
|
||||
switch (kind) {
|
||||
case 'add': {
|
||||
return 'added';
|
||||
}
|
||||
case 'delete':
|
||||
case 'remove': {
|
||||
return 'deleted';
|
||||
}
|
||||
case 'rename': {
|
||||
return 'renamed';
|
||||
}
|
||||
default: {
|
||||
return 'modified';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getFileChangeKindLabel = (kind: CodexFileChangeKind) => {
|
||||
switch (kind) {
|
||||
case 'added': {
|
||||
return 'Add';
|
||||
}
|
||||
case 'deleted': {
|
||||
return 'Delete';
|
||||
}
|
||||
case 'renamed': {
|
||||
return 'Rename';
|
||||
}
|
||||
default: {
|
||||
return 'Modify';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const formatFileChangeLineStats = (linesAdded = 0, linesDeleted = 0) => {
|
||||
if (linesAdded === 0 && linesDeleted === 0) return '';
|
||||
return `+${linesAdded} -${linesDeleted}`;
|
||||
};
|
||||
|
||||
export const getFileChangeData = (
|
||||
args?: CodexFileChangeArgs,
|
||||
pluginState?: CodexFileChangeState,
|
||||
) => ({
|
||||
changes: pluginState?.changes?.length ? pluginState.changes : (args?.changes ?? []),
|
||||
linesAdded: pluginState?.linesAdded ?? 0,
|
||||
linesDeleted: pluginState?.linesDeleted ?? 0,
|
||||
});
|
||||
|
||||
export const getFileChangeStats = (
|
||||
args?: CodexFileChangeArgs,
|
||||
pluginState?: CodexFileChangeState,
|
||||
): CodexFileChangeStats => {
|
||||
const byKind = {
|
||||
added: 0,
|
||||
deleted: 0,
|
||||
modified: 0,
|
||||
renamed: 0,
|
||||
} satisfies Record<CodexFileChangeKind, number>;
|
||||
|
||||
const data = getFileChangeData(args, pluginState);
|
||||
let firstPath: string | undefined;
|
||||
let total = 0;
|
||||
|
||||
for (const change of data.changes) {
|
||||
if (!firstPath && change.path) firstPath = change.path;
|
||||
byKind[getFileChangeKind(change.kind)] += 1;
|
||||
total += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
byKind,
|
||||
firstPath,
|
||||
linesAdded: data.linesAdded,
|
||||
linesDeleted: data.linesDeleted,
|
||||
total,
|
||||
};
|
||||
};
|
||||
@@ -4,11 +4,19 @@ import {
|
||||
} from '@lobechat/builtin-tool-claude-code/client';
|
||||
import { type RenderDisplayControl } from '@lobechat/types';
|
||||
|
||||
import { CodexRenderDisplayControls } from './codex';
|
||||
|
||||
// Kept separate from `./renders` so consumers that only need display-control
|
||||
// fallbacks (e.g. the tool store selector) don't pull in every builtin tool's
|
||||
// render registry — that graph cycles back through `@/store/tool/selectors`.
|
||||
const BuiltinRenderDisplayControls: Record<string, Record<string, RenderDisplayControl>> = {
|
||||
[ClaudeCodeIdentifier]: ClaudeCodeRenderDisplayControls,
|
||||
const getBuiltinRenderDisplayControls = (): Record<
|
||||
string,
|
||||
Record<string, RenderDisplayControl>
|
||||
> => {
|
||||
return {
|
||||
[ClaudeCodeIdentifier]: ClaudeCodeRenderDisplayControls,
|
||||
codex: CodexRenderDisplayControls,
|
||||
};
|
||||
};
|
||||
|
||||
export const getBuiltinRenderDisplayControl = (
|
||||
@@ -16,5 +24,5 @@ export const getBuiltinRenderDisplayControl = (
|
||||
apiName?: string,
|
||||
): RenderDisplayControl | undefined => {
|
||||
if (!identifier || !apiName) return undefined;
|
||||
return BuiltinRenderDisplayControls[identifier]?.[apiName];
|
||||
return getBuiltinRenderDisplayControls()[identifier]?.[apiName];
|
||||
};
|
||||
|
||||
@@ -48,8 +48,11 @@ import {
|
||||
WebBrowsingInspectors,
|
||||
WebBrowsingManifest,
|
||||
} from '@lobechat/builtin-tool-web-browsing/client';
|
||||
import { createRunCommandInspector } from '@lobechat/shared-tool-ui/inspectors';
|
||||
import { type BuiltinInspector } from '@lobechat/types';
|
||||
|
||||
import { CodexInspectors } from './codex';
|
||||
|
||||
/**
|
||||
* Builtin tools inspector registry
|
||||
* Organized by toolset (identifier) -> API name
|
||||
@@ -86,6 +89,10 @@ const BuiltinToolInspectors: Record<string, Record<string, BuiltinInspector>> =
|
||||
[SkillStoreManifest.identifier]: SkillStoreInspectors as Record<string, BuiltinInspector>,
|
||||
[SkillsManifest.identifier]: SkillsInspectors as Record<string, BuiltinInspector>,
|
||||
[WebBrowsingManifest.identifier]: WebBrowsingInspectors as Record<string, BuiltinInspector>,
|
||||
codex: {
|
||||
...CodexInspectors,
|
||||
command_execution: createRunCommandInspector('Run') as BuiltinInspector,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,8 +33,17 @@ import {
|
||||
WebBrowsingManifest,
|
||||
WebBrowsingRenders,
|
||||
} from '@lobechat/builtin-tool-web-browsing/client';
|
||||
import { RunCommandRender } from '@lobechat/shared-tool-ui/renders';
|
||||
import { type BuiltinRender } from '@lobechat/types';
|
||||
|
||||
import { CodexRenders } from './codex';
|
||||
|
||||
export interface BuiltinRenderRegistryEntry {
|
||||
apiName: string;
|
||||
identifier: string;
|
||||
render: BuiltinRender;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builtin tools renders registry
|
||||
* Organized by toolset (identifier) -> API name
|
||||
@@ -59,8 +68,23 @@ const BuiltinToolsRenders: Record<string, Record<string, BuiltinRender>> = {
|
||||
// @deprecated backward compat: old messages stored 'lobe-tools' as identifier
|
||||
['lobe-tools']: LobeActivatorRenders as Record<string, BuiltinRender>,
|
||||
[WebBrowsingManifest.identifier]: WebBrowsingRenders as Record<string, BuiltinRender>,
|
||||
codex: {
|
||||
...CodexRenders,
|
||||
command_execution: RunCommandRender as BuiltinRender,
|
||||
},
|
||||
};
|
||||
|
||||
export const listBuiltinRenderEntries = (): BuiltinRenderRegistryEntry[] =>
|
||||
Object.entries(BuiltinToolsRenders).flatMap(([identifier, toolset]) =>
|
||||
Object.entries(toolset)
|
||||
.filter((entry): entry is [string, BuiltinRender] => !!entry[1])
|
||||
.map(([apiName, render]) => ({
|
||||
apiName,
|
||||
identifier,
|
||||
render,
|
||||
})),
|
||||
);
|
||||
|
||||
/**
|
||||
* Get builtin render component for a specific API
|
||||
* @param identifier - Tool identifier (e.g., 'lobe-local-system')
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
// @vitest-environment node
|
||||
import { ASYNC_TASK_TIMEOUT } from '@lobechat/business-config/server';
|
||||
import type { UserMemoryExtractionMetadata } from '@lobechat/types';
|
||||
import { AsyncTaskStatus, AsyncTaskType } from '@lobechat/types';
|
||||
import {
|
||||
AsyncTaskError,
|
||||
AsyncTaskErrorType,
|
||||
AsyncTaskStatus,
|
||||
AsyncTaskType,
|
||||
} from '@lobechat/types';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -154,6 +159,37 @@ describe('AsyncTaskModel', () => {
|
||||
expect(secondMetadata?.progress?.completedTopics).toBe(2);
|
||||
expect(task?.status).toBe(AsyncTaskStatus.Success);
|
||||
});
|
||||
|
||||
it('should preserve error status and error payload when progress reaches total after failure', async () => {
|
||||
const error = new AsyncTaskError(AsyncTaskErrorType.ServerError, 'Extraction failed');
|
||||
|
||||
const { id } = await serverDB
|
||||
.insert(asyncTasks)
|
||||
.values({
|
||||
error,
|
||||
metadata: {
|
||||
progress: {
|
||||
completedTopics: 1,
|
||||
totalTopics: 2,
|
||||
},
|
||||
source: 'chat_topic',
|
||||
},
|
||||
status: AsyncTaskStatus.Error,
|
||||
type: AsyncTaskType.UserMemoryExtractionWithChatTopic,
|
||||
userId,
|
||||
})
|
||||
.returning()
|
||||
.then((res) => res[0]);
|
||||
|
||||
await asyncTaskModel.incrementUserMemoryExtractionProgress(id);
|
||||
|
||||
const task = await serverDB.query.asyncTasks.findFirst({ where: eq(asyncTasks.id, id) });
|
||||
const metadata = task?.metadata as UserMemoryExtractionMetadata | undefined;
|
||||
|
||||
expect(metadata?.progress?.completedTopics).toBe(2);
|
||||
expect(task?.status).toBe(AsyncTaskStatus.Error);
|
||||
expect(task?.error).toEqual(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findActiveByType', () => {
|
||||
|
||||
@@ -89,6 +89,8 @@ export class AsyncTaskModel {
|
||||
`,
|
||||
status: sql`
|
||||
CASE
|
||||
WHEN ${asyncTasks.status} = ${AsyncTaskStatus.Error} OR ${asyncTasks.error} IS NOT NULL
|
||||
THEN ${AsyncTaskStatus.Error}
|
||||
WHEN ${totalExpr} IS NOT NULL AND ${completedExpr} >= ${totalExpr}
|
||||
THEN ${AsyncTaskStatus.Success}
|
||||
ELSE ${AsyncTaskStatus.Processing}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
export const CLAUDE_CODE_CLI_INSTALL_DOCS_URL =
|
||||
'https://docs.anthropic.com/en/docs/claude-code/setup';
|
||||
|
||||
export const CLAUDE_CODE_CLI_INSTALL_COMMANDS = [
|
||||
'curl -fsSL https://claude.ai/install.sh | bash',
|
||||
'brew install --cask claude-code',
|
||||
] as const;
|
||||
|
||||
export const CODEX_CLI_INSTALL_DOCS_URL =
|
||||
'https://github.com/openai/codex#installing-and-running-codex-cli';
|
||||
|
||||
export const CODEX_CLI_INSTALL_COMMANDS = [
|
||||
'npm install -g @openai/codex',
|
||||
'brew install --cask codex',
|
||||
] as const;
|
||||
|
||||
export const HeterogeneousAgentSessionErrorCode = {
|
||||
AuthRequired: 'auth_required',
|
||||
CliNotFound: 'cli_not_found',
|
||||
RateLimit: 'rate_limit',
|
||||
ResumeCwdMismatch: 'resume_cwd_mismatch',
|
||||
ResumeThreadNotFound: 'resume_thread_not_found',
|
||||
} as const;
|
||||
|
||||
export type HeterogeneousAgentSessionErrorCode =
|
||||
(typeof HeterogeneousAgentSessionErrorCode)[keyof typeof HeterogeneousAgentSessionErrorCode];
|
||||
|
||||
export interface HeterogeneousAgentRateLimitInfo {
|
||||
isUsingOverage?: boolean;
|
||||
overageDisabledReason?: string;
|
||||
overageStatus?: string;
|
||||
rateLimitType?: string;
|
||||
resetsAt?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface HeterogeneousAgentSessionError {
|
||||
agentType?: string;
|
||||
code?: HeterogeneousAgentSessionErrorCode | string;
|
||||
command?: string;
|
||||
docsUrl?: string;
|
||||
installCommands?: readonly string[];
|
||||
message: string;
|
||||
rateLimitInfo?: HeterogeneousAgentRateLimitInfo;
|
||||
resumeSessionId?: string;
|
||||
stderr?: string;
|
||||
workingDirectory?: string;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './dataSync';
|
||||
export * from './git';
|
||||
export * from './heterogeneousAgent';
|
||||
export * from './localSystem';
|
||||
export * from './mcpInstall';
|
||||
export * from './notification';
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export interface ShowDesktopNotificationParams {
|
||||
body: string;
|
||||
force?: boolean;
|
||||
requestAttention?: boolean;
|
||||
silent?: boolean;
|
||||
title: string;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,13 @@ export interface ToolInfo {
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export type HeterogeneousCliAgentType = 'claude-code' | 'codex';
|
||||
|
||||
export interface DetectHeterogeneousAgentCommandParams {
|
||||
agentType: HeterogeneousCliAgentType;
|
||||
command: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code CLI auth status (from `claude auth status --json`)
|
||||
*/
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
"name": "@lobechat/heterogeneous-agents",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./client": "./src/client/index.ts"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest --coverage --silent='passed-only'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lobechat/builtin-tool-claude-code": "workspace:*"
|
||||
"@lobechat/builtin-tool-claude-code": "workspace:*",
|
||||
"@lobehub/icons": "^5.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lobechat/types": "workspace:*"
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{"type":"thread.started","thread_id":"019dba1e-eec2-7a22-bdfb-ac6175e03081"}
|
||||
{"type":"turn.started"}
|
||||
{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Delegating a minimal side task to a subagent now, then I’ll wait for its result and return a two-point summary only."}}
|
||||
{"type":"item.started","item":{"id":"item_1","type":"collab_tool_call","tool":"spawn_agent","sender_thread_id":"019dba1e-eec2-7a22-bdfb-ac6175e03081","receiver_thread_ids":[],"prompt":"Return a single short line stating what 2 + 2 equals. Do not add anything else.","agents_states":{},"status":"in_progress"}}
|
||||
{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"The first delegation call was rejected because of incompatible spawn settings. Retrying with a minimal subagent configuration."}}
|
||||
{"type":"item.started","item":{"id":"item_3","type":"collab_tool_call","tool":"spawn_agent","sender_thread_id":"019dba1e-eec2-7a22-bdfb-ac6175e03081","receiver_thread_ids":[],"prompt":"Return a single short line stating what 2 + 2 equals. Do not add anything else.","agents_states":{},"status":"in_progress"}}
|
||||
{"type":"item.completed","item":{"id":"item_3","type":"collab_tool_call","tool":"spawn_agent","sender_thread_id":"019dba1e-eec2-7a22-bdfb-ac6175e03081","receiver_thread_ids":["019dba1f-171e-7ae0-8d0d-2c659c15a4f0"],"prompt":"Return a single short line stating what 2 + 2 equals. Do not add anything else.","agents_states":{"019dba1f-171e-7ae0-8d0d-2c659c15a4f0":{"status":"pending_init","message":null}},"status":"completed"}}
|
||||
{"type":"item.started","item":{"id":"item_4","type":"collab_tool_call","tool":"wait","sender_thread_id":"019dba1e-eec2-7a22-bdfb-ac6175e03081","receiver_thread_ids":["019dba1f-171e-7ae0-8d0d-2c659c15a4f0"],"prompt":null,"agents_states":{},"status":"in_progress"}}
|
||||
{"type":"item.completed","item":{"id":"item_4","type":"collab_tool_call","tool":"wait","sender_thread_id":"019dba1e-eec2-7a22-bdfb-ac6175e03081","receiver_thread_ids":["019dba1f-171e-7ae0-8d0d-2c659c15a4f0"],"prompt":null,"agents_states":{"019dba1f-171e-7ae0-8d0d-2c659c15a4f0":{"status":"completed","message":"2 + 2 = 4"}},"status":"completed"}}
|
||||
{"type":"item.completed","item":{"id":"item_5","type":"agent_message","text":"- Spawned one subagent to handle the side task of answering `2 + 2`.\n- Waited for completion, and the subagent returned: `2 + 2 = 4`."}}
|
||||
{"type":"turn.completed","usage":{"input_tokens":52937,"cached_input_tokens":43008,"output_tokens":416}}
|
||||
@@ -32,6 +32,71 @@ describe('ClaudeCodeAdapter', () => {
|
||||
expect(events.map((e) => e.type)).toEqual(['stream_end', 'error']);
|
||||
expect(events[1].data.message).toBe('boom');
|
||||
});
|
||||
|
||||
it('classifies auth failures from failed result events', () => {
|
||||
const adapter = new ClaudeCodeAdapter();
|
||||
const rawError =
|
||||
'Failed to authenticate. API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"}}';
|
||||
|
||||
adapter.adapt({ subtype: 'init', type: 'system' });
|
||||
const events = adapter.adapt({ is_error: true, result: rawError, type: 'result' });
|
||||
|
||||
expect(events.map((e) => e.type)).toEqual(['stream_end', 'error']);
|
||||
expect(events[1].data).toMatchObject({
|
||||
agentType: 'claude-code',
|
||||
clearEchoedContent: true,
|
||||
code: 'auth_required',
|
||||
docsUrl: 'https://docs.anthropic.com/en/docs/claude-code/setup',
|
||||
stderr: rawError,
|
||||
});
|
||||
expect(events[1].data.message).toBe(
|
||||
'Claude Code could not authenticate. Sign in again or refresh its credentials, then retry.',
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies rate-limit failures from paired rate_limit_event + result events', () => {
|
||||
const adapter = new ClaudeCodeAdapter();
|
||||
const rawError = "You've hit your limit · resets 9am (Asia/Shanghai)";
|
||||
|
||||
adapter.adapt({ subtype: 'init', type: 'system' });
|
||||
expect(
|
||||
adapter.adapt({
|
||||
rate_limit_info: {
|
||||
isUsingOverage: false,
|
||||
overageDisabledReason: 'org_level_disabled',
|
||||
overageStatus: 'rejected',
|
||||
rateLimitType: 'seven_day',
|
||||
resetsAt: 1_776_992_400,
|
||||
status: 'rejected',
|
||||
},
|
||||
type: 'rate_limit_event',
|
||||
}),
|
||||
).toEqual([]);
|
||||
|
||||
const events = adapter.adapt({
|
||||
api_error_status: 429,
|
||||
is_error: true,
|
||||
result: rawError,
|
||||
type: 'result',
|
||||
});
|
||||
|
||||
expect(events.map((e) => e.type)).toEqual(['stream_end', 'error']);
|
||||
expect(events[1].data).toMatchObject({
|
||||
agentType: 'claude-code',
|
||||
clearEchoedContent: true,
|
||||
code: 'rate_limit',
|
||||
message: rawError,
|
||||
rateLimitInfo: {
|
||||
isUsingOverage: false,
|
||||
overageDisabledReason: 'org_level_disabled',
|
||||
overageStatus: 'rejected',
|
||||
rateLimitType: 'seven_day',
|
||||
resetsAt: 1_776_992_400,
|
||||
status: 'rejected',
|
||||
},
|
||||
stderr: rawError,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('content mapping', () => {
|
||||
@@ -838,9 +903,9 @@ describe('ClaudeCodeAdapter', () => {
|
||||
expect(adapter.adapt('string')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for unknown event types (rate_limit_event)', () => {
|
||||
it('returns empty array for unknown event types', () => {
|
||||
const adapter = new ClaudeCodeAdapter();
|
||||
const events = adapter.adapt({ type: 'rate_limit_event', data: {} });
|
||||
const events = adapter.adapt({ type: 'something_unexpected', data: {} });
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* {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)
|
||||
* {type: 'rate_limit_event', ...}
|
||||
*
|
||||
* With `--include-partial-messages` (enabled by default in this adapter), CC
|
||||
* also emits token-level deltas wrapped as:
|
||||
@@ -44,6 +44,8 @@ import type {
|
||||
AgentCLIPreset,
|
||||
AgentEventAdapter,
|
||||
HeterogeneousAgentEvent,
|
||||
HeterogeneousRateLimitInfo,
|
||||
HeterogeneousTerminalErrorData,
|
||||
StreamChunkData,
|
||||
SubagentEventContext,
|
||||
ToolCallPayload,
|
||||
@@ -51,6 +53,97 @@ import type {
|
||||
UsageData,
|
||||
} from '../types';
|
||||
|
||||
const CLAUDE_CODE_CLI_INSTALL_DOCS_URL = 'https://docs.anthropic.com/en/docs/claude-code/setup';
|
||||
|
||||
const CLI_AUTH_REQUIRED_PATTERNS = [
|
||||
/failed to authenticate/i,
|
||||
/invalid authentication credentials/i,
|
||||
/authentication[_ ]error/i,
|
||||
/not authenticated/i,
|
||||
/\bunauthorized\b/i,
|
||||
/\b401\b/,
|
||||
] as const;
|
||||
|
||||
const CLI_RATE_LIMIT_PATTERNS = [/you'?ve hit your limit/i, /rate limit/i] as const;
|
||||
|
||||
const getCliResultMessage = (result: unknown): string | undefined => {
|
||||
if (typeof result === 'string') return result;
|
||||
if (
|
||||
result &&
|
||||
typeof result === 'object' &&
|
||||
'message' in result &&
|
||||
typeof result.message === 'string'
|
||||
) {
|
||||
return result.message;
|
||||
}
|
||||
|
||||
try {
|
||||
return result == null ? undefined : JSON.stringify(result);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const getAuthRequiredTerminalError = (
|
||||
result: unknown,
|
||||
): HeterogeneousTerminalErrorData | undefined => {
|
||||
const rawMessage = getCliResultMessage(result);
|
||||
if (!rawMessage || !CLI_AUTH_REQUIRED_PATTERNS.some((pattern) => pattern.test(rawMessage))) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
agentType: 'claude-code',
|
||||
clearEchoedContent: true,
|
||||
code: 'auth_required',
|
||||
docsUrl: CLAUDE_CODE_CLI_INSTALL_DOCS_URL,
|
||||
error: rawMessage,
|
||||
message:
|
||||
'Claude Code could not authenticate. Sign in again or refresh its credentials, then retry.',
|
||||
stderr: rawMessage,
|
||||
};
|
||||
};
|
||||
|
||||
const toRateLimitInfo = (value: unknown): HeterogeneousRateLimitInfo | undefined => {
|
||||
if (!value || typeof value !== 'object') return;
|
||||
|
||||
const raw = value as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
isUsingOverage: typeof raw.isUsingOverage === 'boolean' ? raw.isUsingOverage : undefined,
|
||||
overageDisabledReason:
|
||||
typeof raw.overageDisabledReason === 'string' ? raw.overageDisabledReason : undefined,
|
||||
overageStatus: typeof raw.overageStatus === 'string' ? raw.overageStatus : undefined,
|
||||
rateLimitType: typeof raw.rateLimitType === 'string' ? raw.rateLimitType : undefined,
|
||||
resetsAt: typeof raw.resetsAt === 'number' ? raw.resetsAt : undefined,
|
||||
status: typeof raw.status === 'string' ? raw.status : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const getRateLimitTerminalError = (
|
||||
result: unknown,
|
||||
rateLimitInfo?: HeterogeneousRateLimitInfo,
|
||||
apiErrorStatus?: unknown,
|
||||
): HeterogeneousTerminalErrorData | undefined => {
|
||||
const rawMessage = getCliResultMessage(result);
|
||||
const looksLikeRateLimit =
|
||||
apiErrorStatus === 429 ||
|
||||
!!rateLimitInfo ||
|
||||
(!!rawMessage && CLI_RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(rawMessage)));
|
||||
|
||||
if (!looksLikeRateLimit || !rawMessage) return;
|
||||
|
||||
return {
|
||||
agentType: 'claude-code',
|
||||
clearEchoedContent: true,
|
||||
code: 'rate_limit',
|
||||
error: rawMessage,
|
||||
message: rawMessage,
|
||||
rateLimitInfo,
|
||||
stderr: rawMessage,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* CC's TodoWrite is a declarative state-write tool: its `tool_use.input` IS
|
||||
* the target todos list, and the `tool_result` content is just a confirmation
|
||||
@@ -140,6 +233,7 @@ export const claudeCodePreset: AgentCLIPreset = {
|
||||
|
||||
export class ClaudeCodeAdapter implements AgentEventAdapter {
|
||||
sessionId?: string;
|
||||
private pendingRateLimitInfo?: HeterogeneousRateLimitInfo;
|
||||
|
||||
/** Pending tool_use ids awaiting their tool_result */
|
||||
private pendingToolCalls = new Set<string>();
|
||||
@@ -201,6 +295,9 @@ export class ClaudeCodeAdapter implements AgentEventAdapter {
|
||||
if (!raw || typeof raw !== 'object') return [];
|
||||
|
||||
switch (raw.type) {
|
||||
case 'rate_limit_event': {
|
||||
return this.handleRateLimitEvent(raw);
|
||||
}
|
||||
case 'system': {
|
||||
return this.handleSystem(raw);
|
||||
}
|
||||
@@ -218,7 +315,7 @@ export class ClaudeCodeAdapter implements AgentEventAdapter {
|
||||
}
|
||||
default: {
|
||||
return [];
|
||||
} // rate_limit_event, etc.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +343,12 @@ export class ClaudeCodeAdapter implements AgentEventAdapter {
|
||||
}
|
||||
|
||||
private handleAssistant(raw: any): HeterogeneousAgentEvent[] {
|
||||
// Claude Code emits a synthetic assistant text turn for rate-limit
|
||||
// failures. We already surface the structured rate-limit metadata via
|
||||
// the paired `rate_limit_event` + terminal `result`, so letting this
|
||||
// text through would momentarily render a duplicate plain-text bubble.
|
||||
if (raw.error === 'rate_limit') return [];
|
||||
|
||||
const content = raw.message?.content;
|
||||
if (!Array.isArray(content)) return [];
|
||||
|
||||
@@ -328,6 +431,11 @@ export class ClaudeCodeAdapter implements AgentEventAdapter {
|
||||
return events;
|
||||
}
|
||||
|
||||
private handleRateLimitEvent(raw: any): HeterogeneousAgentEvent[] {
|
||||
this.pendingRateLimitInfo = toRateLimitInfo(raw.rate_limit_info);
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a subagent assistant event (tagged with `parent_tool_use_id`).
|
||||
*
|
||||
@@ -604,13 +712,25 @@ export class ClaudeCodeAdapter implements AgentEventAdapter {
|
||||
);
|
||||
}
|
||||
|
||||
const resultMessage = getCliResultMessage(raw.result) || 'Agent execution failed';
|
||||
const rateLimitError = getRateLimitTerminalError(
|
||||
raw.result,
|
||||
this.pendingRateLimitInfo,
|
||||
raw.api_error_status,
|
||||
);
|
||||
const finalEvent: HeterogeneousAgentEvent = raw.is_error
|
||||
? this.makeEvent('error', {
|
||||
error: raw.result || 'Agent execution failed',
|
||||
message: raw.result || 'Agent execution failed',
|
||||
})
|
||||
? this.makeEvent(
|
||||
'error',
|
||||
rateLimitError ||
|
||||
getAuthRequiredTerminalError(raw.result) || {
|
||||
error: resultMessage,
|
||||
message: resultMessage,
|
||||
},
|
||||
)
|
||||
: this.makeEvent('agent_runtime_end', {});
|
||||
|
||||
this.pendingRateLimitInfo = undefined;
|
||||
|
||||
return [...events, this.makeEvent('stream_end', {}), finalEvent];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,633 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { CodexAdapter } from './codex';
|
||||
|
||||
const loadFixture = async (name: string) => {
|
||||
const raw = await readFile(new URL(`./__fixtures__/codex/${name}`, import.meta.url), 'utf8');
|
||||
return raw
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => JSON.parse(line));
|
||||
};
|
||||
|
||||
describe('CodexAdapter', () => {
|
||||
it('captures the session id from thread.started', () => {
|
||||
const adapter = new CodexAdapter();
|
||||
|
||||
const events = adapter.adapt({
|
||||
thread_id: 'thread-123',
|
||||
type: 'thread.started',
|
||||
});
|
||||
|
||||
expect(events).toHaveLength(0);
|
||||
expect(adapter.sessionId).toBe('thread-123');
|
||||
});
|
||||
|
||||
it('emits stream start and text chunks for turn + agent messages', () => {
|
||||
const adapter = new CodexAdapter();
|
||||
|
||||
const start = adapter.adapt({ type: 'turn.started' });
|
||||
const text = adapter.adapt({
|
||||
item: {
|
||||
id: 'item_0',
|
||||
text: 'hello from codex',
|
||||
type: 'agent_message',
|
||||
},
|
||||
type: 'item.completed',
|
||||
});
|
||||
|
||||
expect(start[0]).toMatchObject({
|
||||
data: { provider: 'codex' },
|
||||
type: 'stream_start',
|
||||
});
|
||||
expect(text[0]).toMatchObject({
|
||||
data: { chunkType: 'text', content: 'hello from codex' },
|
||||
type: 'stream_chunk',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits a new-step boundary when a second turn starts', () => {
|
||||
const adapter = new CodexAdapter();
|
||||
|
||||
const firstTurn = adapter.adapt({ type: 'turn.started' });
|
||||
const secondTurn = adapter.adapt({ type: 'turn.started' });
|
||||
|
||||
expect(firstTurn).toHaveLength(1);
|
||||
expect(firstTurn[0]).toMatchObject({
|
||||
data: { provider: 'codex' },
|
||||
stepIndex: 0,
|
||||
type: 'stream_start',
|
||||
});
|
||||
|
||||
expect(secondTurn).toHaveLength(2);
|
||||
expect(secondTurn[0]).toMatchObject({
|
||||
data: {},
|
||||
stepIndex: 1,
|
||||
type: 'stream_end',
|
||||
});
|
||||
expect(secondTurn[1]).toMatchObject({
|
||||
data: { newStep: true, provider: 'codex' },
|
||||
stepIndex: 1,
|
||||
type: 'stream_start',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits a new-step boundary when a later agent_message item arrives in the same turn', () => {
|
||||
const adapter = new CodexAdapter();
|
||||
|
||||
adapter.adapt({ type: 'turn.started' });
|
||||
adapter.adapt({
|
||||
item: {
|
||||
id: 'item_0',
|
||||
text: 'Running the first checks.',
|
||||
type: 'agent_message',
|
||||
},
|
||||
type: 'item.completed',
|
||||
});
|
||||
adapter.adapt({
|
||||
item: {
|
||||
command: '/bin/zsh -lc pwd',
|
||||
id: 'item_1',
|
||||
status: 'in_progress',
|
||||
type: 'command_execution',
|
||||
},
|
||||
type: 'item.started',
|
||||
});
|
||||
adapter.adapt({
|
||||
item: {
|
||||
aggregated_output: '/repo\\n',
|
||||
command: '/bin/zsh -lc pwd',
|
||||
exit_code: 0,
|
||||
id: 'item_1',
|
||||
status: 'completed',
|
||||
type: 'command_execution',
|
||||
},
|
||||
type: 'item.completed',
|
||||
});
|
||||
|
||||
const secondMessage = adapter.adapt({
|
||||
item: {
|
||||
id: 'item_2',
|
||||
text: 'Now I will inspect the branch.',
|
||||
type: 'agent_message',
|
||||
},
|
||||
type: 'item.completed',
|
||||
});
|
||||
|
||||
expect(secondMessage).toHaveLength(3);
|
||||
expect(secondMessage[0]).toMatchObject({
|
||||
data: {},
|
||||
stepIndex: 1,
|
||||
type: 'stream_end',
|
||||
});
|
||||
expect(secondMessage[1]).toMatchObject({
|
||||
data: { newStep: true, provider: 'codex' },
|
||||
stepIndex: 1,
|
||||
type: 'stream_start',
|
||||
});
|
||||
expect(secondMessage[2]).toMatchObject({
|
||||
data: { chunkType: 'text', content: 'Now I will inspect the branch.' },
|
||||
stepIndex: 1,
|
||||
type: 'stream_chunk',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps command execution items into tool lifecycle events', () => {
|
||||
const adapter = new CodexAdapter();
|
||||
|
||||
const started = adapter.adapt({
|
||||
item: {
|
||||
command: '/bin/zsh -lc pwd',
|
||||
id: 'item_1',
|
||||
status: 'in_progress',
|
||||
type: 'command_execution',
|
||||
},
|
||||
type: 'item.started',
|
||||
});
|
||||
const completed = adapter.adapt({
|
||||
item: {
|
||||
aggregated_output: '/tmp\\n',
|
||||
command: '/bin/zsh -lc pwd',
|
||||
exit_code: 0,
|
||||
id: 'item_1',
|
||||
status: 'completed',
|
||||
type: 'command_execution',
|
||||
},
|
||||
type: 'item.completed',
|
||||
});
|
||||
|
||||
expect(started).toHaveLength(2);
|
||||
expect(started[0]).toMatchObject({
|
||||
data: {
|
||||
chunkType: 'tools_calling',
|
||||
toolsCalling: [
|
||||
{
|
||||
apiName: 'command_execution',
|
||||
id: 'item_1',
|
||||
identifier: 'codex',
|
||||
},
|
||||
],
|
||||
},
|
||||
type: 'stream_chunk',
|
||||
});
|
||||
expect(started[1]).toMatchObject({
|
||||
data: { toolCallId: 'item_1' },
|
||||
type: 'tool_start',
|
||||
});
|
||||
|
||||
expect(completed).toHaveLength(2);
|
||||
expect(completed[0]).toMatchObject({
|
||||
data: {
|
||||
content: '/tmp\\n',
|
||||
pluginState: {
|
||||
exitCode: 0,
|
||||
isBackground: false,
|
||||
output: '/tmp\\n',
|
||||
stdout: '/tmp\\n',
|
||||
success: true,
|
||||
},
|
||||
toolCallId: 'item_1',
|
||||
},
|
||||
type: 'tool_result',
|
||||
});
|
||||
expect(completed[1]).toMatchObject({
|
||||
data: { isSuccess: true, toolCallId: 'item_1' },
|
||||
type: 'tool_end',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps todo_list items into shared todo plugin state', () => {
|
||||
const adapter = new CodexAdapter();
|
||||
|
||||
const todoItem = {
|
||||
id: 'item_0',
|
||||
items: [
|
||||
{ completed: true, text: 'Create the three-item todo list' },
|
||||
{ completed: false, text: 'Keep the second item incomplete' },
|
||||
{ completed: false, text: 'Keep the third item incomplete' },
|
||||
],
|
||||
type: 'todo_list',
|
||||
};
|
||||
|
||||
const started = adapter.adapt({
|
||||
item: todoItem,
|
||||
type: 'item.started',
|
||||
});
|
||||
const completed = adapter.adapt({
|
||||
item: todoItem,
|
||||
type: 'item.completed',
|
||||
});
|
||||
|
||||
expect(started[0]).toMatchObject({
|
||||
data: {
|
||||
chunkType: 'tools_calling',
|
||||
toolsCalling: [
|
||||
{
|
||||
apiName: 'todo_list',
|
||||
id: 'item_0',
|
||||
identifier: 'codex',
|
||||
},
|
||||
],
|
||||
},
|
||||
type: 'stream_chunk',
|
||||
});
|
||||
expect(completed[0]).toMatchObject({
|
||||
data: {
|
||||
content: 'Todo list updated (1/3 completed).',
|
||||
pluginState: {
|
||||
todos: {
|
||||
items: [
|
||||
{ status: 'completed', text: 'Create the three-item todo list' },
|
||||
{ status: 'processing', text: 'Keep the second item incomplete' },
|
||||
{ status: 'todo', text: 'Keep the third item incomplete' },
|
||||
],
|
||||
},
|
||||
},
|
||||
toolCallId: 'item_0',
|
||||
},
|
||||
type: 'tool_result',
|
||||
});
|
||||
expect(completed[1]).toMatchObject({
|
||||
data: { isSuccess: true, toolCallId: 'item_0' },
|
||||
type: 'tool_end',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps file_change items into readable tool results', () => {
|
||||
const adapter = new CodexAdapter();
|
||||
|
||||
const started = adapter.adapt({
|
||||
item: {
|
||||
changes: [{ kind: 'add', path: '/private/tmp/codex-file-change-sample.txt' }],
|
||||
id: 'item_1',
|
||||
status: 'in_progress',
|
||||
type: 'file_change',
|
||||
},
|
||||
type: 'item.started',
|
||||
});
|
||||
const completed = adapter.adapt({
|
||||
item: {
|
||||
changes: [
|
||||
{
|
||||
kind: 'add',
|
||||
linesAdded: 3,
|
||||
linesDeleted: 0,
|
||||
path: '/private/tmp/codex-file-change-sample.txt',
|
||||
},
|
||||
],
|
||||
id: 'item_1',
|
||||
linesAdded: 3,
|
||||
linesDeleted: 0,
|
||||
status: 'completed',
|
||||
type: 'file_change',
|
||||
},
|
||||
type: 'item.completed',
|
||||
});
|
||||
|
||||
expect(started[0]).toMatchObject({
|
||||
data: {
|
||||
chunkType: 'tools_calling',
|
||||
toolsCalling: [
|
||||
{
|
||||
apiName: 'file_change',
|
||||
id: 'item_1',
|
||||
identifier: 'codex',
|
||||
},
|
||||
],
|
||||
},
|
||||
type: 'stream_chunk',
|
||||
});
|
||||
expect(completed[0]).toMatchObject({
|
||||
data: {
|
||||
content: 'File changes applied (1 added, +3 -0).',
|
||||
isError: false,
|
||||
pluginState: {
|
||||
changes: [
|
||||
{
|
||||
kind: 'add',
|
||||
linesAdded: 3,
|
||||
linesDeleted: 0,
|
||||
path: '/private/tmp/codex-file-change-sample.txt',
|
||||
},
|
||||
],
|
||||
linesAdded: 3,
|
||||
linesDeleted: 0,
|
||||
},
|
||||
toolCallId: 'item_1',
|
||||
},
|
||||
type: 'tool_result',
|
||||
});
|
||||
expect(completed[1]).toMatchObject({
|
||||
data: { isSuccess: true, toolCallId: 'item_1' },
|
||||
type: 'tool_end',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses failure copy for unsuccessful non-command tool completions', () => {
|
||||
const adapter = new CodexAdapter();
|
||||
|
||||
adapter.adapt({
|
||||
item: {
|
||||
id: 'todo_failed',
|
||||
items: [{ completed: false, text: 'Keep this pending' }],
|
||||
status: 'failed',
|
||||
type: 'todo_list',
|
||||
},
|
||||
type: 'item.started',
|
||||
});
|
||||
const failedTodo = adapter.adapt({
|
||||
item: {
|
||||
id: 'todo_failed',
|
||||
items: [{ completed: false, text: 'Keep this pending' }],
|
||||
status: 'failed',
|
||||
type: 'todo_list',
|
||||
},
|
||||
type: 'item.completed',
|
||||
});
|
||||
|
||||
expect(failedTodo[0]).toMatchObject({
|
||||
data: {
|
||||
content: 'Todo list update failed.',
|
||||
isError: true,
|
||||
toolCallId: 'todo_failed',
|
||||
},
|
||||
type: 'tool_result',
|
||||
});
|
||||
expect(failedTodo[0].data).not.toHaveProperty('pluginState');
|
||||
expect(failedTodo[1]).toMatchObject({
|
||||
data: { isSuccess: false, toolCallId: 'todo_failed' },
|
||||
type: 'tool_end',
|
||||
});
|
||||
|
||||
adapter.adapt({
|
||||
item: {
|
||||
changes: [{ kind: 'add', path: '/private/tmp/cancelled-change.ts' }],
|
||||
id: 'file_cancelled',
|
||||
status: 'cancelled',
|
||||
type: 'file_change',
|
||||
},
|
||||
type: 'item.started',
|
||||
});
|
||||
const cancelledFileChange = adapter.adapt({
|
||||
item: {
|
||||
changes: [{ kind: 'add', path: '/private/tmp/cancelled-change.ts' }],
|
||||
id: 'file_cancelled',
|
||||
status: 'cancelled',
|
||||
type: 'file_change',
|
||||
},
|
||||
type: 'item.completed',
|
||||
});
|
||||
|
||||
expect(cancelledFileChange[0]).toMatchObject({
|
||||
data: {
|
||||
content: 'File changes cancelled.',
|
||||
isError: true,
|
||||
toolCallId: 'file_cancelled',
|
||||
},
|
||||
type: 'tool_result',
|
||||
});
|
||||
expect(cancelledFileChange[0].data).not.toHaveProperty('pluginState');
|
||||
expect(cancelledFileChange[1]).toMatchObject({
|
||||
data: { isSuccess: false, toolCallId: 'file_cancelled' },
|
||||
type: 'tool_end',
|
||||
});
|
||||
|
||||
adapter.adapt({
|
||||
item: {
|
||||
id: 'wait_failed',
|
||||
status: 'error',
|
||||
tool: 'wait',
|
||||
type: 'collab_tool_call',
|
||||
},
|
||||
type: 'item.started',
|
||||
});
|
||||
const failedWait = adapter.adapt({
|
||||
item: {
|
||||
id: 'wait_failed',
|
||||
status: 'error',
|
||||
tool: 'wait',
|
||||
type: 'collab_tool_call',
|
||||
},
|
||||
type: 'item.completed',
|
||||
});
|
||||
|
||||
expect(failedWait[0]).toMatchObject({
|
||||
data: {
|
||||
content: 'wait failed.',
|
||||
isError: true,
|
||||
toolCallId: 'wait_failed',
|
||||
},
|
||||
type: 'tool_result',
|
||||
});
|
||||
expect(failedWait[1]).toMatchObject({
|
||||
data: { isSuccess: false, toolCallId: 'wait_failed' },
|
||||
type: 'tool_end',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a real collab_tool_call stream fixture readable and flushes unfinished attempts', async () => {
|
||||
const adapter = new CodexAdapter();
|
||||
const rawEvents = await loadFixture('collab_tool_call.spawn_wait.jsonl');
|
||||
|
||||
const adapted = rawEvents.flatMap((event) => adapter.adapt(event));
|
||||
const flushed = adapter.flush();
|
||||
|
||||
const toolStarts = adapted
|
||||
.filter((event) => event.type === 'tool_start')
|
||||
.map((event) => event.data.toolCallId);
|
||||
const toolResults = adapted
|
||||
.filter((event) => event.type === 'tool_result')
|
||||
.map((event) => event.data);
|
||||
|
||||
expect(toolStarts).toEqual(['item_1', 'item_3', 'item_4']);
|
||||
expect(toolResults).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
content: 'Spawned 1 subagent.',
|
||||
toolCallId: 'item_3',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
content: 'Wait completed: 2 + 2 = 4',
|
||||
toolCallId: 'item_4',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(flushed).toEqual([
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
isSuccess: false,
|
||||
toolCallId: 'item_1',
|
||||
},
|
||||
type: 'tool_end',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits cumulative tools_calling within the same Codex step', () => {
|
||||
const adapter = new CodexAdapter();
|
||||
|
||||
adapter.adapt({ type: 'turn.started' });
|
||||
|
||||
const firstTool = adapter.adapt({
|
||||
item: {
|
||||
command: '/bin/zsh -lc pwd',
|
||||
id: 'item_1',
|
||||
status: 'in_progress',
|
||||
type: 'command_execution',
|
||||
},
|
||||
type: 'item.started',
|
||||
});
|
||||
const secondTool = adapter.adapt({
|
||||
item: {
|
||||
command: "/bin/zsh -lc 'git status --short'",
|
||||
id: 'item_2',
|
||||
status: 'in_progress',
|
||||
type: 'command_execution',
|
||||
},
|
||||
type: 'item.started',
|
||||
});
|
||||
|
||||
expect(firstTool[0]).toMatchObject({
|
||||
data: {
|
||||
chunkType: 'tools_calling',
|
||||
toolsCalling: [{ id: 'item_1' }],
|
||||
},
|
||||
type: 'stream_chunk',
|
||||
});
|
||||
expect(secondTool[0]).toMatchObject({
|
||||
data: {
|
||||
chunkType: 'tools_calling',
|
||||
toolsCalling: [{ id: 'item_1' }, { id: 'item_2' }],
|
||||
},
|
||||
type: 'stream_chunk',
|
||||
});
|
||||
expect(secondTool[1]).toMatchObject({
|
||||
data: { toolCallId: 'item_2' },
|
||||
type: 'tool_start',
|
||||
});
|
||||
});
|
||||
|
||||
it('resets cumulative tools_calling after a same-turn agent_message step boundary', () => {
|
||||
const adapter = new CodexAdapter();
|
||||
|
||||
adapter.adapt({ type: 'turn.started' });
|
||||
adapter.adapt({
|
||||
item: {
|
||||
id: 'item_0',
|
||||
text: 'Running the first checks.',
|
||||
type: 'agent_message',
|
||||
},
|
||||
type: 'item.completed',
|
||||
});
|
||||
adapter.adapt({
|
||||
item: {
|
||||
command: '/bin/zsh -lc pwd',
|
||||
id: 'item_1',
|
||||
status: 'in_progress',
|
||||
type: 'command_execution',
|
||||
},
|
||||
type: 'item.started',
|
||||
});
|
||||
adapter.adapt({
|
||||
item: {
|
||||
id: 'item_2',
|
||||
text: 'Now I will inspect the branch.',
|
||||
type: 'agent_message',
|
||||
},
|
||||
type: 'item.completed',
|
||||
});
|
||||
|
||||
const nextStepTool = adapter.adapt({
|
||||
item: {
|
||||
command: "/bin/zsh -lc 'git branch --show-current'",
|
||||
id: 'item_3',
|
||||
status: 'in_progress',
|
||||
type: 'command_execution',
|
||||
},
|
||||
type: 'item.started',
|
||||
});
|
||||
|
||||
expect(nextStepTool[0]).toMatchObject({
|
||||
data: {
|
||||
chunkType: 'tools_calling',
|
||||
toolsCalling: [{ id: 'item_3' }],
|
||||
},
|
||||
stepIndex: 1,
|
||||
type: 'stream_chunk',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps turn.completed usage into turn metadata', () => {
|
||||
const adapter = new CodexAdapter();
|
||||
|
||||
const events = adapter.adapt({
|
||||
type: 'turn.completed',
|
||||
usage: {
|
||||
cached_input_tokens: 4,
|
||||
input_tokens: 10,
|
||||
output_tokens: 3,
|
||||
},
|
||||
});
|
||||
|
||||
expect(events[0]).toMatchObject({
|
||||
data: {
|
||||
phase: 'turn_metadata',
|
||||
provider: 'codex',
|
||||
usage: {
|
||||
inputCachedTokens: 4,
|
||||
inputCacheMissTokens: 10,
|
||||
totalInputTokens: 14,
|
||||
totalOutputTokens: 3,
|
||||
totalTokens: 17,
|
||||
},
|
||||
},
|
||||
type: 'step_complete',
|
||||
});
|
||||
});
|
||||
|
||||
it('hydrates turn metadata model from session_configured when turn.completed omits it', () => {
|
||||
const adapter = new CodexAdapter();
|
||||
|
||||
adapter.adapt({
|
||||
model: 'gpt-5.3-codex',
|
||||
type: 'session_configured',
|
||||
});
|
||||
|
||||
const events = adapter.adapt({
|
||||
type: 'turn.completed',
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 3,
|
||||
},
|
||||
});
|
||||
|
||||
expect(events[0]).toMatchObject({
|
||||
data: {
|
||||
model: 'gpt-5.3-codex',
|
||||
phase: 'turn_metadata',
|
||||
provider: 'codex',
|
||||
},
|
||||
type: 'step_complete',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits turn metadata when turn.completed reports a model without usage', () => {
|
||||
const adapter = new CodexAdapter();
|
||||
|
||||
const events = adapter.adapt({
|
||||
model: 'gpt-5.4',
|
||||
type: 'turn.completed',
|
||||
});
|
||||
|
||||
expect(events[0]).toMatchObject({
|
||||
data: {
|
||||
model: 'gpt-5.4',
|
||||
phase: 'turn_metadata',
|
||||
provider: 'codex',
|
||||
},
|
||||
type: 'step_complete',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,527 @@
|
||||
import type {
|
||||
AgentCLIPreset,
|
||||
AgentEventAdapter,
|
||||
HeterogeneousAgentEvent,
|
||||
StepCompleteData,
|
||||
ToolCallPayload,
|
||||
ToolResultData,
|
||||
UsageData,
|
||||
} from '../types';
|
||||
|
||||
const CODEX_IDENTIFIER = 'codex';
|
||||
const CODEX_COLLAB_TOOL_CALL_API = 'collab_tool_call';
|
||||
const CODEX_COMMAND_API = 'command_execution';
|
||||
const CODEX_FILE_CHANGE_API = 'file_change';
|
||||
const CODEX_TODO_LIST_API = 'todo_list';
|
||||
|
||||
interface CodexBaseItem {
|
||||
id: string;
|
||||
status?: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface CodexCommandExecutionItem extends CodexBaseItem {
|
||||
aggregated_output?: string;
|
||||
command?: string;
|
||||
exit_code?: number | null;
|
||||
}
|
||||
|
||||
interface CodexTodoListEntry {
|
||||
completed?: boolean;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface CodexTodoListItem extends CodexBaseItem {
|
||||
items?: CodexTodoListEntry[];
|
||||
}
|
||||
|
||||
interface CodexFileChangeEntry {
|
||||
kind?: string;
|
||||
linesAdded?: number;
|
||||
linesDeleted?: number;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface CodexFileChangeItem extends CodexBaseItem {
|
||||
changes?: CodexFileChangeEntry[];
|
||||
linesAdded?: number;
|
||||
linesDeleted?: number;
|
||||
}
|
||||
|
||||
interface CodexCollabAgentState {
|
||||
message?: string | null;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface CodexCollabToolCallItem extends CodexBaseItem {
|
||||
agents_states?: Record<string, CodexCollabAgentState>;
|
||||
prompt?: string | null;
|
||||
receiver_thread_ids?: string[];
|
||||
sender_thread_id?: string;
|
||||
tool?: string;
|
||||
}
|
||||
|
||||
type CodexToolItem =
|
||||
| CodexBaseItem
|
||||
| CodexCollabToolCallItem
|
||||
| CodexCommandExecutionItem
|
||||
| CodexFileChangeItem
|
||||
| CodexTodoListItem;
|
||||
|
||||
const isCommandExecutionItem = (item: CodexToolItem): item is CodexCommandExecutionItem =>
|
||||
item.type === CODEX_COMMAND_API;
|
||||
|
||||
const isCollabToolCallItem = (item: CodexToolItem): item is CodexCollabToolCallItem =>
|
||||
item.type === CODEX_COLLAB_TOOL_CALL_API;
|
||||
|
||||
const isFileChangeItem = (item: CodexToolItem): item is CodexFileChangeItem =>
|
||||
item.type === CODEX_FILE_CHANGE_API;
|
||||
|
||||
const isTodoListItem = (item: CodexToolItem): item is CodexTodoListItem =>
|
||||
item.type === CODEX_TODO_LIST_API;
|
||||
|
||||
const toUsageData = (
|
||||
raw:
|
||||
| {
|
||||
cached_input_tokens?: number;
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
}
|
||||
| null
|
||||
| undefined,
|
||||
): UsageData | undefined => {
|
||||
if (!raw) return undefined;
|
||||
|
||||
const inputCacheMissTokens = raw.input_tokens || 0;
|
||||
const inputCachedTokens = raw.cached_input_tokens || 0;
|
||||
const totalInputTokens = inputCacheMissTokens + inputCachedTokens;
|
||||
const totalOutputTokens = raw.output_tokens || 0;
|
||||
|
||||
if (totalInputTokens + totalOutputTokens === 0) return undefined;
|
||||
|
||||
return {
|
||||
inputCachedTokens: inputCachedTokens || undefined,
|
||||
inputCacheMissTokens,
|
||||
totalInputTokens,
|
||||
totalOutputTokens,
|
||||
totalTokens: totalInputTokens + totalOutputTokens,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeTodoListItems = (item: CodexTodoListItem) =>
|
||||
(item.items || [])
|
||||
.map((todo) => ({
|
||||
completed: !!todo.completed,
|
||||
text: typeof todo.text === 'string' ? todo.text.trim() : '',
|
||||
}))
|
||||
.filter((todo) => todo.text.length > 0);
|
||||
|
||||
/**
|
||||
* Codex's `todo_list` only exposes a boolean completed flag. To light up the
|
||||
* shared todo progress UI, treat the first incomplete item as the active one
|
||||
* and the remaining incomplete items as pending.
|
||||
*/
|
||||
const synthesizeTodoListPluginState = (item: CodexTodoListItem) => {
|
||||
const todos = normalizeTodoListItems(item);
|
||||
if (todos.length === 0) return;
|
||||
|
||||
let assignedProcessing = false;
|
||||
const items = todos.map((todo) => {
|
||||
if (todo.completed) return { status: 'completed', text: todo.text } as const;
|
||||
if (!assignedProcessing) {
|
||||
assignedProcessing = true;
|
||||
return { status: 'processing', text: todo.text } as const;
|
||||
}
|
||||
return { status: 'todo', text: todo.text } as const;
|
||||
});
|
||||
|
||||
return {
|
||||
todos: {
|
||||
items,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const synthesizeFileChangePluginState = (item: CodexFileChangeItem) => {
|
||||
const changes = (item.changes || []).map((change) => ({
|
||||
kind: change.kind,
|
||||
linesAdded: change.linesAdded ?? 0,
|
||||
linesDeleted: change.linesDeleted ?? 0,
|
||||
path: change.path,
|
||||
}));
|
||||
|
||||
if (changes.length === 0 && item.linesAdded === undefined && item.linesDeleted === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
changes,
|
||||
linesAdded: item.linesAdded ?? 0,
|
||||
linesDeleted: item.linesDeleted ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
const pluralize = (count: number, singular: string, plural = `${singular}s`) =>
|
||||
count === 1 ? singular : plural;
|
||||
|
||||
const toToolPayload = (item: CodexToolItem): ToolCallPayload => ({
|
||||
apiName: item.type || CODEX_COMMAND_API,
|
||||
arguments: JSON.stringify(isCommandExecutionItem(item) ? { command: item.command || '' } : item),
|
||||
id: item.id,
|
||||
identifier: CODEX_IDENTIFIER,
|
||||
type: 'default',
|
||||
});
|
||||
|
||||
const getFileChangeKind = (kind?: string) => {
|
||||
switch (kind) {
|
||||
case 'add': {
|
||||
return 'added';
|
||||
}
|
||||
case 'delete':
|
||||
case 'remove': {
|
||||
return 'deleted';
|
||||
}
|
||||
case 'rename': {
|
||||
return 'renamed';
|
||||
}
|
||||
default: {
|
||||
return 'modified';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const summarizeTodoList = (item: CodexTodoListItem): string => {
|
||||
const todos = normalizeTodoListItems(item);
|
||||
if (todos.length === 0) return 'Todo list updated.';
|
||||
|
||||
const completed = todos.filter((todo) => todo.completed).length;
|
||||
return `Todo list updated (${completed}/${todos.length} completed).`;
|
||||
};
|
||||
|
||||
const summarizeFileChange = (item: CodexFileChangeItem): string => {
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
for (const change of item.changes || []) {
|
||||
const kind = getFileChangeKind(change.kind);
|
||||
counts.set(kind, (counts.get(kind) || 0) + 1);
|
||||
}
|
||||
|
||||
const totalChanges = [...counts.values()].reduce((sum, count) => sum + count, 0);
|
||||
if (totalChanges === 0) return 'File changes applied.';
|
||||
|
||||
const parts = [...counts.entries()].map(([kind, count]) => `${count} ${kind}`);
|
||||
const statsSuffix =
|
||||
item.linesAdded || item.linesDeleted
|
||||
? `, +${item.linesAdded || 0} -${item.linesDeleted || 0}`
|
||||
: '';
|
||||
|
||||
return `File changes applied (${parts.join(', ')}${statsSuffix}).`;
|
||||
};
|
||||
|
||||
const summarizeCollabToolCall = (item: CodexCollabToolCallItem): string => {
|
||||
const toolName = item.tool || 'collaboration';
|
||||
const agentStates = Object.values(item.agents_states || {});
|
||||
const agentCount = item.receiver_thread_ids?.length || agentStates.length;
|
||||
const completedMessage = agentStates.find(
|
||||
(state) => state.status === 'completed' && typeof state.message === 'string' && state.message,
|
||||
)?.message;
|
||||
|
||||
if (toolName === 'spawn_agent') {
|
||||
return agentCount > 0
|
||||
? `Spawned ${agentCount} ${pluralize(agentCount, 'subagent')}.`
|
||||
: 'Spawned subagent.';
|
||||
}
|
||||
|
||||
if (toolName === 'wait') {
|
||||
if (completedMessage) return `Wait completed: ${completedMessage}`;
|
||||
return agentCount > 0
|
||||
? `Wait completed for ${agentCount} ${pluralize(agentCount, 'subagent')}.`
|
||||
: 'Wait completed.';
|
||||
}
|
||||
|
||||
return `${toolName} completed.`;
|
||||
};
|
||||
|
||||
const summarizeFallbackTool = (item: CodexToolItem): string => {
|
||||
return `Completed ${item.type}.`;
|
||||
};
|
||||
|
||||
const getFailureVerb = (item: CodexToolItem): 'cancelled' | 'failed' =>
|
||||
item.status === 'cancelled' ? 'cancelled' : 'failed';
|
||||
|
||||
const getToolFailureContent = (item: CodexToolItem): string => {
|
||||
if (isTodoListItem(item)) return `Todo list update ${getFailureVerb(item)}.`;
|
||||
if (isFileChangeItem(item)) return `File changes ${getFailureVerb(item)}.`;
|
||||
if (isCollabToolCallItem(item)) return `${item.tool || 'Collaboration'} ${getFailureVerb(item)}.`;
|
||||
|
||||
return `${item.type} ${getFailureVerb(item)}.`;
|
||||
};
|
||||
|
||||
const getToolContent = (item: CodexToolItem, isSuccess: boolean): string => {
|
||||
if (isCommandExecutionItem(item)) {
|
||||
return typeof item.aggregated_output === 'string' ? item.aggregated_output : '';
|
||||
}
|
||||
|
||||
if (!isSuccess) return getToolFailureContent(item);
|
||||
|
||||
if (isTodoListItem(item)) return summarizeTodoList(item);
|
||||
if (isFileChangeItem(item)) return summarizeFileChange(item);
|
||||
if (isCollabToolCallItem(item)) return summarizeCollabToolCall(item);
|
||||
|
||||
return summarizeFallbackTool(item);
|
||||
};
|
||||
|
||||
const isSuccessfulToolCompletion = (item: CodexToolItem): boolean => {
|
||||
if (isCommandExecutionItem(item)) {
|
||||
const exitCode = item.exit_code ?? undefined;
|
||||
return item.status === 'completed' && (exitCode === undefined || exitCode === 0);
|
||||
}
|
||||
|
||||
return item.status !== 'cancelled' && item.status !== 'error' && item.status !== 'failed';
|
||||
};
|
||||
|
||||
const getToolResultData = (item: CodexToolItem): ToolResultData => {
|
||||
const isSuccess = isSuccessfulToolCompletion(item);
|
||||
const output = getToolContent(item, isSuccess);
|
||||
|
||||
if (isCommandExecutionItem(item)) {
|
||||
const exitCode = item.exit_code ?? undefined;
|
||||
|
||||
return {
|
||||
content: output,
|
||||
isError: !isSuccess,
|
||||
pluginState: {
|
||||
...(exitCode !== undefined ? { exitCode } : {}),
|
||||
...(isSuccess ? {} : { error: output || `Command failed (${exitCode ?? 'unknown'})` }),
|
||||
isBackground: false,
|
||||
output,
|
||||
stdout: output,
|
||||
success: isSuccess,
|
||||
},
|
||||
toolCallId: item.id,
|
||||
};
|
||||
}
|
||||
|
||||
const pluginState =
|
||||
isSuccess && isTodoListItem(item)
|
||||
? synthesizeTodoListPluginState(item)
|
||||
: isSuccess && isFileChangeItem(item)
|
||||
? synthesizeFileChangePluginState(item)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
content: output,
|
||||
isError: !isSuccess,
|
||||
...(pluginState ? { pluginState } : {}),
|
||||
toolCallId: item.id,
|
||||
};
|
||||
};
|
||||
|
||||
const getEventModel = (raw: any): string | undefined => {
|
||||
const candidates = [
|
||||
raw?.model,
|
||||
raw?.session?.model,
|
||||
raw?.sessionMeta?.model,
|
||||
raw?.session_meta?.model,
|
||||
raw?.turn?.model,
|
||||
raw?.turn_context?.model,
|
||||
];
|
||||
|
||||
return candidates.find((candidate): candidate is string => typeof candidate === 'string');
|
||||
};
|
||||
|
||||
export const codexPreset: AgentCLIPreset = {
|
||||
baseArgs: ['exec', '--json', '--skip-git-repo-check', '--full-auto'],
|
||||
promptMode: 'stdin',
|
||||
resumeArgs: (sessionId) => ['exec', 'resume', '--json', '--skip-git-repo-check', sessionId],
|
||||
};
|
||||
|
||||
export class CodexAdapter implements AgentEventAdapter {
|
||||
private currentAgentMessageItemId?: string;
|
||||
private currentModel?: string;
|
||||
sessionId?: string;
|
||||
|
||||
private hasStepActivity = false;
|
||||
private pendingToolCalls = new Set<string>();
|
||||
private stepToolCalls: ToolCallPayload[] = [];
|
||||
private stepToolCallIds = new Set<string>();
|
||||
private started = false;
|
||||
private stepIndex = 0;
|
||||
|
||||
adapt(raw: any): HeterogeneousAgentEvent[] {
|
||||
if (!raw || typeof raw !== 'object') return [];
|
||||
|
||||
switch (raw.type) {
|
||||
case 'thread.started': {
|
||||
this.sessionId = raw.thread_id;
|
||||
return [];
|
||||
}
|
||||
case 'turn.started': {
|
||||
return this.handleTurnStarted();
|
||||
}
|
||||
case 'session.configured':
|
||||
case 'session_configured': {
|
||||
return this.handleSessionConfigured(raw);
|
||||
}
|
||||
case 'turn.completed': {
|
||||
return this.handleTurnCompleted(raw);
|
||||
}
|
||||
case 'item.started': {
|
||||
return this.handleItemStarted(raw.item);
|
||||
}
|
||||
case 'item.completed': {
|
||||
return this.handleItemCompleted(raw.item);
|
||||
}
|
||||
default: {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flush(): HeterogeneousAgentEvent[] {
|
||||
const events = [...this.pendingToolCalls].map((toolCallId) =>
|
||||
this.makeEvent('tool_end', {
|
||||
isSuccess: false,
|
||||
toolCallId,
|
||||
}),
|
||||
);
|
||||
|
||||
this.pendingToolCalls.clear();
|
||||
return events;
|
||||
}
|
||||
|
||||
private handleTurnCompleted(raw: any): HeterogeneousAgentEvent[] {
|
||||
const model = getEventModel(raw) || this.currentModel;
|
||||
if (model) this.currentModel = model;
|
||||
|
||||
const usage = toUsageData(raw.usage);
|
||||
if (!usage && !model) return [];
|
||||
|
||||
const data: StepCompleteData = {
|
||||
...(model ? { model } : {}),
|
||||
phase: 'turn_metadata',
|
||||
provider: CODEX_IDENTIFIER,
|
||||
...(usage ? { usage } : {}),
|
||||
};
|
||||
|
||||
return [this.makeEvent('step_complete', data)];
|
||||
}
|
||||
|
||||
private handleSessionConfigured(raw: any): HeterogeneousAgentEvent[] {
|
||||
const model = getEventModel(raw);
|
||||
if (model) this.currentModel = model;
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private handleTurnStarted(): HeterogeneousAgentEvent[] {
|
||||
this.currentAgentMessageItemId = undefined;
|
||||
this.hasStepActivity = false;
|
||||
this.resetStepToolCalls();
|
||||
|
||||
if (!this.started) {
|
||||
this.started = true;
|
||||
return [this.makeEvent('stream_start', { provider: CODEX_IDENTIFIER })];
|
||||
}
|
||||
|
||||
this.stepIndex += 1;
|
||||
return [
|
||||
this.makeEvent('stream_end', {}),
|
||||
this.makeEvent('stream_start', { newStep: true, provider: CODEX_IDENTIFIER }),
|
||||
];
|
||||
}
|
||||
|
||||
private handleItemStarted(item: any): HeterogeneousAgentEvent[] {
|
||||
if (!item?.id || !item?.type || item.type === 'agent_message') return [];
|
||||
|
||||
this.hasStepActivity = true;
|
||||
|
||||
const tool = toToolPayload(item);
|
||||
this.pendingToolCalls.add(tool.id);
|
||||
|
||||
return this.emitToolChunk(tool);
|
||||
}
|
||||
|
||||
private handleItemCompleted(item: any): HeterogeneousAgentEvent[] {
|
||||
if (!item?.type) return [];
|
||||
|
||||
if (item.type === 'agent_message') {
|
||||
if (!item.text) return [];
|
||||
|
||||
const events: HeterogeneousAgentEvent[] = [];
|
||||
const shouldStartNewStep =
|
||||
this.hasStepActivity && !!item.id && item.id !== this.currentAgentMessageItemId;
|
||||
|
||||
if (shouldStartNewStep) {
|
||||
this.stepIndex += 1;
|
||||
this.resetStepToolCalls();
|
||||
events.push(this.makeEvent('stream_end', {}));
|
||||
events.push(this.makeEvent('stream_start', { newStep: true, provider: CODEX_IDENTIFIER }));
|
||||
}
|
||||
|
||||
this.currentAgentMessageItemId = item.id;
|
||||
this.hasStepActivity = true;
|
||||
events.push(
|
||||
this.makeEvent('stream_chunk', {
|
||||
chunkType: 'text',
|
||||
content: item.text,
|
||||
}),
|
||||
);
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
if (!item.id) return [];
|
||||
|
||||
const events: HeterogeneousAgentEvent[] = [];
|
||||
|
||||
if (!this.pendingToolCalls.has(item.id)) {
|
||||
const tool = toToolPayload(item);
|
||||
events.push(...this.emitToolChunk(tool));
|
||||
}
|
||||
|
||||
this.pendingToolCalls.delete(item.id);
|
||||
this.hasStepActivity = true;
|
||||
events.push(this.makeEvent('tool_result', getToolResultData(item as CodexToolItem)));
|
||||
events.push(
|
||||
this.makeEvent('tool_end', {
|
||||
isSuccess: isSuccessfulToolCompletion(item as CodexToolItem),
|
||||
toolCallId: item.id,
|
||||
}),
|
||||
);
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private emitToolChunk(tool: ToolCallPayload): HeterogeneousAgentEvent[] {
|
||||
if (!this.stepToolCallIds.has(tool.id)) {
|
||||
this.stepToolCallIds.add(tool.id);
|
||||
this.stepToolCalls.push(tool);
|
||||
}
|
||||
|
||||
return [
|
||||
this.makeEvent('stream_chunk', {
|
||||
chunkType: 'tools_calling',
|
||||
toolsCalling: [...this.stepToolCalls],
|
||||
}),
|
||||
this.makeEvent('tool_start', {
|
||||
toolCallId: tool.id,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
private resetStepToolCalls(): void {
|
||||
this.stepToolCalls = [];
|
||||
this.stepToolCallIds.clear();
|
||||
}
|
||||
|
||||
private makeEvent(type: HeterogeneousAgentEvent['type'], data: any): HeterogeneousAgentEvent {
|
||||
return {
|
||||
data,
|
||||
stepIndex: this.stepIndex,
|
||||
timestamp: Date.now(),
|
||||
type,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { ClaudeCodeAdapter, claudeCodePreset } from './claudeCode';
|
||||
export { CodexAdapter, codexPreset } from './codex';
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { IconType } from '@lobehub/icons';
|
||||
import { ClaudeCode, Codex, getLobeIconCDN } from '@lobehub/icons';
|
||||
|
||||
import {
|
||||
getHeterogeneousAgentConfig,
|
||||
HETEROGENEOUS_AGENT_CONFIGS,
|
||||
type HeterogeneousAgentConfig,
|
||||
} from '../config';
|
||||
|
||||
export interface HeterogeneousAgentClientConfig extends HeterogeneousAgentConfig {
|
||||
avatar: string;
|
||||
icon: IconType;
|
||||
}
|
||||
|
||||
const heterogeneousAgentIcons = {
|
||||
'claude-code': ClaudeCode,
|
||||
'codex': Codex,
|
||||
} as const satisfies Record<HeterogeneousAgentConfig['type'], IconType>;
|
||||
|
||||
const createAgentAvatar = (iconId: string) =>
|
||||
getLobeIconCDN(iconId, {
|
||||
cdn: 'aliyun',
|
||||
format: 'avatar',
|
||||
});
|
||||
|
||||
export const HETEROGENEOUS_AGENT_CLIENT_CONFIGS = HETEROGENEOUS_AGENT_CONFIGS.map((config) => ({
|
||||
...config,
|
||||
avatar: createAgentAvatar(config.iconId),
|
||||
icon: heterogeneousAgentIcons[config.type],
|
||||
})) as readonly HeterogeneousAgentClientConfig[];
|
||||
|
||||
export const getHeterogeneousAgentClientConfig = (type: HeterogeneousAgentConfig['type']) => {
|
||||
const config = getHeterogeneousAgentConfig(type);
|
||||
|
||||
if (!config) return undefined;
|
||||
|
||||
return {
|
||||
...config,
|
||||
avatar: createAgentAvatar(config.iconId),
|
||||
icon: heterogeneousAgentIcons[config.type],
|
||||
} satisfies HeterogeneousAgentClientConfig;
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getHeterogeneousAgentConfig, HETEROGENEOUS_AGENT_CONFIGS } from './config';
|
||||
import { HETEROGENEOUS_TYPE_LABELS } from './labels';
|
||||
|
||||
describe('heterogeneous agent config', () => {
|
||||
it('defines create config for all registered agent types', () => {
|
||||
expect(HETEROGENEOUS_AGENT_CONFIGS.map((config) => config.type)).toEqual([
|
||||
'claude-code',
|
||||
'codex',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves config by type', () => {
|
||||
expect(getHeterogeneousAgentConfig('claude-code')).toMatchObject({
|
||||
command: 'claude',
|
||||
title: 'Claude Code',
|
||||
type: 'claude-code',
|
||||
});
|
||||
expect(getHeterogeneousAgentConfig('codex')).toMatchObject({
|
||||
command: 'codex',
|
||||
title: 'Codex',
|
||||
type: 'codex',
|
||||
});
|
||||
});
|
||||
|
||||
it('derives display labels from the shared config source', () => {
|
||||
expect(HETEROGENEOUS_TYPE_LABELS).toEqual({
|
||||
'claude-code': 'Claude Code',
|
||||
'codex': 'Codex',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { HeterogeneousProviderConfig } from '@lobechat/types';
|
||||
|
||||
export type HeterogeneousAgentMenuLabelKey = 'newClaudeCodeAgent' | 'newCodexAgent';
|
||||
|
||||
export interface HeterogeneousAgentConfig {
|
||||
command: string;
|
||||
iconId: string;
|
||||
menuKey: string;
|
||||
menuLabelKey: HeterogeneousAgentMenuLabelKey;
|
||||
title: string;
|
||||
type: HeterogeneousProviderConfig['type'];
|
||||
}
|
||||
|
||||
export const HETEROGENEOUS_AGENT_CONFIGS = [
|
||||
{
|
||||
command: 'claude',
|
||||
iconId: 'ClaudeCode',
|
||||
menuKey: 'newClaudeCodeAgent',
|
||||
menuLabelKey: 'newClaudeCodeAgent',
|
||||
title: 'Claude Code',
|
||||
type: 'claude-code',
|
||||
},
|
||||
{
|
||||
command: 'codex',
|
||||
iconId: 'Codex',
|
||||
menuKey: 'newCodexAgent',
|
||||
menuLabelKey: 'newCodexAgent',
|
||||
title: 'Codex',
|
||||
type: 'codex',
|
||||
},
|
||||
] as const satisfies readonly HeterogeneousAgentConfig[];
|
||||
|
||||
export const getHeterogeneousAgentConfig = (type: HeterogeneousProviderConfig['type']) =>
|
||||
HETEROGENEOUS_AGENT_CONFIGS.find((config) => config.type === type);
|
||||
@@ -1,4 +1,5 @@
|
||||
export { ClaudeCodeAdapter, claudeCodePreset } from './adapters';
|
||||
export { getHeterogeneousAgentConfig, HETEROGENEOUS_AGENT_CONFIGS } from './config';
|
||||
export { HETEROGENEOUS_TYPE_LABELS } from './labels';
|
||||
export { createAdapter, getPreset, listAgentTypes } from './registry';
|
||||
export type {
|
||||
@@ -7,6 +8,7 @@ export type {
|
||||
AgentProcessConfig,
|
||||
HeterogeneousAgentEvent,
|
||||
HeterogeneousEventType,
|
||||
HeterogeneousTerminalErrorData,
|
||||
StreamChunkData,
|
||||
StreamChunkType,
|
||||
StreamStartData,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { HETEROGENEOUS_AGENT_CONFIGS } from './config';
|
||||
|
||||
/**
|
||||
* Display-name mapping for heterogeneous agent types.
|
||||
*
|
||||
@@ -5,7 +7,6 @@
|
||||
* use this to render user-facing names (e.g. "Claude Code is running")
|
||||
* without knowing adapter-specific branding.
|
||||
*/
|
||||
export const HETEROGENEOUS_TYPE_LABELS: Record<string, string> = {
|
||||
'claude-code': 'Claude Code',
|
||||
'codex': 'Codex',
|
||||
};
|
||||
export const HETEROGENEOUS_TYPE_LABELS: Record<string, string> = Object.fromEntries(
|
||||
HETEROGENEOUS_AGENT_CONFIGS.map((config) => [config.type, config.title]),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ClaudeCodeAdapter } from './adapters';
|
||||
import { ClaudeCodeAdapter, CodexAdapter } from './adapters';
|
||||
import { createAdapter, getPreset, listAgentTypes } from './registry';
|
||||
|
||||
describe('registry', () => {
|
||||
@@ -10,6 +10,11 @@ describe('registry', () => {
|
||||
expect(adapter).toBeInstanceOf(ClaudeCodeAdapter);
|
||||
});
|
||||
|
||||
it('creates a CodexAdapter for "codex"', () => {
|
||||
const adapter = createAdapter('codex');
|
||||
expect(adapter).toBeInstanceOf(CodexAdapter);
|
||||
});
|
||||
|
||||
it('throws for unknown agent type', () => {
|
||||
expect(() => createAdapter('unknown-agent')).toThrow('Unknown agent type: "unknown-agent"');
|
||||
});
|
||||
@@ -33,6 +38,13 @@ describe('registry', () => {
|
||||
expect(args).toContain('sess_abc');
|
||||
});
|
||||
|
||||
it('returns preset with exec args for codex', () => {
|
||||
const preset = getPreset('codex');
|
||||
expect(preset.baseArgs).toContain('exec');
|
||||
expect(preset.baseArgs).toContain('--json');
|
||||
expect(preset.promptMode).toBe('stdin');
|
||||
});
|
||||
|
||||
it('throws for unknown agent type', () => {
|
||||
expect(() => getPreset('nope')).toThrow('Unknown agent type: "nope"');
|
||||
});
|
||||
@@ -42,6 +54,7 @@ describe('registry', () => {
|
||||
it('includes claude-code', () => {
|
||||
const types = listAgentTypes();
|
||||
expect(types).toContain('claude-code');
|
||||
expect(types).toContain('codex');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* New agents are added by registering here — no other code changes needed.
|
||||
*/
|
||||
|
||||
import { ClaudeCodeAdapter, claudeCodePreset } from './adapters';
|
||||
import { ClaudeCodeAdapter, claudeCodePreset, CodexAdapter, codexPreset } from './adapters';
|
||||
import type { AgentCLIPreset, AgentEventAdapter } from './types';
|
||||
|
||||
interface AgentRegistryEntry {
|
||||
@@ -18,8 +18,10 @@ const registry: Record<string, AgentRegistryEntry> = {
|
||||
createAdapter: () => new ClaudeCodeAdapter(),
|
||||
preset: claudeCodePreset,
|
||||
},
|
||||
// Future:
|
||||
// 'codex': { createAdapter: () => new CodexAdapter(), preset: codexPreset },
|
||||
'codex': {
|
||||
createAdapter: () => new CodexAdapter(),
|
||||
preset: codexPreset,
|
||||
},
|
||||
// 'kimi-cli': { createAdapter: () => new KimiCLIAdapter(), preset: kimiPreset },
|
||||
};
|
||||
|
||||
|
||||
@@ -203,6 +203,33 @@ export interface StepCompleteData {
|
||||
usage?: UsageData;
|
||||
}
|
||||
|
||||
export interface HeterogeneousRateLimitInfo {
|
||||
isUsingOverage?: boolean;
|
||||
overageDisabledReason?: string;
|
||||
overageStatus?: string;
|
||||
rateLimitType?: string;
|
||||
resetsAt?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized terminal error payload emitted by adapters when the upstream CLI
|
||||
* exposes enough context to classify the failure. The executor can persist
|
||||
* this directly as a `ChatMessageError` body without re-parsing provider-
|
||||
* specific stderr shapes.
|
||||
*/
|
||||
export interface HeterogeneousTerminalErrorData {
|
||||
agentType?: string;
|
||||
clearEchoedContent?: boolean;
|
||||
code?: string;
|
||||
docsUrl?: string;
|
||||
error?: string;
|
||||
installCommands?: readonly string[];
|
||||
message: string;
|
||||
rateLimitInfo?: HeterogeneousRateLimitInfo;
|
||||
stderr?: string;
|
||||
}
|
||||
|
||||
// ─── Adapter Interface ───
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,21 +5,79 @@ const deepseekChatModels: AIChatModelCard[] = [
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
structuredOutput: true,
|
||||
},
|
||||
contextWindowTokens: 131_072,
|
||||
contextWindowTokens: 1_000_000,
|
||||
description:
|
||||
'DeepSeek V3.2 is DeepSeek’s latest general model with a hybrid reasoning architecture and stronger agent capabilities.',
|
||||
displayName: 'DeepSeek V3.2',
|
||||
'DeepSeek V4 Flash is the cost-efficient member of the V4 family with a 1M context window and hybrid thinking. Thinking mode is on by default and can be toggled via the `thinking` parameter; non-thinking mode is optimized for latency-sensitive workflows.',
|
||||
displayName: 'DeepSeek V4 Flash',
|
||||
enabled: true,
|
||||
id: 'deepseek-chat',
|
||||
maxOutput: 8192,
|
||||
id: 'deepseek-v4-flash',
|
||||
maxOutput: 384_000,
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
units: [
|
||||
{ name: 'textInput_cacheRead', rate: 0.2, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 3, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput', rate: 1, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
releasedAt: '2026-04-24',
|
||||
settings: {
|
||||
extendParams: ['thinking', 'deepseekV4ReasoningEffort'],
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
structuredOutput: true,
|
||||
},
|
||||
contextWindowTokens: 1_000_000,
|
||||
description:
|
||||
'DeepSeek V4 Pro is the flagship of the V4 family, optimized for high-intensity reasoning, agentic workflows, and long-horizon planning. Thinking mode is on by default and can be toggled via the `thinking` parameter.',
|
||||
displayName: 'DeepSeek V4 Pro',
|
||||
enabled: true,
|
||||
id: 'deepseek-v4-pro',
|
||||
maxOutput: 384_000,
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
units: [
|
||||
{ name: 'textInput_cacheRead', rate: 1, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput', rate: 12, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 24, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
releasedAt: '2026-04-24',
|
||||
settings: {
|
||||
extendParams: ['thinking', 'deepseekV4ReasoningEffort'],
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
structuredOutput: true,
|
||||
},
|
||||
contextWindowTokens: 1_000_000,
|
||||
// Per official docs: deepseek-chat is now a compatibility alias pointing to
|
||||
// the non-thinking mode of deepseek-v4-flash and is slated for deprecation.
|
||||
// Pricing and sizing mirror deepseek-v4-flash since that is what the endpoint serves.
|
||||
description:
|
||||
'Compatibility alias for DeepSeek V4 Flash non-thinking mode. Slated for deprecation — use deepseek-v4-flash instead.',
|
||||
displayName: 'DeepSeek V3.2 (routes to V4 Flash)',
|
||||
enabled: true,
|
||||
id: 'deepseek-chat',
|
||||
legacy: true,
|
||||
maxOutput: 384_000,
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
units: [
|
||||
{ name: 'textInput_cacheRead', rate: 0.2, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput', rate: 1, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
releasedAt: '2025-12-01',
|
||||
@@ -30,19 +88,22 @@ const deepseekChatModels: AIChatModelCard[] = [
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
},
|
||||
contextWindowTokens: 131_072,
|
||||
contextWindowTokens: 1_000_000,
|
||||
// Per official docs: deepseek-reasoner is now a compatibility alias pointing
|
||||
// to the thinking mode of deepseek-v4-flash and is slated for deprecation.
|
||||
description:
|
||||
'DeepSeek V3.2 thinking mode outputs a chain-of-thought before the final answer to improve accuracy.',
|
||||
displayName: 'DeepSeek V3.2 Thinking',
|
||||
'Compatibility alias for DeepSeek V4 Flash thinking mode. Slated for deprecation — use deepseek-v4-flash instead.',
|
||||
displayName: 'DeepSeek V3.2 Thinking (routes to V4 Flash)',
|
||||
enabled: true,
|
||||
id: 'deepseek-reasoner',
|
||||
maxOutput: 65_536,
|
||||
legacy: true,
|
||||
maxOutput: 384_000,
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
units: [
|
||||
{ name: 'textInput_cacheRead', rate: 0.2, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 3, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput', rate: 1, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
releasedAt: '2025-12-01',
|
||||
|
||||
@@ -4,18 +4,73 @@ export const deepseekChatModels: AIChatModelCard[] = [
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
},
|
||||
contextWindowTokens: 65_536,
|
||||
contextWindowTokens: 1_000_000,
|
||||
description:
|
||||
'DeepSeek V3.2 balances reasoning and output length for daily QA and agent tasks. Public benchmarks reach GPT-5 levels, and it is the first to integrate thinking into tool use, leading open-source agent evaluations.',
|
||||
displayName: 'DeepSeek V3.2',
|
||||
'DeepSeek V4 Flash is the cost-efficient member of the V4 family with a 1M context window and hybrid thinking. Toggle thinking via the `thinking` parameter; non-thinking mode targets latency-sensitive workflows while thinking mode enables deeper reasoning.',
|
||||
displayName: 'DeepSeek V4 Flash',
|
||||
enabled: true,
|
||||
id: 'deepseek-chat',
|
||||
id: 'deepseek-v4-flash',
|
||||
maxOutput: 384_000,
|
||||
pricing: {
|
||||
units: [
|
||||
{ name: 'textInput', rate: 0.56, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput_cacheRead', rate: 0.07, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 1.68, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput_cacheRead', rate: 0.028, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput', rate: 0.14, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 0.28, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
releasedAt: '2026-04-24',
|
||||
settings: {
|
||||
extendParams: ['thinking', 'deepseekV4ReasoningEffort'],
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
},
|
||||
contextWindowTokens: 1_000_000,
|
||||
description:
|
||||
'DeepSeek V4 Pro is the flagship of the V4 family, purpose-built for high-intensity reasoning, agent workflows, and long-horizon planning with a 1M context window. Thinking mode is on by default and toggleable via the `thinking` parameter.',
|
||||
displayName: 'DeepSeek V4 Pro',
|
||||
enabled: true,
|
||||
id: 'deepseek-v4-pro',
|
||||
maxOutput: 384_000,
|
||||
pricing: {
|
||||
units: [
|
||||
{ name: 'textInput_cacheRead', rate: 0.145, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput', rate: 1.74, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 3.48, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
releasedAt: '2026-04-24',
|
||||
settings: {
|
||||
extendParams: ['thinking', 'deepseekV4ReasoningEffort'],
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
},
|
||||
contextWindowTokens: 1_000_000,
|
||||
// Per official docs: deepseek-chat is now a compatibility alias that points
|
||||
// to the non-thinking mode of deepseek-v4-flash and will be deprecated.
|
||||
// Pricing and sizing mirror deepseek-v4-flash since that is what the endpoint serves.
|
||||
description:
|
||||
'Compatibility alias for DeepSeek V4 Flash non-thinking mode. Slated for deprecation — use DeepSeek V4 Flash instead.',
|
||||
displayName: 'DeepSeek V3.2 (routes to V4 Flash)',
|
||||
enabled: true,
|
||||
id: 'deepseek-chat',
|
||||
legacy: true,
|
||||
maxOutput: 384_000,
|
||||
pricing: {
|
||||
units: [
|
||||
{ name: 'textInput_cacheRead', rate: 0.028, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput', rate: 0.14, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 0.28, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
releasedAt: '2025-12-01',
|
||||
@@ -26,17 +81,21 @@ export const deepseekChatModels: AIChatModelCard[] = [
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
},
|
||||
contextWindowTokens: 65_536,
|
||||
contextWindowTokens: 1_000_000,
|
||||
// Per official docs: deepseek-reasoner is now a compatibility alias that
|
||||
// points to the thinking mode of deepseek-v4-flash and will be deprecated.
|
||||
description:
|
||||
'DeepSeek V3.2 Thinking is a deep reasoning model that generates chain-of-thought before outputs for higher accuracy, with top competition results and reasoning comparable to Gemini-3.0-Pro.',
|
||||
displayName: 'DeepSeek V3.2 Thinking',
|
||||
'Compatibility alias for DeepSeek V4 Flash thinking mode. Slated for deprecation — use DeepSeek V4 Flash instead.',
|
||||
displayName: 'DeepSeek V3.2 Thinking (routes to V4 Flash)',
|
||||
enabled: true,
|
||||
id: 'deepseek-reasoner',
|
||||
legacy: true,
|
||||
maxOutput: 384_000,
|
||||
pricing: {
|
||||
units: [
|
||||
{ name: 'textInput', rate: 0.55, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 2.19, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput_cacheRead', rate: 0.14, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput_cacheRead', rate: 0.028, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textInput', rate: 0.14, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{ name: 'textOutput', rate: 0.28, strategy: 'fixed', unit: 'millionTokens' },
|
||||
],
|
||||
},
|
||||
releasedAt: '2025-12-01',
|
||||
|
||||
@@ -1,6 +1,124 @@
|
||||
import type { AIChatModelCard } from '../../../types/aiModel';
|
||||
|
||||
// Authoritative pricing + capability + RPM/TPM reference:
|
||||
// https://platform.xiaomimimo.com/docs/pricing (SPA — fetch via chrome-devtools MCP, not WebFetch)
|
||||
// Model detail pages:
|
||||
// https://mimo.xiaomi.com/mimo-v2-5-pro (URL uses dashes for dots)
|
||||
// https://mimo.xiaomi.com/mimo-v2-5
|
||||
// IMPORTANT: Xiaomi's Token Plan (Credits subscription) billing and the
|
||||
// per-token API billing are separate. Announcements like "unified 256K/1M
|
||||
// Credit multiplier" only affect Token Plan; the per-token API billing still
|
||||
// keeps context-tiered pricing (0-256K / 256K-1M). Always cross-check
|
||||
// /docs/pricing when updating these rates.
|
||||
export const xiaomimimoChatModels: AIChatModelCard[] = [
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
search: false,
|
||||
},
|
||||
contextWindowTokens: 1_000_000,
|
||||
description:
|
||||
'Xiaomi MiMo-V2.5-Pro is the flagship of the MiMo-V2.5 series. It retains the 1T total / 42B active hybrid-attention architecture with a 1M context window, and delivers major gains in general agentic capabilities, complex software engineering, and long-horizon tasks (more than a thousand tool calls per task). Performance on demanding agentic benchmarks is comparable to Claude Opus 4.6.',
|
||||
displayName: 'MiMo-V2.5 Pro',
|
||||
enabled: true,
|
||||
id: 'mimo-v2.5-pro',
|
||||
maxOutput: 131_072,
|
||||
pricing: {
|
||||
units: [
|
||||
{
|
||||
name: 'textInput',
|
||||
strategy: 'tiered',
|
||||
tiers: [
|
||||
{ rate: 1, upTo: 256_000 },
|
||||
{ rate: 2, upTo: 'infinity' },
|
||||
],
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
{
|
||||
name: 'textInput_cacheRead',
|
||||
strategy: 'tiered',
|
||||
tiers: [
|
||||
{ rate: 0.2, upTo: 256_000 },
|
||||
{ rate: 0.4, upTo: 'infinity' },
|
||||
],
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
// Cache write is temporarily free per official announcement
|
||||
// TODO: restore actual pricing when promotion ends
|
||||
{ name: 'textInput_cacheWrite', rate: 0, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{
|
||||
name: 'textOutput',
|
||||
strategy: 'tiered',
|
||||
tiers: [
|
||||
{ rate: 3, upTo: 256_000 },
|
||||
{ rate: 6, upTo: 'infinity' },
|
||||
],
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
],
|
||||
},
|
||||
releasedAt: '2026-04-22',
|
||||
settings: {
|
||||
extendParams: ['enableReasoning'],
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
search: false,
|
||||
video: true,
|
||||
vision: true,
|
||||
},
|
||||
contextWindowTokens: 1_000_000,
|
||||
description:
|
||||
'Xiaomi MiMo-V2.5 is a native omni-modal Agent foundation model with 1M context that understands images, video, audio, and text in a unified architecture. It delivers Pro-level agentic performance at roughly half the inference cost, with stronger multimodal perception than MiMo-V2-Omni and faster inference — a strong fit for latency-sensitive, multi-step agent frameworks.',
|
||||
displayName: 'MiMo-V2.5',
|
||||
enabled: true,
|
||||
id: 'mimo-v2.5',
|
||||
maxOutput: 131_072,
|
||||
pricing: {
|
||||
units: [
|
||||
{
|
||||
name: 'textInput',
|
||||
strategy: 'tiered',
|
||||
tiers: [
|
||||
{ rate: 0.4, upTo: 256_000 },
|
||||
{ rate: 0.8, upTo: 'infinity' },
|
||||
],
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
{
|
||||
name: 'textInput_cacheRead',
|
||||
strategy: 'tiered',
|
||||
tiers: [
|
||||
{ rate: 0.08, upTo: 256_000 },
|
||||
{ rate: 0.16, upTo: 'infinity' },
|
||||
],
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
// Cache write is temporarily free per official announcement
|
||||
// TODO: restore actual pricing when promotion ends
|
||||
{ name: 'textInput_cacheWrite', rate: 0, strategy: 'fixed', unit: 'millionTokens' },
|
||||
{
|
||||
name: 'textOutput',
|
||||
strategy: 'tiered',
|
||||
tiers: [
|
||||
{ rate: 2, upTo: 256_000 },
|
||||
{ rate: 4, upTo: 'infinity' },
|
||||
],
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
],
|
||||
},
|
||||
releasedAt: '2026-04-22',
|
||||
settings: {
|
||||
extendParams: ['enableReasoning'],
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
|
||||
@@ -1,6 +1,114 @@
|
||||
import type { AIChatModelCard } from '../types/aiModel';
|
||||
|
||||
const xiaomimimoChatModels: AIChatModelCard[] = [
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
search: true,
|
||||
structuredOutput: true,
|
||||
},
|
||||
contextWindowTokens: 1_000_000,
|
||||
description:
|
||||
"MiMo-V2.5-Pro is Xiaomi's most capable flagship model to date, delivering significant improvements in general agentic capabilities, complex software engineering, and long-horizon tasks. It retains the 1T total / 42B active hybrid-attention architecture with a 1M context window, and can sustain complex long-horizon tasks spanning more than a thousand tool calls. Performance on demanding agentic benchmarks (ClawEval, GDPVal, SWE-bench Pro) is comparable to Claude Opus 4.6.",
|
||||
displayName: 'MiMo-V2.5 Pro',
|
||||
enabled: true,
|
||||
id: 'mimo-v2.5-pro',
|
||||
maxOutput: 131_072,
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
units: [
|
||||
{
|
||||
name: 'textInput_cacheRead',
|
||||
strategy: 'tiered',
|
||||
tiers: [
|
||||
{ rate: 1.4, upTo: 0.256 },
|
||||
{ rate: 2.8, upTo: 'infinity' },
|
||||
],
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
{
|
||||
name: 'textInput',
|
||||
strategy: 'tiered',
|
||||
tiers: [
|
||||
{ rate: 7, upTo: 0.256 },
|
||||
{ rate: 14, upTo: 'infinity' },
|
||||
],
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
{
|
||||
name: 'textOutput',
|
||||
strategy: 'tiered',
|
||||
tiers: [
|
||||
{ rate: 21, upTo: 0.256 },
|
||||
{ rate: 42, upTo: 'infinity' },
|
||||
],
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
],
|
||||
},
|
||||
releasedAt: '2026-04-22',
|
||||
settings: {
|
||||
extendParams: ['enableReasoning'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
reasoning: true,
|
||||
search: true,
|
||||
structuredOutput: true,
|
||||
video: true,
|
||||
vision: true,
|
||||
},
|
||||
contextWindowTokens: 1_000_000,
|
||||
description:
|
||||
'MiMo-V2.5 is a native omni-modal Agent foundation model that understands images, video, audio, and text in a unified architecture, with a 1M context window. It delivers Pro-level agentic performance at roughly half the inference cost of MiMo-V2.5-Pro, with improved multimodal perception over MiMo-V2-Omni. Its built-in agentic capabilities (browsing, understanding, reasoning, execution) and faster inference make it well-suited to latency-sensitive and multi-step agent frameworks such as OpenClaw.',
|
||||
displayName: 'MiMo-V2.5',
|
||||
enabled: true,
|
||||
id: 'mimo-v2.5',
|
||||
maxOutput: 131_072,
|
||||
pricing: {
|
||||
currency: 'CNY',
|
||||
units: [
|
||||
{
|
||||
name: 'textInput_cacheRead',
|
||||
strategy: 'tiered',
|
||||
tiers: [
|
||||
{ rate: 0.56, upTo: 0.256 },
|
||||
{ rate: 1.12, upTo: 'infinity' },
|
||||
],
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
{
|
||||
name: 'textInput',
|
||||
strategy: 'tiered',
|
||||
tiers: [
|
||||
{ rate: 2.8, upTo: 0.256 },
|
||||
{ rate: 5.6, upTo: 'infinity' },
|
||||
],
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
{
|
||||
name: 'textOutput',
|
||||
strategy: 'tiered',
|
||||
tiers: [
|
||||
{ rate: 14, upTo: 0.256 },
|
||||
{ rate: 28, upTo: 'infinity' },
|
||||
],
|
||||
unit: 'millionTokens',
|
||||
},
|
||||
],
|
||||
},
|
||||
releasedAt: '2026-04-22',
|
||||
settings: {
|
||||
extendParams: ['enableReasoning'],
|
||||
searchImpl: 'params',
|
||||
},
|
||||
type: 'chat',
|
||||
},
|
||||
{
|
||||
abilities: {
|
||||
functionCall: true,
|
||||
|
||||
@@ -2,9 +2,9 @@ import type { ModelProviderCard } from '@/types/llm';
|
||||
|
||||
const DeepSeek: ModelProviderCard = {
|
||||
chatModels: [],
|
||||
checkModel: 'deepseek-chat',
|
||||
checkModel: 'deepseek-v4-flash',
|
||||
description:
|
||||
'DeepSeek focuses on AI research and applications; its latest DeepSeek-V3 benchmarks surpass open models like Qwen2.5-72B and Llama-3.1-405B, aligning with leading closed models such as GPT-4o and Claude-3.5-Sonnet.',
|
||||
'DeepSeek focuses on AI research and applications. Its latest DeepSeek V4 family ships in Flash and Pro variants with a 1M context window and hybrid thinking — competitive with leading closed frontier models on reasoning and agent benchmarks.',
|
||||
id: 'deepseek',
|
||||
modelList: { showModelFetcher: true },
|
||||
modelsUrl: 'https://platform.deepseek.com/api-docs/zh-cn/quick_start/pricing',
|
||||
|
||||
@@ -23,5 +23,5 @@ export const planCardModels = [
|
||||
'claude-sonnet-4-6',
|
||||
'gemini-3.1-pro-preview',
|
||||
'gpt-5.4',
|
||||
'deepseek-chat',
|
||||
'deepseek-v4-flash',
|
||||
];
|
||||
|
||||
@@ -254,6 +254,7 @@ export type ExtendParamsType =
|
||||
| 'gpt5_2ReasoningEffort'
|
||||
| 'gpt5_2ProReasoningEffort'
|
||||
| 'grok4_20ReasoningEffort'
|
||||
| 'deepseekV4ReasoningEffort'
|
||||
| 'codexMaxReasoningEffort'
|
||||
| 'opus47Effort'
|
||||
| 'textVerbosity'
|
||||
@@ -301,6 +302,7 @@ export const ExtendParamsTypeSchema = z.enum([
|
||||
'gpt5_2ReasoningEffort',
|
||||
'gpt5_2ProReasoningEffort',
|
||||
'grok4_20ReasoningEffort',
|
||||
'deepseekV4ReasoningEffort',
|
||||
'codexMaxReasoningEffort',
|
||||
'opus47Effort',
|
||||
'textVerbosity',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { imageUrlToBase64, videoUrlToBase64 } from '@lobechat/utils';
|
||||
import { Buffer } from 'buffer/';
|
||||
import { Buffer } from 'buffer.js';
|
||||
import type OpenAI from 'openai';
|
||||
import { toFile } from 'openai';
|
||||
|
||||
|
||||
@@ -173,6 +173,127 @@ describe('LobeDeepSeekAI - custom features', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// DeepSeek V4 models default to thinking mode unless thinking.type === 'disabled'.
|
||||
// In thinking mode the API rejects follow-up turns whose assistant messages omit
|
||||
// reasoning_content when tool calls are involved — see index.ts for details.
|
||||
describe('deepseek-v4 thinking mode reasoning_content enforcement', () => {
|
||||
it('should force reasoning_content on v4-flash assistant messages by default', () => {
|
||||
const payload = {
|
||||
messages: [
|
||||
{ role: 'user', content: 'Search weather' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'search', arguments: '{"q":"weather"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
model: 'deepseek-v4-flash',
|
||||
};
|
||||
|
||||
const result = params.chatCompletion!.handlePayload!(payload as any);
|
||||
|
||||
expect(result.messages[1]).toEqual({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
reasoning_content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'search', arguments: '{"q":"weather"}' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should force reasoning_content on v4-pro assistant messages by default', () => {
|
||||
const payload = {
|
||||
messages: [{ role: 'assistant', content: 'hi' }],
|
||||
model: 'deepseek-v4-pro',
|
||||
};
|
||||
|
||||
const result = params.chatCompletion!.handlePayload!(payload as any);
|
||||
|
||||
expect(result.messages[0]).toEqual({
|
||||
role: 'assistant',
|
||||
content: 'hi',
|
||||
reasoning_content: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should force reasoning_content when thinking.type is explicitly enabled', () => {
|
||||
const payload = {
|
||||
messages: [{ role: 'assistant', content: 'hi' }],
|
||||
model: 'deepseek-v4-flash',
|
||||
thinking: { type: 'enabled' },
|
||||
};
|
||||
|
||||
const result = params.chatCompletion!.handlePayload!(payload as any);
|
||||
|
||||
expect(result.messages[0]).toEqual({
|
||||
role: 'assistant',
|
||||
content: 'hi',
|
||||
reasoning_content: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT force reasoning_content when thinking.type is disabled', () => {
|
||||
const payload = {
|
||||
messages: [{ role: 'assistant', content: 'hi' }],
|
||||
model: 'deepseek-v4-flash',
|
||||
thinking: { type: 'disabled' },
|
||||
};
|
||||
|
||||
const result = params.chatCompletion!.handlePayload!(payload as any);
|
||||
|
||||
expect(result.messages[0]).toEqual({
|
||||
role: 'assistant',
|
||||
content: 'hi',
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve existing reasoning_content on v4 assistant messages', () => {
|
||||
const payload = {
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'answer',
|
||||
reasoning_content: 'prior reasoning',
|
||||
},
|
||||
],
|
||||
model: 'deepseek-v4-flash',
|
||||
};
|
||||
|
||||
const result = params.chatCompletion!.handlePayload!(payload as any);
|
||||
|
||||
expect(result.messages[0]).toEqual({
|
||||
role: 'assistant',
|
||||
content: 'answer',
|
||||
reasoning_content: 'prior reasoning',
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT force reasoning_content on non-v4 / non-reasoner models', () => {
|
||||
const payload = {
|
||||
messages: [{ role: 'assistant', content: 'hi' }],
|
||||
model: 'deepseek-chat',
|
||||
};
|
||||
|
||||
const result = params.chatCompletion!.handlePayload!(payload as any);
|
||||
|
||||
expect(result.messages[0]).toEqual({
|
||||
role: 'assistant',
|
||||
content: 'hi',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Debug Configuration', () => {
|
||||
|
||||
@@ -12,7 +12,16 @@ export const params = {
|
||||
baseURL: 'https://api.deepseek.com/v1',
|
||||
chatCompletion: {
|
||||
handlePayload: (payload) => {
|
||||
const shouldForceAssistantReasoningContent = payload.model === 'deepseek-reasoner';
|
||||
// deepseek-v4-* defaults to thinking=enabled unless the caller explicitly
|
||||
// sets thinking.type === 'disabled'. In thinking mode the API rejects
|
||||
// (HTTP 400) follow-up turns that omit reasoning_content on assistant
|
||||
// messages with tool calls — see
|
||||
// https://api-docs.deepseek.com/guides/thinking_mode#tool-calls
|
||||
const isV4Model =
|
||||
typeof payload.model === 'string' && payload.model.startsWith('deepseek-v4');
|
||||
const thinkingExplicitlyDisabled = payload.thinking?.type === 'disabled';
|
||||
const shouldForceAssistantReasoningContent =
|
||||
payload.model === 'deepseek-reasoner' || (isV4Model && !thinkingExplicitlyDisabled);
|
||||
|
||||
// Transform reasoning object to reasoning_content string for multi-turn conversations
|
||||
const messages = payload.messages.map((message: any) => {
|
||||
@@ -25,7 +34,8 @@ export const params = {
|
||||
? reasoning.content
|
||||
: undefined;
|
||||
|
||||
// DeepSeek reasoner with tool calls requires assistant history messages to carry reasoning_content
|
||||
// DeepSeek thinking mode with tool calls requires assistant history
|
||||
// messages to carry reasoning_content, or the API returns a 400.
|
||||
if (message.role === 'assistant' && shouldForceAssistantReasoningContent) {
|
||||
return {
|
||||
...rest,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Buffer } from 'buffer/';
|
||||
import { Buffer } from 'buffer.js';
|
||||
import Replicate from 'replicate';
|
||||
|
||||
import type { LobeRuntimeAI } from '../../core/BaseAI';
|
||||
|
||||
@@ -322,16 +322,24 @@ describe('modelParse', () => {
|
||||
|
||||
it('xiaomimimo: should infer multimodal abilities for omni', async () => {
|
||||
const out = await processModelList(
|
||||
[{ id: 'mimo-v2-flash' }, { id: 'mimo-v2-pro' }, { id: 'mimo-v2-omni' }],
|
||||
[
|
||||
{ id: 'mimo-v2-flash' },
|
||||
{ id: 'mimo-v2-pro' },
|
||||
{ id: 'mimo-v2-omni' },
|
||||
{ id: 'mimo-v2.5-pro' },
|
||||
{ id: 'mimo-v2.5' },
|
||||
],
|
||||
MODEL_LIST_CONFIGS.xiaomimimo,
|
||||
'xiaomimimo',
|
||||
);
|
||||
|
||||
expect(out).toHaveLength(3);
|
||||
expect(out).toHaveLength(5);
|
||||
|
||||
const flash = out.find((m) => m.id === 'mimo-v2-flash')!;
|
||||
const pro = out.find((m) => m.id === 'mimo-v2-pro')!;
|
||||
const omni = out.find((m) => m.id === 'mimo-v2-omni')!;
|
||||
const v25Pro = out.find((m) => m.id === 'mimo-v2.5-pro')!;
|
||||
const v25 = out.find((m) => m.id === 'mimo-v2.5')!;
|
||||
|
||||
expect(flash.functionCall).toBe(true);
|
||||
expect(flash.reasoning).toBe(true);
|
||||
@@ -345,6 +353,14 @@ describe('modelParse', () => {
|
||||
expect(omni.reasoning).toBe(true);
|
||||
expect(omni.vision).toBe(true);
|
||||
|
||||
expect(v25Pro.functionCall).toBe(true);
|
||||
expect(v25Pro.reasoning).toBe(true);
|
||||
expect(v25Pro.vision).toBe(false);
|
||||
|
||||
expect(v25.functionCall).toBe(true);
|
||||
expect(v25.reasoning).toBe(true);
|
||||
expect(v25.vision).toBe(true);
|
||||
|
||||
const tts = await processModelList(
|
||||
[{ id: 'mimo-v2-tts' }],
|
||||
MODEL_LIST_CONFIGS.xiaomimimo,
|
||||
@@ -744,6 +760,18 @@ describe('modelParse', () => {
|
||||
expect(results[3][0].reasoning).toBe(true); // Deepseek 'r1'
|
||||
expect(results[4][0].reasoning).toBe(true); // Volcengine 'thinking'
|
||||
});
|
||||
|
||||
it('should detect v4 keyword for deepseek model capabilities', async () => {
|
||||
// Cover the new 'v4' keyword added to the deepseek config so unknown
|
||||
// model IDs like deepseek-v4-custom still get functionCall + reasoning.
|
||||
const result = await processModelList(
|
||||
[{ id: 'deepseek-v4-custom' }],
|
||||
MODEL_LIST_CONFIGS.deepseek,
|
||||
);
|
||||
|
||||
expect(result[0].functionCall).toBe(true);
|
||||
expect(result[0].reasoning).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Extended tests for processMultiProviderModelList', () => {
|
||||
|
||||
@@ -31,8 +31,8 @@ export const MODEL_LIST_CONFIGS = {
|
||||
visionKeywords: [],
|
||||
},
|
||||
deepseek: {
|
||||
functionCallKeywords: ['v3', 'r1', 'deepseek-chat'],
|
||||
reasoningKeywords: ['r1', 'deepseek-reasoner', 'v3.'],
|
||||
functionCallKeywords: ['v3', 'v4', 'r1', 'deepseek-chat'],
|
||||
reasoningKeywords: ['r1', 'deepseek-reasoner', 'v3.', 'v4'],
|
||||
visionKeywords: ['ocr'],
|
||||
},
|
||||
google: {
|
||||
@@ -130,7 +130,9 @@ export const MODEL_LIST_CONFIGS = {
|
||||
excludeKeywords: ['tts'],
|
||||
functionCallKeywords: ['mimo'],
|
||||
reasoningKeywords: ['mimo'],
|
||||
visionKeywords: ['omni'],
|
||||
// mimo-v2.5 (non-pro) is natively omni-modal; match the exact id
|
||||
// without also catching mimo-v2.5-pro, which is text-only.
|
||||
visionKeywords: ['omni', 're:^mimo-v2\\.5$'],
|
||||
},
|
||||
zeroone: {
|
||||
functionCallKeywords: ['fc'],
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface HeterogeneousProviderConfig {
|
||||
/** Custom environment variables */
|
||||
env?: Record<string, string>;
|
||||
/** Agent runtime type */
|
||||
type: 'claude-code';
|
||||
type: 'claude-code' | 'codex';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,13 +25,14 @@ export interface LobeAgentChatConfig extends AgentMemoryChatConfig {
|
||||
*/
|
||||
compressionModelId?: string;
|
||||
|
||||
deepseekV4ReasoningEffort?: 'high' | 'max';
|
||||
|
||||
/**
|
||||
* Disable context caching
|
||||
*/
|
||||
disableContextCaching?: boolean;
|
||||
|
||||
effort?: 'low' | 'medium' | 'high' | 'max';
|
||||
|
||||
/**
|
||||
* Whether to enable adaptive thinking (Claude Opus 4.6)
|
||||
*/
|
||||
@@ -191,6 +192,7 @@ export const AgentChatConfigSchema = z
|
||||
gpt5_2ProReasoningEffort: z.enum(['medium', 'high', 'xhigh']).optional(),
|
||||
gpt5_2ReasoningEffort: z.enum(['none', 'low', 'medium', 'high', 'xhigh']).optional(),
|
||||
grok4_20ReasoningEffort: z.enum(['low', 'medium', 'high', 'xhigh']).optional(),
|
||||
deepseekV4ReasoningEffort: z.enum(['high', 'max']).optional(),
|
||||
historyCount: z.number().optional(),
|
||||
imageAspectRatio: z.string().optional(),
|
||||
imageAspectRatio2: z.string().optional(),
|
||||
|
||||
@@ -57,7 +57,7 @@ export interface SendMessageServerParams {
|
||||
* Message metadata (e.g., isSupervisor for group orchestration)
|
||||
*/
|
||||
metadata?: Record<string, unknown>;
|
||||
model: string;
|
||||
model?: string;
|
||||
provider: string;
|
||||
};
|
||||
/**
|
||||
|
||||
@@ -49,8 +49,27 @@ export enum AsyncTaskErrorType {
|
||||
Timeout = 'TaskTimeout',
|
||||
}
|
||||
|
||||
export interface AsyncTaskStructuredErrorItem {
|
||||
layer?: string;
|
||||
memoryIndex?: number;
|
||||
message: string;
|
||||
preview?: string;
|
||||
sourceId?: string;
|
||||
sourceType?: string;
|
||||
stack?: string;
|
||||
stage?: string;
|
||||
}
|
||||
|
||||
export interface AsyncTaskErrorBody {
|
||||
detail: string;
|
||||
extractErrors?: AsyncTaskStructuredErrorItem[];
|
||||
persistErrors?: AsyncTaskStructuredErrorItem[];
|
||||
progressErrors?: AsyncTaskStructuredErrorItem[];
|
||||
retrievalErrors?: AsyncTaskStructuredErrorItem[];
|
||||
}
|
||||
|
||||
export interface IAsyncTaskError {
|
||||
body: string | { detail: string };
|
||||
body: string | AsyncTaskErrorBody;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@@ -62,7 +81,7 @@ export class AsyncTaskError implements IAsyncTaskError {
|
||||
|
||||
name: string;
|
||||
|
||||
body: { detail: string };
|
||||
body: AsyncTaskErrorBody;
|
||||
}
|
||||
|
||||
export interface FileParsingTask {
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"remark-html": "^16.0.1",
|
||||
"tokenx": "^1.2.1",
|
||||
"ua-parser-js": "^1.0.41",
|
||||
"uuid": "^11.1.0",
|
||||
"uuid": "^14.0.0",
|
||||
"yaml": "^2.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Works in both browser and Node.js environments
|
||||
*/
|
||||
|
||||
import { Buffer } from 'buffer/';
|
||||
import { Buffer } from 'buffer.js';
|
||||
|
||||
/**
|
||||
* Encode a string to base64
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Buffer } from 'buffer/';
|
||||
import { Buffer } from 'buffer.js';
|
||||
|
||||
export const imageToBase64 = ({
|
||||
size,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TracePayload } from '@lobechat/const';
|
||||
import { LOBE_CHAT_TRACE_HEADER, LOBE_CHAT_TRACE_ID } from '@lobechat/const';
|
||||
import { Buffer } from 'buffer/';
|
||||
import { Buffer } from 'buffer.js';
|
||||
|
||||
export const getTracePayload = (req: Request): TracePayload | undefined => {
|
||||
const header = req.headers.get(LOBE_CHAT_TRACE_HEADER);
|
||||
|
||||
@@ -143,6 +143,14 @@ export function sharedRendererPlugins(options: SharedRendererOptions) {
|
||||
viteNodeModuleStub(),
|
||||
vitePlatformResolve(options.platform),
|
||||
|
||||
isDev && {
|
||||
name: 'lobe-dev-strip-manifest',
|
||||
transformIndexHtml: {
|
||||
order: 'pre' as const,
|
||||
handler: (html: string) => html.replace(/\s*<link\s+rel="manifest"[^>]*>\s*/i, '\n '),
|
||||
},
|
||||
},
|
||||
|
||||
isDev &&
|
||||
codeInspectorPlugin({
|
||||
bundler: 'vite',
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { EdgeConfig } from '@lobechat/edge-config';
|
||||
import { createEnv } from '@t3-oss/env-nextjs';
|
||||
import debug from 'debug';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { merge } from '@/utils/merge';
|
||||
@@ -8,8 +6,6 @@ import { merge } from '@/utils/merge';
|
||||
import { DEFAULT_FEATURE_FLAGS, mapFeatureFlagsEnvToState } from './schema';
|
||||
import { parseFeatureFlag } from './utils/parser';
|
||||
|
||||
const log = debug('lobe-feature-flags');
|
||||
|
||||
const env = createEnv({
|
||||
runtimeEnv: {
|
||||
FEATURE_FLAGS: process.env.FEATURE_FLAGS,
|
||||
@@ -27,56 +23,10 @@ export const getServerFeatureFlagsValue = () => {
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get feature flags from EdgeConfig with fallback to environment variables
|
||||
* @param userId - Optional user ID for user-specific feature flag evaluation
|
||||
*/
|
||||
export const getServerFeatureFlagsFromEdgeConfig = async (userId?: string) => {
|
||||
// Try to get feature flags from EdgeConfig first
|
||||
if (EdgeConfig.isEnabled()) {
|
||||
try {
|
||||
const edgeConfig = new EdgeConfig();
|
||||
const edgeFeatureFlags = await edgeConfig.getFeatureFlags();
|
||||
|
||||
if (edgeFeatureFlags && Object.keys(edgeFeatureFlags).length > 0) {
|
||||
// Merge EdgeConfig flags with defaults
|
||||
const mergedFlags = merge(DEFAULT_FEATURE_FLAGS, edgeFeatureFlags);
|
||||
log('[FeatureFlags] Using EdgeConfig flags for user:', userId || 'anonymous');
|
||||
return mergedFlags;
|
||||
} else {
|
||||
log(
|
||||
'[FeatureFlags] EdgeConfig returned empty/null/undefined, falling back to environment variables',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
log(
|
||||
'[FeatureFlags] Failed to fetch feature flags from EdgeConfig, falling back to environment variables:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log('[FeatureFlags] EdgeConfig not enabled, using environment variables');
|
||||
}
|
||||
|
||||
// Fallback to environment variable-based feature flags
|
||||
const envFlags = getServerFeatureFlagsValue();
|
||||
log('[FeatureFlags] Using environment variable flags for user:', userId || 'anonymous');
|
||||
return envFlags;
|
||||
};
|
||||
|
||||
export const serverFeatureFlags = (userId?: string) => {
|
||||
const serverConfig = getServerFeatureFlagsValue();
|
||||
|
||||
return mapFeatureFlagsEnvToState(serverConfig, userId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get server feature flags from EdgeConfig and map them to state with user ID
|
||||
* @param userId - Optional user ID for user-specific feature flag evaluation
|
||||
*/
|
||||
export const getServerFeatureFlagsStateFromEdgeConfig = async (userId?: string) => {
|
||||
const flags = await getServerFeatureFlagsFromEdgeConfig(userId);
|
||||
return mapFeatureFlagsEnvToState(flags, userId);
|
||||
};
|
||||
|
||||
export * from './schema';
|
||||
|
||||
@@ -171,7 +171,7 @@ const KanbanBoard = memo<KanbanBoardProps>(({ agentId }) => {
|
||||
width: COLUMN_WIDTH - 8,
|
||||
}}
|
||||
>
|
||||
<AgentTaskItem task={activeTask} />
|
||||
<AgentTaskItem task={activeTask} variant="compact" />
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { TaskListItem } from '@/store/task/slices/list/initialState';
|
||||
import AgentTaskItem from '../features/AgentTaskItem';
|
||||
import TaskStatusIcon from '../features/TaskStatusIcon';
|
||||
|
||||
export const COLUMN_WIDTH = 520;
|
||||
export const COLUMN_WIDTH = 300;
|
||||
|
||||
const cardStyles = createStaticStyles(({ css, cssVar }) => ({
|
||||
card: css`
|
||||
@@ -45,7 +45,7 @@ const DraggableTaskCard = memo<{ task: TaskListItem }>(({ task }) => {
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
<AgentTaskItem task={task} />
|
||||
<AgentTaskItem task={task} variant="compact" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import TaskTriggerTag from './TaskTriggerTag';
|
||||
|
||||
interface TaskItemProps {
|
||||
task: TaskListItem;
|
||||
variant?: 'compact' | 'default';
|
||||
}
|
||||
|
||||
const formatTime = (time?: string | Date | null) => {
|
||||
@@ -40,7 +41,7 @@ type TaskStatus = 'backlog' | 'canceled' | 'completed' | 'failed' | 'paused' | '
|
||||
const toTaskStatus = (status: string): TaskStatus =>
|
||||
TASK_STATUS_SET.has(status) ? (status as TaskStatus) : 'backlog';
|
||||
|
||||
const AgentTaskItem = memo<TaskItemProps>(({ task }) => {
|
||||
const AgentTaskItem = memo<TaskItemProps>(({ task, variant = 'default' }) => {
|
||||
const activeAgentId = useAgentStore((s) => s.activeAgentId);
|
||||
const useFetchTaskDetail = useTaskStore((s) => s.useFetchTaskDetail);
|
||||
useFetchTaskDetail(task.identifier);
|
||||
@@ -60,55 +61,70 @@ const AgentTaskItem = memo<TaskItemProps>(({ task }) => {
|
||||
if (targetAgentId) navigate(`/agent/${targetAgentId}/tasks/${task.identifier}`);
|
||||
}, [targetAgentId, navigate, task.identifier]);
|
||||
|
||||
const titleRow = (
|
||||
<Flexbox horizontal align={'center'} gap={8}>
|
||||
<TaskPriorityTag priority={task.priority} taskIdentifier={task.identifier} />
|
||||
<TaskStatusTag status={status} taskIdentifier={task.identifier} />
|
||||
<Text ellipsis weight={500}>
|
||||
{task.name || task.identifier}
|
||||
</Text>
|
||||
<TaskSubtaskProgressTag
|
||||
currentIdentifier={task.identifier}
|
||||
subtasks={taskDetail?.subtasks}
|
||||
onSubtaskClick={(identifier) => {
|
||||
if (targetAgentId) navigate(`/agent/${targetAgentId}/tasks/${identifier}`);
|
||||
}}
|
||||
/>
|
||||
</Flexbox>
|
||||
);
|
||||
|
||||
const metaRow = (
|
||||
<Flexbox horizontal align={'center'} flex={'none'} gap={8}>
|
||||
<TaskScheduleConfig
|
||||
currentInterval={taskDetail?.heartbeat?.interval ?? 0}
|
||||
taskId={task.identifier}
|
||||
>
|
||||
<TaskTriggerTag
|
||||
heartbeatInterval={taskDetail?.heartbeat?.interval}
|
||||
schedulePattern={task.schedulePattern}
|
||||
scheduleTimezone={task.scheduleTimezone}
|
||||
/>
|
||||
</TaskScheduleConfig>
|
||||
<AssigneeAgentSelector
|
||||
currentAgentId={task.assigneeAgentId}
|
||||
disabled={status === 'running'}
|
||||
taskIdentifier={task.identifier}
|
||||
>
|
||||
<AssigneeAvatar agentId={task.assigneeAgentId} />
|
||||
</AssigneeAgentSelector>
|
||||
{time && (
|
||||
<Text
|
||||
align={'right'}
|
||||
fontSize={12}
|
||||
style={{ whiteSpace: 'nowrap', width: variant === 'compact' ? undefined : 76 }}
|
||||
type={'secondary'}
|
||||
>
|
||||
{time}
|
||||
</Text>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<Block clickable gap={4} padding={12} variant={'borderless'} onClick={handleClick}>
|
||||
{titleRow}
|
||||
<TaskLatestActivity activities={taskDetail?.activities} />
|
||||
{metaRow}
|
||||
</Block>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Block clickable gap={4} padding={12} variant={'borderless'} onClick={handleClick}>
|
||||
<Flexbox horizontal align={'center'} gap={4} justify={'space-between'}>
|
||||
<Flexbox horizontal align="center" gap={8}>
|
||||
<TaskPriorityTag priority={task.priority} taskIdentifier={task.identifier} />
|
||||
<TaskStatusTag status={status} taskIdentifier={task.identifier} />
|
||||
<Text ellipsis weight={500}>
|
||||
{task.name || task.identifier}
|
||||
</Text>
|
||||
<TaskSubtaskProgressTag
|
||||
currentIdentifier={task.identifier}
|
||||
subtasks={taskDetail?.subtasks}
|
||||
onSubtaskClick={(identifier) => {
|
||||
if (targetAgentId) navigate(`/agent/${targetAgentId}/tasks/${identifier}`);
|
||||
}}
|
||||
/>
|
||||
</Flexbox>
|
||||
<Flexbox horizontal align={'center'} flex={'none'} gap={8}>
|
||||
<TaskScheduleConfig
|
||||
currentInterval={taskDetail?.heartbeat?.interval ?? 0}
|
||||
taskId={task.identifier}
|
||||
>
|
||||
<TaskTriggerTag
|
||||
heartbeatInterval={taskDetail?.heartbeat?.interval}
|
||||
schedulePattern={task.schedulePattern}
|
||||
scheduleTimezone={task.scheduleTimezone}
|
||||
/>
|
||||
</TaskScheduleConfig>
|
||||
<AssigneeAgentSelector
|
||||
currentAgentId={task.assigneeAgentId}
|
||||
disabled={status === 'running'}
|
||||
taskIdentifier={task.identifier}
|
||||
>
|
||||
<AssigneeAvatar agentId={task.assigneeAgentId} />
|
||||
</AssigneeAgentSelector>
|
||||
{time && (
|
||||
<Text
|
||||
align={'right'}
|
||||
fontSize={12}
|
||||
type={'secondary'}
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
width: 76,
|
||||
}}
|
||||
>
|
||||
{time}
|
||||
</Text>
|
||||
)}
|
||||
</Flexbox>
|
||||
{titleRow}
|
||||
{metaRow}
|
||||
</Flexbox>
|
||||
<TaskLatestActivity activities={taskDetail?.activities} />
|
||||
</Block>
|
||||
|
||||
@@ -9,7 +9,7 @@ interface AssigneeAvatarProps {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const AssigneeAvatar = memo<AssigneeAvatarProps>(({ agentId, size = 22 }) => {
|
||||
const AssigneeAvatar = memo<AssigneeAvatarProps>(({ agentId, size = 18 }) => {
|
||||
const displayMeta = useAgentDisplayMeta(agentId);
|
||||
|
||||
if (!displayMeta) return null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isDesktop } from '@lobechat/const';
|
||||
import { HotkeyEnum, KeyEnum } from '@lobechat/const/hotkeys';
|
||||
import { HETEROGENEOUS_TYPE_LABELS } from '@lobechat/heterogeneous-agents';
|
||||
import { chainInputCompletion } from '@lobechat/prompts';
|
||||
import { isCommandPressed, merge } from '@lobechat/utils';
|
||||
import { INSERT_MENTION_COMMAND, ReactAutoCompletePlugin, ReactMathPlugin } from '@lobehub/editor';
|
||||
@@ -110,7 +111,9 @@ const InputEditor = memo<{
|
||||
const heterogeneousType = useAgentStore(
|
||||
(s) => agentByIdSelectors.getAgencyConfigById(agentId)(s)?.heterogeneousProvider?.type,
|
||||
);
|
||||
const heterogeneousName = heterogeneousType === 'claude-code' ? 'Claude Code' : undefined;
|
||||
const heterogeneousName = heterogeneousType
|
||||
? (HETEROGENEOUS_TYPE_LABELS[heterogeneousType] ?? heterogeneousType)
|
||||
: undefined;
|
||||
// Heterogeneous agents (e.g. Claude Code) don't yet support @-assigning to other agents
|
||||
const showAgentAssignmentHint =
|
||||
!heterogeneousName && categories.some((category) => category.id === 'agent');
|
||||
|
||||
@@ -2,8 +2,13 @@ import { Flexbox } from '@lobehub/ui';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { type ReactNode } from 'react';
|
||||
import { memo, Suspense, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { dataSelectors, useConversationStore } from '@/features/Conversation/store';
|
||||
import {
|
||||
dataSelectors,
|
||||
messageStateSelectors,
|
||||
useConversationStore,
|
||||
} from '@/features/Conversation/store';
|
||||
import dynamic from '@/libs/next/dynamic';
|
||||
|
||||
import { type ChatItemProps } from '../../type';
|
||||
@@ -59,15 +64,32 @@ const MessageContent = memo<MessageContentProps>(
|
||||
className,
|
||||
variant,
|
||||
}) => {
|
||||
const [toggleMessageEditing, updateMessageContent] = useConversationStore((s) => [
|
||||
s.toggleMessageEditing,
|
||||
s.updateMessageContent,
|
||||
]);
|
||||
const [toggleMessageEditing, updateMessageContent, regenerateUserMessage] =
|
||||
useConversationStore((s) => [
|
||||
s.toggleMessageEditing,
|
||||
s.updateMessageContent,
|
||||
s.regenerateUserMessage,
|
||||
]);
|
||||
|
||||
const editorData = useConversationStore(
|
||||
(s) => dataSelectors.getDisplayMessageById(id)(s)?.editorData,
|
||||
);
|
||||
|
||||
// Short-circuit on non-editing rows so streaming token updates stay O(1) per row
|
||||
// instead of each row running `findLast` on displayMessages (O(N²) per update).
|
||||
// Use isInputLoading (covers sendMessage + AI runtime) rather than isAIGenerating,
|
||||
// otherwise the initial send phase — where the persisted id has just swapped in
|
||||
// under an optimistic tmp_* op — would flip to Send and kick off a duplicate
|
||||
// regenerate for the same prompt.
|
||||
const shouldSendOnConfirm = useConversationStore((s) => {
|
||||
if (!editing) return false;
|
||||
if (dataSelectors.getDisplayMessageById(id)(s)?.role !== 'user') return false;
|
||||
if (s.displayMessages.findLast((m) => m.role === 'user')?.id !== id) return false;
|
||||
return !messageStateSelectors.isInputLoading(s);
|
||||
});
|
||||
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const onEditingChange = useCallback(
|
||||
(edit: boolean) => toggleMessageEditing(id, edit),
|
||||
[id, toggleMessageEditing],
|
||||
@@ -93,14 +115,22 @@ const MessageContent = memo<MessageContentProps>(
|
||||
{editing && (
|
||||
<EditorModal
|
||||
editorData={editorData}
|
||||
okText={shouldSendOnConfirm ? t('send') : t('save')}
|
||||
open={editing}
|
||||
value={message ? String(message) : ''}
|
||||
onCancel={() => onEditingChange(false)}
|
||||
onConfirm={async (value, newEditorData) => {
|
||||
await updateMessageContent(id, value, {
|
||||
onEditingChange(false);
|
||||
// updateMessageContent does an optimistic state update synchronously before
|
||||
// awaiting the DB round trip. Kick off regenerate in parallel so the old
|
||||
// assistant reply is replaced by switchMessageBranch without waiting for persistence.
|
||||
const save = updateMessageContent(id, value, {
|
||||
editorData: newEditorData as Record<string, any> | undefined,
|
||||
});
|
||||
onEditingChange(false);
|
||||
if (shouldSendOnConfirm) {
|
||||
await regenerateUserMessage(id);
|
||||
}
|
||||
await save;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user