mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Compare commits
301 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d38d59e8e | |||
| 41c71655b6 | |||
| 26da6b9ad4 | |||
| 1d4fb21885 | |||
| 38c92fa04a | |||
| 555a375e67 | |||
| 6989e8f9e6 | |||
| e4d1d1fc17 | |||
| 026c79a4c2 | |||
| 1e2782ece4 | |||
| b5ddac56dc | |||
| ad0da3753e | |||
| e6905fe0fd | |||
| a9d2110565 | |||
| e4d5f69b27 | |||
| a372acd50d | |||
| 0af5e51477 | |||
| 40f0557158 | |||
| 62f06540ba | |||
| 43b064f803 | |||
| 8e8a463a05 | |||
| decc25554e | |||
| 1c8ec2681c | |||
| 0a32fbc737 | |||
| 7fc41a9677 | |||
| 22c880763d | |||
| d324736edf | |||
| 608498a950 | |||
| 5e1a35f259 | |||
| 6b010c8380 | |||
| ead5631bab | |||
| ddd5c20836 | |||
| c51835193f | |||
| 0c375e4428 | |||
| 58cda8a645 | |||
| 65ba4ad435 | |||
| 41ffd1e0d3 | |||
| 02767bac55 | |||
| be5d61d40a | |||
| 282b20c454 | |||
| cc506c036d | |||
| 5fca91a488 | |||
| c3530ad221 | |||
| 8b8b0f0579 | |||
| 958bf52978 | |||
| 480d4b2b4e | |||
| 4d00c22e7f | |||
| f30d9da5a9 | |||
| 831b4ee5ca | |||
| c744eab116 | |||
| 7697399da8 | |||
| 05a9eae504 | |||
| cc1e0d29d3 | |||
| 0e6eba61a9 | |||
| 3e8016b502 | |||
| 970733aaeb | |||
| c72b1ee698 | |||
| 7bf923d762 | |||
| 10300ba0e1 | |||
| 431abf36d6 | |||
| ce516fff9d | |||
| 9e231835b2 | |||
| 79b84a68ec | |||
| 56e811f5bd | |||
| 5fb795b092 | |||
| fbe71e76db | |||
| d83f0a0f2f | |||
| fe65741a32 | |||
| b5e4cd0805 | |||
| f565ca9450 | |||
| e6d49fdb76 | |||
| 47c524a388 | |||
| cb4412421f | |||
| 78b3dbed03 | |||
| 95375cec79 | |||
| aa3c7e585b | |||
| 11e6619a3c | |||
| 41719dfd29 | |||
| b66e83a57c | |||
| bc103b2e11 | |||
| d28b401aaf | |||
| a79cdd19f8 | |||
| 222f525bf4 | |||
| 317fdcec13 | |||
| 162d6cfa67 | |||
| 2870cc73c2 | |||
| d5097c7964 | |||
| aa3d245cfd | |||
| 61c3f42f10 | |||
| 2dd52c6813 | |||
| 3f82249ed1 | |||
| b49c1c15b7 | |||
| df32dd4966 | |||
| b5d7696dbd | |||
| d2d81ba64a | |||
| b2130f7612 | |||
| 626d274859 | |||
| 9c509680b9 | |||
| 70f81ad1a1 | |||
| c401d1b97f | |||
| eddb0c991b | |||
| 6340ab55e9 | |||
| 86a23b5555 | |||
| 3cb06e07e3 | |||
| c9b44935ed | |||
| 948ba5ec68 | |||
| d0091901dc | |||
| 8c3b83f8b3 | |||
| b031513321 | |||
| c2b379139d | |||
| 6d1d8a0d16 | |||
| dc3c48e469 | |||
| 79dc61ac50 | |||
| 506bb7b29f | |||
| 807af0688f | |||
| 1d9b6099bd | |||
| 5fc7eea754 | |||
| a9716975a7 | |||
| c77d201c49 | |||
| 39107ba107 | |||
| d0e99aada4 | |||
| 13e8ef9c7b | |||
| 8387067807 | |||
| 375e6381ce | |||
| f7c1ebf652 | |||
| 156a870cf3 | |||
| f017dcd0ea | |||
| 719a554456 | |||
| 3b1eef72d8 | |||
| 9e20cd6b3a | |||
| a5f4b4b569 | |||
| 5a15f759d6 | |||
| b7ecf2fd4d | |||
| 24062bb412 | |||
| 61d432a991 | |||
| f59954137a | |||
| 1324b67590 | |||
| f390d04ef2 | |||
| 84df8a9994 | |||
| 9aea74659f | |||
| 105321bfe1 | |||
| b0b6e67d5f | |||
| d2aa3cd1b4 | |||
| babdc6ade5 | |||
| 7e6255096a | |||
| 0e7eda4b47 | |||
| 71cfba9906 | |||
| b8fe675508 | |||
| 990942fb45 | |||
| ecec2e87e3 | |||
| 7b6978271a | |||
| 28c2e9002a | |||
| b9034ce9c1 | |||
| 2eb7ee824f | |||
| e78949cd23 | |||
| afae236628 | |||
| 8830c6d560 | |||
| f42fc7d65d | |||
| e5e154afcb | |||
| 346812ab88 | |||
| a099749b41 | |||
| fbe8ab3891 | |||
| 2965cbc83a | |||
| fc44aaef38 | |||
| a2b8f4c81a | |||
| 6f9f5643d1 | |||
| e4877436fe | |||
| 04775f66ff | |||
| 9fff5fccf0 | |||
| 5a46c5a971 | |||
| 5722b7159b | |||
| 49a71bed6e | |||
| d5511a6af2 | |||
| e46e81a08a | |||
| 9555e4fda3 | |||
| 729fbc72d5 | |||
| 0e1a55f2f8 | |||
| c1e2d134ed | |||
| 8663991c7c | |||
| 35edca5531 | |||
| 101b9f9973 | |||
| c6a013a1a1 | |||
| 19643ba662 | |||
| 2654c4d31e | |||
| b94aa1da90 | |||
| e896024b68 | |||
| 2835b99d1a | |||
| 47812b2be9 | |||
| 798644414a | |||
| 54bb83f229 | |||
| 65da232c64 | |||
| dacc7798ab | |||
| 9508807da7 | |||
| 6a7eb17cd2 | |||
| c5da34b680 | |||
| 2a37b77482 | |||
| b814cf2611 | |||
| c37817e2d8 | |||
| bbf239705c | |||
| 8a9f42596d | |||
| 682657ba50 | |||
| 29235dc1ed | |||
| e326400dbe | |||
| deeb97ab5b | |||
| d73858ef42 | |||
| 6b9584714d | |||
| b9a4a9093c | |||
| ef5be7e17c | |||
| a4235d3f68 | |||
| fa508f4259 | |||
| 94767fddcb | |||
| 685b17e59e | |||
| 9acb128943 | |||
| ee55d74dd4 | |||
| cca1050e82 | |||
| 92a848c69c | |||
| f32fff19dd | |||
| 376976849b | |||
| 38d7bdbd96 | |||
| a52104552a | |||
| 3e236ec36f | |||
| 57781850ce | |||
| a101957715 | |||
| 4e309e6f26 | |||
| fd9b0531ec | |||
| 91db61b74f | |||
| 1d7b81233a | |||
| 35c3d5e08d | |||
| a176288670 | |||
| f0ba92776b | |||
| d12e050157 | |||
| cc48e9ff8e | |||
| 939f20e783 | |||
| 8f6848fba2 | |||
| 8b22e55271 | |||
| 196c0a7650 | |||
| ec7e696587 | |||
| 9b48e24ded | |||
| 79d5d2286a | |||
| 998c22890d | |||
| d5315fe745 | |||
| 5c75b0865f | |||
| 7f6f77ec9d | |||
| 7c0203a9c7 | |||
| 84fd8da4a3 | |||
| f98a314cf5 | |||
| 35c43fb580 | |||
| 56bc216c5e | |||
| 66c25cce4b | |||
| 774e29e400 | |||
| eec89338da | |||
| 91cb2a8e65 | |||
| 61d27b46a0 | |||
| 01f6858cc1 | |||
| b3e993f7b1 | |||
| 22e6e1dbcc | |||
| f7205552e8 | |||
| 0077a7286a | |||
| 697ac3bf6e | |||
| fc12fac53b | |||
| ba59d85ae6 | |||
| a6cb200d5b | |||
| 87d7b41186 | |||
| 8e807c6b10 | |||
| 53c5a014ba | |||
| ba05c32489 | |||
| d4a12c0ebb | |||
| 7f025b9c5a | |||
| 35c9e1b224 | |||
| 043d2a81fb | |||
| f39392749a | |||
| b3dc59f77a | |||
| 9b6a60339f | |||
| b55cf6b936 | |||
| 933cfbf789 | |||
| 0e11d3d9c0 | |||
| 600f10fcea | |||
| 421427f3a2 | |||
| 5dc7c2592c | |||
| a19b6b50e0 | |||
| fd2112cbcd | |||
| 0b57c9d3da | |||
| 1958a59f4e | |||
| f7ed6df35b | |||
| a18569c690 | |||
| 4ff4dead20 | |||
| 5a7d46e900 | |||
| 92f34bcc0d | |||
| 7955a43a9e | |||
| fa0ec62d71 | |||
| 3b94f86303 | |||
| 05b2aca92b | |||
| e4b15caf74 | |||
| 82096dcd89 | |||
| 66d096e963 | |||
| 50ffa5b100 | |||
| 8e20bd182f | |||
| 53b4b4d4d3 | |||
| decbc4ce7f | |||
| 4e31a33599 | |||
| 57e3940bc6 |
@@ -0,0 +1,209 @@
|
||||
---
|
||||
name: agent-runtime-hooks
|
||||
description: "Agent runtime lifecycle hooks for observing and intercepting agent execution. Use when adding hooks to agent operations, mocking tool calls, logging step events, handling human intervention, sub-agent calls, context compression, or building eval/tracing integrations. Triggers on 'hooks', 'beforeToolCall', 'afterToolCall', 'beforeStep', 'afterStep', 'onComplete', 'onError', 'tool mock', 'agent lifecycle', 'human intervention', 'callAgent', 'compact'."
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# Agent Runtime Hooks
|
||||
|
||||
Lifecycle hooks for observing and intercepting agent execution. Hooks are registered per-operation via `execAgent({ hooks })` and dispatched by `HookDispatcher`.
|
||||
|
||||
## Hook Types
|
||||
|
||||
16 hook types across 5 categories:
|
||||
|
||||
```
|
||||
execAgent({ hooks })
|
||||
│
|
||||
├─ beforeStep ──────────── Before each step executes
|
||||
│ │
|
||||
│ ├─ [call_llm] LLM inference
|
||||
│ │
|
||||
│ ├─ [call_tool]
|
||||
│ │ ├─ beforeToolCall ── Before tool executes (supports mocking)
|
||||
│ │ ├─ (tool execution)
|
||||
│ │ ├─ afterToolCall ─── After tool completes (observation only)
|
||||
│ │ └─ onToolCallError ─ Tool threw an exception
|
||||
│ │
|
||||
│ ├─ [request_human_approve]
|
||||
│ │ ├─ beforeHumanIntervention ── Before agent pauses
|
||||
│ │ ├─ afterHumanIntervention ─── After approve/reject + resume
|
||||
│ │ └─ onStopByHumanIntervention ── User rejected, agent halted
|
||||
│ │
|
||||
│ ├─ [compress_context]
|
||||
│ │ ├─ beforeCompact ──── Before compression starts
|
||||
│ │ ├─ afterCompact ───── After compression completes
|
||||
│ │ └─ onCompactError ─── Compression failed
|
||||
│ │
|
||||
│ ├─ [callAgent] (via execSubAgentTask)
|
||||
│ │ ├─ beforeCallAgent ── Before sub-agent starts
|
||||
│ │ ├─ afterCallAgent ─── After sub-agent completes
|
||||
│ │ └─ onCallAgentError ── Sub-agent failed
|
||||
│ │
|
||||
│ └─ afterStep ──────────── After step completes
|
||||
│
|
||||
├─ (next step...)
|
||||
│
|
||||
├─ onComplete ───────────── Operation reaches terminal state
|
||||
└─ onError ──────────────── Error during execution
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Role |
|
||||
| ---------------------------------------------------------- | ------------------------------------------------------ |
|
||||
| `packages/agent-runtime/src/types/hooks.ts` | Type definitions (AgentHookType, all event interfaces) |
|
||||
| `src/server/services/agentRuntime/hooks/types.ts` | Server-side types (AgentHook, re-exports) |
|
||||
| `src/server/services/agentRuntime/hooks/HookDispatcher.ts` | Registration, dispatch, dispatchBeforeToolCall |
|
||||
| `src/server/modules/AgentRuntime/RuntimeExecutors.ts` | Tool/Compact/HumanIntervention hook dispatch |
|
||||
| `src/server/services/agentRuntime/AgentRuntimeService.ts` | Step hooks + HumanIntervention resume/reject |
|
||||
| `src/server/services/aiAgent/index.ts` | CallAgent hook dispatch |
|
||||
|
||||
## Registration Flow
|
||||
|
||||
```ts
|
||||
const hooks: AgentHook[] = [
|
||||
{ id: 'my-hook', type: 'afterStep', handler: async (event) => { ... } },
|
||||
];
|
||||
await aiAgentService.execAgent({ agentId, prompt, hooks });
|
||||
// Internally: hookDispatcher.register(operationId, hooks)
|
||||
// Cleanup: hookDispatcher.unregister(operationId)
|
||||
```
|
||||
|
||||
## Hook Reference
|
||||
|
||||
### Step Level
|
||||
|
||||
**`beforeStep`** — Before each step. `event: AgentHookEvent`
|
||||
**`afterStep`** — After each step. `event: AgentHookEvent` (content, toolsCalling, totalCost, etc.)
|
||||
**`onComplete`** — Terminal state. `event: AgentHookEvent` (reason: done/error/interrupted/max_steps/cost_limit)
|
||||
**`onError`** — Error occurred. `event: AgentHookEvent` (errorMessage, errorDetail)
|
||||
|
||||
### Tool Call Level
|
||||
|
||||
**`beforeToolCall`** — Before tool executes. **Supports mocking** via `event.mock()`.
|
||||
|
||||
```ts
|
||||
// event: ToolCallHookEvent
|
||||
{
|
||||
(identifier, apiName, args, callIndex, stepIndex, operationId, mock);
|
||||
}
|
||||
// Mock example:
|
||||
event.mock({ content: '{"error":"rate limited"}' });
|
||||
```
|
||||
|
||||
Dispatch method: `hookDispatcher.dispatchBeforeToolCall()` (returns mock result or null).
|
||||
|
||||
**`afterToolCall`** — After tool completes. Observation only.
|
||||
|
||||
```ts
|
||||
// event: AfterToolCallHookEvent
|
||||
{
|
||||
(identifier, apiName, args, callIndex, content, success, mocked, executionTimeMs, stepIndex);
|
||||
}
|
||||
```
|
||||
|
||||
**`onToolCallError`** — Tool threw an exception (catch block, not just `success=false`).
|
||||
|
||||
```ts
|
||||
// event: ToolCallErrorHookEvent
|
||||
{
|
||||
(identifier, apiName, args, callIndex, error, stepIndex);
|
||||
}
|
||||
```
|
||||
|
||||
### Human Intervention
|
||||
|
||||
**`beforeHumanIntervention`** — Before agent pauses for approval.
|
||||
|
||||
```ts
|
||||
// event: BeforeHumanInterventionHookEvent
|
||||
{ operationId, stepIndex, pendingTools: [{ identifier, apiName }] }
|
||||
```
|
||||
|
||||
**`afterHumanIntervention`** — After approve/reject, agent resumes.
|
||||
|
||||
```ts
|
||||
// event: AfterHumanInterventionHookEvent
|
||||
{ operationId, action: 'approve' | 'reject' | 'rejectAndContinue', toolCallId?, rejectionReason? }
|
||||
```
|
||||
|
||||
**`onStopByHumanIntervention`** — User rejected, agent halted.
|
||||
|
||||
```ts
|
||||
// event: StopByHumanInterventionHookEvent
|
||||
{ operationId, toolCallId?, rejectionReason? }
|
||||
```
|
||||
|
||||
### Context Compression
|
||||
|
||||
**`beforeCompact`** — Before compression starts.
|
||||
|
||||
```ts
|
||||
// event: BeforeCompactHookEvent
|
||||
{
|
||||
(operationId, stepIndex, messageCount, tokenCount);
|
||||
}
|
||||
```
|
||||
|
||||
**`afterCompact`** — After compression completes.
|
||||
|
||||
```ts
|
||||
// event: AfterCompactHookEvent
|
||||
{
|
||||
(operationId, stepIndex, groupId, messagesBefore, messagesAfter, summary);
|
||||
}
|
||||
```
|
||||
|
||||
**`onCompactError`** — Compression failed.
|
||||
|
||||
```ts
|
||||
// event: CompactErrorHookEvent
|
||||
{
|
||||
(operationId, stepIndex, tokenCount, error);
|
||||
}
|
||||
```
|
||||
|
||||
### Sub-Agent (CallAgent)
|
||||
|
||||
**`beforeCallAgent`** — Before calling sub-agent. Dispatched on **parent** operation.
|
||||
|
||||
```ts
|
||||
// event: BeforeCallAgentHookEvent
|
||||
{
|
||||
(operationId, agentId, instruction);
|
||||
}
|
||||
```
|
||||
|
||||
**`afterCallAgent`** — Sub-agent completed. Dispatched on **parent** operation.
|
||||
|
||||
```ts
|
||||
// event: AfterCallAgentHookEvent
|
||||
{
|
||||
(operationId, agentId, subOperationId, threadId, success);
|
||||
}
|
||||
```
|
||||
|
||||
**`onCallAgentError`** — Sub-agent failed. Dispatched on **parent** operation.
|
||||
|
||||
```ts
|
||||
// event: CallAgentErrorHookEvent
|
||||
{
|
||||
(operationId, agentId, error);
|
||||
}
|
||||
```
|
||||
|
||||
Note: CallAgent hooks require `parentOperationId` in `ExecSubAgentTaskParams`.
|
||||
|
||||
## Design Notes
|
||||
|
||||
- **Fire-and-forget**: All handlers return `Promise<void>`. Errors are non-fatal.
|
||||
- **Exception**: `beforeToolCall` supports mock via `event.mock()` — uses `dispatchBeforeToolCall()` which returns the mock result.
|
||||
- **Sequential**: Same-type hooks run in registration order.
|
||||
- **Local only**: `beforeToolCall` mock only works in local mode (in-memory hooks). Webhook mode does not support mocking.
|
||||
- **Scoped per operation**: Auto-cleaned via `hookDispatcher.unregister()` on completion.
|
||||
- **Sandbox/MCP**: No separate hooks — they go through `executeTool`, so `beforeToolCall`/`afterToolCall` cover them. Use `event.identifier` to filter.
|
||||
|
||||
## Real-World Example: agent-evals
|
||||
|
||||
See `devtools/agent-evals/helpers/runner.ts` — `createEvalHooks()` uses `afterStep`, `onComplete`, `afterToolCall`, and `beforeToolCall` (for mock).
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
name: agent-signal
|
||||
description: Build or extend LobeHub Agent Signal pipelines for background or quiet agent work driven by event sources, semantic signals, and action handlers. Use when adding a new Agent Signal source, signal or action type, policy, middleware handler, workflow handoff, dedupe or scope behavior, or observability around `src/server/services/agentSignal/**`, `packages/agent-signal`, or `packages/observability-otel/src/modules/agent-signal`.
|
||||
---
|
||||
|
||||
# Agent Signal
|
||||
|
||||
Use this skill to implement event-driven background work for agents without coupling the work to the foreground chat request.
|
||||
|
||||
Agent Signal has one consistent shape:
|
||||
|
||||
`source event` -> `signal interpretation` -> `action execution` -> built-in result signals
|
||||
|
||||
## Start Here
|
||||
|
||||
1. Read `references/architecture.md` to map the package boundary, runtime queue, scope model, and async workflow handoff.
|
||||
2. Read `references/handlers.md` before writing any new policy, source handler, signal handler, or action handler.
|
||||
3. Read `references/observability.md` when you need tracing, metrics, debugging, or workflow snapshot visibility.
|
||||
|
||||
## Use The Right Entry Point
|
||||
|
||||
- Use `emitAgentSignalSourceEvent(...)` when a server-owned producer should execute the pipeline immediately.
|
||||
- Use `executeAgentSignalSourceEvent(...)` when a worker or controlled backend path already owns execution timing and may inject a runtime guard backend.
|
||||
- Use `enqueueAgentSignalSourceEvent(...)` when the caller should return quickly and let Upstash Workflow process the event out-of-band.
|
||||
- Use `emitAgentSignalSourceEventWithStore(...)` for isolated tests or evals that should avoid ambient Redis state.
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/services/agentSignal/index.ts`
|
||||
- `src/server/workflows/agentSignal/index.ts`
|
||||
- `src/server/workflows/agentSignal/run.ts`
|
||||
|
||||
## Core Model
|
||||
|
||||
- `source`: A normalized fact that happened. Sources come from producers such as runtime lifecycle events, user messages, or bot ingress.
|
||||
- `signal`: A semantic interpretation derived from one source or from another signal. Signals express meaning, routing, or policy state.
|
||||
- `action`: A concrete side effect planned from one signal. Actions do the work.
|
||||
- `policy`: An installable middleware bundle that registers source, signal, and action handlers.
|
||||
- `procedure`: Not a distinct runtime node. Treat "procedure" as the end-to-end flow for one use case: ingress source, matching handlers, planned actions, execution result, and observability.
|
||||
|
||||
Keep the boundaries strict:
|
||||
|
||||
- Add a new `source` when the outside world produced a new event.
|
||||
- Add a new `signal` when the system needs a reusable semantic interpretation.
|
||||
- Add a new `action` when the runtime needs a concrete side effect.
|
||||
- Add or update a `policy` when you are wiring those pieces together.
|
||||
|
||||
## Implementation Workflow
|
||||
|
||||
1. Decide whether the use case is synchronous or quiet background work.
|
||||
2. Define or reuse a source type in `src/server/services/agentSignal/sourceTypes.ts`.
|
||||
3. Define or reuse signal and action types in `src/server/services/agentSignal/policies/types.ts`.
|
||||
4. Implement handlers with `defineSourceHandler`, `defineSignalHandler`, or `defineActionHandler`.
|
||||
5. Bundle handlers with `defineAgentSignalHandlers(...)`.
|
||||
6. Register the policy in `src/server/services/agentSignal/policies/index.ts` and pass it into the runtime factory if needed.
|
||||
7. Add or update ingress code that emits or enqueues the source event.
|
||||
8. Add observability and tests before considering the flow complete.
|
||||
|
||||
## Default Reading Set
|
||||
|
||||
- Shared semantic core:
|
||||
`packages/agent-signal/src/index.ts`
|
||||
`packages/agent-signal/src/base/builders.ts`
|
||||
`packages/agent-signal/src/base/types.ts`
|
||||
- Server-owned runtime and middleware:
|
||||
`src/server/services/agentSignal/runtime/AgentSignalRuntime.ts`
|
||||
`src/server/services/agentSignal/runtime/AgentSignalScheduler.ts`
|
||||
`src/server/services/agentSignal/runtime/middleware.ts`
|
||||
`src/server/services/agentSignal/runtime/context.ts`
|
||||
- Existing policy example:
|
||||
`src/server/services/agentSignal/policies/analyzeIntent/index.ts`
|
||||
`src/server/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
|
||||
`src/server/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
|
||||
`src/server/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
|
||||
`src/server/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
|
||||
- Observability:
|
||||
`src/server/services/agentSignal/observability/projector.ts`
|
||||
`src/server/services/agentSignal/observability/traceEvents.ts`
|
||||
`packages/observability-otel/src/modules/agent-signal/index.ts`
|
||||
|
||||
## Implementation Rules
|
||||
|
||||
- Reuse existing source, signal, and action types before adding new ones.
|
||||
- Keep source handlers focused on interpretation and fan-out, not heavy side effects.
|
||||
- Keep action handlers responsible for side effects, idempotency, and executor-style result reporting.
|
||||
- Use stable ids and idempotency keys when the same source can arrive more than once.
|
||||
- Preserve scope discipline. The runtime uses `scopeKey` to serialize related background work.
|
||||
- Prefer the dedicated shared package types and builders from `@lobechat/agent-signal` for normalized nodes and result contracts.
|
||||
- Add focused tests near the touched runtime, policy, or store module. Existing tests under `src/server/services/agentSignal/**/__tests__` are the reference pattern.
|
||||
|
||||
## References
|
||||
|
||||
- Architecture and boundaries: `references/architecture.md`
|
||||
- Writing handlers and policies: `references/handlers.md`
|
||||
- Observability, metrics, and debugging: `references/observability.md`
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: 'Agent Signal'
|
||||
short_description: 'Build AgentSignal sources, signals, actions, and policies.'
|
||||
default_prompt: 'Use $agent-signal to add a new Agent Signal source, policy, handler, or observability flow.'
|
||||
@@ -0,0 +1,199 @@
|
||||
# Agent Signal Architecture
|
||||
|
||||
## Pipeline
|
||||
|
||||
Use this mental model first:
|
||||
|
||||
```text
|
||||
producer
|
||||
-> emitAgentSignalSourceEvent(...) or enqueueAgentSignalSourceEvent(...)
|
||||
-> emitSourceEvent(...)
|
||||
-> dedupe + scope lock + source normalization
|
||||
-> runtime.emitNormalized(source)
|
||||
-> source handlers
|
||||
-> signal handlers
|
||||
-> action handlers
|
||||
-> built-in result signals
|
||||
-> observability projection + persistence
|
||||
```
|
||||
|
||||
The scheduler is queue-driven, not hard-coded for one policy:
|
||||
|
||||
```text
|
||||
source node
|
||||
-> matching source handlers
|
||||
-> dispatch signals/actions
|
||||
-> matching signal handlers
|
||||
-> dispatch more signals/actions
|
||||
-> matching action handlers
|
||||
-> ExecutorResult
|
||||
-> signal.action.applied | signal.action.skipped | signal.action.failed
|
||||
```
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/services/agentSignal/index.ts`
|
||||
- `src/server/services/agentSignal/sources/index.ts`
|
||||
- `src/server/services/agentSignal/runtime/AgentSignalScheduler.ts`
|
||||
|
||||
## Package Boundaries
|
||||
|
||||
### `packages/agent-signal`
|
||||
|
||||
Treat this as the shared semantic core.
|
||||
|
||||
It provides:
|
||||
|
||||
- base node types: source, signal, action
|
||||
- builders: `createSource`, `createSignal`, `createAction`
|
||||
- built-in result signal types
|
||||
- runtime result contracts such as `RuntimeProcessorResult` and `ExecutorResult`
|
||||
|
||||
Read:
|
||||
|
||||
- `packages/agent-signal/src/base/types.ts`
|
||||
- `packages/agent-signal/src/base/builders.ts`
|
||||
- `packages/agent-signal/src/types/events.ts`
|
||||
- `packages/agent-signal/src/types/builtin.ts`
|
||||
|
||||
### `src/server/services/agentSignal`
|
||||
|
||||
Treat this as the server-owned implementation layer.
|
||||
|
||||
It owns:
|
||||
|
||||
- source catalogs and payload maps
|
||||
- policy-specific signal and action catalogs
|
||||
- middleware registration
|
||||
- runtime scheduling and guard backends
|
||||
- Redis-backed dedupe, waypoint, and policy state
|
||||
- service entrypoints for synchronous and async execution
|
||||
|
||||
### `packages/observability-otel/src/modules/agent-signal`
|
||||
|
||||
Treat this as shared OTEL ownership for Agent Signal metrics and tracer instances.
|
||||
|
||||
## Core Vocabulary
|
||||
|
||||
### Source
|
||||
|
||||
A source is the normalized external fact that started the chain.
|
||||
|
||||
Examples:
|
||||
|
||||
- `agent.user.message`
|
||||
- `runtime.before_step`
|
||||
- `runtime.after_step`
|
||||
- `client.runtime.start`
|
||||
- `bot.message.merged`
|
||||
|
||||
Define source payloads in:
|
||||
|
||||
- `src/server/services/agentSignal/sourceTypes.ts`
|
||||
|
||||
Build normalized sources in:
|
||||
|
||||
- `src/server/services/agentSignal/sources/buildSource.ts`
|
||||
- `packages/agent-signal/src/base/builders.ts`
|
||||
|
||||
### Signal
|
||||
|
||||
A signal is a semantic interpretation. Signals should be reusable and meaning-oriented.
|
||||
|
||||
Examples from `analyzeIntent`:
|
||||
|
||||
- `signal.feedback.satisfaction`
|
||||
- `signal.feedback.domain.memory`
|
||||
- `signal.feedback.domain.prompt`
|
||||
- `signal.feedback.domain.skill`
|
||||
|
||||
Define server-owned signal types in:
|
||||
|
||||
- `src/server/services/agentSignal/policies/types.ts`
|
||||
|
||||
### Action
|
||||
|
||||
An action is a concrete side effect the runtime should execute.
|
||||
|
||||
Example:
|
||||
|
||||
- `action.user-memory.handle`
|
||||
|
||||
Action handlers usually:
|
||||
|
||||
- check idempotency
|
||||
- call tools, models, or services
|
||||
- return `ExecutorResult`
|
||||
|
||||
### Policy
|
||||
|
||||
A policy is an installable bundle of handlers. It is the composition unit that turns the generic runtime into a feature.
|
||||
|
||||
Example:
|
||||
|
||||
- `createAnalyzeIntentPolicy(...)`
|
||||
|
||||
### Procedure
|
||||
|
||||
"Procedure" is not a first-class type in this runtime. Use the word to describe one end-to-end use case:
|
||||
|
||||
1. define ingress source
|
||||
2. emit or enqueue the source
|
||||
3. interpret source into signals
|
||||
4. plan actions from signals
|
||||
5. execute actions
|
||||
6. persist trace and metrics
|
||||
|
||||
When a user asks for "the procedure", document the flow above and point to the exact producer, handlers, and execution entrypoint.
|
||||
|
||||
## Scope, Deduping, And Quiet Background Work
|
||||
|
||||
`scopeKey` is the serialization boundary for related work. It is used for:
|
||||
|
||||
- source dedupe windows
|
||||
- scope locks during source generation
|
||||
- runtime guard state
|
||||
- waypoint persistence for queued processing
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/services/agentSignal/sources/index.ts`
|
||||
- `src/server/services/agentSignal/runtime/context.ts`
|
||||
- `src/server/services/agentSignal/constants.ts`
|
||||
|
||||
Use `enqueueAgentSignalSourceEvent(...)` when the work should stay quiet and out-of-band. That path:
|
||||
|
||||
1. normalizes the source envelope
|
||||
2. derives or reuses `scopeKey`
|
||||
3. triggers `AgentSignalWorkflow`
|
||||
4. executes later in `runAgentSignalWorkflow`
|
||||
|
||||
This is the preferred path when the UI request should finish immediately and the policy can run in the background.
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/workflows/agentSignal/index.ts`
|
||||
- `src/server/workflows/agentSignal/run.ts`
|
||||
|
||||
## Existing Example: `analyzeIntent`
|
||||
|
||||
Use `analyzeIntent` as the reference chain:
|
||||
|
||||
```text
|
||||
agent.user.message
|
||||
-> feedback satisfaction source handler
|
||||
-> signal.feedback.satisfaction
|
||||
-> feedback domain signal handler
|
||||
-> signal.feedback.domain.*
|
||||
-> feedback action planner
|
||||
-> action.user-memory.handle
|
||||
-> signal.action.applied | skipped | failed
|
||||
```
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/index.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
|
||||
@@ -0,0 +1,228 @@
|
||||
# Writing Handlers And Policies
|
||||
|
||||
## Fluent Registration API
|
||||
|
||||
Use the middleware helpers in `src/server/services/agentSignal/runtime/middleware.ts`.
|
||||
|
||||
They provide:
|
||||
|
||||
- `defineSourceHandler(...)`
|
||||
- `defineSignalHandler(...)`
|
||||
- `defineActionHandler(...)`
|
||||
- `defineAgentSignalHandlers(...)`
|
||||
|
||||
These helpers do two jobs:
|
||||
|
||||
1. keep handler registration terse
|
||||
2. preserve strong typing when `listen` points at concrete source, signal, or action types
|
||||
|
||||
## Handler Shape
|
||||
|
||||
Each handler receives:
|
||||
|
||||
- the current runtime node
|
||||
- `RuntimeProcessorContext`
|
||||
|
||||
The context gives you:
|
||||
|
||||
- `scopeKey`
|
||||
- `now()`
|
||||
- `runtimeState.getGuardState(lane)`
|
||||
- `runtimeState.touchGuardState(lane, now?)`
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/services/agentSignal/runtime/context.ts`
|
||||
|
||||
## Return Contracts
|
||||
|
||||
Return one of these shapes:
|
||||
|
||||
- `void`: no fan-out, stop at this handler
|
||||
- `{ status: 'dispatch', signals?, actions? }`: continue the chain
|
||||
- `{ status: 'wait', pending? }`: pause for later host coordination
|
||||
- `{ status: 'schedule', nextHop }`: schedule another hop
|
||||
- `{ status: 'conclude', concluded? }`: stop with a terminal runtime result
|
||||
- `ExecutorResult`: only for action handlers that performed a concrete side effect
|
||||
|
||||
Read:
|
||||
|
||||
- `packages/agent-signal/src/base/types.ts`
|
||||
- `src/server/services/agentSignal/runtime/AgentSignalScheduler.ts`
|
||||
|
||||
## Policy Composition Pattern
|
||||
|
||||
Use `defineAgentSignalHandlers([...])` to bundle related handlers into one policy.
|
||||
|
||||
Example from `analyzeIntent`:
|
||||
|
||||
```ts
|
||||
return defineAgentSignalHandlers([
|
||||
createFeedbackSatisfactionJudgeProcessor(...),
|
||||
createFeedbackDomainJudgeSignalHandler(...),
|
||||
createFeedbackActionPlannerSignalHandler(),
|
||||
defineUserMemoryActionHandler(...),
|
||||
]);
|
||||
```
|
||||
|
||||
That bundle is later passed into the runtime via:
|
||||
|
||||
- `createDefaultAgentSignalPolicies(...)`
|
||||
- `createAgentSignalRuntime({ policies })`
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/services/agentSignal/policies/index.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/index.ts`
|
||||
|
||||
## Source Handler Pattern
|
||||
|
||||
Use a source handler when you are interpreting a producer event into semantic signals.
|
||||
|
||||
Reference:
|
||||
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
|
||||
|
||||
Pattern:
|
||||
|
||||
```ts
|
||||
return defineSourceHandler(
|
||||
AGENT_SIGNAL_SOURCE_TYPES.agentUserMessage,
|
||||
'agent.user.message:my-handler',
|
||||
async (source, ctx): Promise<RuntimeProcessorResult | void> => {
|
||||
// interpret source payload
|
||||
// optionally use ctx.runtimeState
|
||||
|
||||
return {
|
||||
signals: [
|
||||
/* one or more semantic signals */
|
||||
],
|
||||
status: 'dispatch',
|
||||
};
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
Write source handlers when:
|
||||
|
||||
- a raw message, lifecycle event, or bot ingress needs interpretation
|
||||
- the work is still semantic, not side-effectful
|
||||
|
||||
## Signal Handler Pattern
|
||||
|
||||
Use a signal handler when one semantic state should branch into more semantic states or planned actions.
|
||||
|
||||
References:
|
||||
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
|
||||
|
||||
Pattern:
|
||||
|
||||
```ts
|
||||
return defineSignalHandler(
|
||||
MY_SIGNAL_TYPE,
|
||||
'signal.my-policy-router',
|
||||
async (signal): Promise<RuntimeProcessorResult | void> => {
|
||||
return {
|
||||
actions: [
|
||||
/* planned work */
|
||||
],
|
||||
status: 'dispatch',
|
||||
};
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
Use signal handlers for:
|
||||
|
||||
- routing
|
||||
- fan-out
|
||||
- filtering
|
||||
- conflict resolution
|
||||
- converting interpretation into planned actions
|
||||
|
||||
## Action Handler Pattern
|
||||
|
||||
Use an action handler when the runtime should do actual work.
|
||||
|
||||
Reference:
|
||||
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
|
||||
|
||||
Pattern:
|
||||
|
||||
```ts
|
||||
return defineActionHandler(
|
||||
MY_ACTION_TYPE,
|
||||
'action.my-policy-executor',
|
||||
async (action, ctx): Promise<ExecutorResult> => {
|
||||
// run service/tool/model side effect
|
||||
// check idempotency if needed
|
||||
|
||||
return {
|
||||
actionId: action.actionId,
|
||||
attempt: {
|
||||
completedAt: ctx.now(),
|
||||
current: 1,
|
||||
startedAt,
|
||||
status: 'succeeded',
|
||||
},
|
||||
status: 'applied',
|
||||
};
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
Keep these rules:
|
||||
|
||||
- perform idempotency checks here or immediately before side effects
|
||||
- return stable `actionId`
|
||||
- include failure detail in `error`
|
||||
- let the scheduler turn the `ExecutorResult` into built-in result signals
|
||||
|
||||
## Source, Signal, And Action Type Placement
|
||||
|
||||
Use this split:
|
||||
|
||||
- external event payloads:
|
||||
`src/server/services/agentSignal/sourceTypes.ts`
|
||||
- policy-owned signal and action payloads:
|
||||
`src/server/services/agentSignal/policies/types.ts`
|
||||
- normalized shared node contracts:
|
||||
`packages/agent-signal/src/base/types.ts`
|
||||
|
||||
Do not put app-specific signal catalogs into `packages/agent-signal`. That package should stay generic and reusable.
|
||||
|
||||
## Choosing The Right Node
|
||||
|
||||
Choose `source` when:
|
||||
|
||||
- the outside world emitted a new fact
|
||||
|
||||
Choose `signal` when:
|
||||
|
||||
- the system needs semantic meaning that downstream handlers can reuse
|
||||
|
||||
Choose `action` when:
|
||||
|
||||
- the runtime is ready for a concrete side effect
|
||||
|
||||
If a handler both interprets meaning and performs side effects, split it. That keeps chains inspectable and testable.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Prefer focused tests near the touched code.
|
||||
|
||||
Useful references:
|
||||
|
||||
- `src/server/services/agentSignal/runtime/__tests__/AgentSignalRuntime.test.ts`
|
||||
- `src/server/services/agentSignal/__tests__/index.integration.test.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/__tests__/*`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/actions/__tests__/*`
|
||||
|
||||
Test at the smallest level that proves the behavior:
|
||||
|
||||
- handler unit test for one routing rule
|
||||
- runtime test for queue fan-out
|
||||
- integration test for service ingress and observability persistence
|
||||
@@ -0,0 +1,118 @@
|
||||
# Observability And Debugging
|
||||
|
||||
## OTEL Ownership
|
||||
|
||||
Use `packages/observability-otel/src/modules/agent-signal/index.ts` for the shared tracer and metrics.
|
||||
|
||||
Available instruments:
|
||||
|
||||
- `tracer`
|
||||
- `sourceCounter`
|
||||
- `signalCounter`
|
||||
- `actionCounter`
|
||||
- `actionResultCounter`
|
||||
- `chainCounter`
|
||||
- `signalActionTransitionCounter`
|
||||
- `chainDurationHistogram`
|
||||
- `actionDurationHistogram`
|
||||
|
||||
Use this module when you need shared telemetry ownership instead of creating feature-local meters or tracers.
|
||||
|
||||
## Projection Pipeline
|
||||
|
||||
After runtime execution, the service projects one compact observability model from the full chain.
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/services/agentSignal/observability/projector.ts`
|
||||
- `src/server/services/agentSignal/observability/traceEvents.ts`
|
||||
- `src/server/services/agentSignal/observability/store.ts`
|
||||
|
||||
Projection outputs:
|
||||
|
||||
- a trace envelope with source, signals, actions, results, edges, and handler runs
|
||||
- a compact telemetry record with dominant path, status breakdown, and chain metadata
|
||||
|
||||
This projection is built from:
|
||||
|
||||
- source node
|
||||
- emitted signals
|
||||
- planned actions
|
||||
- executor results
|
||||
|
||||
## How To Inspect A Chain
|
||||
|
||||
Use this order:
|
||||
|
||||
1. Inspect the source type and payload.
|
||||
2. Inspect emitted signals.
|
||||
3. Inspect planned actions.
|
||||
4. Inspect executor results.
|
||||
5. Inspect projected edges and dominant path.
|
||||
|
||||
The helper `toAgentSignalTraceEvents(...)` flattens a chain into compact event records suitable for tracing snapshots.
|
||||
|
||||
## Workflow Snapshot Bridge
|
||||
|
||||
Workflow-triggered runs do not naturally pass through the normal foreground runtime snapshot path, so `runAgentSignalWorkflow` adds a development-only bridge into `.agent-tracing/`.
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/workflows/agentSignal/run.ts`
|
||||
|
||||
Use that path when:
|
||||
|
||||
- the source was enqueued with `enqueueAgentSignalSourceEvent(...)`
|
||||
- you need local trace visibility for quiet background work
|
||||
|
||||
## Common Debug Questions
|
||||
|
||||
### The source emits but nothing happens
|
||||
|
||||
Check:
|
||||
|
||||
- feature gate enabled for the user
|
||||
- source type matches a registered source handler
|
||||
- dedupe or scope lock did not short-circuit generation
|
||||
|
||||
Read:
|
||||
|
||||
- `src/server/services/agentSignal/index.ts`
|
||||
- `src/server/services/agentSignal/sources/index.ts`
|
||||
|
||||
### The signal exists but no action runs
|
||||
|
||||
Check:
|
||||
|
||||
- the signal type has a registered signal handler
|
||||
- the signal handler returns `status: 'dispatch'`
|
||||
- the handler actually returned actions
|
||||
|
||||
### The action runs twice
|
||||
|
||||
Check:
|
||||
|
||||
- source dedupe key stability
|
||||
- action idempotency strategy
|
||||
- scope key stability across retries and workflow handoff
|
||||
|
||||
Reference:
|
||||
|
||||
- `src/server/services/agentSignal/policies/actionIdempotency.ts`
|
||||
- `src/server/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
|
||||
|
||||
### Background runs are hard to discover
|
||||
|
||||
Check:
|
||||
|
||||
- workflow snapshot bridge in development
|
||||
- projected telemetry record contents
|
||||
- OTEL counters and histograms in the shared module
|
||||
|
||||
## Minimal Completion Checklist
|
||||
|
||||
- source ingress is testable
|
||||
- handler registration is discoverable from the policy factory
|
||||
- action executor returns structured results
|
||||
- projection includes the new path cleanly
|
||||
- tests cover at least one happy path and one no-op or failure path
|
||||
@@ -76,7 +76,7 @@ The router caches loaded bots in memory. Cache is **invalidated** by `BotMessage
|
||||
2. Calls `execAgent` with `stepWebhook` and `completionWebhook` pointing at `${INTERNAL_APP_URL ?? APP_URL}/api/agent/webhooks/bot-callback`, plus `webhookDelivery: 'qstash'`.
|
||||
3. Returns immediately; the bridge `finally` block keeps the active-thread marker held until the `completion` callback fires.
|
||||
|
||||
`/api/agent/webhooks/bot-callback/route.ts` verifies the QStash signature and hands off to `BotCallbackService.handleCallback`:
|
||||
`POST /api/agent/webhooks/bot-callback` (`src/server/agent-hono/handlers/botCallback.ts`) verifies the QStash signature via the `qstashAuth` middleware and hands off to `BotCallbackService.handleCallback`:
|
||||
|
||||
- `type: 'step'` → `handleStep` re-renders `renderStepProgress`, edits `progressMessageId` (skipped if `displayToolCalls=false` or platform `supportsMessageEdit=false`).
|
||||
- `type: 'completion'` → `handleCompletion` writes the final reply (or error/interrupted message), removes the 👀 reaction, clears active-thread tracker, fires async `summarizeTopicTitle`.
|
||||
@@ -140,12 +140,12 @@ Webhook platforms run fine in serverless functions. Persistent platforms (`webso
|
||||
- On Vercel + webhook mode → start the client inline (one HTTP call).
|
||||
- Off-Vercel → `GatewayManager` singleton holds long-lived clients in process.
|
||||
|
||||
**`GET /api/agent/gateway/route.ts`** (cron, `Bearer ${CRON_SECRET}`):
|
||||
**`GET /api/agent/gateway`** (`src/server/agent-hono/handlers/gatewayCron.ts`, cron, `Bearer ${CRON_SECRET}`):
|
||||
|
||||
- Iterates registered platforms and starts every enabled persistent provider with `durationMs = 10min`, then in `after(...)` polls `BotConnectQueue` every 30s for new connect requests, until the window expires.
|
||||
- `getEffectiveConnectionMode(platform, settings)` is the only place that resolves per-provider mode — respect it everywhere.
|
||||
|
||||
**`POST /api/agent/gateway/start/route.ts`** is the non-Vercel `ensureRunning` entry point (`Bearer ${KEY_VAULTS_SECRET}`).
|
||||
**`POST /api/agent/gateway/start`** (`src/server/agent-hono/handlers/gatewayStart.ts`) is the non-Vercel `ensureRunning` entry point (`Bearer ${KEY_VAULTS_SECRET}`).
|
||||
|
||||
**Runtime status** is stored in Redis at `bot:runtime-status:platform:appId` with TTL ≈ `durationMs + 60s`. States: `starting | connected | disconnected | failed | queued`. Updated by each `PlatformClient.start/stop` and by the gateway service.
|
||||
|
||||
@@ -166,7 +166,7 @@ Each platform exposes a `PlatformDefinition` registered in `platforms/index.ts`:
|
||||
}
|
||||
```
|
||||
|
||||
`schema` drives both server validation (`mergeWithDefaults`, `extractDefaults`) **and** the auto-generated UI form. Top-level keys `applicationId` / `credentials` / `settings` map to DB columns. Common settings fields live in `platforms/const.ts` (`displayToolCallsField`, `serverIdField`, `userIdField`).
|
||||
`schema` drives both server validation (`mergeWithDefaults`, `extractDefaults`) **and** the auto-generated UI form. Top-level keys `applicationId` / `credentials` / `settings` map to DB columns. Common settings fields live in `platforms/const.ts` (`displayToolCallsField`, `makeServerIdField(platform?)`, `makeUserIdField(platform?)`). The `serverId` / `userId` factories take a platform identifier so the field's hint can render platform-specific "how to find this ID" guidance (Discord Developer Mode, Telegram @userinfobot, etc.); pass no argument to fall back to generic copy.
|
||||
|
||||
Each platform implements `PlatformClient` (see `platforms/types.ts`):
|
||||
|
||||
@@ -226,11 +226,11 @@ Client service: `src/services/agentBotProvider.ts`. Store actions: `src/store/ag
|
||||
## Key Files
|
||||
|
||||
```plaintext
|
||||
Webhook routes:
|
||||
src/app/(backend)/api/agent/webhooks/[platform]/[[...appId]]/route.ts — inbound catch-all
|
||||
src/app/(backend)/api/agent/webhooks/bot-callback/route.ts — qstash bot callback
|
||||
src/app/(backend)/api/agent/gateway/route.ts — cron gateway (10min window)
|
||||
src/app/(backend)/api/agent/gateway/start/route.ts — non-Vercel ensureRunning
|
||||
Webhook routes (mounted via `src/app/(backend)/api/agent/[[...route]]/route.ts` → `src/server/agent-hono`):
|
||||
src/server/agent-hono/handlers/platformWebhook.ts — inbound catch-all (POST /webhooks/:platform/:appId?)
|
||||
src/server/agent-hono/handlers/botCallback.ts — qstash bot callback
|
||||
src/server/agent-hono/handlers/gatewayCron.ts — cron gateway (10min window)
|
||||
src/server/agent-hono/handlers/gatewayStart.ts — non-Vercel ensureRunning
|
||||
|
||||
Bot service:
|
||||
src/server/services/bot/index.ts — barrel
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
name: builtin-tool
|
||||
description: Build a new builtin tool package under `packages/builtin-tool-<name>/`. Use when adding a new agent-callable toolset, designing its API surface (manifest / ApiName / Params / State), implementing the Executor + ExecutionRuntime, building the Inspector / Render / Placeholder / Streaming / Intervention / Portal UI, or wiring a tool into the central registries (`packages/builtin-tools/src/{index,identifiers,inspectors,renders,placeholders,streamings,interventions,portals}.ts` and `src/store/tool/slices/builtin/executors/index.ts`). Triggers on "new builtin tool", "add a tool", "tool inspector", "tool render", "tool placeholder", "tool streaming", "tool intervention", "BuiltinToolManifest", "BaseExecutor", "ExecutionRuntime".
|
||||
---
|
||||
|
||||
# Builtin Tool Authoring Guide
|
||||
|
||||
A builtin tool is a package the agent runtime can call. It ships **five faces**:
|
||||
|
||||
| Face | Lives in | Audience |
|
||||
| -------------------- | -------------------------------------------------------------------------------------- | ------------------------------------- |
|
||||
| **Manifest + types** | `src/{manifest,types,systemRole}.ts` | The LLM (tool spec + system prompt) |
|
||||
| **ExecutionRuntime** | `src/ExecutionRuntime/` | Server / desktop / any runtime caller |
|
||||
| **Executor** | `src/client/executor/` | Frontend (wraps stores/services) |
|
||||
| **Client UI** | `src/client/{Inspector,Render,…}/` | Chat UI |
|
||||
| **Registry wiring** | `packages/builtin-tools/src/*.ts` + `src/store/tool/slices/builtin/executors/index.ts` | Framework |
|
||||
|
||||
---
|
||||
|
||||
## Read These First
|
||||
|
||||
| Question | Doc |
|
||||
| ------------------------------------------------------------------------------------ | ---------------------------------- |
|
||||
| Where do files live? What does each face do? Wiring? | [architecture.md](architecture.md) |
|
||||
| How do I name the tool, design APIs, write the manifest, executor, ExecutionRuntime? | [tool-design.md](tool-design.md) |
|
||||
| How do I build Inspector / Render / Placeholder / Streaming / Intervention / Portal? | [ui.md](ui.md) |
|
||||
|
||||
---
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- Creating a new `packages/builtin-tool-<name>/` package
|
||||
- Adding a new API method to an existing builtin tool
|
||||
- Building or restyling any of the 6 client surfaces for a tool
|
||||
- Wiring a tool into the central registries
|
||||
- Debugging "tool not found / API not found / render not showing / placeholder stuck" errors
|
||||
|
||||
---
|
||||
|
||||
## Top-Level Design Principles
|
||||
|
||||
1. **`lobe-<domain>` identifier is permanent.** It's stored in message history. Renames need `@deprecated` aliases (see `packages/builtin-tools/src/inspectors.ts:88-89`). Get it right the first time.
|
||||
2. **ApiName is an `as const` object**, not a TS enum. It doubles as the runtime list `BaseExecutor` iterates over.
|
||||
3. **Three result fields, three audiences:**
|
||||
- `content: string` → the LLM reads it
|
||||
- `state: Record<…>` → the UI's `pluginState`; **result-domain only**, never echo all params back
|
||||
- `error: { type, message, body? }` → both LLM and UI; `type` is a stable code
|
||||
4. **Split execution from frontend wiring.**
|
||||
- `src/ExecutionRuntime/` — pure runtime, no React, no Zustand, accepts services via constructor. **The default place for new logic.**
|
||||
- `src/client/executor/` — `BaseExecutor` subclass that calls `ExecutionRuntime` (or stores/services directly when frontend-only).
|
||||
5. **UI defaults to "do nothing".** Inspector is required (the header strip). Render/Placeholder/Streaming/Intervention/Portal are added **only when there's something specific to show** — empty registries are fine.
|
||||
6. **Style with `createStaticStyles + cssVar.*`** (zero-runtime). Fall back to `createStyles + token` only when you genuinely need runtime values. Use `@lobehub/ui` components, not raw antd.
|
||||
7. **i18n keys live in `src/locales/default/plugin.ts`.** Inspector titles must come from `t('builtins.<identifier>.apiName.<api>')` so something renders while args stream.
|
||||
|
||||
---
|
||||
|
||||
## Package Layout (preferred, post-2026 convention)
|
||||
|
||||
```
|
||||
packages/builtin-tool-<name>/
|
||||
├── package.json
|
||||
└── src/
|
||||
├── index.ts # exports manifest + types + systemRole + Identifier (no React, no stores)
|
||||
├── manifest.ts # BuiltinToolManifest with JSON Schema for every API
|
||||
├── types.ts # ApiName const + Params/State interfaces per API
|
||||
├── systemRole.ts # System prompt teaching the model when/how to use the APIs
|
||||
├── ExecutionRuntime/ # ✅ Default home for runtime logic (server- or anywhere-callable)
|
||||
│ └── index.ts
|
||||
└── client/
|
||||
├── index.ts # Re-exports for the registries
|
||||
├── executor/ # ✅ Frontend executor — extends BaseExecutor, often delegates to ExecutionRuntime
|
||||
│ └── index.ts
|
||||
├── Inspector/ # required — header chip per API
|
||||
├── Render/ # optional — rich result card
|
||||
├── Placeholder/ # optional — skeleton during streaming/execution
|
||||
├── Streaming/ # optional — live output renderer (e.g. RunCommand, WriteFile)
|
||||
├── Intervention/ # optional — approval / edit-before-run UI
|
||||
├── Portal/ # optional — full-screen detail view
|
||||
└── components/ # shared subcomponents used by the surfaces above
|
||||
```
|
||||
|
||||
**Older packages** (`builtin-tool-task`, `builtin-tool-calculator`, etc.) still have `src/executor/` as a sibling of `src/client/`. That's grandfathered; **don't relocate without a deliberate refactor**. New packages and new APIs added to existing packages should follow the layout above.
|
||||
|
||||
`package.json` exports map:
|
||||
|
||||
```json
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./client": "./src/client/index.ts",
|
||||
"./executor": "./src/client/executor/index.ts",
|
||||
"./executionRuntime": "./src/ExecutionRuntime/index.ts"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authoring Checklist
|
||||
|
||||
Before opening the PR:
|
||||
|
||||
- [ ] Identifier follows `lobe-<domain>` and is **stable** (lives in message history).
|
||||
- [ ] Every `<Name>ApiName` value has: a manifest `api[]` entry, an executor method, an Inspector, an i18n `apiName.*` key.
|
||||
- [ ] `Params` interfaces match the JSON Schema; `State` interfaces match what the executor returns and what the UI surfaces read.
|
||||
- [ ] System prompt disambiguates confusable APIs and points to batch variants.
|
||||
- [ ] Runtime logic lives in `ExecutionRuntime/`; the `client/executor/` only wires stores/services and delegates.
|
||||
- [ ] Executor returns `{ success, content, state, error? }` via a single `toResult()` funnel — `content` always non-empty (default to `error.message`).
|
||||
- [ ] Inspector handles `isArgumentsStreaming`, `isLoading`, `partialArgs`, missing `pluginState`.
|
||||
- [ ] Render returns `null` until it has data; only created for APIs with rich results.
|
||||
- [ ] Placeholder added if the API has a perceivable execution lag (search, list, crawl).
|
||||
- [ ] Streaming added for APIs that emit incremental output (run command, write file, code execution).
|
||||
- [ ] Intervention added if `humanIntervention` is set in the manifest.
|
||||
- [ ] All registry files updated (see [architecture.md → Registry wiring](architecture.md#registry-wiring)).
|
||||
- [ ] i18n keys in `src/locales/default/plugin.ts` plus dev seeds in `en-US`/`zh-CN`.
|
||||
- [ ] `bunx vitest run --silent='passed-only' 'packages/builtin-tool-<name>'` passes.
|
||||
- [ ] `bun run type-check` passes.
|
||||
|
||||
---
|
||||
|
||||
## Reference Tools
|
||||
|
||||
Pick the closest neighbor and copy:
|
||||
|
||||
| If your tool is… | Read first |
|
||||
| ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| Pure-compute, no UI state | `packages/builtin-tool-calculator/` — `ExecutionRuntime` reuses executor (mathjs/nerdamer work everywhere) |
|
||||
| CRUD over a domain entity | `packages/builtin-tool-task/` — full Inspector + Render set, batch variants |
|
||||
| Heavy UI (Inspector/Render/Placeholder/Portal) | `packages/builtin-tool-web-browsing/` — search-style result UI, Portal for detail view |
|
||||
| Desktop / filesystem with all surfaces (incl. Streaming + Intervention) | `packages/builtin-tool-local-system/` — `ExecutionRuntime` injects an `ILocalSystemService`, executor calls it |
|
||||
| Server-side pure (no client executor) | `packages/builtin-tool-web-browsing/` — only `ExecutionRuntime` is exported; the chat client doesn't run it |
|
||||
| Needs human approval before running | `packages/builtin-tool-local-system/src/client/Intervention/` — per-API approval components |
|
||||
@@ -0,0 +1,315 @@
|
||||
# Builtin Tool Architecture
|
||||
|
||||
## The Five Faces
|
||||
|
||||
A builtin tool ships five distinct faces, each compiled into a different bundle:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ./ │
|
||||
│ Manifest + Types + systemRole │
|
||||
│ ─ Pure data, no React, no Node-only deps. │
|
||||
│ ─ Imported by: server (LLM tool spec), client (registries), │
|
||||
│ anyone who needs to know "what tools exist". │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ./executionRuntime │
|
||||
│ src/ExecutionRuntime/index.ts │
|
||||
│ ─ Pure runtime logic. Accepts services via constructor — │
|
||||
│ never imports concrete services or stores directly. │
|
||||
│ ─ Imported by: server (BuiltinServerRuntimeOutput), tests, │
|
||||
│ and the client executor as a delegate. │
|
||||
│ ─ Returns: BuiltinServerRuntimeOutput { content, state, … } │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ./executor │
|
||||
│ src/client/executor/index.ts │
|
||||
│ ─ BaseExecutor subclass. Wires Zustand stores and frontend │
|
||||
│ services into ExecutionRuntime, then funnels through │
|
||||
│ toResult() into BuiltinToolResult { content, state, error, │
|
||||
│ success }. │
|
||||
│ ─ Imported by: src/store/tool/slices/builtin/executors/ │
|
||||
│ index.ts (registered as a singleton). │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ./client │
|
||||
│ src/client/{Inspector,Render,Placeholder,Streaming, │
|
||||
│ Intervention,Portal,components}/ │
|
||||
│ ─ React 'use client' surfaces. Read args + pluginState. │
|
||||
│ ─ Imported by: packages/builtin-tools/src/{inspectors, │
|
||||
│ renders,placeholders,streamings,interventions,portals}.ts. │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Registry wiring │
|
||||
│ packages/builtin-tools/src/*.ts │
|
||||
│ src/store/tool/slices/builtin/executors/index.ts │
|
||||
│ ─ Aggregator maps: identifier → { apiName → component }. │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The split exists so:
|
||||
|
||||
- Server bundles import only `./` and `./executionRuntime` and never touch React.
|
||||
- Frontend bundles import `./client` and never touch Node-only services.
|
||||
- The runtime is testable without React or Electron present.
|
||||
|
||||
---
|
||||
|
||||
## Why ExecutionRuntime is the Default Home for Logic
|
||||
|
||||
**Old pattern (grandfathered):** business logic in `src/executor/` directly. Examples: `builtin-tool-task`, older tools. Works, but the executor mixes runtime logic with frontend service plumbing — hard to reuse on the server.
|
||||
|
||||
**New pattern (preferred):** business logic in `src/ExecutionRuntime/`, frontend wiring in `src/client/executor/`. Examples: `builtin-tool-local-system`, `builtin-tool-web-browsing`, `builtin-tool-calculator`.
|
||||
|
||||
```
|
||||
ExecutionRuntime
|
||||
├─ accepts services via constructor (or `static create(opts)`)
|
||||
├─ returns BuiltinServerRuntimeOutput (content + state + success)
|
||||
└─ no React, no Zustand, no `@/services/...` direct imports
|
||||
|
||||
client/executor
|
||||
├─ extends BaseExecutor<typeof <Name>ApiName>
|
||||
├─ holds a `runtime = new <Name>ExecutionRuntime(realService)` instance
|
||||
├─ each ApiName method:
|
||||
│ 1. resolve scope / pull defaults from BuiltinToolContext
|
||||
│ 2. call runtime.<method>(args)
|
||||
│ 3. funnel through toResult() → BuiltinToolResult
|
||||
└─ exported singleton: export const <name>Executor = new <Name>Executor()
|
||||
```
|
||||
|
||||
### Service injection
|
||||
|
||||
`ExecutionRuntime` should declare a TypeScript interface for the services it needs and accept the implementation via constructor. Server callers wire in real implementations; tests wire in mocks. Example from `local-system`:
|
||||
|
||||
```ts
|
||||
export interface ILocalSystemService {
|
||||
readLocalFile: (params: any) => Promise<any>;
|
||||
writeFile: (params: any) => Promise<any>;
|
||||
/* … */
|
||||
}
|
||||
|
||||
export class LocalSystemExecutionRuntime extends ComputerRuntime {
|
||||
constructor(private service: ILocalSystemService) {
|
||||
super();
|
||||
}
|
||||
/* methods delegate to this.service.* */
|
||||
}
|
||||
```
|
||||
|
||||
The `client/executor` instantiates it once with the real service:
|
||||
|
||||
```ts
|
||||
import { localFileService } from '@/services/electron/localFileService';
|
||||
import { LocalSystemExecutionRuntime } from '../../ExecutionRuntime';
|
||||
|
||||
class LocalSystemExecutor extends BaseExecutor<typeof LocalSystemApiEnum> {
|
||||
private runtime = new LocalSystemExecutionRuntime(localFileService);
|
||||
/* … */
|
||||
}
|
||||
```
|
||||
|
||||
### When ExecutionRuntime is the only thing you ship
|
||||
|
||||
Some tools are server-only — there's no frontend executor. `builtin-tool-web-browsing` is the canonical example: only `./` and `./executionRuntime` are exported, no `./executor`, and the runtime is constructed by the server-side `ToolExecutionService`. Skip `client/executor/` entirely for those.
|
||||
|
||||
### When the executor reuses the runtime as-is
|
||||
|
||||
Pure-compute tools (`builtin-tool-calculator`) often have an executor whose ApiName methods call `executor.calculate(args)` and an `ExecutionRuntime` whose methods call `calculatorExecutor.calculate(args)` — same logic, two thin wrappers. That's fine; the duplication buys you the bundle split.
|
||||
|
||||
---
|
||||
|
||||
## The Result Contract
|
||||
|
||||
### `BuiltinServerRuntimeOutput` (what ExecutionRuntime returns)
|
||||
|
||||
```ts
|
||||
{
|
||||
content: string; // the LLM-facing text — never undefined; default to error message
|
||||
state?: any; // result-domain object the UI reads as pluginState
|
||||
success: boolean; // mandatory
|
||||
error?: any; // raw error; the executor will repackage
|
||||
}
|
||||
```
|
||||
|
||||
### `BuiltinToolResult` (what the executor returns to the runtime)
|
||||
|
||||
```ts
|
||||
{
|
||||
success: boolean;
|
||||
content?: string;
|
||||
state?: any;
|
||||
error?: { type: string; message: string; body?: any };
|
||||
metadata?: Record<string, any>; // rare; e.g. { agentCouncil: true }
|
||||
stop?: boolean; // rare; halt the orchestration step
|
||||
}
|
||||
```
|
||||
|
||||
### The `toResult` funnel (mandatory)
|
||||
|
||||
Every executor method returns through a single `toResult()` to enforce two invariants:
|
||||
|
||||
1. **`content` is never undefined.** A missing content collapses downstream into `''`, leaving the Debug pane blank while `pluginState` was already saved. See the `globLocalFiles` regression in `local-system/src/client/executor/index.ts:60-84`.
|
||||
2. **`state` survives failures.** Renderers can keep showing partial output even when `success: false`.
|
||||
|
||||
```ts
|
||||
private toResult(output: BuiltinServerRuntimeOutput): BuiltinToolResult {
|
||||
const errorMessage = typeof output.error?.message === 'string' ? output.error.message : undefined;
|
||||
const safeContent = output.content || errorMessage || 'Tool execution failed';
|
||||
|
||||
if (!output.success) {
|
||||
return {
|
||||
success: false,
|
||||
content: safeContent,
|
||||
state: output.state,
|
||||
error: output.error
|
||||
? { type: 'PluginServerError', message: errorMessage ?? safeContent, body: output.error }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
return { success: true, content: safeContent, state: output.state };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `BaseExecutor` — How Method Dispatch Works
|
||||
|
||||
`BaseExecutor.invoke(apiName, params, ctx)` does:
|
||||
|
||||
```ts
|
||||
if (!this.hasApi(apiName)) return { error: { type: 'ApiNotFound', … }, success: false };
|
||||
return (this as any)[apiName](params, ctx); // method name MUST equal apiName value
|
||||
```
|
||||
|
||||
So:
|
||||
|
||||
- **Method names must equal `<Name>ApiName` values, exactly.** A typo silently routes to "ApiNotFound".
|
||||
- **Methods must be class fields, not class methods**, because `this` is lost when registry calls `executor.invoke(apiName, params, ctx)`. Always declare as `methodName = async (…) => { … }`.
|
||||
- **Always destructure `apiEnum` and `identifier` as `readonly` instance fields**, not getters — `BaseExecutor.hasApi/getApiNames` reads them synchronously.
|
||||
|
||||
---
|
||||
|
||||
## `BuiltinToolContext` — What the Executor Receives
|
||||
|
||||
The runtime hands every executor method an optional `BuiltinToolContext` as the second argument:
|
||||
|
||||
| Field | Use |
|
||||
| ----------------------------- | -------------------------------------------------------------- |
|
||||
| `agentId` | Default agent for "current agent" semantics (e.g. `listTasks`) |
|
||||
| `groupId` | Group chat scope |
|
||||
| `topicId` | Current topic — needed when creating messages/operations |
|
||||
| `taskId` | Current task identifier — fallback for "implicit" param |
|
||||
| `documentId` | Current page/document scope |
|
||||
| `messageId` | The tool message being created (for state attachments) |
|
||||
| `sourceMessageId` | The user message that triggered this tool turn |
|
||||
| `operationId` | Operation lineage (use for cancellation, tracing) |
|
||||
| `scope` | `'task' \| 'agent' \| …` — toggles default behaviors |
|
||||
| `signal: AbortSignal` | Honor for long-running ops |
|
||||
| `stepContext` | Cross-message runtime state (GTD todos, etc.) |
|
||||
| `registerAfterCompletion(cb)` | Defer side-effects past message-update race |
|
||||
| `groupOrchestration` | Group orchestration callbacks |
|
||||
|
||||
**Use rule:** read with `?.`, fall back to explicit params, **never silently override** an explicit param with a context value.
|
||||
|
||||
---
|
||||
|
||||
## i18n Integration
|
||||
|
||||
Source of truth: `src/locales/default/plugin.ts`. Keys follow `builtins.<identifier>.<topic>.<…>`:
|
||||
|
||||
| Key | Use |
|
||||
| ------------------------------------- | ------------------------------------------------------------ |
|
||||
| `builtins.<identifier>.title` | Display title (overrides `manifest.meta.title` when present) |
|
||||
| `builtins.<identifier>.apiName.<api>` | Inspector header label (one per ApiName) |
|
||||
| `builtins.<identifier>.inspector.<…>` | Extra Inspector strings ("no results", chips, counters) |
|
||||
| `builtins.<identifier>.<feature>.<…>` | Render / Intervention strings, free-form per tool |
|
||||
|
||||
For dev preview, also seed `locales/zh-CN/plugin.json` and `locales/en-US/plugin.json`. Run `pnpm i18n` before opening a PR — it's slow, so do it once at the end. (See the **i18n** skill for the full workflow.)
|
||||
|
||||
---
|
||||
|
||||
## Registry Wiring
|
||||
|
||||
Five core files plus optional ones. Miss any and you'll see "tool not found", a missing chip, a blank result card, a stuck spinner, or an approval dialog that never appears.
|
||||
|
||||
| File | Add what |
|
||||
| -------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| **Required** | |
|
||||
| `packages/builtin-tools/src/index.ts` | Import `<Name>Manifest`; push entry to `builtinTools`. Set `hidden`/`discoverable` flags. |
|
||||
| `packages/builtin-tools/src/identifiers.ts` | Add `<Name>Manifest.identifier` to `builtinToolIdentifiers`. |
|
||||
| `packages/builtin-tools/src/inspectors.ts` | Import `<Name>Inspectors, <Name>Manifest`; add to `BuiltinToolInspectors`. |
|
||||
| `src/store/tool/slices/builtin/executors/index.ts` | Import `<name>Executor`; add to `registerExecutors([…])`. |
|
||||
| **Conditional — add only if the surface exists** | |
|
||||
| `packages/builtin-tools/src/renders.ts` | Add to `BuiltinToolsRenders` if any API has a Render. |
|
||||
| `packages/builtin-tools/src/placeholders.ts` | Add to `BuiltinToolPlaceholders` if any API has a Placeholder. |
|
||||
| `packages/builtin-tools/src/streamings.ts` | Add to `BuiltinToolStreamings` if any API has a Streaming renderer. |
|
||||
| `packages/builtin-tools/src/interventions.ts` | Add to `BuiltinToolInterventions` if any API has an Intervention component. |
|
||||
| `packages/builtin-tools/src/portals.ts` | Add to `BuiltinToolsPortals` if the tool has a Portal. |
|
||||
| `packages/builtin-tools/src/displayControls.ts` | Add if Render must show/hide based on result content (rare; see ClaudeCode/Codex). |
|
||||
|
||||
### Optional flags in `packages/builtin-tools/src/index.ts`
|
||||
|
||||
```ts
|
||||
{
|
||||
identifier: TaskManifest.identifier,
|
||||
manifest: TaskManifest,
|
||||
type: 'builtin',
|
||||
hidden: true, // hide from chat-input Tools popover
|
||||
discoverable: false, // exclude from agent builder / skill discovery
|
||||
}
|
||||
```
|
||||
|
||||
Lists in the same file you may need to touch:
|
||||
|
||||
- `defaultToolIds` — added to the agent's tool list by default
|
||||
- `alwaysOnToolIds` — forced on regardless of user selection (use sparingly)
|
||||
- `runtimeManagedToolIds` — enable state controlled by runtime, not user UI; **must mirror the rules map** in `src/server/modules/Mecha/AgentToolsEngine/index.ts` and `src/helpers/toolEngineering/index.ts`
|
||||
|
||||
---
|
||||
|
||||
## File-Map at a Glance
|
||||
|
||||
```
|
||||
packages/builtin-tool-<name>/
|
||||
├── package.json # exports: ., ./client, ./executor, ./executionRuntime
|
||||
└── src/
|
||||
├── index.ts # export Manifest, Identifier, types, systemPrompt
|
||||
├── manifest.ts # BuiltinToolManifest + Identifier const
|
||||
├── types.ts # ApiName + Params/State per API
|
||||
├── systemRole.ts # System prompt (multiple variants OK: systemRole.desktop.ts)
|
||||
├── ExecutionRuntime/
|
||||
│ └── index.ts # <Name>ExecutionRuntime — pure runtime, service injection
|
||||
└── client/
|
||||
├── index.ts # exports for the registries
|
||||
├── executor/
|
||||
│ └── index.ts # <Name>Executor extends BaseExecutor; export <name>Executor
|
||||
├── Inspector/
|
||||
│ ├── index.ts # <Name>Inspectors record
|
||||
│ └── <ApiName>/index.tsx # one folder per API (or .tsx file when trivial)
|
||||
├── Render/
|
||||
│ ├── index.ts # <Name>Renders record
|
||||
│ └── <ApiName>/ # rich renders → folder with subcomponents
|
||||
├── Placeholder/
|
||||
│ ├── index.ts
|
||||
│ └── <ApiName>.tsx # usually a single skeleton file
|
||||
├── Streaming/
|
||||
│ ├── index.ts
|
||||
│ └── <ApiName>/ # live-output renderer
|
||||
├── Intervention/
|
||||
│ ├── index.ts
|
||||
│ └── <ApiName>/ # approval / edit-before-run UI
|
||||
├── Portal/
|
||||
│ ├── index.tsx # routing component (switch on apiName)
|
||||
│ └── <ApiName>/ # full-screen detail view
|
||||
└── components/ # FileItem, EngineAvatar, etc. — shared subcomponents
|
||||
```
|
||||
|
||||
Skip every `client/<surface>/` directory you don't need — empty registries are fine.
|
||||
@@ -0,0 +1,478 @@
|
||||
# Tool Design (Naming, Manifest, Executor, Runtime)
|
||||
|
||||
This doc covers everything that **isn't UI**: the tool's identifier, API surface, manifest, types, system prompt, ExecutionRuntime, and the executor that wires it into the frontend.
|
||||
|
||||
For UI surfaces (Inspector / Render / Placeholder / Streaming / Intervention / Portal), see [ui.md](ui.md).
|
||||
For where files live and how registries work, see [architecture.md](architecture.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Naming
|
||||
|
||||
| Thing | Convention | Example |
|
||||
| ----------------------- | -------------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| Package directory | `packages/builtin-tool-<kebab>/` | `builtin-tool-task` |
|
||||
| npm name | `@lobechat/builtin-tool-<kebab>` | `@lobechat/builtin-tool-task` |
|
||||
| Tool `identifier` | `lobe-<kebab-domain>` — **persisted in message history** | `lobe-task`, `lobe-calculator`, `lobe-knowledge-base` |
|
||||
| Identifier const | `<Name>Identifier` exported from `manifest.ts` (or `types.ts`) | `export const TaskIdentifier = 'lobe-task'` |
|
||||
| API name const | `<Name>ApiName` — `as const` object, **camelCase verbs** | `createTask`, `listTasks`, `runTask` |
|
||||
| Executor class | `<Name>Executor extends BaseExecutor<typeof <Name>ApiName>` | `TaskExecutor` |
|
||||
| Executor singleton | `<name>Executor` (camelCase) | `export const taskExecutor = new TaskExecutor()` |
|
||||
| ExecutionRuntime class | `<Name>ExecutionRuntime` | `LocalSystemExecutionRuntime`, `WebBrowsingExecutionRuntime` |
|
||||
| Inspector / Render etc. | `<ApiName>Inspector` / `<ApiName>Render` | `CreateTaskInspector`, `SearchInspector` |
|
||||
|
||||
### Identifier rules
|
||||
|
||||
- **`lobe-` prefix is mandatory** — many switches in the codebase key off it.
|
||||
- Pick a **domain noun**, not a verb (`lobe-task`, not `lobe-task-manager`).
|
||||
- The identifier is **persisted in message history** — renaming after release means the `@deprecated` alias trick (register the legacy identifier as a second key in `inspectors.ts` / `renders.ts` pointing at the new module). Get it right the first time.
|
||||
|
||||
### ApiName rules
|
||||
|
||||
- Verb + noun, camelCase: `createTask`, `viewTask`, `runTasks`.
|
||||
- **Plural variant for batch** (`createTasks`, `runTasks`) — describe in the manifest description that it's preferred over multiple single calls. The system prompt should also push the batch form.
|
||||
- Reserve **clear separation between mutating verbs** (`updateTaskStatus`, `editTask`) and **execution verbs** (`runTask`). The system prompt must warn the model when these are confusable — see `task` for the canonical "do NOT use updateTaskStatus(running) to start a task" warning.
|
||||
- Read-only verbs: `list*`, `view*`, `get*`, `search*`. Mutating: `create*`, `edit*`, `update*`, `delete*`. Triggers/effects: `run*`, `execute*`, `submit*`.
|
||||
|
||||
---
|
||||
|
||||
## 2. `types.ts` — ApiName + Params/State
|
||||
|
||||
Define `<Name>ApiName` as `as const` so it doubles as a runtime enum (used by `BaseExecutor`) and a literal type. Then declare `Params` and `State` per API.
|
||||
|
||||
```ts
|
||||
export const TaskIdentifier = 'lobe-task';
|
||||
|
||||
export const TaskApiName = {
|
||||
createTask: 'createTask',
|
||||
createTasks: 'createTasks',
|
||||
listTasks: 'listTasks',
|
||||
/* …one entry per API, group logically (CRUD then run-style) */
|
||||
} as const;
|
||||
|
||||
export type TaskApiNameType = (typeof TaskApiName)[keyof typeof TaskApiName];
|
||||
|
||||
// One block per API
|
||||
export interface CreateTaskParams {
|
||||
name: string;
|
||||
instruction: string; /* … */
|
||||
}
|
||||
export interface CreateTaskState {
|
||||
identifier?: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface CreateTasksParams {
|
||||
tasks: CreateTaskParams[];
|
||||
}
|
||||
export interface CreateTasksItemResult {
|
||||
error?: string;
|
||||
identifier?: string;
|
||||
name: string;
|
||||
success: boolean;
|
||||
}
|
||||
export interface CreateTasksState {
|
||||
failed: number;
|
||||
results: CreateTasksItemResult[];
|
||||
succeeded: number;
|
||||
}
|
||||
```
|
||||
|
||||
**The result-domain rule for `State`** (memory: "pluginState is result-domain, not call-domain"):
|
||||
|
||||
- Include only fields the UI **renders after the call returns** — ids the LLM didn't have when calling, counts, summary numbers, server-assigned status.
|
||||
- **Don't echo all params.** The Inspector/Render gets `args` for free.
|
||||
- Keep batch results as `{ succeeded, failed, results }` so the Render can show a one-line summary plus a detail list.
|
||||
|
||||
---
|
||||
|
||||
## 3. `manifest.ts` — JSON Schema for the LLM
|
||||
|
||||
```ts
|
||||
import type { BuiltinToolManifest } from '@lobechat/types';
|
||||
|
||||
import { systemPrompt } from './systemRole';
|
||||
import { TaskApiName, TaskIdentifier } from './types';
|
||||
|
||||
export const TaskManifest: BuiltinToolManifest = {
|
||||
identifier: TaskIdentifier,
|
||||
type: 'builtin',
|
||||
systemRole: systemPrompt,
|
||||
meta: {
|
||||
avatar: '📋',
|
||||
title: 'Task Tools',
|
||||
description: 'Create, list, edit, delete tasks with dependencies',
|
||||
readme: 'Optional long description shown in tool detail pages',
|
||||
},
|
||||
api: [
|
||||
{
|
||||
name: TaskApiName.createTask,
|
||||
description:
|
||||
'Create a new task. Optionally attach as a subtask via parentIdentifier. ' +
|
||||
'Prefer createTasks when planning a batch.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
required: ['name', 'instruction'],
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Short, descriptive name.' },
|
||||
instruction: {
|
||||
type: 'string',
|
||||
description: 'Detailed instruction for what the task should accomplish.',
|
||||
},
|
||||
parentIdentifier: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Identifier of the parent task (e.g. "TASK-1"). If provided, the new task becomes a subtask.',
|
||||
},
|
||||
priority: {
|
||||
type: 'number',
|
||||
description: 'Priority level: 0=none, 1=urgent, 2=high, 3=normal, 4=low. Default is 0.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
/* …one entry per ApiName */
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
### Manifest writing checklist
|
||||
|
||||
- **Every API in `<Name>ApiName` has exactly one entry in `api[]`.** Easy to drift after a refactor.
|
||||
- **`description` on each API is the model's only docs.** Make it long enough for the LLM to pick the right tool. Mention edge cases ("If you provide any filter, omitted filters are not applied implicitly"), defaults, and the relationship to sibling APIs ("To START a task, use runTask — updateTaskStatus only flips a flag").
|
||||
- **`parameters` is JSON Schema** (`LobeChatPluginApi`). Use `enum`, `required`, `items`, `oneOf`, `additionalProperties: false` etc. — these survive into the LLM's tool spec.
|
||||
- **Use `additionalProperties: false`** on parameter objects so the model can't sneak unknown fields past validation.
|
||||
- **Number parameters with semantic values** (`priority: 0=none, 1=urgent, …`) should describe the mapping in the description. Don't rely on `enum` alone for numbers — the model often fills the wrong one.
|
||||
- **`enum` arrays for known string sets** (statuses, categories, engines). Spread from a constants module (`enum: [...TASK_STATUSES]`) so the manifest stays in sync.
|
||||
|
||||
### Optional manifest fields
|
||||
|
||||
```ts
|
||||
{
|
||||
/* Where this tool can run.
|
||||
'client' → Agent Gateway dispatches to the desktop client (filesystem, Electron only)
|
||||
'server' → ToolExecutionService runs it on the server
|
||||
omitted → server only */
|
||||
executors: ['client', 'server'],
|
||||
|
||||
/* Default human intervention policy for all APIs that don't specify one.
|
||||
Pair with an Intervention component (see ui.md). */
|
||||
humanIntervention: 'never' | 'always' | { /* extended config */ },
|
||||
}
|
||||
```
|
||||
|
||||
Per-API `humanIntervention` and `renderDisplayControl` go inside each `api[]` entry.
|
||||
|
||||
---
|
||||
|
||||
## 4. `systemRole.ts` — Operator Instructions for the Model
|
||||
|
||||
This is appended to the agent system prompt whenever the tool is enabled. Treat it as a **how-to-use guide for the LLM**, not marketing copy.
|
||||
|
||||
```ts
|
||||
export const systemPrompt = `You have access to Task management tools. Use them to:
|
||||
|
||||
- **createTask**: Create a new task. Use parentIdentifier to make it a subtask.
|
||||
- **createTasks**: Prefer this over multiple createTask calls when planning a batch
|
||||
(e.g. all subtasks under one parent, or all chapters of an outline).
|
||||
- **runTask**: Actually START a task — kicks off the agent in a new (or continued)
|
||||
topic. Do NOT use updateTaskStatus(running) to start a task; that only flips a
|
||||
flag without executing. The task must have an assigneeAgentId.
|
||||
- **updateTaskStatus**: Change a task's status (completed/cancelled/paused/failed).
|
||||
If you mark a task as failed, include an error message explaining why.
|
||||
- ...
|
||||
|
||||
When planning work:
|
||||
1. Create tasks for each major piece (use parentIdentifier to organize as subtasks).
|
||||
2. Use editTask with addDependencies to control execution order.
|
||||
3. Use updateTaskStatus to mark the current task completed when done.`;
|
||||
```
|
||||
|
||||
### Patterns that work well
|
||||
|
||||
- **Bulleted list, bold the API name, one line per API.** The model picks tools by skimming.
|
||||
- **Disambiguate confusable APIs explicitly** (`runTask` vs `updateTaskStatus`).
|
||||
- **Push toward batched APIs** ("Prefer this when…").
|
||||
- **End with a numbered workflow** if the tool has a typical sequence.
|
||||
- **For tools with multiple environments** (e.g. desktop vs cloud), keep variants in `systemRole.ts` and `systemRole.desktop.ts` and pick at the manifest level. See `builtin-tool-local-system`.
|
||||
|
||||
### Dynamic system prompts
|
||||
|
||||
If the prompt depends on runtime state (current date, available models), export a function and call it in the manifest:
|
||||
|
||||
```ts
|
||||
// systemRole.ts
|
||||
export const systemPrompt = (today: string) => `Today is ${today}. You have web search tools…`;
|
||||
|
||||
// manifest.ts
|
||||
import dayjs from 'dayjs';
|
||||
systemRole: systemPrompt(dayjs(new Date()).format('YYYY-MM-DD')),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. `ExecutionRuntime/index.ts` — Pure Runtime
|
||||
|
||||
This is **the default home for new tool logic** going forward. The runtime is a class that:
|
||||
|
||||
- Has no React, no Zustand, no `@/services/...` direct imports.
|
||||
- Receives services as **constructor injection** (or as method args).
|
||||
- Returns `BuiltinServerRuntimeOutput` from each method.
|
||||
- Is unit-testable by passing in mocks.
|
||||
|
||||
### Pattern A: Inject a service interface
|
||||
|
||||
Use when the runtime calls out to IPC, network, or DB.
|
||||
|
||||
```ts
|
||||
// ExecutionRuntime/index.ts
|
||||
import type { BuiltinServerRuntimeOutput } from '@lobechat/types';
|
||||
|
||||
export interface IWebBrowsingService {
|
||||
search: (q: SearchQuery) => Promise<UniformSearchResponse>;
|
||||
crawlPages: (urls: string[]) => Promise<CrawlResults>;
|
||||
}
|
||||
|
||||
export interface WebBrowsingRuntimeOptions {
|
||||
searchService: IWebBrowsingService;
|
||||
documentService?: WebBrowsingDocumentService;
|
||||
agentId?: string;
|
||||
topicId?: string;
|
||||
}
|
||||
|
||||
export class WebBrowsingExecutionRuntime {
|
||||
constructor(private opts: WebBrowsingRuntimeOptions) {}
|
||||
|
||||
async search(
|
||||
args: SearchQuery,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<BuiltinServerRuntimeOutput> {
|
||||
try {
|
||||
const data = await this.opts.searchService.search(args, options);
|
||||
if (data.errorDetail) {
|
||||
return {
|
||||
success: false,
|
||||
content: data.errorDetail,
|
||||
error: { message: data.errorDetail },
|
||||
state: data,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
content: searchResultsPrompt(data.results.slice(0, 10)),
|
||||
state: data,
|
||||
};
|
||||
} catch (e) {
|
||||
return { success: false, content: (e as Error).message, error: e };
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern B: Reuse the executor
|
||||
|
||||
Use when the same logic runs in browser and Node (e.g. mathjs, nerdamer). The runtime is a thin wrapper that imports the executor and re-types the state per API. See `builtin-tool-calculator/src/ExecutionRuntime/index.ts` for the canonical example.
|
||||
|
||||
### Pattern C: Extend a shared base
|
||||
|
||||
When you're implementing a domain that already has a base runtime (file ops via `ComputerRuntime`), extend and only override `callService` + result normalization. See `builtin-tool-local-system/src/ExecutionRuntime/index.ts`.
|
||||
|
||||
### Runtime contract
|
||||
|
||||
Every method returns:
|
||||
|
||||
```ts
|
||||
{
|
||||
content: string; // LLM-facing — never undefined; default to error message
|
||||
state?: any; // result-domain — what the UI's pluginState becomes
|
||||
success: boolean; // mandatory
|
||||
error?: any; // raw error object; the executor will repackage
|
||||
}
|
||||
```
|
||||
|
||||
Use `@lobechat/prompts` formatters (`searchResultsPrompt`, `crawlResultsPrompt`, `formatTaskCreated`, etc.) to produce structured `content`. They emit XML/markdown that's already tuned for token efficiency.
|
||||
|
||||
---
|
||||
|
||||
## 6. `client/executor/index.ts` — Frontend Wiring
|
||||
|
||||
The executor's job is to **resolve frontend defaults** (current agent, current task, scope) and **call the runtime**. It then funnels through `toResult()` into the `BuiltinToolResult` shape.
|
||||
|
||||
```ts
|
||||
import { BaseExecutor, type BuiltinToolContext, type BuiltinToolResult } from '@lobechat/types';
|
||||
import debug from 'debug';
|
||||
|
||||
import { taskService } from '@/services/task';
|
||||
import { getTaskStoreState } from '@/store/task';
|
||||
|
||||
import { TaskIdentifier } from '../../manifest';
|
||||
import { TaskApiName, type CreateTaskParams } from '../../types';
|
||||
|
||||
const log = debug('lobe-task:executor');
|
||||
|
||||
class TaskExecutor extends BaseExecutor<typeof TaskApiName> {
|
||||
readonly identifier = TaskIdentifier;
|
||||
protected readonly apiEnum = TaskApiName;
|
||||
|
||||
// ⚠ class FIELD, not a method — preserves `this` when invoked via registry
|
||||
createTask = async (
|
||||
params: CreateTaskParams,
|
||||
ctx?: BuiltinToolContext,
|
||||
): Promise<BuiltinToolResult> => {
|
||||
try {
|
||||
log('createTask params=%o', params);
|
||||
const task = await getTaskStoreState().createTask({
|
||||
name: params.name,
|
||||
instruction: params.instruction,
|
||||
// Default assignee from context — never silently override an explicit value
|
||||
assigneeAgentId:
|
||||
params.assigneeAgentId ?? (ctx?.scope === 'task' ? undefined : ctx?.agentId),
|
||||
parentTaskId: params.parentIdentifier?.trim() || undefined,
|
||||
priority: params.priority,
|
||||
});
|
||||
|
||||
if (!task) return this.errorResult('Failed to create task', 'CreateFailed');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
content: formatTaskCreated({ identifier: task.identifier, name: task.name /* … */ }),
|
||||
state: { identifier: task.identifier, success: true },
|
||||
};
|
||||
} catch (error) {
|
||||
return this.errorResult(error, 'CreateTaskFailed');
|
||||
}
|
||||
};
|
||||
|
||||
private errorResult(err: unknown, type: string): BuiltinToolResult {
|
||||
const message = err instanceof Error ? err.message : String(err) || 'Unknown error';
|
||||
return { success: false, content: `Failed: ${message}`, error: { type, message } };
|
||||
}
|
||||
}
|
||||
|
||||
export const taskExecutor = new TaskExecutor();
|
||||
```
|
||||
|
||||
### Hard rules
|
||||
|
||||
1. **Methods are class fields** (`name = async (…) => {…}`), not class methods. The registry calls `(executor as any)[apiName](params, ctx)`; arrow-function fields keep `this` bound.
|
||||
2. **`identifier` and `apiEnum` are `readonly` instance fields**, not getters — `BaseExecutor.hasApi/getApiNames` reads them synchronously at registration time.
|
||||
3. **Default missing params from `ctx`**, but never silently override explicit values. Use `params.foo ?? ctx?.foo`, not `ctx?.foo ?? params.foo`.
|
||||
4. **One funnel for all returns.** Either always return through `toResult(runtime.x())` (when delegating) or through `errorResult(…)` for the catch arm. Never inline `{ success: false, content: '' }` — `content: ''` collapses the Debug pane to blank.
|
||||
5. **`debug('lobe-<name>:executor')`.** Match the namespace to the identifier minus `lobe-` when convenient.
|
||||
6. **Singleton export.** `export const <name>Executor = new <Name>Executor()` — the registry imports the instance, not the class.
|
||||
|
||||
### When the executor delegates to ExecutionRuntime
|
||||
|
||||
```ts
|
||||
class LocalSystemExecutor extends BaseExecutor<typeof LocalSystemApiEnum> {
|
||||
readonly identifier = LocalSystemIdentifier;
|
||||
protected readonly apiEnum = LocalSystemApiEnum;
|
||||
private runtime = new LocalSystemExecutionRuntime(localFileService);
|
||||
|
||||
readLocalFile = async (params: LocalReadFileParams): Promise<BuiltinToolResult> => {
|
||||
try {
|
||||
const result = await this.runtime.readFile({
|
||||
path: params.path,
|
||||
startLine: params.loc?.[0],
|
||||
endLine: params.loc?.[1],
|
||||
});
|
||||
return this.toResult(result);
|
||||
} catch (error) {
|
||||
return this.errorResult(error);
|
||||
}
|
||||
};
|
||||
|
||||
private toResult(out: BuiltinServerRuntimeOutput): BuiltinToolResult {
|
||||
const errMsg = typeof out.error?.message === 'string' ? out.error.message : undefined;
|
||||
const safe = out.content || errMsg || 'Tool execution failed';
|
||||
if (!out.success) {
|
||||
return {
|
||||
success: false,
|
||||
content: safe,
|
||||
state: out.state, // ← preserve partial state on failure
|
||||
error: out.error
|
||||
? { type: 'PluginServerError', message: errMsg ?? safe, body: out.error }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
return { success: true, content: safe, state: out.state };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `toResult` funnel is **mandatory**: it enforces never-undefined `content` and partial-state preservation. Both invariants caught real production bugs (`globLocalFiles` Response empty, `editLocalFile` partial state lost).
|
||||
|
||||
---
|
||||
|
||||
## 7. `index.ts` — Package Entry Point
|
||||
|
||||
Keep it pure data + the manifest. **No React, no stores, no Node-only imports.**
|
||||
|
||||
```ts
|
||||
export { TaskIdentifier, TaskManifest } from './manifest';
|
||||
export { systemPrompt } from './systemRole';
|
||||
export {
|
||||
TaskApiName,
|
||||
type TaskApiNameType,
|
||||
type CreateTaskParams,
|
||||
type CreateTaskState,
|
||||
/* …all Params/State types */
|
||||
} from './types';
|
||||
|
||||
// Optional helpers used by both the runtime and the UI
|
||||
export { TASK_STATUSES, UNFINISHED_TASK_STATUSES } from './constants';
|
||||
```
|
||||
|
||||
This entry is what `packages/builtin-tools/src/index.ts` and `identifiers.ts` import — it must be importable from server bundles.
|
||||
|
||||
---
|
||||
|
||||
## 8. `package.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@lobechat/prompts": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lobechat/types": "workspace:*"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./client": "./src/client/index.ts",
|
||||
"./executor": "./src/client/executor/index.ts",
|
||||
"./executionRuntime": "./src/ExecutionRuntime/index.ts"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"name": "@lobechat/builtin-tool-<name>",
|
||||
"peerDependencies": {
|
||||
"@lobehub/ui": "^5",
|
||||
"antd": "^6",
|
||||
"antd-style": "*",
|
||||
"lucide-react": "*",
|
||||
"react": "*",
|
||||
"react-i18next": "*"
|
||||
},
|
||||
"private": true,
|
||||
"version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
**Why peer not direct deps for client libs:** the `./` and `./executionRuntime` entry points must be importable from server code. Listing React etc. as peer deps prevents bundlers from following them when only the runtime is consumed.
|
||||
|
||||
**Skip `./executor`** if the package has no frontend executor (server-only tools like `builtin-tool-web-browsing`).
|
||||
|
||||
---
|
||||
|
||||
## 9. Common Pitfalls
|
||||
|
||||
| Symptom | Likely cause |
|
||||
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| "ApiNotFound" at runtime | Method name in executor doesn't match `ApiName` value (typo, wrong case) |
|
||||
| Method works once, then "this is undefined" | Method declared as `async fn() {}` instead of `fn = async () => {}` — `this` lost when registry invokes |
|
||||
| Debug "Response" pane blank but `pluginState` populated | Returning `content: ''` or letting `output.content` be undefined — use the `toResult` funnel |
|
||||
| Partial result vanishes on failure | `toResult` discarded `state` when `success: false`; preserve it |
|
||||
| Tool shows up but doesn't run on desktop | `executors` in manifest doesn't include `'client'` (or vice versa for server-only) |
|
||||
| Same tool registered twice / legacy identifier ghost | Identifier collision; check `@deprecated` aliases in `inspectors.ts`/`renders.ts` |
|
||||
| Manifest test fails after adding API | Forgot to add the corresponding i18n `apiName.<api>` key |
|
||||
| TypeScript error on `BaseExecutor<typeof X>` | `X` declared with `enum` instead of `as const` object — must be the const-object form |
|
||||
@@ -0,0 +1,721 @@
|
||||
# Tool UI Surfaces
|
||||
|
||||
A builtin tool can ship up to **six client-side surfaces**, each with a different role in the chat UI. Only `Inspector` is required; the other five are added on demand and registered in their own central files.
|
||||
|
||||
| Surface | Required? | When the chat shows it | Registered in |
|
||||
| ------------ | --------- | --------------------------------------------------------------------- | --------------------------------------------- |
|
||||
| Inspector | ✅ Always | Header strip of every tool call (one-line chip) | `packages/builtin-tools/src/inspectors.ts` |
|
||||
| Render | Optional | Rich result card below the header, after the call returns | `packages/builtin-tools/src/renders.ts` |
|
||||
| Placeholder | Optional | Skeleton between "args streaming complete" and "result arrives" | `packages/builtin-tools/src/placeholders.ts` |
|
||||
| Streaming | Optional | Live output during execution (e.g. command stdout) | `packages/builtin-tools/src/streamings.ts` |
|
||||
| Intervention | Optional | Approval / edit-before-run dialog (when `humanIntervention` triggers) | `packages/builtin-tools/src/interventions.ts` |
|
||||
| Portal | Optional | Full-screen detail view (right-side or modal) | `packages/builtin-tools/src/portals.ts` |
|
||||
|
||||
The two reference tools to read end-to-end:
|
||||
|
||||
- **`builtin-tool-web-browsing/src/client/`** — Inspector + Render + Placeholder + Portal (no Intervention/Streaming).
|
||||
- **`builtin-tool-local-system/src/client/`** — all six surfaces, including `components/` for shared building blocks.
|
||||
|
||||
---
|
||||
|
||||
## 0. Shared Style Rules
|
||||
|
||||
These apply across every surface.
|
||||
|
||||
### 0.1 Use `'use client'` at the top of every component file
|
||||
|
||||
Tool surfaces are leaves in the chat tree and must not block server rendering.
|
||||
|
||||
### 0.2 Prefer `createStaticStyles + cssVar.*`
|
||||
|
||||
Zero-runtime CSS-in-JS — the styles compile once and read CSS variables at runtime.
|
||||
|
||||
```tsx
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
chip: css`
|
||||
padding-block: 2px;
|
||||
padding-inline: 8px;
|
||||
border-radius: 999px;
|
||||
color: ${cssVar.colorText};
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
}));
|
||||
```
|
||||
|
||||
Fall back to `createStyles + token` only when you need runtime token computation (rare). Inline `style={{ color: cssVar.colorTextSecondary }}` is fine for one-off dynamic values.
|
||||
|
||||
### 0.3 Use `@lobehub/ui`, not raw `antd`
|
||||
|
||||
`Block`, `Text`, `Flexbox`, `Highlighter`, `Alert`, `Tooltip`, `Skeleton` all come from `@lobehub/ui`. Modals come from `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill.
|
||||
|
||||
Memory note: `@lobehub/ui`'s `<Text type='secondary'>` is a lighter shade than `colorTextSecondary`. If you need that exact token color, write `<Text style={{ color: cssVar.colorTextSecondary }}>`.
|
||||
|
||||
### 0.4 Always `memo` and set `displayName`
|
||||
|
||||
```tsx
|
||||
export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
|
||||
({ args /* … */ }) => {
|
||||
/* … */
|
||||
},
|
||||
);
|
||||
SearchInspector.displayName = 'SearchInspector';
|
||||
export default SearchInspector;
|
||||
```
|
||||
|
||||
### 0.5 Always type with `BuiltinXProps<Args, State>` generics
|
||||
|
||||
Don't widen to `any`. The Args generic is the JSON Schema params, the State generic is the executor's `state` field. The two should match `<Name>Params` and `<Name>State` from `types.ts`.
|
||||
|
||||
### 0.6 Pull strings from `t('plugin')`
|
||||
|
||||
```tsx
|
||||
const { t } = useTranslation('plugin');
|
||||
t('builtins.<identifier>.apiName.<api>');
|
||||
```
|
||||
|
||||
Every Inspector should default to `t('builtins.<identifier>.apiName.<api>')` so it shows something while args stream in.
|
||||
|
||||
### 0.7 Read store state from `@/store/chat`, not props
|
||||
|
||||
Tool surfaces sometimes need cross-cutting state (loading, streaming buffer). Read it inside the component via Zustand selectors, not from props — props only carry args/state/messageId.
|
||||
|
||||
---
|
||||
|
||||
## 1. Inspector — Header Chip (required)
|
||||
|
||||
**Lifecycle:** Inspector renders for **every phase** of a tool call: while args are streaming in, while the executor is running, and after results come back. It's the only surface that's always visible.
|
||||
|
||||
**Goal:** keep it to a single line. Show what's happening with as much context as is currently available.
|
||||
|
||||
### Props (`BuiltinInspectorProps<Args, State>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinInspectorProps<Arguments = any, State = any> {
|
||||
apiName: string;
|
||||
args: Arguments; // final args (only after the assistant stops streaming)
|
||||
identifier: string;
|
||||
isArgumentsStreaming?: boolean; // args still arriving
|
||||
isLoading?: boolean; // args complete, executor running
|
||||
partialArgs?: Arguments; // partial JSON during streaming
|
||||
pluginState?: State; // executor's `state` after success
|
||||
result?: { content: string | null; error?: any };
|
||||
}
|
||||
```
|
||||
|
||||
### State machine
|
||||
|
||||
| Phase | What's available | What to show |
|
||||
| ----------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| Args streaming, no useful field yet | `isArgumentsStreaming === true`, `partialArgs.X` undefined | Just the API title with `shinyTextStyles.shinyText` |
|
||||
| Args streaming, key field arrived | `partialArgs.X` populated | Title + key field chip, still pulse-animated |
|
||||
| Args complete, executor running | `args` populated, `isLoading === true` | Same as above, still pulse-animated |
|
||||
| Result arrived | `pluginState` populated, `isLoading === false` | Title + chips + result summary (count, identifier, status) |
|
||||
|
||||
### Canonical example — Search
|
||||
|
||||
`packages/builtin-tool-web-browsing/src/client/Inspector/Search/index.tsx`:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import type { BuiltinInspectorProps, SearchQuery, UniformSearchResponse } from '@lobechat/types';
|
||||
import { Text } from '@lobehub/ui';
|
||||
import { cssVar, cx } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
|
||||
|
||||
export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
|
||||
({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
|
||||
const query = args?.query || partialArgs?.query || '';
|
||||
const resultCount = pluginState?.results?.length ?? 0;
|
||||
const hasResults = resultCount > 0;
|
||||
|
||||
if (isArgumentsStreaming && !query) {
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-web-browsing.apiName.search')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
inspectorTextStyles.root,
|
||||
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
|
||||
)}
|
||||
>
|
||||
<span>{t('builtins.lobe-web-browsing.apiName.search')}: </span>
|
||||
{query && <span className={highlightTextStyles.primary}>{query}</span>}
|
||||
{!isLoading &&
|
||||
!isArgumentsStreaming &&
|
||||
pluginState?.results &&
|
||||
(hasResults ? (
|
||||
<span style={{ marginInlineStart: 4 }}>({resultCount})</span>
|
||||
) : (
|
||||
<Text as="span" color={cssVar.colorTextDescription} fontSize={12}>
|
||||
({t('builtins.lobe-web-browsing.inspector.noResults')})
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
SearchInspector.displayName = 'SearchInspector';
|
||||
export default SearchInspector;
|
||||
```
|
||||
|
||||
### Inspector rules
|
||||
|
||||
- Wrap the whole row with `inspectorTextStyles.root` (provides correct flex / line-height baseline).
|
||||
- Pulse with `shinyTextStyles.shinyText` whenever `isArgumentsStreaming || isLoading`.
|
||||
- Show the i18n title first so the row is non-empty during the earliest streaming phase.
|
||||
- Read both `args?.X` and `partialArgs?.X` together — `args` is final, `partialArgs` is in-stream.
|
||||
- Use chips/tags for distinct facets (identifier, name, parent, status, count). Each chip should clip with `text-overflow: ellipsis` and have a `max-width` so long values don't blow out the chat bubble.
|
||||
- Append `pluginState`-derived suffixes only **after** loading finishes — count or "(no results)" should not appear while still searching.
|
||||
|
||||
### Inspector registry — `client/Inspector/index.ts`
|
||||
|
||||
```ts
|
||||
import type { BuiltinInspector } from '@lobechat/types';
|
||||
|
||||
import { TaskApiName } from '../../types';
|
||||
import { CreateTaskInspector } from './CreateTask';
|
||||
import { ListTasksInspector } from './ListTasks';
|
||||
/* … */
|
||||
|
||||
export const TaskInspectors: Record<string, BuiltinInspector> = {
|
||||
[TaskApiName.createTask]: CreateTaskInspector as BuiltinInspector,
|
||||
[TaskApiName.listTasks]: ListTasksInspector as BuiltinInspector,
|
||||
/* one entry per ApiName */
|
||||
};
|
||||
|
||||
export { CreateTaskInspector } from './CreateTask';
|
||||
export { ListTasksInspector } from './ListTasks';
|
||||
/* re-export each */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Render — Rich Result Card (optional)
|
||||
|
||||
**Lifecycle:** rendered **once the result arrives** (after Placeholder/Streaming hand off). Sits below the Inspector header.
|
||||
|
||||
**Skip if** the API is read-only or the result is just text — the framework already shows the executor's `content` string. Add a Render only when there's a structured artifact worth seeing: a card, a chart, a diff, a list of files.
|
||||
|
||||
### Props (`BuiltinRenderProps<Args, State, Content>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinRenderProps<Arguments = any, State = any, Content = any> {
|
||||
apiName?: string;
|
||||
args: Arguments; // final params from the LLM
|
||||
content: Content; // executor's content string (or parsed)
|
||||
identifier?: string;
|
||||
messageId: string; // for store lookups
|
||||
pluginError?: any; // from BuiltinToolResult.error
|
||||
pluginState?: State; // executor's state
|
||||
toolCallId?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Two patterns
|
||||
|
||||
**Pattern A — Single-file Render** (web-browsing CrawlSinglePage):
|
||||
|
||||
```tsx
|
||||
// client/Render/CrawlSinglePage.tsx
|
||||
import type { BuiltinRenderProps, CrawlPluginState, CrawlSinglePageQuery } from '@lobechat/types';
|
||||
import { memo } from 'react';
|
||||
|
||||
import PageContent from './PageContent';
|
||||
|
||||
const CrawlSinglePage = memo<BuiltinRenderProps<CrawlSinglePageQuery, CrawlPluginState>>(
|
||||
({ messageId, pluginState, args }) => (
|
||||
<PageContent messageId={messageId} results={pluginState?.results} urls={[args?.url]} />
|
||||
),
|
||||
);
|
||||
export default CrawlSinglePage;
|
||||
```
|
||||
|
||||
**Pattern B — Folder with subcomponents** (web-browsing Search):
|
||||
|
||||
```
|
||||
client/Render/Search/
|
||||
├── index.tsx # composes the subcomponents, handles error states
|
||||
├── ConfigForm.tsx # appears when pluginError.type === 'PluginSettingsInvalid'
|
||||
├── SearchQuery.tsx # editable query header
|
||||
└── SearchResult.tsx # result list
|
||||
```
|
||||
|
||||
Use Pattern B when the Render has internal state (editing mode, expanded items), error variants, or is large enough to benefit from splitting.
|
||||
|
||||
### Error handling in Render
|
||||
|
||||
Renders are the canonical place to surface `pluginError` because the chat doesn't auto-render typed errors:
|
||||
|
||||
```tsx
|
||||
if (pluginError) {
|
||||
if (pluginError?.type === 'PluginSettingsInvalid') {
|
||||
return <ConfigForm id={messageId} provider={pluginError.body?.provider} />;
|
||||
}
|
||||
return (
|
||||
<Alert
|
||||
title={pluginError?.message}
|
||||
type="error"
|
||||
extra={<Highlighter language="json">{JSON.stringify(pluginError.body, null, 2)}</Highlighter>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Render rules
|
||||
|
||||
- **Return `null`** if there's nothing useful to draw yet (avoids empty cards during stream).
|
||||
- Use `pluginState` for server-truth (ids, counts, server-assigned status) and `args` for what the LLM asked. **Combine — neither alone is enough.**
|
||||
- For lists, summarize with a header line and show top N items with a "+N more" tail rather than rendering everything.
|
||||
- For modals from a Render, use `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill.
|
||||
|
||||
### Render registry — `client/Render/index.ts`
|
||||
|
||||
```ts
|
||||
import type { BuiltinRender } from '@lobechat/types';
|
||||
|
||||
import { TaskApiName } from '../../types';
|
||||
import CreateTaskRender from './CreateTask';
|
||||
import RunTasksRender from './RunTasks';
|
||||
|
||||
export const TaskRenders: Record<string, BuiltinRender> = {
|
||||
[TaskApiName.createTask]: CreateTaskRender as BuiltinRender,
|
||||
[TaskApiName.runTasks]: RunTasksRender as BuiltinRender,
|
||||
/* only the APIs with rich result UI — others fall back to text content */
|
||||
};
|
||||
|
||||
export { default as CreateTaskRender } from './CreateTask';
|
||||
export { default as RunTasksRender } from './RunTasks';
|
||||
```
|
||||
|
||||
### Render display control (rare)
|
||||
|
||||
If the Render should hide for certain results (e.g. ClaudeCode's TodoWrite hides when the agent is mid-stream), add a `RenderDisplayControl` to `packages/builtin-tools/src/displayControls.ts`. See `ClaudeCodeRenderDisplayControls` for the pattern.
|
||||
|
||||
---
|
||||
|
||||
## 3. Placeholder — Skeleton Between Args and Result (optional)
|
||||
|
||||
**Lifecycle:** rendered when the args have finished streaming but the executor hasn't returned yet. Disappears when `pluginState` arrives. Bridges the moment of perceived lag.
|
||||
|
||||
**Add for** APIs with noticeable execution time: web search, network crawl, file list, large grep. **Skip for** instant ops (status flips, calculator).
|
||||
|
||||
### Props (`BuiltinPlaceholderProps<Args>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinPlaceholderProps<T extends Record<string, any> = any> {
|
||||
apiName: string;
|
||||
args?: T;
|
||||
identifier: string;
|
||||
}
|
||||
```
|
||||
|
||||
No `pluginState` — Placeholder lives entirely in the "executing" gap.
|
||||
|
||||
### Canonical example — Search Placeholder
|
||||
|
||||
`packages/builtin-tool-web-browsing/src/client/Placeholder/Search.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { BuiltinPlaceholderProps, SearchQuery } from '@lobechat/types';
|
||||
import { Flexbox, Icon, Skeleton } from '@lobehub/ui';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { SearchIcon } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { shinyTextStyles } from '@/styles';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
query: cx(
|
||||
css`
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
shinyTextStyles.shinyText,
|
||||
),
|
||||
}));
|
||||
|
||||
export const Search = memo<BuiltinPlaceholderProps<SearchQuery>>(({ args }) => {
|
||||
const { query } = args || {};
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
<Flexbox horizontal={!isMobile} gap={isMobile ? 8 : 40}>
|
||||
<Flexbox horizontal align="center" className={styles.query} gap={8}>
|
||||
<Icon icon={SearchIcon} />
|
||||
{query ? query : <Skeleton.Block active style={{ height: 20, width: 40 }} />}
|
||||
</Flexbox>
|
||||
<Skeleton.Block active style={{ height: 20, width: 40 }} />
|
||||
</Flexbox>
|
||||
<Flexbox horizontal gap={12}>
|
||||
{[1, 2, 3, 4, 5].map((id) => (
|
||||
<Skeleton.Button active key={id} style={{ borderRadius: 8, height: 80, width: 160 }} />
|
||||
))}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Placeholder rules
|
||||
|
||||
- **Mirror the eventual Render's layout.** When the result arrives the Placeholder unmounts and the Render mounts; if they share dimensions, the chat doesn't jump.
|
||||
- Use `Skeleton.Block` / `Skeleton.Button` from `@lobehub/ui` for placeholder shapes.
|
||||
- Embed any args you have (e.g. the query text) — context helps the user know what's loading.
|
||||
- Pulse with `shinyTextStyles.shinyText` if the Placeholder includes literal text.
|
||||
|
||||
### Placeholder registry — `client/Placeholder/index.ts`
|
||||
|
||||
```ts
|
||||
import { WebBrowsingApiName } from '../../types';
|
||||
import CrawlMultiPages from './CrawlMultiPages';
|
||||
import CrawlSinglePage from './CrawlSinglePage';
|
||||
import { Search } from './Search';
|
||||
|
||||
export const WebBrowsingPlaceholders = {
|
||||
[WebBrowsingApiName.crawlMultiPages]: CrawlMultiPages,
|
||||
[WebBrowsingApiName.crawlSinglePage]: CrawlSinglePage,
|
||||
[WebBrowsingApiName.search]: Search,
|
||||
};
|
||||
|
||||
export { CrawlMultiPages, CrawlSinglePage, Search };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Streaming — Live Output During Execution (optional)
|
||||
|
||||
**Lifecycle:** rendered **while the executor is still running** for APIs that emit incremental output. The component is responsible for fetching the in-flight stream from the chat store and rendering it.
|
||||
|
||||
**Add for** long-running ops with continuous output: shell command execution (stdout/stderr), file write progress, code interpreter cells.
|
||||
|
||||
### Props (`BuiltinStreamingProps<Args>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinStreamingProps<Arguments = any> {
|
||||
apiName: string;
|
||||
args: Arguments;
|
||||
identifier: string;
|
||||
messageId: string; // use to fetch the streaming buffer from store
|
||||
toolCallId: string;
|
||||
}
|
||||
```
|
||||
|
||||
Note there's **no `state` or `result` prop** — the Streaming component is for the in-flight phase. It pulls the live buffer from the store itself (typically via `chatToolSelectors.streamingContent(messageId)` or similar).
|
||||
|
||||
### Canonical example — RunCommandStreaming
|
||||
|
||||
`packages/builtin-tool-local-system/src/client/Streaming/RunCommand/index.tsx`:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import type { BuiltinStreamingProps } from '@lobechat/types';
|
||||
import { Highlighter } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
|
||||
interface RunCommandParams {
|
||||
command?: string;
|
||||
description?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export const RunCommandStreaming = memo<BuiltinStreamingProps<RunCommandParams>>(({ args }) => {
|
||||
const { command } = args || {};
|
||||
if (!command) return null;
|
||||
|
||||
return (
|
||||
<Highlighter
|
||||
animated
|
||||
wrap
|
||||
language="sh"
|
||||
showLanguage={false}
|
||||
style={{ padding: '4px 8px' }}
|
||||
variant="outlined"
|
||||
>
|
||||
{command}
|
||||
</Highlighter>
|
||||
);
|
||||
});
|
||||
RunCommandStreaming.displayName = 'RunCommandStreaming';
|
||||
```
|
||||
|
||||
For real-time output beyond just the command (stderr/stdout streaming), pull from the chat store:
|
||||
|
||||
```tsx
|
||||
const buffer = useChatStore((state) =>
|
||||
chatToolSelectors.streamingBuffer(messageId, toolCallId)(state),
|
||||
);
|
||||
```
|
||||
|
||||
### Streaming rules
|
||||
|
||||
- Render `null` until you have something to display (avoids flash).
|
||||
- For terminal-style output, use `Highlighter` with `animated` to show typing-like effect.
|
||||
- The Streaming component must **unmount cleanly** when execution ends — typically the framework swaps it out for the Render automatically.
|
||||
|
||||
### Streaming registry — `client/Streaming/index.ts`
|
||||
|
||||
```ts
|
||||
import { LocalSystemApiName } from '../..';
|
||||
import { RunCommandStreaming } from './RunCommand';
|
||||
import { WriteFileStreaming } from './WriteFile';
|
||||
|
||||
export const LocalSystemStreamings = {
|
||||
[LocalSystemApiName.runCommand]: RunCommandStreaming,
|
||||
[LocalSystemApiName.writeLocalFile]: WriteFileStreaming,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Intervention — Approval / Edit-Before-Run (optional)
|
||||
|
||||
**Lifecycle:** rendered **before the executor runs** for APIs whose manifest sets `humanIntervention`. The user sees a preview of the args, can edit them, then approves or skips/cancels.
|
||||
|
||||
**Add for** destructive or sensitive ops: shell commands, file writes, file moves, payments, message broadcasts.
|
||||
|
||||
### Props (`BuiltinInterventionProps<Args>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinInterventionProps<Arguments = any> {
|
||||
apiName?: string;
|
||||
args: Arguments;
|
||||
identifier?: string;
|
||||
interactionMode?: 'approval' | 'custom';
|
||||
messageId: string;
|
||||
|
||||
/** Called when the user edits the args; the approve action awaits this. */
|
||||
onArgsChange?: (args: Arguments) => void | Promise<void>;
|
||||
|
||||
/** Called on approve / skip / cancel. */
|
||||
onInteractionAction?: (
|
||||
action:
|
||||
| { type: 'submit'; payload: Record<string, unknown> }
|
||||
| { type: 'skip'; payload?: Record<string, unknown>; reason?: string }
|
||||
| { type: 'cancel'; payload?: Record<string, unknown> },
|
||||
) => Promise<void>;
|
||||
|
||||
/** Register a callback to flush pending saves before approval. Returns cleanup. */
|
||||
registerBeforeApprove?: (id: string, callback: () => void | Promise<void>) => () => void;
|
||||
}
|
||||
```
|
||||
|
||||
### Canonical example — RunCommand Intervention
|
||||
|
||||
`packages/builtin-tool-local-system/src/client/Intervention/RunCommand/index.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { RunCommandParams } from '@lobechat/electron-client-ipc';
|
||||
import type { BuiltinInterventionProps } from '@lobechat/types';
|
||||
import { Flexbox, Highlighter, Text } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
|
||||
const RunCommand = memo<BuiltinInterventionProps<RunCommandParams>>(({ args }) => {
|
||||
const { description, command, timeout } = args;
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
<Flexbox horizontal justify="space-between">
|
||||
{description && <Text>{description}</Text>}
|
||||
{timeout && (
|
||||
<Text style={{ fontSize: 12 }} type="secondary">
|
||||
timeout: {formatTimeout(timeout)}
|
||||
</Text>
|
||||
)}
|
||||
</Flexbox>
|
||||
{command && (
|
||||
<Highlighter wrap language="sh" showLanguage={false} variant="outlined">
|
||||
{command}
|
||||
</Highlighter>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
export default RunCommand;
|
||||
```
|
||||
|
||||
### Intervention rules
|
||||
|
||||
- **Show a preview, not a form by default.** Editing UI is opt-in via `onArgsChange` and is usually inline (click to edit a code block, etc.).
|
||||
- For args with debounced edit state (text fields), use `registerBeforeApprove(id, flushFn)` so the approve action waits for the debounce to flush. Always return the cleanup function.
|
||||
- Call `onInteractionAction({ type: 'submit', payload })` when the user approves; `'skip'` if they skip with a reason; `'cancel'` if they cancel the whole turn.
|
||||
- Add a corresponding `interventionAudit.ts` in the package root if the tool needs scope/path validation before approval (see `local-system/src/interventionAudit.ts`).
|
||||
|
||||
### Intervention registry — `client/Intervention/index.ts`
|
||||
|
||||
```ts
|
||||
import { LocalSystemApiName } from '../..';
|
||||
import EditLocalFile from './EditLocalFile';
|
||||
import RunCommand from './RunCommand';
|
||||
import WriteFile from './WriteFile';
|
||||
/* … */
|
||||
|
||||
export const LocalSystemInterventions = {
|
||||
[LocalSystemApiName.editLocalFile]: EditLocalFile,
|
||||
[LocalSystemApiName.runCommand]: RunCommand,
|
||||
[LocalSystemApiName.writeLocalFile]: WriteFile,
|
||||
/* one entry per API that needs approval */
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Portal — Full-Screen Detail View (optional)
|
||||
|
||||
**Lifecycle:** rendered when the user opens the tool message in a side panel or full-screen modal. One Portal per **tool**, not per API — the Portal switches on `apiName` internally.
|
||||
|
||||
**Add for** tools whose results deserve a deep-dive view: search results with editable filters, page content with reader mode, code interpreter sessions.
|
||||
|
||||
### Props (`BuiltinPortalProps<Args, State>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinPortalProps<Arguments = Record<string, any>, State = any> {
|
||||
apiName?: string;
|
||||
arguments: Arguments;
|
||||
identifier: string;
|
||||
messageId: string;
|
||||
state: State;
|
||||
}
|
||||
```
|
||||
|
||||
### Canonical example — Web-Browsing Portal
|
||||
|
||||
`packages/builtin-tool-web-browsing/src/client/Portal/index.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { BuiltinPortalProps, CrawlPluginState, SearchQuery } from '@lobechat/types';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { WebBrowsingApiName } from '../../types';
|
||||
import PageContent from './PageContent';
|
||||
import PageContents from './PageContents';
|
||||
import Search from './Search';
|
||||
|
||||
const Portal = memo<BuiltinPortalProps>(({ arguments: args, messageId, state, apiName }) => {
|
||||
switch (apiName) {
|
||||
case WebBrowsingApiName.search:
|
||||
return <Search messageId={messageId} query={args as SearchQuery} response={state} />;
|
||||
|
||||
case WebBrowsingApiName.crawlSinglePage: {
|
||||
const result = (state as CrawlPluginState).results.find((r) => r.originalUrl === args.url);
|
||||
return <PageContent messageId={messageId} result={result} />;
|
||||
}
|
||||
|
||||
case WebBrowsingApiName.crawlMultiPages:
|
||||
return (
|
||||
<PageContents
|
||||
messageId={messageId}
|
||||
results={(state as CrawlPluginState).results}
|
||||
urls={args.urls}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
export default Portal;
|
||||
```
|
||||
|
||||
### Portal rules
|
||||
|
||||
- One Portal per tool — the file is the routing layer, subcomponents implement each API's view.
|
||||
- Portals can read the chat store directly to detect "still streaming" and render a Skeleton internally (see `Search/index.tsx:20-46`).
|
||||
- Layout assumes more space than the Render — use `Flexbox` with `height={'100%'}` and structure for a side panel viewport.
|
||||
|
||||
### Portal registry — `packages/builtin-tools/src/portals.ts`
|
||||
|
||||
```ts
|
||||
import { WebBrowsingManifest, WebBrowsingPortal } from '@lobechat/builtin-tool-web-browsing/client';
|
||||
import { type BuiltinPortal } from '@lobechat/types';
|
||||
|
||||
export const BuiltinToolsPortals: Record<string, BuiltinPortal> = {
|
||||
[WebBrowsingManifest.identifier]: WebBrowsingPortal as BuiltinPortal,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. `client/components/` — Shared Subcomponents
|
||||
|
||||
Cross-cutting building blocks used by multiple surfaces live here, not duplicated in each surface folder.
|
||||
|
||||
Examples from `web-browsing/src/client/components/`:
|
||||
|
||||
- `CategoryAvatar.tsx` — search category icon
|
||||
- `EngineAvatar.tsx` — search engine logo (used in Inspector chip + Render list + Portal header)
|
||||
- `SearchBar.tsx` — editable query bar (used in Render and Portal)
|
||||
|
||||
Examples from `local-system/src/client/components/`:
|
||||
|
||||
- `FileItem.tsx` — single file row (used in ListFiles Render, SearchFiles Render, MoveLocalFiles Render)
|
||||
- `FilePathDisplay.tsx` — path with truncation (used everywhere)
|
||||
|
||||
### Rules
|
||||
|
||||
- Live under `client/components/`, exported via `client/components/index.ts`.
|
||||
- Re-export from `client/index.ts` only if other packages need them; otherwise keep internal.
|
||||
- Keep them dumb — props in, JSX out, no store reads. The store reads belong in the surface that composes them.
|
||||
|
||||
---
|
||||
|
||||
## 8. `client/index.ts` — Package Public API
|
||||
|
||||
Re-exports everything the registries need plus useful types/manifest:
|
||||
|
||||
```ts
|
||||
// Inspector — required
|
||||
export { TaskInspectors } from './Inspector';
|
||||
|
||||
// Render — only if any API has one
|
||||
export { TaskRenders, CreateTaskRender, RunTasksRender } from './Render';
|
||||
|
||||
// Placeholder / Streaming / Intervention — only if used
|
||||
export { LocalSystemListFilesPlaceholder, LocalSystemSearchFilesPlaceholder } from './Placeholder';
|
||||
export { LocalSystemStreamings } from './Streaming';
|
||||
export { LocalSystemInterventions } from './Intervention';
|
||||
|
||||
// Portal — single export per tool
|
||||
export { default as WebBrowsingPortal } from './Portal';
|
||||
|
||||
// Reusable components if other packages need them
|
||||
export { CategoryAvatar, EngineAvatar, SearchBar } from './components';
|
||||
|
||||
// Re-export manifest, identifier, types for convenience
|
||||
export { TaskManifest, TaskIdentifier } from '../manifest';
|
||||
export * from '../types';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Diagnostic Quick-Lookup
|
||||
|
||||
| Symptom | Surface to check | | |
|
||||
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | --- | ------------------------- |
|
||||
| No header at all on the tool call | Inspector missing from `client/Inspector/index.ts` registry | | |
|
||||
| Header shows the API name but no chips | Inspector missing \`args?.X | | partialArgs?.X\` fallback |
|
||||
| Header doesn't pulse during loading | Missing `shinyTextStyles.shinyText` on `isArgumentsStreaming \|\| isLoading` | | |
|
||||
| Empty result card under header | Render returned `<div />` instead of `null` when no data | | |
|
||||
| Layout jump when result arrives | Placeholder dimensions don't match Render dimensions | | |
|
||||
| Approval dialog never appears | Manifest missing `humanIntervention`, or Intervention not in registry | | |
|
||||
| Approval click doesn't wait for inline edit | Missing `registerBeforeApprove(id, flushFn)` | | |
|
||||
| Portal opens but blank | Switch in `Portal/index.tsx` doesn't cover the apiName | | |
|
||||
| Strings show as `builtins.lobe-foo.apiName.bar` | Missing i18n key in `src/locales/default/plugin.ts` (or not seeded in dev locale files) | | |
|
||||
| Wrong color shade on `<Text type="secondary">` | `type='secondary'` is lighter than `colorTextSecondary` — pass via `style={{ color: cssVar.colorTextSecondary }}` | | |
|
||||
@@ -8,16 +8,20 @@ Generate text, images, videos, speech, and transcriptions.
|
||||
|
||||
```
|
||||
lh generate (alias: gen)
|
||||
├── text <prompt> # Text generation
|
||||
├── image <prompt> # Image generation
|
||||
├── video <prompt> # Video generation
|
||||
├── tts <text> # Text-to-speech
|
||||
├── asr <audioFile> # Audio-to-text (speech recognition)
|
||||
├── download <genId> <taskId> # Wait & download generation result
|
||||
├── status <genId> <taskId> # Check async task status
|
||||
└── list # List generation topics
|
||||
├── text <prompt> # Text generation
|
||||
├── image <prompt> # Image generation
|
||||
├── video <prompt> # Video generation
|
||||
├── tts <text> # Text-to-speech
|
||||
├── asr <audioFile> # Audio-to-text (speech recognition)
|
||||
├── download <generationId> <asyncTaskId> # Wait & download generation result
|
||||
├── status <generationId> <asyncTaskId> # Check async task status
|
||||
└── list # List generation topics
|
||||
```
|
||||
|
||||
> ⚠️ **Important**: `status` and `download` require an `asyncTaskId` (UUID format, e.g.
|
||||
> `7ad0eb13-e9a5-4403-8070-1f7fe95b2f95`), **not** the generation ID (`gen_xxx`).
|
||||
> The asyncTaskId is printed after "→ Task" in the `video` / `image` command output.
|
||||
|
||||
---
|
||||
|
||||
## `lh generate text <prompt>` / `lh gen text <prompt>`
|
||||
@@ -54,7 +58,7 @@ cat README.md | lh gen text "summarize this" --pipe
|
||||
|
||||
## `lh generate image <prompt>` / `lh gen image <prompt>`
|
||||
|
||||
Generate images from text prompt. This is an async operation — the command submits the task and returns a generation ID + task ID for tracking.
|
||||
Generate images from text prompt. This is an async operation — the command submits the task and returns a generation ID + async task ID for tracking.
|
||||
|
||||
**Source**: `apps/cli/src/commands/generate/image.ts`
|
||||
|
||||
@@ -80,17 +84,22 @@ lh gen image "A cute cat" --model dall-e-3 --provider openai --json
|
||||
✓ Image generation started
|
||||
Batch ID: gb_xxx
|
||||
1 image(s) queued
|
||||
Generation gen_xxx → Task <taskId>
|
||||
Generation gen_xxx → Task 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
This is the asyncTaskId — use this for status/download
|
||||
|
||||
Use "lh generate status <generationId> <taskId>" to check progress.
|
||||
Use "lh generate status <generationId> <asyncTaskId>" to check progress.
|
||||
```
|
||||
|
||||
**Typical workflow**:
|
||||
|
||||
```bash
|
||||
# Generate image, then wait & download
|
||||
# 1. Submit generation — note down BOTH IDs from the output
|
||||
lh gen image "A cute cat"
|
||||
lh gen download <generationId> <taskId> -o cat.png
|
||||
# Generation gen_abc123 → Task 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95
|
||||
|
||||
# 2. Wait & download using generationId + asyncTaskId (the UUID)
|
||||
lh gen download gen_abc123 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95 -o cat.png
|
||||
```
|
||||
|
||||
---
|
||||
@@ -102,7 +111,7 @@ Generate video from text prompt. This is an async operation.
|
||||
**Source**: `apps/cli/src/commands/generate/video.ts`
|
||||
|
||||
```bash
|
||||
lh gen video "A cat playing piano" -m < model > -p < provider > [options]
|
||||
lh gen video "A cat playing piano" -m <model> -p <provider> [options]
|
||||
```
|
||||
|
||||
| Option | Description | Required |
|
||||
@@ -122,9 +131,26 @@ lh gen video "A cat playing piano" -m < model > -p < provider > [options]
|
||||
```
|
||||
✓ Video generation started
|
||||
Batch ID: gb_xxx
|
||||
Generation gen_xxx → Task <taskId>
|
||||
Generation gen_xxx → Task 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
This is the asyncTaskId — use this for status/download
|
||||
|
||||
Use "lh generate status <generationId> <taskId>" to check progress.
|
||||
Use "lh generate status <generationId> <asyncTaskId>" to check progress.
|
||||
```
|
||||
|
||||
**Typical workflow**:
|
||||
|
||||
```bash
|
||||
# 1. Find available video models for a provider
|
||||
lh model list volcengine --json | grep -i seedance
|
||||
|
||||
# 2. Submit generation — note down BOTH IDs from the output
|
||||
lh gen video "A cat on a runway" -m doubao-seedance-2-0-260128 -p volcengine \
|
||||
--aspect-ratio 9:16 --duration 5 --resolution 1080p
|
||||
# Generation gen_abc123 → Task 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95
|
||||
|
||||
# 3. Wait & download using generationId + asyncTaskId (the UUID)
|
||||
lh gen download gen_abc123 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95 -o result.mp4 --timeout 600
|
||||
```
|
||||
|
||||
---
|
||||
@@ -153,15 +179,18 @@ lh gen asr recording.wav [options]
|
||||
|
||||
---
|
||||
|
||||
## `lh generate download <generationId> <taskId>`
|
||||
## `lh generate download <generationId> <asyncTaskId>`
|
||||
|
||||
Wait for an async generation task to complete and download the result file.
|
||||
|
||||
**Source**: `apps/cli/src/commands/generate/index.ts`
|
||||
|
||||
> ⚠️ `<asyncTaskId>` is the UUID printed after "→ Task" in the video/image output.
|
||||
> Do **not** pass the generation ID (`gen_xxx`) here — that will cause a server error.
|
||||
|
||||
```bash
|
||||
lh gen download <generationId> <taskId> [-o output.png]
|
||||
lh gen download gen_xxx task_xxx -o ~/Desktop/result.mp4 --timeout 600
|
||||
lh gen download <generationId> <asyncTaskId> [-o output.png]
|
||||
lh gen download gen_xxx 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx -o ~/Desktop/result.mp4 --timeout 600
|
||||
```
|
||||
|
||||
| Option | Description | Default |
|
||||
@@ -175,30 +204,21 @@ lh gen download gen_xxx task_xxx -o ~/Desktop/result.mp4 --timeout 600
|
||||
1. Polls `generation.getGenerationStatus` at the specified interval
|
||||
2. Shows live progress: `⋯ Status: processing... (42s)`
|
||||
3. On success: downloads asset URL to local file
|
||||
4. On error: displays error message and exits
|
||||
4. On error / wrong ID: displays a clear message pointing to the correct ID format
|
||||
5. On timeout: suggests using `lh gen status` to check later
|
||||
|
||||
**Typical workflow**:
|
||||
|
||||
```bash
|
||||
# One-shot: generate and download
|
||||
lh gen image "A sunset"
|
||||
# Copy the generation ID and task ID from output
|
||||
lh gen download gen_xxx taskId_xxx -o sunset.png
|
||||
|
||||
# Video (longer timeout)
|
||||
lh gen video "A cat running" -m model -p provider
|
||||
lh gen download gen_xxx taskId_xxx -o cat.mp4 --timeout 600
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `lh generate status <generationId> <taskId>`
|
||||
## `lh generate status <generationId> <asyncTaskId>`
|
||||
|
||||
Check the status of an async generation task.
|
||||
|
||||
> ⚠️ `<asyncTaskId>` is the UUID printed after "→ Task" in the video/image output.
|
||||
> Do **not** pass the generation ID (`gen_xxx`) here — that will cause a server error.
|
||||
|
||||
```bash
|
||||
lh gen status <generationId> <taskId> [--json]
|
||||
lh gen status <generationId> <asyncTaskId> [--json]
|
||||
lh gen status gen_xxx 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
@@ -235,12 +255,17 @@ Image and video generation use an async task pattern:
|
||||
- Triggers async background task (image via `createAsyncCaller`, video via `initModelRuntimeFromDB`)
|
||||
- Returns `{ data: { batch, generations }, success }` with `asyncTaskId` in each generation
|
||||
3. **Poll status** → `generation.getGenerationStatus`
|
||||
- Input: `{ generationId, asyncTaskId }` — both are required, and `asyncTaskId` must be the
|
||||
UUID from the `async_tasks` table, not `gen_xxx`
|
||||
- Returns `{ status, error, generation }` (generation includes asset URLs on success)
|
||||
- Before querying, calls `checkTimeoutTasks` which marks tasks as `error` if they have been
|
||||
`pending` or `processing` for more than ~5 minutes (`ASYNC_TASK_TIMEOUT = 298s`)
|
||||
|
||||
**Server routes**:
|
||||
|
||||
- `src/server/routers/lambda/image/index.ts` — image creation (uses `authedProcedure` + `serverDatabase`)
|
||||
- `src/server/routers/lambda/video/index.ts` — video creation (uses `authedProcedure` + `serverDatabase`)
|
||||
- `src/server/routers/lambda/generation.ts` — status checking
|
||||
- `packages/database/src/models/asyncTask.ts` — `AsyncTaskModel` including `checkTimeoutTasks`
|
||||
|
||||
**Note**: Image/video routes do NOT use the `keyVaults` middleware — they read API keys from the database via `initModelRuntimeFromDB` or `createAsyncCaller`.
|
||||
|
||||
@@ -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.
|
||||
@@ -30,6 +30,17 @@ This is NON-NEGOTIABLE. Skipping Linear comments is a workflow violation.
|
||||
|
||||
When creating issues with `mcp__linear-server__create_issue`, **MUST add the `claude code` label**.
|
||||
|
||||
## Language
|
||||
|
||||
Issue titles, descriptions, and comments **MUST follow the language of the current conversation**, not default to English.
|
||||
|
||||
- Conversation in 中文 → issue body in 中文;technical terms (file paths, identifiers, library names, commands, error messages) stay in English.
|
||||
- Conversation in English → issue body in English.
|
||||
- Code blocks, file paths, and quoted strings always stay in their original form regardless of surrounding language.
|
||||
- This applies equally to **updates** — when editing an existing issue (description **and titles**), preserve the language of the conversation that triggered the edit; do not switch the issue language during a refactor (Chinese → English or vice versa).
|
||||
|
||||
Rationale: the issue is a continuation of the conversation. Forcing English when the discussion is in Chinese creates translation friction for the collaborator who came from that thread.
|
||||
|
||||
## Creating Sub-issue Trees
|
||||
|
||||
When breaking a parent issue into a tree of sub-issues (e.g., task decomposition for LOBE-xxx), follow these rules — they work around real limitations of the Linear MCP tools.
|
||||
|
||||
@@ -11,86 +11,167 @@
|
||||
# Environment variables:
|
||||
# CDP_PORT — Chrome DevTools Protocol port (default: 9222)
|
||||
# ELECTRON_LOG — Log file path (default: /tmp/electron-dev.log)
|
||||
# ELECTRON_WAIT_S — Max seconds to wait for Electron process (default: 60)
|
||||
# RENDERER_WAIT_S — Max seconds to wait for renderer/SPA (default: 60)
|
||||
# ELECTRON_WAIT_S — Max seconds to wait for CDP to become reachable (default: 90)
|
||||
# RENDERER_WAIT_S — Max seconds to wait for SPA after CDP is up (default: 60)
|
||||
# FORCE_KILL_USER — When set to 1, silently kill the user's `bun run dev`
|
||||
# Electron without confirmation (default: always confirm-by-action)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
CDP_PORT="${CDP_PORT:-9222}"
|
||||
ELECTRON_LOG="${ELECTRON_LOG:-/tmp/electron-dev.log}"
|
||||
ELECTRON_WAIT_S="${ELECTRON_WAIT_S:-60}"
|
||||
ELECTRON_WAIT_S="${ELECTRON_WAIT_S:-90}"
|
||||
RENDERER_WAIT_S="${RENDERER_WAIT_S:-60}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||
PIDFILE="/tmp/electron-dev-cdp-${CDP_PORT}.pid"
|
||||
|
||||
# Project-scoped electron path prefix used for pgrep matching. Any Electron
|
||||
# binary from this project (main + helpers, with or without --remote-debugging-port)
|
||||
# starts with this string in its argv[0], so a single substring match catches all.
|
||||
PROJECT_ELECTRON_PATH="${PROJECT_ROOT}/apps/desktop/node_modules/.pnpm/electron@"
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
# Get the Electron binary path used by this project
|
||||
electron_bin_pattern() {
|
||||
echo "${PROJECT_ROOT}/apps/desktop/node_modules/.pnpm/electron@*/node_modules/electron/dist/Electron.app"
|
||||
# Print pid + every descendant pid (DFS via pgrep -P).
|
||||
expand_descendants() {
|
||||
local pid="$1"
|
||||
echo "$pid"
|
||||
local children
|
||||
children=$(pgrep -P "$pid" 2>/dev/null || true)
|
||||
for c in $children; do
|
||||
expand_descendants "$c"
|
||||
done
|
||||
}
|
||||
|
||||
# Find all PIDs related to the project's Electron dev session
|
||||
find_electron_pids() {
|
||||
# Find seed PIDs related to this project's Electron dev session.
|
||||
# Matches REGARDLESS of whether --remote-debugging-port was passed, so it also
|
||||
# catches a plain `bun run dev` session the user started outside this script.
|
||||
find_project_pids() {
|
||||
local pids=""
|
||||
|
||||
# 1. Main Electron process (launched with --remote-debugging-port)
|
||||
local main_pids
|
||||
main_pids=$(pgrep -f "Electron\.app.*--remote-debugging-port=${CDP_PORT}" 2>/dev/null || true)
|
||||
[ -n "$main_pids" ] && pids="$pids $main_pids"
|
||||
# 1. Any process whose command line mentions this project's electron path
|
||||
# (covers the main Electron binary AND every Helper subprocess)
|
||||
local electron_pids
|
||||
electron_pids=$(pgrep -f "$PROJECT_ELECTRON_PATH" 2>/dev/null || true)
|
||||
pids="$pids $electron_pids"
|
||||
|
||||
# 2. Electron Helper processes (gpu, renderer, utility) spawned from the project's electron binary
|
||||
local helper_pids
|
||||
helper_pids=$(pgrep -f "${PROJECT_ROOT}/apps/desktop/node_modules/.*Electron Helper" 2>/dev/null || true)
|
||||
[ -n "$helper_pids" ] && pids="$pids $helper_pids"
|
||||
|
||||
# 3. electron-vite dev server
|
||||
# 2. electron-vite dev server (narrow match to avoid catching unrelated Vite invocations)
|
||||
local vite_pids
|
||||
vite_pids=$(pgrep -f "electron-vite.*dev" 2>/dev/null || true)
|
||||
[ -n "$vite_pids" ] && pids="$pids $vite_pids"
|
||||
vite_pids=$(pgrep -f "electron-vite[/.].*\\bdev\\b" 2>/dev/null || true)
|
||||
pids="$pids $vite_pids"
|
||||
|
||||
# 4. PID from pidfile (fallback)
|
||||
# 3. The launcher subshell from a previous `start` (saved to pidfile)
|
||||
if [ -f "$PIDFILE" ]; then
|
||||
local saved_pid
|
||||
saved_pid=$(cat "$PIDFILE")
|
||||
if kill -0 "$saved_pid" 2>/dev/null; then
|
||||
saved_pid=$(cat "$PIDFILE" 2>/dev/null || true)
|
||||
if [ -n "$saved_pid" ] && kill -0 "$saved_pid" 2>/dev/null; then
|
||||
pids="$pids $saved_pid"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Deduplicate
|
||||
echo "$pids" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ' || true
|
||||
# 4. Whatever is currently bound to the CDP port — catches strays whose
|
||||
# binary path doesn't match (e.g. orphaned from a crashed restart)
|
||||
local port_pid
|
||||
port_pid=$(lsof -ti tcp:"$CDP_PORT" -sTCP:LISTEN 2>/dev/null || true)
|
||||
pids="$pids $port_pid"
|
||||
|
||||
echo "$pids" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' '
|
||||
}
|
||||
|
||||
# Wait for the CDP HTTP endpoint to respond, with a deadline + early bail-out
|
||||
# if the launcher process died (no point waiting if Electron crashed).
|
||||
wait_for_cdp() {
|
||||
local deadline=$(( $(date +%s) + ELECTRON_WAIT_S ))
|
||||
echo "[electron-dev] Waiting for CDP on port ${CDP_PORT} (up to ${ELECTRON_WAIT_S}s)..."
|
||||
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
if curl -sf --max-time 2 "http://localhost:${CDP_PORT}/json/version" >/dev/null 2>&1; then
|
||||
echo "[electron-dev] CDP is reachable."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# If our launcher subshell died, abort early so we don't hang the full timeout
|
||||
if [ -f "$PIDFILE" ]; then
|
||||
local saved_pid
|
||||
saved_pid=$(cat "$PIDFILE" 2>/dev/null || true)
|
||||
if [ -n "$saved_pid" ] && ! kill -0 "$saved_pid" 2>/dev/null; then
|
||||
echo "[electron-dev] Launcher PID $saved_pid is gone before CDP came up."
|
||||
echo "[electron-dev] Last 30 lines of $ELECTRON_LOG:"
|
||||
tail -30 "$ELECTRON_LOG" 2>/dev/null || true
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "[electron-dev] ERROR: CDP did not respond within ${ELECTRON_WAIT_S}s"
|
||||
echo "[electron-dev] Last 30 lines of $ELECTRON_LOG:"
|
||||
tail -30 "$ELECTRON_LOG" 2>/dev/null || true
|
||||
return 1
|
||||
}
|
||||
|
||||
# After CDP is up, wait until the SPA renders interactive elements.
|
||||
wait_for_renderer() {
|
||||
local deadline=$(( $(date +%s) + RENDERER_WAIT_S ))
|
||||
echo "[electron-dev] Waiting for SPA to load (up to ${RENDERER_WAIT_S}s)..."
|
||||
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
local snap
|
||||
snap=$(agent-browser --cdp "$CDP_PORT" snapshot -i 2>&1 || true)
|
||||
if echo "$snap" | grep -qE '\b(link|button)\b'; then
|
||||
echo "[electron-dev] Renderer ready."
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "[electron-dev] WARNING: Renderer not interactive within ${RENDERER_WAIT_S}s — proceeding anyway."
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Commands ─────────────────────────────────────────────────────────
|
||||
|
||||
do_stop() {
|
||||
echo "[electron-dev] Stopping Electron dev environment..."
|
||||
|
||||
local pids
|
||||
pids=$(find_electron_pids)
|
||||
local seed_pids
|
||||
seed_pids=$(find_project_pids)
|
||||
|
||||
if [ -z "$pids" ]; then
|
||||
echo "[electron-dev] No Electron processes found."
|
||||
# Expand to include all descendants — catches helpers spawned by the main
|
||||
# process AFTER our pgrep snapshot, and the launcher's child node/electron-vite
|
||||
# process tree.
|
||||
local all_pids=""
|
||||
for pid in $seed_pids; do
|
||||
all_pids="$all_pids $(expand_descendants "$pid")"
|
||||
done
|
||||
all_pids=$(echo "$all_pids" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ')
|
||||
|
||||
if [ -z "$all_pids" ]; then
|
||||
echo "[electron-dev] No project Electron/vite processes found."
|
||||
else
|
||||
echo "[electron-dev] Killing PIDs: $pids"
|
||||
for pid in $pids; do
|
||||
local count
|
||||
count=$(echo "$all_pids" | tr ' ' '\n' | grep -c .)
|
||||
echo "[electron-dev] Sending SIGTERM to $count process(es): $all_pids"
|
||||
for pid in $all_pids; do
|
||||
kill "$pid" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# Wait up to 5s for graceful exit, then force-kill survivors
|
||||
# Wait up to 5s for graceful exit
|
||||
local waited=0
|
||||
while [ $waited -lt 5 ]; do
|
||||
local alive=""
|
||||
for pid in $pids; do
|
||||
kill -0 "$pid" 2>/dev/null && alive="$alive $pid"
|
||||
local any_alive=0
|
||||
for pid in $all_pids; do
|
||||
if kill -0 "$pid" 2>/dev/null; then any_alive=1; break; fi
|
||||
done
|
||||
[ -z "$alive" ] && break
|
||||
[ "$any_alive" = "0" ] && break
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
|
||||
# Force-kill any remaining
|
||||
for pid in $pids; do
|
||||
# SIGKILL anyone still alive
|
||||
for pid in $all_pids; do
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo "[electron-dev] Force-killing PID $pid"
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
@@ -98,7 +179,27 @@ do_stop() {
|
||||
done
|
||||
fi
|
||||
|
||||
# Also close any agent-browser sessions connected to this port
|
||||
# Belt-and-suspenders: anything still bound to the CDP port goes away
|
||||
local port_pid
|
||||
port_pid=$(lsof -ti tcp:"$CDP_PORT" -sTCP:LISTEN 2>/dev/null || true)
|
||||
if [ -n "$port_pid" ]; then
|
||||
echo "[electron-dev] Port $CDP_PORT still bound by PID $port_pid; force-killing"
|
||||
# shellcheck disable=SC2086
|
||||
kill -9 $port_pid 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Also re-sweep the project's electron processes — sometimes the OS spawns
|
||||
# new helpers during shutdown that didn't exist when we first enumerated.
|
||||
local stragglers
|
||||
stragglers=$(pgrep -f "$PROJECT_ELECTRON_PATH" 2>/dev/null || true)
|
||||
if [ -n "$stragglers" ]; then
|
||||
echo "[electron-dev] Cleaning up stragglers: $stragglers"
|
||||
for pid in $stragglers; do
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
# Close any agent-browser sessions connected to this port
|
||||
agent-browser --cdp "$CDP_PORT" close --all 2>/dev/null || true
|
||||
|
||||
rm -f "$PIDFILE"
|
||||
@@ -107,113 +208,84 @@ do_stop() {
|
||||
|
||||
do_status() {
|
||||
local pids
|
||||
pids=$(find_electron_pids)
|
||||
pids=$(find_project_pids)
|
||||
|
||||
if [ -z "$pids" ]; then
|
||||
echo "[electron-dev] Electron is NOT running."
|
||||
echo "[electron-dev] No project Electron processes found."
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "[electron-dev] Electron is running (PIDs: $pids)"
|
||||
echo "[electron-dev] Project processes: $pids"
|
||||
|
||||
# Check CDP connectivity
|
||||
if agent-browser --cdp "$CDP_PORT" get url >/dev/null 2>&1; then
|
||||
if curl -sf --max-time 2 "http://localhost:${CDP_PORT}/json/version" >/dev/null 2>&1; then
|
||||
local url
|
||||
url=$(agent-browser --cdp "$CDP_PORT" get url 2>&1 | tail -1)
|
||||
url=$(agent-browser --cdp "$CDP_PORT" get url 2>&1 | tail -1 || echo "?")
|
||||
echo "[electron-dev] CDP port ${CDP_PORT} is reachable. URL: $url"
|
||||
return 0
|
||||
else
|
||||
echo "[electron-dev] CDP port ${CDP_PORT} is NOT reachable (Electron may still be loading)."
|
||||
echo "[electron-dev] CDP port ${CDP_PORT} is NOT reachable (no --remote-debugging-port, or still loading)."
|
||||
return 2
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_electron() {
|
||||
echo "[electron-dev] Waiting for Electron process (up to ${ELECTRON_WAIT_S}s)..."
|
||||
local elapsed=0
|
||||
local interval=3
|
||||
while [ $elapsed -lt "$ELECTRON_WAIT_S" ]; do
|
||||
if strings "$ELECTRON_LOG" 2>/dev/null | grep -q "starting electron"; then
|
||||
echo "[electron-dev] Electron process started."
|
||||
return 0
|
||||
fi
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
echo "[electron-dev] Still waiting... (${elapsed}/${ELECTRON_WAIT_S}s)"
|
||||
done
|
||||
echo "[electron-dev] ERROR: Electron did not start within ${ELECTRON_WAIT_S}s"
|
||||
echo "[electron-dev] Last 20 lines of log:"
|
||||
tail -20 "$ELECTRON_LOG" 2>/dev/null || true
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_renderer() {
|
||||
echo "[electron-dev] Waiting for renderer/SPA to load (up to ${RENDERER_WAIT_S}s)..."
|
||||
|
||||
# Initial delay — renderer needs time to bootstrap
|
||||
sleep 10
|
||||
|
||||
local elapsed=10
|
||||
local interval=5
|
||||
while [ $elapsed -lt "$RENDERER_WAIT_S" ]; do
|
||||
if agent-browser --cdp "$CDP_PORT" wait 2000 >/dev/null 2>&1; then
|
||||
# Check if interactive elements are present (SPA loaded)
|
||||
local snap
|
||||
snap=$(agent-browser --cdp "$CDP_PORT" snapshot -i 2>&1 || true)
|
||||
if echo "$snap" | grep -qE 'link |button '; then
|
||||
echo "[electron-dev] Renderer ready (interactive elements found)."
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
echo "[electron-dev] SPA still loading... (${elapsed}/${RENDERER_WAIT_S}s)"
|
||||
done
|
||||
|
||||
echo "[electron-dev] WARNING: Timed out waiting for renderer, proceeding anyway."
|
||||
return 0
|
||||
}
|
||||
|
||||
do_start() {
|
||||
# If already running and healthy, skip
|
||||
local status_ok=0
|
||||
do_status >/dev/null 2>&1 || status_ok=$?
|
||||
if [ "$status_ok" -eq 0 ]; then
|
||||
echo "[electron-dev] Electron is already running and CDP is reachable. Skipping start."
|
||||
echo "[electron-dev] Use 'restart' to force a fresh session, or 'stop' to tear down."
|
||||
# Already up and CDP is reachable → nothing to do
|
||||
if curl -sf --max-time 2 "http://localhost:${CDP_PORT}/json/version" >/dev/null 2>&1; then
|
||||
echo "[electron-dev] CDP already reachable on port $CDP_PORT. Skipping start."
|
||||
echo "[electron-dev] Use 'restart' to force a fresh session."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Clean up any stale processes
|
||||
# Detect the user's existing dev session (or stale processes) BEFORE killing
|
||||
local existing
|
||||
existing=$(find_project_pids)
|
||||
if [ -n "$existing" ]; then
|
||||
echo "[electron-dev] Existing project Electron/vite processes detected:"
|
||||
echo "$existing" | tr ' ' '\n' | sed 's/^/[electron-dev] PID /'
|
||||
echo "[electron-dev] Tearing them down so we can start a CDP-enabled session..."
|
||||
fi
|
||||
|
||||
do_stop
|
||||
|
||||
# Start fresh
|
||||
# Wait for port + user-data-dir locks to release. Without this, the new
|
||||
# Electron may fail with "user data directory in use" or fail to bind CDP.
|
||||
local waited=0
|
||||
while [ $waited -lt 10 ]; do
|
||||
if ! lsof -i tcp:"$CDP_PORT" >/dev/null 2>&1 \
|
||||
&& ! pgrep -f "$PROJECT_ELECTRON_PATH" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
[ $waited -eq 0 ] && echo "[electron-dev] Waiting for port + Electron locks to release..."
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
|
||||
echo "[electron-dev] Starting Electron dev server..."
|
||||
echo "[electron-dev] Project: $PROJECT_ROOT"
|
||||
echo "[electron-dev] Project: $PROJECT_ROOT"
|
||||
echo "[electron-dev] CDP port: $CDP_PORT"
|
||||
echo "[electron-dev] Log: $ELECTRON_LOG"
|
||||
echo "[electron-dev] Log: $ELECTRON_LOG"
|
||||
|
||||
: > "$ELECTRON_LOG" # Truncate log
|
||||
|
||||
(
|
||||
cd "$PROJECT_ROOT/apps/desktop" && \
|
||||
ELECTRON_ENABLE_LOGGING=1 npx electron-vite dev -- --remote-debugging-port="$CDP_PORT" \
|
||||
>> "$ELECTRON_LOG" 2>&1
|
||||
) &
|
||||
local bg_pid=$!
|
||||
echo "$bg_pid" > "$PIDFILE"
|
||||
echo "[electron-dev] Background PID: $bg_pid"
|
||||
# Launch in a new session (setsid) so the whole process tree shares a PGID
|
||||
# we can later signal in one shot. `setsid bash -c '... exec ...' &` keeps
|
||||
# the bash shell as the session leader; its PID is what we save.
|
||||
setsid bash -c "
|
||||
cd '$PROJECT_ROOT/apps/desktop'
|
||||
exec npx electron-vite dev -- --remote-debugging-port=$CDP_PORT
|
||||
" >> "$ELECTRON_LOG" 2>&1 < /dev/null &
|
||||
local launcher_pid=$!
|
||||
echo "$launcher_pid" > "$PIDFILE"
|
||||
echo "[electron-dev] Launcher PID (session leader): $launcher_pid"
|
||||
|
||||
# Wait for Electron process to start
|
||||
if ! wait_for_electron; then
|
||||
echo "[electron-dev] Failed to start. Cleaning up..."
|
||||
if ! wait_for_cdp; then
|
||||
echo "[electron-dev] Failed to bring up CDP. Cleaning up..."
|
||||
do_stop
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Wait for renderer to be interactive
|
||||
if ! wait_for_renderer; then
|
||||
echo "[electron-dev] Renderer not ready, but Electron is running. You may need to wait more."
|
||||
echo "[electron-dev] Renderer not interactive — you may need to wait more."
|
||||
fi
|
||||
|
||||
echo "[electron-dev] Ready! Use: agent-browser --cdp $CDP_PORT snapshot -i"
|
||||
@@ -221,7 +293,7 @@ do_start() {
|
||||
|
||||
do_restart() {
|
||||
do_stop
|
||||
sleep 2
|
||||
sleep 1
|
||||
do_start
|
||||
}
|
||||
|
||||
@@ -235,10 +307,12 @@ case "${1:-help}" in
|
||||
*)
|
||||
echo "Usage: $0 {start|stop|status|restart}"
|
||||
echo ""
|
||||
echo " start — Start Electron dev with CDP (idempotent, skips if already running)"
|
||||
echo " stop — Kill all Electron dev processes (main + helpers + vite)"
|
||||
echo " status — Check if Electron is running and CDP is reachable"
|
||||
echo " restart — Stop then start"
|
||||
echo " start — Start Electron dev with CDP. Detects + tears down any"
|
||||
echo " existing project Electron (e.g. \`bun run dev\`) first."
|
||||
echo " stop — Kill all project Electron/vite processes (main + helpers"
|
||||
echo " + descendants), with SIGTERM → 5s wait → SIGKILL fallback."
|
||||
echo " status — Check if Electron is running and CDP is reachable."
|
||||
echo " restart — Stop then start."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -1,58 +1,51 @@
|
||||
---
|
||||
name: code-review
|
||||
description: 'Code review checklist for LobeHub. Use when reviewing PRs, diffs, or code changes. Covers correctness, security, quality, and project-specific patterns.'
|
||||
name: review-checklist
|
||||
description: 'Common recurring mistakes in LobeHub code review — console leftovers, missing return await, hardcoded secrets, hardcoded i18n strings, desktop router pair drift, antd vs @lobehub/ui, non-idempotent migrations, cloud impact red flags. Use as a quick checklist when reviewing PRs, diffs, or branch changes.'
|
||||
---
|
||||
|
||||
# Code Review Guide
|
||||
# Review Checklist
|
||||
|
||||
## Before You Start
|
||||
|
||||
1. Read `/typescript` and `/testing` skills for code style and test conventions
|
||||
2. Get the diff (skip if already in context, e.g., injected by GitHub review app): `git diff` or `git diff origin/canary..HEAD`
|
||||
|
||||
## Checklist
|
||||
|
||||
### Correctness
|
||||
## Correctness
|
||||
|
||||
- Leftover `console.log` / `console.debug` — should use `debug` package or remove
|
||||
- Missing `return await` in try/catch — see <https://typescript-eslint.io/rules/return-await/> (not in our ESLint config yet, requires type info)
|
||||
- Can the fix/implementation be more concise, efficient, or have better compatibility?
|
||||
|
||||
### Security
|
||||
## Security
|
||||
|
||||
- No sensitive data (API keys, tokens, credentials) in `console.*` or `debug()` output
|
||||
- No base64 output to terminal — extremely long, freezes output
|
||||
- No hardcoded secrets — use environment variables
|
||||
|
||||
### Testing
|
||||
## Testing
|
||||
|
||||
- Bug fixes must include tests covering the fixed scenario
|
||||
- New logic (services, store actions, utilities) should have test coverage
|
||||
- Existing tests still cover the changed behavior?
|
||||
- Prefer `vi.spyOn` over `vi.mock` (see `/testing` skill)
|
||||
|
||||
### i18n
|
||||
## i18n
|
||||
|
||||
- New user-facing strings use i18n keys, not hardcoded text
|
||||
- Keys added to `src/locales/default/{namespace}.ts` with `{feature}.{context}.{action|status}` naming
|
||||
- For PRs: `locales/` translations for all languages updated (`pnpm i18n`)
|
||||
|
||||
### SPA / routing
|
||||
## SPA / routing
|
||||
|
||||
- **`desktopRouter` pair:** If the diff touches `src/spa/router/desktopRouter.config.tsx`, does it also update `src/spa/router/desktopRouter.config.desktop.tsx` with the same route paths and nesting? Single-file edits often cause drift and blank screens.
|
||||
|
||||
### Reuse
|
||||
## Reuse
|
||||
|
||||
- Newly written code duplicates existing utilities in `packages/utils` or shared modules?
|
||||
- Copy-pasted blocks with slight variation — extract into shared function
|
||||
- `antd` imports replaceable with `@lobehub/ui` wrapped components (`Input`, `Button`, `Modal`, `Avatar`, etc.)
|
||||
- Use `antd-style` token system, not hardcoded colors; prefer `createStaticStyles` + `cssVar.*` over `createStyles` + `token` unless runtime computation is required
|
||||
|
||||
### Database
|
||||
## Database
|
||||
|
||||
- Migration scripts must be idempotent (`IF NOT EXISTS`, `IF EXISTS` guards)
|
||||
|
||||
### Cloud Impact
|
||||
## Cloud Impact
|
||||
|
||||
A downstream cloud deployment depends on this repo. Flag changes that may require cloud-side updates:
|
||||
|
||||
@@ -61,13 +54,3 @@ A downstream cloud deployment depends on this repo. Flag changes that may requir
|
||||
- **Dependency versions bumped** — e.g., upgrading `next` or `drizzle-orm` in `package.json`
|
||||
- **`@lobechat/business-*` exports changed** — e.g., renaming a function in `src/business/` or changing type signatures in `packages/business/`
|
||||
- `src/business/` and `packages/business/` must not expose cloud commercial logic in comments or code
|
||||
|
||||
## Output Format
|
||||
|
||||
For local CLI review only (GitHub review app posts inline PR comments instead):
|
||||
|
||||
- Number all findings sequentially
|
||||
- Indicate priority: `[high]` / `[medium]` / `[low]`
|
||||
- Include file path and line number for each finding
|
||||
- Only list problems — no summary, no praise
|
||||
- Re-read full source for each finding to verify it's real, then output "All findings verified."
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: 'source-command-dedupe'
|
||||
description: 'Find duplicate GitHub issues'
|
||||
---
|
||||
|
||||
# source-command-dedupe
|
||||
|
||||
Use this skill when the user asks to run the migrated source command `dedupe`.
|
||||
|
||||
## Command Template
|
||||
|
||||
Find up to 3 likely duplicate issues for a given GitHub issue.
|
||||
|
||||
To do this, follow these steps precisely:
|
||||
|
||||
1. Use an agent to check if the Github issue (a) is closed, (b) does not need to be deduped (eg. because it is broad product feedback without a specific solution, or positive feedback), or (c) already has a duplicates comment that you made earlier. If so, do not proceed.
|
||||
2. Use an agent to view a Github issue, and ask the agent to return a summary of the issue
|
||||
3. Then, launch 5 parallel agents to search Github for duplicates of this issue, using diverse keywords and search approaches, using the summary from #1
|
||||
4. Next, feed the results from #1 and #2 into another agent, so that it can filter out false positives, that are likely not actually duplicates of the original issue. If there are no duplicates remaining, do not proceed.
|
||||
5. Finally, comment back on the issue with a list of up to three duplicate issues (or zero, if there are no likely duplicates)
|
||||
|
||||
Notes (be sure to tell this to your agents, too):
|
||||
|
||||
- Use `gh` to interact with Github, rather than web fetch
|
||||
- Do not use other tools, beyond `gh` (eg. don't use other MCP servers, file edit, etc.)
|
||||
- Make a todo list first
|
||||
- For your comment, follow the following format precisely (assuming for this example that you found 3 suspected duplicates):
|
||||
|
||||
---
|
||||
|
||||
Found 3 possible duplicate issues:
|
||||
|
||||
1. <link to issue>
|
||||
2. <link to issue>
|
||||
3. <link to issue>
|
||||
|
||||
This issue will be automatically closed as a duplicate in 3 days.
|
||||
|
||||
- If your issue is a duplicate, please close it and 👍 the existing issue instead
|
||||
- To prevent auto-closure, add a comment or 👎 this comment
|
||||
|
||||
> 🤖 Generated with Codex
|
||||
|
||||
---
|
||||
@@ -117,7 +117,7 @@ it('should handle tool calls', async () => {
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call_123',
|
||||
name: 'lobe-web-browsing____search____builtin',
|
||||
name: 'lobe-web-browsing____search',
|
||||
arguments: JSON.stringify({ query: 'weather' }),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -189,7 +189,7 @@ If metrics cannot be reliably computed, omit unknown numbers instead of guessing
|
||||
|
||||
Follow this section order unless the user asks otherwise:
|
||||
|
||||
1. `# 🚀 LobeHub v<x.y.z> (<YYYYMMDD>)`
|
||||
1. `# 🚀 LobeHub Release (<YYYYMMDD>)`
|
||||
2. Metadata lines:
|
||||
- `Release Date`
|
||||
- `Since <Previous Version>` metrics
|
||||
@@ -238,13 +238,34 @@ Use `---` separators between major blocks for long releases.
|
||||
- Keep concise.
|
||||
- Must include `Migration overview`, operator impact, and rollback/backup note.
|
||||
|
||||
### Contributor Ordering
|
||||
|
||||
Render contributors as a **single flat list** (no separate "Community" / "Core Team" subsections). Order: **community contributors first, team members after**. Within each group, sort by PR count desc. Bots (`@lobehubbot`, `renovate[bot]`) go on a separate "maintenance" line.
|
||||
|
||||
**LobeHub team roster** — anyone in this list is a team member; anyone not in this list is a community contributor:
|
||||
|
||||
- @arvinxx
|
||||
- @Innei
|
||||
- @tjx666 (commit author name: YuTengjing)
|
||||
- @LiJian
|
||||
- @Neko
|
||||
- @Rdmclin2
|
||||
- @AmAzing129
|
||||
- @sudongyuer
|
||||
- @rivertwilight
|
||||
- @CanisMinor
|
||||
|
||||
> **Resolving handles** — git author names (e.g. `YuTengjing`) are not always the GitHub handle. Verify via `gh pr view <PR> --json author` or `gh api search/users -f q='<email>'` before listing.
|
||||
|
||||
If a new contributor appears who is not on this list, treat them as community by default and ask the user whether to add them to the roster.
|
||||
|
||||
### GitHub Release Changelog Template
|
||||
|
||||
```md
|
||||
# 🚀 LobeHub v<x.y.z> (<YYYYMMDD>)
|
||||
# 🚀 LobeHub Release (<YYYYMMDD>)
|
||||
|
||||
**Release Date:** <Month DD, YYYY>
|
||||
**Since <Previous Version>:** <N commits> · <N merged PRs> · <N resolved issues> · <N contributors>
|
||||
**Since <Previous Version>:** <N merged PRs> · <N resolved issues> · <N contributors>
|
||||
|
||||
> <One release thesis sentence: what this release unlocks in practice.>
|
||||
|
||||
@@ -296,12 +317,11 @@ Use `---` separators between major blocks for long releases.
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
**<N merged PRs>** from **<N contributors>** across **<N commits>**.
|
||||
Huge thanks to **<N contributors>** who shipped **<N merged PRs>** this cycle.
|
||||
|
||||
### Community Contributors
|
||||
@<community-handle> · @<community-handle> · @<team-handle> · @<team-handle>
|
||||
|
||||
- @<username> - <notable contribution area>
|
||||
- @<username> - <notable contribution area>
|
||||
Plus @lobehubbot and renovate[bot] for maintenance.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 🚀 LobeHub v2.1.50 (20260416)
|
||||
# 🚀 LobeHub Release (20260416)
|
||||
|
||||
**Release Date:** April 20, 2026\
|
||||
**Migration Scope:** Agent benchmark data model bootstrap (5 new tables, 2 new indexes)
|
||||
@@ -7,14 +7,6 @@
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- **Benchmark Lifecycle Schema** — Added a relational model that tracks benchmark setup, runs, per-topic execution, and record outputs end-to-end.
|
||||
- **Queryability Upgrade** — Added indexes for run status and benchmark-topic joins, improving operational queries in dashboard and debugging workflows.
|
||||
- **Safer Operator Rollout** — Migration is startup-driven and backward-compatible with existing non-benchmark chat workflows.
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Migration Overview
|
||||
|
||||
Added tables:
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# 🚀 LobeHub Release (20260427)
|
||||
|
||||
**Hotfix Scope:** Agent topic-switching regression — stale chat state on agent change
|
||||
|
||||
> Clears residual topic state when navigating between agents and restores blank-canvas behavior on agent switch.
|
||||
|
||||
## 🐛 What's Fixed
|
||||
|
||||
- **Stale topic on agent switch** — Switching from `/agent/agt_A/tpc_X` to `/agent/agt_B` no longer leaves the previous topic's messages on screen, and _Start new topic_ responds again. (#14231)
|
||||
- **Header & sidebar consistency** — Conversation header now shows the active subtopic's title, and the sidebar keeps the parent topic's thread list expanded while a thread is open.
|
||||
|
||||
## ⚙️ Upgrade
|
||||
|
||||
- Self-hosted: pull the new image and restart. No schema or env changes.
|
||||
- Cloud: applied automatically.
|
||||
|
||||
## 👥 Owner
|
||||
|
||||
@{pr-author}
|
||||
|
||||
> **Note for Claude**: Replace `{pr-author}` with the actual PR author. Retrieve via `gh pr view <number> --json author --jq '.author.login'`. Do not hardcode a username.
|
||||
@@ -1,7 +1,7 @@
|
||||
# 🚀 LobeHub v2.1.50 (20260420)
|
||||
# 🚀 LobeHub Release (20260420)
|
||||
|
||||
**Release Date:** April 20, 2026\
|
||||
**Since v2026.04.13:** 96 commits · 58 merged PRs · 31 resolved issues · 17 contributors
|
||||
**Since previous release:** 96 commits · 58 merged PRs · 31 resolved issues · 17 contributors
|
||||
|
||||
> This weekly release focuses on reducing friction in everyday agent work: faster model routing, smoother gateway behavior, stronger task continuity, and clearer operator diagnostics when something goes wrong.
|
||||
|
||||
@@ -77,4 +77,4 @@
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: v2026.04.13...v2026.04.20
|
||||
**Full Changelog**: <previous-tag>...<current-tag>
|
||||
|
||||
@@ -59,7 +59,10 @@ git push -u origin hotfix/v{version}-{short-hash}
|
||||
|
||||
2. **Create PR to main** with a gitmoji prefix title (e.g. `🐛 fix: description`)
|
||||
|
||||
3. **After merge**: auto-tag-release detects `hotfix/*` branch → auto patch +1.
|
||||
3. **Write a short hotfix changelog** — See `changelog-example/hotfix.md`. Keep it minimal: scope line, 1-3 fix bullets (symptom + fix in one sentence), upgrade note, owner. No long root-cause section — that lives in the commit message.
|
||||
- **Hotfix owner**: Use the actual PR author (retrieve via `gh pr view <number> --json author --jq '.author.login'`), never hardcode a username.
|
||||
|
||||
4. **After merge**: auto-tag-release detects `hotfix/*` branch → auto patch +1.
|
||||
|
||||
### Script
|
||||
|
||||
|
||||
@@ -174,9 +174,64 @@ export const chatGroupAction: StateCreator<
|
||||
- `ChatGroupStoreWithRefresh` for member refresh
|
||||
- `ChatGroupStoreWithInternal` for curd `internal_dispatchChatGroup`
|
||||
|
||||
### Slices That Don't Currently Need `set`
|
||||
|
||||
When a slice doesn't write local state at the moment — e.g. it reads context
|
||||
from `#get()` and forwards calls to another store, or just runs hooks — drop
|
||||
the `#set` field. Otherwise ESLint's `no-unused-vars` flags the unused private
|
||||
field.
|
||||
|
||||
Mark the constructor's `set` param as `_set` and `void _set` it to keep the
|
||||
`(set, get, api)` shape aligned with `StateCreator`. This is **a snapshot of
|
||||
the current need, not a permanent contract** — if a later change needs `set`,
|
||||
restore the `#set` field and use it; do not invent a workaround to keep the
|
||||
"unused" form.
|
||||
|
||||
```ts
|
||||
type Setter = StoreSetter<ConversationStore>;
|
||||
|
||||
export const toolSlice = (set: Setter, get: () => ConversationStore, _api?: unknown) =>
|
||||
new ToolActionImpl(set, get, _api);
|
||||
|
||||
export class ToolActionImpl {
|
||||
readonly #get: () => ConversationStore;
|
||||
|
||||
// Mark unused params with `_` prefix and `void _x` so the constructor still
|
||||
// matches StateCreator's `(set, get, api)` shape without triggering unused
|
||||
// diagnostics.
|
||||
constructor(_set: Setter, get: () => ConversationStore, _api?: unknown) {
|
||||
void _set;
|
||||
void _api;
|
||||
this.#get = get;
|
||||
}
|
||||
|
||||
approveToolCall = async (id: string) => {
|
||||
const { context, hooks } = this.#get();
|
||||
await useChatStore.getState().approveToolCalling(id, '', context);
|
||||
hooks.onToolCallComplete?.(id, undefined);
|
||||
};
|
||||
}
|
||||
|
||||
export type ToolAction = Pick<ToolActionImpl, keyof ToolActionImpl>;
|
||||
```
|
||||
|
||||
Rules of thumb:
|
||||
|
||||
- If a slice doesn't currently call `set`, drop `#set` (use `_set` + `void _set`
|
||||
in the constructor). When a later edit needs `set`, restore `#set` and use it.
|
||||
- Don't add `setNamespace` for slices that don't write state. Add it when the
|
||||
slice starts writing state.
|
||||
- Never leave `#set` declared but unused "for future use" — lint will fail and
|
||||
re-adding it later costs nothing.
|
||||
|
||||
### Do / Don't
|
||||
|
||||
- **Do**: keep constructor signature aligned with `StateCreator` params `(set, get, api)`.
|
||||
- **Do**: use `#private` to avoid `set/get` being exposed.
|
||||
- **Do**: use `flattenActions` instead of spreading class instances.
|
||||
- **Do**: drop `#set` (and use `_set` + `void _set` in the constructor) for
|
||||
delegate-only slices that never write state — keeps lint green without
|
||||
breaking the `(set, get, api)` shape.
|
||||
- **Don't**: keep both old slice objects and class actions active at the same time.
|
||||
- **Don't**: keep an unused `#set` field "for future use" — it fails ESLint and
|
||||
re-adding it later costs nothing.
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
|
||||
## Quick Reference by Name
|
||||
|
||||
- **@arvinxx**: Last resort only, mention for priority:high issues, tool calling, mcp, database
|
||||
- **@arvinxx**: General/uncategorized issues (default assignee), priority:high issues, tool calling, mcp, database
|
||||
- **@canisminor1990**: Design, UI components, editor, markdown rendering
|
||||
- **@tjx666**: Image/video generation, vision, cloud version, documentation, TTS, auth, login/register, database
|
||||
- **@ONLY-yours**: Performance, streaming, settings, general bugs, web platform, marketplace, agent builder, schedule task
|
||||
- **@tjx666**: Model providers and configuration, new model additions, image/video generation, vision, cloud version, documentation, TTS, auth, login/register, database
|
||||
- **@ONLY-yours**: Performance, streaming, settings, web platform, marketplace, agent builder, schedule task
|
||||
- **@Innei**: Knowledge base, files (KB-related), group chat, Electron, desktop client, build system
|
||||
- **@nekomeowww**: Memory, backend, deployment, DevOps, database
|
||||
- **@sudongyuer**: Mobile app (React Native)
|
||||
- **@sxjeru**: Model providers and configuration
|
||||
- **@rdmclin2**: Team workspace, IM and bot integration
|
||||
- **@tcmonster**: Subscription, refund, recharge, business cooperation
|
||||
|
||||
@@ -21,7 +20,7 @@ Quick reference for assigning issues based on labels.
|
||||
|
||||
| Label | Owner | Notes |
|
||||
| ---------------- | ------- | -------------------------------------------- |
|
||||
| All `provider:*` | @sxjeru | Model configuration and provider integration |
|
||||
| All `provider:*` | @tjx666 | Model configuration and provider integration |
|
||||
|
||||
### Platform Labels (platform:\*)
|
||||
|
||||
@@ -100,11 +99,10 @@ Quick reference for assigning issues based on labels.
|
||||
|
||||
1. **Specific feature owner** - e.g., `feature:knowledge-base` → @RiverTwilight
|
||||
2. **Platform owner** - e.g., `platform:mobile` → @sudongyuer
|
||||
3. **Provider owner** - e.g., `provider:*` → @sxjeru
|
||||
3. **Provider owner** - e.g., `provider:*` → @tjx666
|
||||
4. **Component owner** - e.g., 💄 Design → @canisminor1990
|
||||
5. **Infrastructure owner** - e.g., `deployment:*` → @nekomeowww
|
||||
6. **General maintainer** - @ONLY-yours for general bugs/issues
|
||||
7. **Last resort** - @arvinxx (only if no clear owner)
|
||||
6. **Default assignee** - @arvinxx for general/uncategorized issues
|
||||
|
||||
### Special Cases
|
||||
|
||||
@@ -121,8 +119,7 @@ Quick reference for assigning issues based on labels.
|
||||
|
||||
**No clear owner:**
|
||||
|
||||
- Assign to @ONLY-yours for general issues
|
||||
- Only mention @arvinxx if critical and truly unclear
|
||||
- Assign to @arvinxx for general issues
|
||||
|
||||
## Comment Templates
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Test Packages
|
||||
env:
|
||||
PACKAGES: '@lobechat/file-loaders @lobechat/prompts @lobechat/model-runtime @lobechat/web-crawler @lobechat/electron-server-ipc @lobechat/utils @lobechat/python-interpreter @lobechat/context-engine @lobechat/agent-runtime @lobechat/conversation-flow @lobechat/ssrf-safe-fetch @lobechat/memory-user-memory model-bank'
|
||||
PACKAGES: '@lobechat/file-loaders @lobechat/prompts @lobechat/model-runtime @lobechat/web-crawler @lobechat/electron-server-ipc @lobechat/utils @lobechat/python-interpreter @lobechat/context-engine @lobechat/agent-runtime @lobechat/conversation-flow @lobechat/ssrf-safe-fetch @lobechat/memory-user-memory @lobechat/types @lobechat/builtin-tool-lobe-agent model-bank'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -146,4 +146,5 @@ apps/desktop/resources/cli-package.json
|
||||
|
||||
# Superpowers plugin brainstorm/spec outputs (local only; do not commit)
|
||||
.superpowers/
|
||||
docs/superpowers/
|
||||
.heerogeneous-tracing
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ module.exports = defineConfig({
|
||||
],
|
||||
temperature: 0,
|
||||
saveImmediately: true,
|
||||
modelName: 'gpt-5.1-chat-latest',
|
||||
modelName: 'gpt-4o',
|
||||
experimental: {
|
||||
jsonMode: true,
|
||||
},
|
||||
|
||||
@@ -1,100 +1,128 @@
|
||||
# LobeHub Development Guidelines
|
||||
|
||||
This document serves as a comprehensive guide for all team members when developing LobeHub.
|
||||
|
||||
## Project Description
|
||||
|
||||
You are developing an open-source, modern-design AI Agent Workspace: LobeHub (previously LobeChat).
|
||||
Guidelines for using AI coding agents in this LobeHub repository.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend**: Next.js 16, React 19, TypeScript
|
||||
- **UI Components**: Ant Design, @lobehub/ui, antd-style
|
||||
- **State Management**: Zustand, SWR
|
||||
- **Database**: PostgreSQL, PGLite, Drizzle ORM
|
||||
- **Testing**: Vitest, Testing Library
|
||||
- **Package Manager**: pnpm (monorepo structure)
|
||||
- Next.js 16 + React 19 + TypeScript
|
||||
- SPA inside Next.js with `react-router-dom`
|
||||
- `@lobehub/ui`, antd for components; antd-style for CSS-in-JS — **prefer `createStaticStyles` with `cssVar.*`** (zero-runtime); only fall back to `createStyles` + `token` when styles genuinely need runtime computation. See `.cursor/docs/createStaticStyles_migration_guide.md`.
|
||||
- react-i18next for i18n; zustand for state management
|
||||
- SWR for data fetching; TRPC for type-safe backend
|
||||
- Drizzle ORM with PostgreSQL; Vitest for testing
|
||||
|
||||
## Directory Structure
|
||||
## Project Structure
|
||||
|
||||
```plaintext
|
||||
lobehub/
|
||||
├── apps/desktop/ # Electron desktop app
|
||||
├── apps/
|
||||
│ ├── desktop/ # Electron desktop app
|
||||
│ ├── cli/ # LobeHub CLI
|
||||
│ └── device-gateway/ # Device gateway service
|
||||
├── packages/ # Shared packages (@lobechat/*)
|
||||
│ ├── database/ # Database schemas, models, repositories
|
||||
│ ├── agent-runtime/ # Agent runtime
|
||||
│ └── ...
|
||||
├── src/
|
||||
│ ├── app/ # Next.js app router
|
||||
│ ├── spa/ # SPA entry points (entry.*.tsx) and router config
|
||||
│ ├── routes/ # SPA page components (roots)
|
||||
│ ├── features/ # Business components by domain
|
||||
│ ├── app/ # Next.js App Router (backend API + auth)
|
||||
│ │ ├── (backend)/ # API routes (trpc, webapi, etc.)
|
||||
│ │ ├── spa/ # SPA HTML template service
|
||||
│ │ └── [variants]/(auth)/ # Auth pages (SSR required)
|
||||
│ ├── routes/ # SPA page components (Vite)
|
||||
│ │ ├── (main)/ # Desktop pages
|
||||
│ │ ├── (mobile)/ # Mobile pages
|
||||
│ │ ├── (desktop)/ # Desktop-specific pages
|
||||
│ │ ├── (popup)/ # Popup window pages
|
||||
│ │ ├── onboarding/ # Onboarding pages
|
||||
│ │ └── share/ # Share pages
|
||||
│ ├── spa/ # SPA entry points and router config
|
||||
│ │ ├── entry.web.tsx # Web entry
|
||||
│ │ ├── entry.mobile.tsx
|
||||
│ │ ├── entry.desktop.tsx
|
||||
│ │ ├── entry.popup.tsx
|
||||
│ │ └── router/ # React Router configuration
|
||||
│ ├── store/ # Zustand stores
|
||||
│ ├── services/ # Client services
|
||||
│ ├── server/ # Server services and routers
|
||||
│ └── ...
|
||||
├── .agents/skills/ # AI development skills
|
||||
└── e2e/ # E2E tests (Cucumber + Playwright)
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
## SPA Routes and Features
|
||||
|
||||
SPA-related code is grouped under `src/spa/` (entries + router) and `src/routes/` (page segments). We use a **roots vs features** split: route trees only hold page segments; business logic and UI live in features.
|
||||
|
||||
- **`src/spa/`** – SPA entry points (`entry.web.tsx`, `entry.mobile.tsx`, `entry.desktop.tsx`, `entry.popup.tsx`) and React Router config (`router/`, with `desktopRouter.config.*`, `mobileRouter.config.tsx`, `popupRouter.config.tsx`). Keeps router config next to entries to avoid confusion with `src/routes/`.
|
||||
|
||||
- **`src/routes/` (roots)**\
|
||||
Only page-segment files: `_layout/index.tsx`, `index.tsx` (or `page.tsx`), and dynamic segments like `[id]/index.tsx`. Keep these **thin**: they should only import from `@/features/*` and compose layout/page, with no business logic or heavy UI.
|
||||
|
||||
- **`src/features/`**\
|
||||
Business components by **domain** (e.g. `Pages`, `PageEditor`, `Home`). Put layout chunks (sidebar, header, body), hooks, and domain-specific UI here. Each feature exposes an `index.ts` (or `index.tsx`) with clear exports.
|
||||
|
||||
When adding or changing SPA routes:
|
||||
|
||||
1. In `src/routes/`, add only the route segment files (layout + page) that delegate to features.
|
||||
2. Implement layout and page content under `src/features/<Domain>/` and export from there.
|
||||
3. In route files, use `import { X } from '@/features/<Domain>'` (or `import Y from '@/features/<Domain>/...'`). Do not add new `features/` folders inside `src/routes/`.
|
||||
4. **Register the desktop route tree in both configs:** `src/spa/router/desktopRouter.config.tsx` and `src/spa/router/desktopRouter.config.desktop.tsx` must stay in sync (same paths and nesting). Updating only one can cause **blank screens** if the other build path expects the route. `desktopRouter.sync.test.tsx` guards this invariant — keep it passing.
|
||||
|
||||
See the **spa-routes** skill (`.agents/skills/spa-routes/SKILL.md`) for the full convention and file-division rules.
|
||||
|
||||
## Development
|
||||
|
||||
### Starting the Dev Environment
|
||||
|
||||
```bash
|
||||
# SPA dev mode (frontend only, proxies API to localhost:3010)
|
||||
bun run dev:spa
|
||||
|
||||
# Full-stack dev (Next.js + Vite SPA concurrently)
|
||||
bun run dev
|
||||
```
|
||||
|
||||
After `dev:spa` starts, the terminal prints a **Debug Proxy** URL:
|
||||
|
||||
```plaintext
|
||||
Debug Proxy: https://app.lobehub.com/_dangerous_local_dev_proxy?debug-host=http%3A%2F%2Flocalhost%3A9876
|
||||
```
|
||||
|
||||
Open this URL to develop locally against the production backend (app.lobehub.com). The proxy page loads your local Vite dev server's SPA into the online environment, enabling HMR with real server config.
|
||||
|
||||
### Git Workflow
|
||||
|
||||
- **Branch strategy**: `canary` is the development branch (cloud production); `main` is the release branch (periodically cherry-picks from canary)
|
||||
- New branches should be created from `canary`; PRs should target `canary`
|
||||
- Use rebase for git pull
|
||||
- Git commit messages should prefix with gitmoji
|
||||
- Git branch name format: `feat/feature-name`
|
||||
- Use `.github/PULL_REQUEST_TEMPLATE.md` for PR descriptions
|
||||
- **Protection of local changes**: Never use `git restore`, `git checkout --`, `git reset --hard`, or any other command or workflow that can forcibly overwrite, discard, or silently replace user-owned uncommitted changes. Before any revert or restoration affecting existing files, inspect the working tree carefully and obtain explicit user confirmation.
|
||||
- Use rebase for `git pull`
|
||||
- Commit messages: prefix with gitmoji
|
||||
- Branch format: `<type>/<feature-name>`
|
||||
|
||||
### Package Management
|
||||
|
||||
- Use `pnpm` as the primary package manager
|
||||
- Use `bun` to run npm scripts
|
||||
- Use `bunx` to run executable npm packages
|
||||
- `pnpm` for dependency management
|
||||
- `bun` to run npm scripts
|
||||
- `bunx` for executable npm packages
|
||||
|
||||
### Code Style Guidelines
|
||||
|
||||
#### TypeScript
|
||||
|
||||
- Prefer interfaces over types for object shapes
|
||||
|
||||
### Testing Strategy
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Web tests
|
||||
bunx vitest run --silent='passed-only' '[file-path-pattern]'
|
||||
# Run specific test (NEVER run `bun run test` - takes ~10 minutes)
|
||||
bunx vitest run --silent='passed-only' '[file-path]'
|
||||
|
||||
# Package tests (e.g., database)
|
||||
cd packages/[package-name] && bunx vitest run --silent='passed-only' '[file-path-pattern]'
|
||||
# Database package
|
||||
cd packages/database && bunx vitest run --silent='passed-only' '[file]'
|
||||
```
|
||||
|
||||
**Important Notes**:
|
||||
|
||||
- Wrap file paths in single quotes to avoid shell expansion
|
||||
- Never run `bun run test` - this runs all tests and takes \~10 minutes
|
||||
|
||||
### Type Checking
|
||||
|
||||
- Use `bun run type-check` to check for type errors
|
||||
- Prefer `vi.spyOn` over `vi.mock`
|
||||
- Tests must pass type check: `bun run type-check`
|
||||
- After 2 failed fix attempts, stop and ask for help
|
||||
|
||||
### i18n
|
||||
|
||||
- **Keys**: Add to `src/locales/default/namespace.ts`
|
||||
- **Dev**: Translate `locales/zh-CN/namespace.json` locale file only for preview
|
||||
- DON'T run `pnpm i18n`, let CI auto handle it
|
||||
- Add keys to a namespace file under `src/locales/default/` (e.g. `agent.ts`, `auth.ts`)
|
||||
- For dev preview: translate `locales/zh-CN/` and `locales/en-US/`
|
||||
- `pnpm i18n` is slow; run it manually when locale keys need updating (e.g. before opening a PR).
|
||||
|
||||
## SPA Routes and Features
|
||||
### Code Review
|
||||
|
||||
- **`src/routes/`** holds only page segments (`_layout/index.tsx`, `index.tsx`, `[id]/index.tsx`). Keep route files **thin** — import from `@/features/*` and compose, no business logic.
|
||||
- **`src/features/`** holds business components by **domain** (e.g. `Pages`, `PageEditor`, `Home`). Layout pieces, hooks, and domain UI go here.
|
||||
- **Desktop router parity:** When changing the main SPA route tree, update **both** `src/spa/router/desktopRouter.config.tsx` (dynamic imports) and `src/spa/router/desktopRouter.config.desktop.tsx` (sync imports) so paths and nesting match. Changing only one can leave routes unregistered and cause **blank screens**.
|
||||
- See the **spa-routes** skill (`.agents/skills/spa-routes/SKILL.md`) for the full convention and file-division rules.
|
||||
|
||||
## Skills (Auto-loaded)
|
||||
|
||||
All AI development skills are available in `.agents/skills/` directory and auto-loaded by Claude Code when relevant.
|
||||
|
||||
**IMPORTANT**: When reviewing PRs or code diffs, ALWAYS read `.agents/skills/code-review/SKILL.md` first.
|
||||
Before reviewing a PR / diff / branch change, read the **review-checklist** skill (`.agents/skills/review-checklist/SKILL.md`) — it lists the recurring mistakes specific to this codebase.
|
||||
|
||||
+100
@@ -2,6 +2,106 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
### [Version 2.1.56](https://github.com/lobehub/lobe-chat/compare/v2.1.55...v2.1.56)
|
||||
|
||||
<sup>Released on **2026-05-01**</sup>
|
||||
|
||||
#### 👷 Build System
|
||||
|
||||
- **database**: add `metadata` and `trigger` to `briefs` table.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### Build System
|
||||
|
||||
- **database**: add `metadata` and `trigger` to `briefs` table, closes [#14354](https://github.com/lobehub/lobe-chat/issues/14354) ([86a23b5](https://github.com/lobehub/lobe-chat/commit/86a23b5))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 2.1.55](https://github.com/lobehub/lobe-chat/compare/v2.1.54...v2.1.55)
|
||||
|
||||
<sup>Released on **2026-04-29**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **chat**: preserve topics across cold route sends.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **chat**: preserve topics across cold route sends, closes [#14284](https://github.com/lobehub/lobe-chat/issues/14284) ([b8fe675](https://github.com/lobehub/lobe-chat/commit/b8fe675))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 2.1.54](https://github.com/lobehub/lobe-chat/compare/v2.1.53...v2.1.54)
|
||||
|
||||
<sup>Released on **2026-04-27**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **misc**: clear stale topic when switching agents from a topic route.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **misc**: clear stale topic when switching agents from a topic route, closes [#14231](https://github.com/lobehub/lobe-chat/issues/14231) ([deeb97a](https://github.com/lobehub/lobe-chat/commit/deeb97a))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 2.1.52](https://github.com/lobehub/lobe-chat/compare/v2.1.51...v2.1.52)
|
||||
|
||||
<sup>Released on **2026-04-20**</sup>
|
||||
|
||||
#### 👷 Build System
|
||||
|
||||
- **database**: add topic status and tasks automation mode.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### Build System
|
||||
|
||||
- **database**: add topic status and tasks automation mode, closes [#13994](https://github.com/lobehub/lobe-chat/issues/13994) ([3bcd581](https://github.com/lobehub/lobe-chat/commit/3bcd581))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 2.1.51](https://github.com/lobehub/lobe-chat/compare/v0.0.0-nightly.pr13850.8503...v2.1.51)
|
||||
|
||||
<sup>Released on **2026-04-16**</sup>
|
||||
|
||||
@@ -1,123 +1 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Guidelines for using Claude Code in this LobeHub repository.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Next.js 16 + React 19 + TypeScript
|
||||
- SPA inside Next.js with `react-router-dom`
|
||||
- `@lobehub/ui`, antd for components; antd-style for CSS-in-JS — **prefer `createStaticStyles` with `cssVar.*`** (zero-runtime); only fall back to `createStyles` + `token` when styles genuinely need runtime computation. See `.cursor/docs/createStaticStyles_migration_guide.md`.
|
||||
- react-i18next for i18n; zustand for state management
|
||||
- SWR for data fetching; TRPC for type-safe backend
|
||||
- Drizzle ORM with PostgreSQL; Vitest for testing
|
||||
|
||||
## Project Structure
|
||||
|
||||
```plaintext
|
||||
lobehub/
|
||||
├── apps/desktop/ # Electron desktop app
|
||||
├── packages/ # Shared packages (@lobechat/*)
|
||||
│ ├── database/ # Database schemas, models, repositories
|
||||
│ ├── agent-runtime/ # Agent runtime
|
||||
│ └── ...
|
||||
├── src/
|
||||
│ ├── app/ # Next.js App Router (backend API + auth)
|
||||
│ │ ├── (backend)/ # API routes (trpc, webapi, etc.)
|
||||
│ │ ├── spa/ # SPA HTML template service
|
||||
│ │ └── [variants]/(auth)/ # Auth pages (SSR required)
|
||||
│ ├── routes/ # SPA page components (Vite)
|
||||
│ │ ├── (main)/ # Desktop pages
|
||||
│ │ ├── (mobile)/ # Mobile pages
|
||||
│ │ ├── (desktop)/ # Desktop-specific pages
|
||||
│ │ ├── onboarding/ # Onboarding pages
|
||||
│ │ └── share/ # Share pages
|
||||
│ ├── spa/ # SPA entry points and router config
|
||||
│ │ ├── entry.web.tsx # Web entry
|
||||
│ │ ├── entry.mobile.tsx
|
||||
│ │ ├── entry.desktop.tsx
|
||||
│ │ └── router/ # React Router configuration
|
||||
│ ├── store/ # Zustand stores
|
||||
│ ├── services/ # Client services
|
||||
│ ├── server/ # Server services and routers
|
||||
│ └── ...
|
||||
└── e2e/ # E2E tests (Cucumber + Playwright)
|
||||
```
|
||||
|
||||
## SPA Routes and Features
|
||||
|
||||
SPA-related code is grouped under `src/spa/` (entries + router) and `src/routes/` (page segments). We use a **roots vs features** split: route trees only hold page segments; business logic and UI live in features.
|
||||
|
||||
- **`src/spa/`** – SPA entry points (`entry.web.tsx`, `entry.mobile.tsx`, `entry.desktop.tsx`) and React Router config (`router/`). Keeps router config next to entries to avoid confusion with `src/routes/`.
|
||||
|
||||
- **`src/routes/` (roots)**\
|
||||
Only page-segment files: `_layout/index.tsx`, `index.tsx` (or `page.tsx`), and dynamic segments like `[id]/index.tsx`. Keep these **thin**: they should only import from `@/features/*` and compose layout/page, with no business logic or heavy UI.
|
||||
|
||||
- **`src/features/`**\
|
||||
Business components by **domain** (e.g. `Pages`, `PageEditor`, `Home`). Put layout chunks (sidebar, header, body), hooks, and domain-specific UI here. Each feature exposes an `index.ts` (or `index.tsx`) with clear exports.
|
||||
|
||||
When adding or changing SPA routes:
|
||||
|
||||
1. In `src/routes/`, add only the route segment files (layout + page) that delegate to features.
|
||||
2. Implement layout and page content under `src/features/<Domain>/` and export from there.
|
||||
3. In route files, use `import { X } from '@/features/<Domain>'` (or `import Y from '@/features/<Domain>/...'`). Do not add new `features/` folders inside `src/routes/`.
|
||||
4. **Register the desktop route tree in both configs:** `src/spa/router/desktopRouter.config.tsx` and `src/spa/router/desktopRouter.config.desktop.tsx` must stay in sync (same paths and nesting). Updating only one can cause **blank screens** if the other build path expects the route.
|
||||
|
||||
See the **spa-routes** skill (`.agents/skills/spa-routes/SKILL.md`) for the full convention and file-division rules.
|
||||
|
||||
## Development
|
||||
|
||||
### Starting the Dev Environment
|
||||
|
||||
```bash
|
||||
# SPA dev mode (frontend only, proxies API to localhost:3010)
|
||||
bun run dev:spa
|
||||
|
||||
# Full-stack dev (Next.js + Vite SPA concurrently)
|
||||
bun run dev
|
||||
```
|
||||
|
||||
After `dev:spa` starts, the terminal prints a **Debug Proxy** URL:
|
||||
|
||||
```plaintext
|
||||
Debug Proxy: https://app.lobehub.com/_dangerous_local_dev_proxy?debug-host=http%3A%2F%2Flocalhost%3A9876
|
||||
```
|
||||
|
||||
Open this URL to develop locally against the production backend (app.lobehub.com). The proxy page loads your local Vite dev server's SPA into the online environment, enabling HMR with real server config.
|
||||
|
||||
### Git Workflow
|
||||
|
||||
- **Branch strategy**: `canary` is the development branch (cloud production); `main` is the release branch (periodically cherry-picks from canary)
|
||||
- New branches should be created from `canary`; PRs should target `canary`
|
||||
- Use rebase for `git pull`
|
||||
- Commit messages: prefix with gitmoji
|
||||
- Branch format: `<type>/<feature-name>`
|
||||
|
||||
### Package Management
|
||||
|
||||
- `pnpm` for dependency management
|
||||
- `bun` to run npm scripts
|
||||
- `bunx` for executable npm packages
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run specific test (NEVER run `bun run test` - takes ~10 minutes)
|
||||
bunx vitest run --silent='passed-only' '[file-path]'
|
||||
|
||||
# Database package
|
||||
cd packages/database && bunx vitest run --silent='passed-only' '[file]'
|
||||
```
|
||||
|
||||
- Prefer `vi.spyOn` over `vi.mock`
|
||||
- Tests must pass type check: `bun run type-check`
|
||||
- After 2 failed fix attempts, stop and ask for help
|
||||
|
||||
### i18n
|
||||
|
||||
- Add keys to `src/locales/default/namespace.ts`
|
||||
- For dev preview: translate `locales/zh-CN/` and `locales/en-US/`
|
||||
- Don't run `pnpm i18n` - CI handles it
|
||||
|
||||
## Skills (Auto-loaded by Claude)
|
||||
|
||||
Claude Code automatically loads relevant skills from `.agents/skills/`.
|
||||
@AGENTS.md
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Manual E2E coverage for `lh agent space fs` against a real backend.
|
||||
*
|
||||
* Run when:
|
||||
* - A local or remote LobeHub backend is reachable by the CLI
|
||||
* - `AGENT_FS_E2E_AGENT_ID` points at an agent with document access
|
||||
*
|
||||
* Expects:
|
||||
* - The command creates and cleans up a temporary VFS directory
|
||||
* - This suite is skipped unless `AGENT_FS_E2E_AGENT_ID` is set
|
||||
*/
|
||||
const AGENT_ID = process.env.AGENT_FS_E2E_AGENT_ID;
|
||||
const CLI = process.env.LH_CLI_PATH || 'LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts';
|
||||
const TIMEOUT = 30_000;
|
||||
|
||||
function run(args: string): string {
|
||||
return execSync(`${CLI} ${args}`, {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, PATH: `${process.env.HOME}/.bun/bin:${process.env.PATH}` },
|
||||
timeout: TIMEOUT,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
describe.skipIf(!AGENT_ID)('lh agent space fs unified VFS - manual E2E', () => {
|
||||
const testRoot = `agent:/vfs-cli-e2e-${Date.now()}`;
|
||||
|
||||
it('exercises root, mounted namespaces, writes, copy, move, trash, and cleanup', () => {
|
||||
const root = run(`agent space fs ls --agent-id ${AGENT_ID} agent:/`);
|
||||
expect(root).toContain('lobe/');
|
||||
|
||||
const mountedRoot = run(`agent space fs ls --agent-id ${AGENT_ID} agent:/lobe/skills`);
|
||||
expect(mountedRoot).toContain('builtin/');
|
||||
expect(mountedRoot).toContain('agent/');
|
||||
|
||||
try {
|
||||
expect(run(`agent space fs mkdir --agent-id ${AGENT_ID} --parents ${testRoot}`)).toContain(
|
||||
'created',
|
||||
);
|
||||
expect(
|
||||
run(
|
||||
`agent space fs write --agent-id ${AGENT_ID} --content "# VFS E2E" ${testRoot}/source.md`,
|
||||
),
|
||||
).toContain('created');
|
||||
expect(run(`agent space fs cat --agent-id ${AGENT_ID} ${testRoot}/source.md`)).toContain(
|
||||
'# VFS E2E',
|
||||
);
|
||||
expect(
|
||||
run(`agent space fs cp --agent-id ${AGENT_ID} ${testRoot}/source.md ${testRoot}/copied.md`),
|
||||
).toContain('copied');
|
||||
expect(
|
||||
run(`agent space fs mv --agent-id ${AGENT_ID} ${testRoot}/copied.md ${testRoot}/moved.md`),
|
||||
).toContain('moved');
|
||||
expect(run(`agent space fs rm --agent-id ${AGENT_ID} --yes ${testRoot}/moved.md`)).toContain(
|
||||
'deleted',
|
||||
);
|
||||
expect(run(`agent space fs trash ls --agent-id ${AGENT_ID} ${testRoot}`)).toContain(
|
||||
`${testRoot}/moved.md`,
|
||||
);
|
||||
expect(
|
||||
run(`agent space fs trash restore --agent-id ${AGENT_ID} ${testRoot}/moved.md`),
|
||||
).toContain('restored');
|
||||
} finally {
|
||||
try {
|
||||
run(`agent space fs rm --agent-id ${AGENT_ID} --yes --recursive ${testRoot}`);
|
||||
} catch {
|
||||
// Cleanup is best-effort because earlier assertions may fail before creation.
|
||||
}
|
||||
|
||||
try {
|
||||
run(`agent space fs trash rm --agent-id ${AGENT_ID} --yes --recursive --force ${testRoot}`);
|
||||
} catch {
|
||||
// Cleanup is best-effort because the trash entry may not exist.
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
|
||||
.\" Manual command details come from the Commander command tree.
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.8" "User Commands"
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.11" "User Commands"
|
||||
.SH NAME
|
||||
lh \- LobeHub CLI \- manage and connect to LobeHub services
|
||||
.SH SYNOPSIS
|
||||
@@ -77,6 +77,9 @@ Generate content (text, image, video, speech) Alias: gen.
|
||||
.B file
|
||||
Manage files
|
||||
.TP
|
||||
.B hetero
|
||||
Run heterogeneous agent CLIs (Claude Code / Codex) and stream their output
|
||||
.TP
|
||||
.B skill
|
||||
Manage agent skills
|
||||
.TP
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.8",
|
||||
"version": "0.0.11",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
@@ -30,11 +30,13 @@
|
||||
"devDependencies": {
|
||||
"@lobechat/agent-gateway-client": "workspace:*",
|
||||
"@lobechat/device-gateway-client": "workspace:*",
|
||||
"@lobechat/heterogeneous-agents": "workspace:*",
|
||||
"@lobechat/local-file-shell": "workspace:*",
|
||||
"@trpc/client": "^11.8.1",
|
||||
"@types/node": "^22.13.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"commander": "^13.1.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"debug": "^4.4.0",
|
||||
"diff": "^8.0.3",
|
||||
"fast-glob": "^3.3.3",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
packages:
|
||||
- '../../packages/agent-gateway-client'
|
||||
- '../../packages/device-gateway-client'
|
||||
- '../../packages/heterogeneous-agents'
|
||||
- '../../packages/local-file-shell'
|
||||
- '../../packages/types'
|
||||
- '../../packages/model-bank'
|
||||
- '../../packages/business/const'
|
||||
- '../../packages/file-loaders'
|
||||
- '.'
|
||||
|
||||
@@ -7,12 +7,14 @@ const CLIENT_ID = 'lobehub-cli';
|
||||
* Get a valid access token, refreshing if expired.
|
||||
* Returns null if no credentials or refresh fails.
|
||||
*/
|
||||
export async function getValidToken(): Promise<{ credentials: StoredCredentials } | null> {
|
||||
export async function getValidToken(
|
||||
bufferSeconds = 60,
|
||||
): Promise<{ credentials: StoredCredentials } | null> {
|
||||
const credentials = loadCredentials();
|
||||
if (!credentials) return null;
|
||||
|
||||
// Check if token is still valid (with 60s buffer)
|
||||
if (credentials.expiresAt && Date.now() / 1000 < credentials.expiresAt - 60) {
|
||||
// Check if token is still valid (with configurable buffer)
|
||||
if (credentials.expiresAt && Date.now() / 1000 < credentials.expiresAt - bufferSeconds) {
|
||||
return { credentials };
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,24 @@ const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
updateAgentConfig: { mutate: vi.fn() },
|
||||
updateAgentPinned: { mutate: vi.fn() },
|
||||
},
|
||||
agentDocument: {
|
||||
copyDocumentByPath: { mutate: vi.fn() },
|
||||
deleteDocumentByPath: { mutate: vi.fn() },
|
||||
deleteDocumentPermanentlyByPath: { mutate: vi.fn() },
|
||||
statDocumentByPath: { query: vi.fn() },
|
||||
listDocumentsByPath: { query: vi.fn() },
|
||||
listTrashDocumentsByPath: { query: vi.fn() },
|
||||
mkdirDocumentByPath: { mutate: vi.fn() },
|
||||
readDocumentByPath: { query: vi.fn() },
|
||||
renameDocumentByPath: { mutate: vi.fn() },
|
||||
restoreDocumentFromTrashByPath: { mutate: vi.fn() },
|
||||
writeDocumentByPath: { mutate: vi.fn() },
|
||||
},
|
||||
agentSkills: {
|
||||
createSkill: { mutate: vi.fn() },
|
||||
deleteSkill: { mutate: vi.fn() },
|
||||
updateSkill: { mutate: vi.fn() },
|
||||
},
|
||||
aiAgent: {
|
||||
execAgent: { mutate: vi.fn() },
|
||||
getOperationStatus: { query: vi.fn() },
|
||||
@@ -41,6 +59,11 @@ const { mockStreamAgentEvents } = vi.hoisted(() => ({
|
||||
mockStreamAgentEvents: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockReplayAgentEvents, mockStreamAgentEventsViaWebSocket } = vi.hoisted(() => ({
|
||||
mockReplayAgentEvents: vi.fn(),
|
||||
mockStreamAgentEventsViaWebSocket: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockGetAgentStreamAuthInfo } = vi.hoisted(() => ({
|
||||
mockGetAgentStreamAuthInfo: vi.fn(),
|
||||
}));
|
||||
@@ -49,9 +72,18 @@ const { mockResolveLocalDeviceId } = vi.hoisted(() => ({
|
||||
mockResolveLocalDeviceId: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockReadStdinText } = vi.hoisted(() => ({
|
||||
mockReadStdinText: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:stream/consumers', () => ({ text: mockReadStdinText }));
|
||||
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
|
||||
vi.mock('../api/http', () => ({ getAgentStreamAuthInfo: mockGetAgentStreamAuthInfo }));
|
||||
vi.mock('../utils/agentStream', () => ({ streamAgentEvents: mockStreamAgentEvents }));
|
||||
vi.mock('../utils/agentStream', () => ({
|
||||
replayAgentEvents: mockReplayAgentEvents,
|
||||
streamAgentEvents: mockStreamAgentEvents,
|
||||
streamAgentEventsViaWebSocket: mockStreamAgentEventsViaWebSocket,
|
||||
}));
|
||||
vi.mock('../utils/device', () => ({ resolveLocalDeviceId: mockResolveLocalDeviceId }));
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: { debug: vi.fn(), error: vi.fn(), heartbeat: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
@@ -71,12 +103,26 @@ describe('agent command', () => {
|
||||
serverUrl: 'https://example.com',
|
||||
});
|
||||
mockStreamAgentEvents.mockResolvedValue(undefined);
|
||||
mockReplayAgentEvents.mockReset();
|
||||
mockStreamAgentEventsViaWebSocket.mockReset();
|
||||
mockStreamAgentEventsViaWebSocket.mockResolvedValue(undefined);
|
||||
mockResolveLocalDeviceId.mockReset();
|
||||
mockReadStdinText.mockReset();
|
||||
for (const method of Object.values(mockTrpcClient.agent)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
for (const method of Object.values(mockTrpcClient.agentDocument)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
for (const method of Object.values(mockTrpcClient.agentSkills)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
for (const method of Object.values(mockTrpcClient.aiAgent)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
@@ -282,7 +328,7 @@ describe('agent command', () => {
|
||||
});
|
||||
|
||||
describe('run', () => {
|
||||
it('should exec agent and connect to SSE stream', async () => {
|
||||
it('should exec agent and connect to the gateway WebSocket stream by default', async () => {
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-123',
|
||||
success: true,
|
||||
@@ -304,11 +350,45 @@ describe('agent command', () => {
|
||||
expect(mockTrpcClient.aiAgent.execAgent.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1', prompt: 'Hello' }),
|
||||
);
|
||||
expect(mockStreamAgentEventsViaWebSocket).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gatewayUrl: expect.any(String),
|
||||
json: undefined,
|
||||
operationId: 'op-123',
|
||||
serverUrl: 'https://example.com',
|
||||
token: undefined,
|
||||
tokenType: undefined,
|
||||
verbose: undefined,
|
||||
}),
|
||||
);
|
||||
expect(mockStreamAgentEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fall back to SSE when --sse is provided', async () => {
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-sse',
|
||||
success: true,
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hello',
|
||||
'--sse',
|
||||
]);
|
||||
|
||||
expect(mockStreamAgentEvents).toHaveBeenCalledWith(
|
||||
'https://example.com/api/agent/stream?operationId=op-123',
|
||||
'https://example.com/api/agent/stream?operationId=op-sse',
|
||||
expect.objectContaining({ 'Oidc-Auth': 'test-token' }),
|
||||
expect.objectContaining({ json: undefined, verbose: undefined }),
|
||||
);
|
||||
expect(mockStreamAgentEventsViaWebSocket).not.toHaveBeenCalled();
|
||||
});
|
||||
it('should support --slug option', async () => {
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
@@ -595,10 +675,8 @@ describe('agent command', () => {
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(mockStreamAgentEvents).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ json: true }),
|
||||
expect(mockStreamAgentEventsViaWebSocket).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ json: true, operationId: 'op-j' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -794,4 +872,540 @@ describe('agent command', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fs', () => {
|
||||
it('should list VFS entries from the unified agent root alias', async () => {
|
||||
mockTrpcClient.agentDocument.listDocumentsByPath.query.mockResolvedValue([
|
||||
{
|
||||
mode: 8,
|
||||
mount: { driver: 'synthetic', source: 'virtual' },
|
||||
name: 'writer',
|
||||
path: './lobe',
|
||||
type: 'directory',
|
||||
},
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'ls',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'agent:/',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.listDocumentsByPath.query).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
cursor: undefined,
|
||||
limit: undefined,
|
||||
path: './',
|
||||
topicId: undefined,
|
||||
});
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify(
|
||||
[
|
||||
{
|
||||
mode: 8,
|
||||
mount: { driver: 'synthetic', source: 'virtual' },
|
||||
name: 'writer',
|
||||
path: './lobe',
|
||||
type: 'directory',
|
||||
},
|
||||
],
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass pagination options to VFS ls', async () => {
|
||||
mockTrpcClient.agentDocument.listDocumentsByPath.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'ls',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--cursor',
|
||||
'100',
|
||||
'--limit',
|
||||
'25',
|
||||
'agent:/notes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.listDocumentsByPath.query).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
cursor: '100',
|
||||
limit: 25,
|
||||
path: './notes',
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should print unix-like long listings with ls -la', async () => {
|
||||
mockTrpcClient.agentDocument.listDocumentsByPath.query.mockResolvedValue([
|
||||
{
|
||||
mode: 14,
|
||||
name: '.config',
|
||||
path: './notes/.config',
|
||||
size: 0,
|
||||
type: 'directory',
|
||||
updatedAt: '2026-04-27T07:18:00',
|
||||
},
|
||||
{
|
||||
mode: 6,
|
||||
name: 'SOUL.md',
|
||||
path: './notes/SOUL.md',
|
||||
size: 399,
|
||||
type: 'file',
|
||||
updatedAt: '2026-04-27T07:19:00',
|
||||
},
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'ls',
|
||||
'-la',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'agent:/notes',
|
||||
]);
|
||||
|
||||
expect(consoleSpy).toHaveBeenNthCalledWith(1, 'total 1');
|
||||
expect(consoleSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.stringMatching(/^dr-x------ {2}1 agent {2}agent {4}0 --- -- --:-- \.$/),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.stringMatching(/^dr-x------ {2}1 agent {2}agent {4}0 --- -- --:-- \.\.$/),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
expect.stringMatching(/^drwx------ {2}1 agent {2}agent {4}0 Apr 27 07:18 \.config\/$/),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
expect.stringMatching(/^-rw------- {2}1 agent {2}agent {2}399 Apr 27 07:19 SOUL\.md$/),
|
||||
);
|
||||
});
|
||||
|
||||
it('should expose VFS commands through agent space fs', async () => {
|
||||
mockTrpcClient.agentDocument.listDocumentsByPath.query.mockResolvedValue([]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'ls',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'agent:/notes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.listDocumentsByPath.query).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
cursor: undefined,
|
||||
limit: undefined,
|
||||
path: './notes',
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should collect tree traversal warnings instead of failing the whole tree', async () => {
|
||||
mockTrpcClient.agentDocument.listDocumentsByPath.query
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
mode: 8,
|
||||
name: 'builtin',
|
||||
path: './lobe/skills/builtin',
|
||||
type: 'directory',
|
||||
},
|
||||
])
|
||||
.mockRejectedValueOnce(new Error('Failed to list builtin skills'));
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'tree',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'agent:/lobe/skills',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.listDocumentsByPath.query).toHaveBeenNthCalledWith(1, {
|
||||
agentId: 'a1',
|
||||
path: './lobe/skills',
|
||||
topicId: undefined,
|
||||
});
|
||||
expect(mockTrpcClient.agentDocument.listDocumentsByPath.query).toHaveBeenNthCalledWith(2, {
|
||||
agentId: 'a1',
|
||||
path: './lobe/skills/builtin',
|
||||
topicId: undefined,
|
||||
});
|
||||
expect(log.warn).toHaveBeenCalledWith('./lobe/skills/builtin: Failed to list builtin skills');
|
||||
});
|
||||
|
||||
it('should read SKILL.md when cat targets a skill directory alias', async () => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
||||
mockTrpcClient.agentDocument.statDocumentByPath.query.mockResolvedValue({
|
||||
content: '# Writer',
|
||||
mode: 2,
|
||||
mount: { driver: 'skills', namespace: 'builtin', source: 'builtin' },
|
||||
name: 'SKILL.md',
|
||||
path: './lobe/skills/builtin/skills/writer/SKILL.md',
|
||||
type: 'file',
|
||||
});
|
||||
mockTrpcClient.agentDocument.readDocumentByPath.query.mockResolvedValue({
|
||||
content: '# Writer',
|
||||
contentType: 'text/markdown',
|
||||
path: './lobe/skills/builtin/skills/writer/SKILL.md',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'cat',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'builtin:/writer',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.statDocumentByPath.query).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
path: './lobe/skills/builtin/skills/writer/SKILL.md',
|
||||
topicId: undefined,
|
||||
});
|
||||
expect(mockTrpcClient.agentDocument.readDocumentByPath.query).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
path: './lobe/skills/builtin/skills/writer/SKILL.md',
|
||||
topicId: undefined,
|
||||
});
|
||||
expect(stdoutSpy).toHaveBeenCalledWith('# Writer');
|
||||
stdoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should create a writable skill through touch when the path does not exist', async () => {
|
||||
mockTrpcClient.agentDocument.statDocumentByPath.query.mockRejectedValue({
|
||||
data: { code: 'NOT_FOUND' },
|
||||
});
|
||||
mockTrpcClient.agentDocument.writeDocumentByPath.mutate.mockResolvedValue({
|
||||
path: './lobe/skills/agent/skills/writer/SKILL.md',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'touch',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'skills:/writer',
|
||||
'--content',
|
||||
'# Writer',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.writeDocumentByPath.mutate).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
content: '# Writer',
|
||||
createMode: 'if-missing',
|
||||
path: './lobe/skills/agent/skills/writer',
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should read write content from stdin when no content option is provided', async () => {
|
||||
const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
|
||||
Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: false });
|
||||
mockReadStdinText.mockResolvedValue('# Piped Content');
|
||||
mockTrpcClient.agentDocument.statDocumentByPath.query.mockRejectedValue({
|
||||
data: { code: 'NOT_FOUND' },
|
||||
});
|
||||
mockTrpcClient.agentDocument.writeDocumentByPath.mutate.mockResolvedValue({
|
||||
path: './notes/piped.md',
|
||||
});
|
||||
|
||||
try {
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'write',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'agent:/notes/piped.md',
|
||||
]);
|
||||
|
||||
expect(mockReadStdinText).toHaveBeenCalledWith(process.stdin);
|
||||
expect(mockTrpcClient.agentDocument.writeDocumentByPath.mutate).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
content: '# Piped Content',
|
||||
createMode: 'if-missing',
|
||||
path: './notes/piped.md',
|
||||
topicId: undefined,
|
||||
});
|
||||
} finally {
|
||||
if (stdinDescriptor) {
|
||||
Object.defineProperty(process.stdin, 'isTTY', stdinDescriptor);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should create directories through the generic mkdir path API', async () => {
|
||||
mockTrpcClient.agentDocument.mkdirDocumentByPath.mutate.mockResolvedValue({
|
||||
path: './notes/archive',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'mkdir',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--parents',
|
||||
'agent:/notes/archive',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.mkdirDocumentByPath.mutate).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
path: './notes/archive',
|
||||
recursive: true,
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should stat unified root paths', async () => {
|
||||
mockTrpcClient.agentDocument.statDocumentByPath.query.mockResolvedValue({
|
||||
mode: 8,
|
||||
name: 'lobe',
|
||||
path: './lobe',
|
||||
type: 'directory',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'stat',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'agent:/lobe',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.statDocumentByPath.query).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
path: './lobe',
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should copy paths through the generic copyDocumentByPath API', async () => {
|
||||
mockTrpcClient.agentDocument.copyDocumentByPath.mutate.mockResolvedValue({
|
||||
path: './notes/published.md',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'cp',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--force',
|
||||
'agent:/notes/draft.md',
|
||||
'agent:/notes/published.md',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.copyDocumentByPath.mutate).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
force: true,
|
||||
fromPath: './notes/draft.md',
|
||||
toPath: './notes/published.md',
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should rename paths through the generic renameDocumentByPath API', async () => {
|
||||
mockTrpcClient.agentDocument.renameDocumentByPath.mutate.mockResolvedValue({
|
||||
path: './notes/final.md',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'mv',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'agent:/notes/draft.md',
|
||||
'agent:/notes/final.md',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.renameDocumentByPath.mutate).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
force: undefined,
|
||||
fromPath: './notes/draft.md',
|
||||
toPath: './notes/final.md',
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should soft-delete paths through the generic deleteDocumentByPath API', async () => {
|
||||
mockTrpcClient.agentDocument.deleteDocumentByPath.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'rm',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--yes',
|
||||
'--recursive',
|
||||
'agent:/notes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.deleteDocumentByPath.mutate).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
force: undefined,
|
||||
path: './notes',
|
||||
recursive: true,
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should list trash through the generic trash path API', async () => {
|
||||
mockTrpcClient.agentDocument.listTrashDocumentsByPath.query.mockResolvedValue([
|
||||
{ path: './notes/deleted.md' },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'trash',
|
||||
'ls',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'agent:/notes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.listTrashDocumentsByPath.query).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
path: './notes',
|
||||
topicId: undefined,
|
||||
});
|
||||
expect(consoleSpy).toHaveBeenCalledWith('agent:/notes/deleted.md');
|
||||
});
|
||||
|
||||
it('should restore trash entries through the generic trash restore API', async () => {
|
||||
mockTrpcClient.agentDocument.restoreDocumentFromTrashByPath.mutate.mockResolvedValue({
|
||||
path: './notes/deleted.md',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'trash',
|
||||
'restore',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'agent:/notes/deleted.md',
|
||||
]);
|
||||
|
||||
expect(
|
||||
mockTrpcClient.agentDocument.restoreDocumentFromTrashByPath.mutate,
|
||||
).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
path: './notes/deleted.md',
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should permanently delete trash entries through the generic trash rm API', async () => {
|
||||
mockTrpcClient.agentDocument.deleteDocumentPermanentlyByPath.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'space',
|
||||
'fs',
|
||||
'trash',
|
||||
'rm',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--yes',
|
||||
'--force',
|
||||
'agent:/notes/deleted.md',
|
||||
]);
|
||||
|
||||
expect(
|
||||
mockTrpcClient.agentDocument.deleteDocumentPermanentlyByPath.mutate,
|
||||
).toHaveBeenCalledWith({
|
||||
agentId: 'a1',
|
||||
force: true,
|
||||
path: './notes/deleted.md',
|
||||
recursive: undefined,
|
||||
topicId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,33 +14,12 @@ import {
|
||||
import { resolveLocalDeviceId } from '../utils/device';
|
||||
import { confirm, outputJson, printTable, truncate } from '../utils/format';
|
||||
import { log, setVerbose } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Resolve an agent identifier (agentId or slug) to a concrete agentId.
|
||||
* When a slug is provided, uses getBuiltinAgent to look up the agent.
|
||||
*/
|
||||
async function resolveAgentId(
|
||||
client: any,
|
||||
opts: { agentId?: string; slug?: string },
|
||||
): Promise<string> {
|
||||
if (opts.agentId) return opts.agentId;
|
||||
|
||||
if (opts.slug) {
|
||||
const agent = await client.agent.getBuiltinAgent.query({ slug: opts.slug });
|
||||
if (!agent) {
|
||||
log.error(`Agent not found for slug: ${opts.slug}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return (agent as any).id || (agent as any).agentId;
|
||||
}
|
||||
|
||||
log.error('Either <agentId> or --slug is required.');
|
||||
process.exit(1);
|
||||
return ''; // unreachable
|
||||
}
|
||||
import { resolveAgentId } from './agent/resolveAgentId';
|
||||
import { registerAgentSpaceFsCommand } from './agent/spaceFs';
|
||||
|
||||
export function registerAgentCommand(program: Command) {
|
||||
const agent = program.command('agent').description('Manage agents');
|
||||
registerAgentSpaceFsCommand(agent);
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { log } from '../../utils/logger';
|
||||
|
||||
interface AgentLookupClient {
|
||||
agent: {
|
||||
getBuiltinAgent: {
|
||||
query: (input: { slug: string }) => Promise<{ agentId?: string; id?: string } | null>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an agent identifier into a concrete agent id.
|
||||
*
|
||||
* Use when:
|
||||
* - A command accepts either a positional agent id or `--slug`.
|
||||
* - Downstream tRPC calls require the concrete agent id.
|
||||
*
|
||||
* Expects:
|
||||
* - `opts.agentId` to win over `opts.slug`.
|
||||
* - `client.agent.getBuiltinAgent` to resolve slugs when needed.
|
||||
*
|
||||
* Returns:
|
||||
* - The resolved agent id, or exits the process after logging a CLI-facing error.
|
||||
*/
|
||||
export async function resolveAgentId(
|
||||
client: AgentLookupClient,
|
||||
opts: { agentId?: string; slug?: string },
|
||||
): Promise<string> {
|
||||
if (opts.agentId) return opts.agentId;
|
||||
|
||||
if (opts.slug) {
|
||||
const agent = await client.agent.getBuiltinAgent.query({ slug: opts.slug });
|
||||
if (!agent) {
|
||||
log.error(`Agent not found for slug: ${opts.slug}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return agent.id || agent.agentId || '';
|
||||
}
|
||||
|
||||
log.error('Either <agentId> or --slug is required.');
|
||||
process.exit(1);
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,908 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { text } from 'node:stream/consumers';
|
||||
|
||||
import type { Command } from 'commander';
|
||||
import dayjs from 'dayjs';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { confirm, outputJson } from '../../utils/format';
|
||||
import { log } from '../../utils/logger';
|
||||
import { resolveAgentId } from './resolveAgentId';
|
||||
|
||||
const SKILL_FILE_NAME = 'SKILL.md';
|
||||
|
||||
const SKILL_NAMESPACE_PREFIXES = {
|
||||
'agent': './lobe/skills/agent/skills',
|
||||
'builtin': './lobe/skills/builtin/skills',
|
||||
'installed-active': './lobe/skills/installed/active/skills',
|
||||
'installed-all': './lobe/skills/installed/all/skills',
|
||||
} as const;
|
||||
|
||||
const FS_PATH_ALIASES = {
|
||||
'agent': './',
|
||||
'builtin': 'builtin',
|
||||
'skills': 'agent',
|
||||
'installed-active': 'installed-active',
|
||||
'installed-all': 'installed-all',
|
||||
} as const;
|
||||
|
||||
type SkillFsNamespace = keyof typeof SKILL_NAMESPACE_PREFIXES;
|
||||
type AgentFsClient = Awaited<ReturnType<typeof getTrpcClient>>;
|
||||
|
||||
interface AgentFsContext {
|
||||
agentId: string;
|
||||
topicId?: string;
|
||||
}
|
||||
|
||||
interface AgentFsNode {
|
||||
content?: string;
|
||||
createdAt?: Date | string;
|
||||
mode?: number;
|
||||
mount?: {
|
||||
driver?: string;
|
||||
namespace?: string;
|
||||
};
|
||||
name: string;
|
||||
path: string;
|
||||
size?: number;
|
||||
type: 'directory' | 'file';
|
||||
updatedAt?: Date | string;
|
||||
}
|
||||
|
||||
interface AgentFsResolvedPath {
|
||||
filePath?: string;
|
||||
namespace?: SkillFsNamespace;
|
||||
path: string;
|
||||
skillName?: string;
|
||||
}
|
||||
|
||||
interface AgentFsOptions {
|
||||
agentId?: string;
|
||||
slug?: string;
|
||||
topicId?: string;
|
||||
}
|
||||
|
||||
function getTrpcErrorCode(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== 'object') return undefined;
|
||||
|
||||
const value = error as {
|
||||
data?: { code?: string };
|
||||
shape?: { data?: { code?: string } };
|
||||
};
|
||||
|
||||
return value.data?.code ?? value.shape?.data?.code;
|
||||
}
|
||||
|
||||
function exitWithError(message: string): never {
|
||||
log.error(message);
|
||||
process.exit(1);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function resolveAgentFsPath(input = 'agent:/'): AgentFsResolvedPath {
|
||||
const raw = input.trim();
|
||||
|
||||
const aliasMatch = raw.match(/^([a-z-]+):(\/.*)?$/);
|
||||
|
||||
if (aliasMatch) {
|
||||
const alias = aliasMatch[1] as keyof typeof FS_PATH_ALIASES;
|
||||
const target = FS_PATH_ALIASES[alias];
|
||||
|
||||
if (!target) {
|
||||
exitWithError(
|
||||
`Unknown fs namespace "${aliasMatch[1]}". Use agent, skills, builtin, installed-all, or installed-active.`,
|
||||
);
|
||||
}
|
||||
|
||||
const suffix = aliasMatch[2]?.replace(/^\/+/, '').replace(/\/+$/, '') ?? '';
|
||||
const prefix = target === './' ? './' : SKILL_NAMESPACE_PREFIXES[target as SkillFsNamespace];
|
||||
|
||||
return resolveAgentFsPath(suffix ? `${prefix}/${suffix}` : prefix);
|
||||
}
|
||||
|
||||
if (raw === './' || raw === '.' || raw === '/') {
|
||||
return { path: './' };
|
||||
}
|
||||
|
||||
const match = Object.entries(SKILL_NAMESPACE_PREFIXES).find(([, prefix]) => {
|
||||
return raw === prefix || raw.startsWith(`${prefix}/`);
|
||||
});
|
||||
|
||||
if (!match) {
|
||||
if (!raw.startsWith('./')) {
|
||||
exitWithError(`Invalid fs path "${input}". Use aliases like "agent:/" or a full VFS path.`);
|
||||
}
|
||||
|
||||
const normalizedPath = raw.replaceAll(/\/+/g, '/').replace(/\/+$/, '') || './';
|
||||
return { path: normalizedPath };
|
||||
}
|
||||
|
||||
const [namespace, prefix] = match as [
|
||||
SkillFsNamespace,
|
||||
(typeof SKILL_NAMESPACE_PREFIXES)[SkillFsNamespace],
|
||||
];
|
||||
const relativePath = raw.slice(prefix.length).replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
|
||||
if (
|
||||
relativePath.includes('//') ||
|
||||
relativePath.split('/').some((segment) => segment === '.' || segment === '..')
|
||||
) {
|
||||
exitWithError(`Invalid fs path "${input}"`);
|
||||
}
|
||||
|
||||
if (!relativePath) {
|
||||
return { namespace, path: prefix };
|
||||
}
|
||||
|
||||
const separatorIndex = relativePath.indexOf('/');
|
||||
|
||||
if (separatorIndex < 0) {
|
||||
return {
|
||||
namespace,
|
||||
path: `${prefix}/${relativePath}`,
|
||||
skillName: relativePath,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
filePath: relativePath.slice(separatorIndex + 1),
|
||||
namespace,
|
||||
path: `${prefix}/${relativePath}`,
|
||||
skillName: relativePath.slice(0, separatorIndex),
|
||||
};
|
||||
}
|
||||
|
||||
function requireSkillNamespace(resolved: AgentFsResolvedPath): SkillFsNamespace {
|
||||
if (!resolved.namespace) {
|
||||
exitWithError(`Expected a skill namespace path, but received "${resolved.path}".`);
|
||||
}
|
||||
|
||||
return resolved.namespace;
|
||||
}
|
||||
|
||||
function canonicalSkillFilePath(resolved: AgentFsResolvedPath) {
|
||||
if (!resolved.skillName) {
|
||||
exitWithError('Expected a skill path, but received a namespace root.');
|
||||
}
|
||||
|
||||
if (resolved.filePath && resolved.filePath !== SKILL_FILE_NAME) {
|
||||
exitWithError(`Unsupported writable path "${resolved.path}". Only SKILL.md is mutable.`);
|
||||
}
|
||||
|
||||
return `${SKILL_NAMESPACE_PREFIXES[requireSkillNamespace(resolved)]}/${resolved.skillName}/${SKILL_FILE_NAME}`;
|
||||
}
|
||||
|
||||
function toDisplayPath(path: string) {
|
||||
if (path === './') return 'agent:/';
|
||||
if (path.startsWith('./') && path !== './lobe' && !path.startsWith('./lobe/')) {
|
||||
return `agent:/${path.slice(2)}`;
|
||||
}
|
||||
|
||||
for (const [namespace, prefix] of Object.entries(SKILL_NAMESPACE_PREFIXES) as Array<
|
||||
[SkillFsNamespace, string]
|
||||
>) {
|
||||
const alias = namespace === 'agent' ? 'skills' : namespace;
|
||||
if (path === prefix) return `${alias}:/`;
|
||||
if (path.startsWith(`${prefix}/`)) return `${alias}:/${path.slice(prefix.length + 1)}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
function isWritableNode(node: { mode?: number }) {
|
||||
return ((node.mode ?? 0) & 4) !== 0;
|
||||
}
|
||||
|
||||
function parseOptionalPositiveInteger(value?: string) {
|
||||
if (value === undefined) return undefined;
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
exitWithError(`Expected a positive integer, received "${value}".`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function formatFsNodeName(node: { mode?: number; name: string; type: 'directory' | 'file' }) {
|
||||
const suffix = node.type === 'directory' ? '/' : '';
|
||||
return isWritableNode(node) ? `${node.name}${suffix}` : pc.dim(`${node.name}${suffix}`);
|
||||
}
|
||||
|
||||
function getFsNodeDisplayName(node: Pick<AgentFsNode, 'name' | 'type'>) {
|
||||
if (node.name === '.' || node.name === '..') return node.name;
|
||||
|
||||
return `${node.name}${node.type === 'directory' ? '/' : ''}`;
|
||||
}
|
||||
|
||||
function getParentFsPath(path: string) {
|
||||
if (path === './') return './';
|
||||
|
||||
const segments = path.replace(/^\.\//, '').split('/').filter(Boolean);
|
||||
if (segments.length <= 1) return './';
|
||||
|
||||
return `./${segments.slice(0, -1).join('/')}`;
|
||||
}
|
||||
|
||||
function createSyntheticListingNode(name: '.' | '..', path: string): AgentFsNode {
|
||||
return {
|
||||
mode: 10,
|
||||
name,
|
||||
path,
|
||||
size: 0,
|
||||
type: 'directory',
|
||||
};
|
||||
}
|
||||
|
||||
function formatFsPermissions(node: Pick<AgentFsNode, 'mode' | 'type'>) {
|
||||
const mode = node.mode ?? 0;
|
||||
const canRead = (mode & 2) !== 0 || (mode & 8) !== 0;
|
||||
const canWrite = (mode & 4) !== 0;
|
||||
const canExecute = (mode & 1) !== 0 || (node.type === 'directory' && (mode & 8) !== 0);
|
||||
const owner = `${canRead ? 'r' : '-'}${canWrite ? 'w' : '-'}${canExecute ? 'x' : '-'}`;
|
||||
|
||||
return `${node.type === 'directory' ? 'd' : '-'}${owner}------`;
|
||||
}
|
||||
|
||||
function formatFsLongDate(value?: Date | string) {
|
||||
if (!value) return '--- -- --:--';
|
||||
|
||||
const date = dayjs(value);
|
||||
if (!date.isValid()) return '--- -- --:--';
|
||||
|
||||
return date.format('MMM DD HH:mm');
|
||||
}
|
||||
|
||||
function formatFsLongListing(nodes: AgentFsNode[]) {
|
||||
const sizeWidth = Math.max(1, ...nodes.map((node) => String(node.size ?? 0).length));
|
||||
const totalBlocks = nodes.reduce((total, node) => total + Math.ceil((node.size ?? 0) / 512), 0);
|
||||
const lines = [`total ${totalBlocks}`];
|
||||
|
||||
for (const node of nodes) {
|
||||
const size = String(node.size ?? 0).padStart(sizeWidth, ' ');
|
||||
const mtime = formatFsLongDate(node.updatedAt ?? node.createdAt);
|
||||
lines.push(
|
||||
`${formatFsPermissions(node)} 1 agent agent ${size} ${mtime} ${getFsNodeDisplayName(node)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
async function readFsContentInput(options: { content?: string; contentFile?: string }) {
|
||||
if (options.contentFile) {
|
||||
return readFileSync(options.contentFile, 'utf8');
|
||||
}
|
||||
|
||||
if (options.content !== undefined) return options.content;
|
||||
|
||||
// NOTICE:
|
||||
// CLI write commands should compose with shell pipelines without blocking interactive runs.
|
||||
// Node marks piped stdin with `isTTY === false`, while normal terminals are `true` or undefined in tests.
|
||||
// Remove this branch only if Commander gains first-class stdin option support for these commands.
|
||||
if (process.stdin.isTTY === false) return text(process.stdin);
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function resolveAgentFsContext(client: AgentFsClient, options: AgentFsOptions) {
|
||||
const agentId = await resolveAgentId(client, options);
|
||||
return { agentId, topicId: options.topicId };
|
||||
}
|
||||
|
||||
async function getFsNode(client: AgentFsClient, context: AgentFsContext, path: string) {
|
||||
try {
|
||||
return (await client.agentDocument.statDocumentByPath.query({
|
||||
agentId: context.agentId,
|
||||
path,
|
||||
topicId: context.topicId,
|
||||
})) as AgentFsNode;
|
||||
} catch (error) {
|
||||
if (getTrpcErrorCode(error) === 'NOT_FOUND') return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function readFsFile(client: AgentFsClient, context: AgentFsContext, inputPath: string) {
|
||||
const resolved = resolveAgentFsPath(inputPath);
|
||||
|
||||
const readPath =
|
||||
resolved.skillName && !resolved.filePath
|
||||
? `${SKILL_NAMESPACE_PREFIXES[requireSkillNamespace(resolved)]}/${resolved.skillName}/${SKILL_FILE_NAME}`
|
||||
: resolved.path;
|
||||
|
||||
const stat = await getFsNode(client, context, readPath);
|
||||
|
||||
if (!stat) {
|
||||
exitWithError(`Path not found: ${inputPath}`);
|
||||
}
|
||||
|
||||
if (stat.type !== 'file') {
|
||||
exitWithError(`Cannot read directory path: ${inputPath}`);
|
||||
}
|
||||
|
||||
const node = (await client.agentDocument.readDocumentByPath.query({
|
||||
agentId: context.agentId,
|
||||
path: readPath,
|
||||
topicId: context.topicId,
|
||||
})) as AgentFsNode;
|
||||
|
||||
return { node, resolved: resolveAgentFsPath(readPath) };
|
||||
}
|
||||
|
||||
async function writeFsFile(
|
||||
client: AgentFsClient,
|
||||
context: AgentFsContext,
|
||||
inputPath: string,
|
||||
content: string,
|
||||
) {
|
||||
const resolved = resolveAgentFsPath(inputPath);
|
||||
const existing = await getFsNode(
|
||||
client,
|
||||
context,
|
||||
resolved.skillName && !resolved.filePath ? canonicalSkillFilePath(resolved) : resolved.path,
|
||||
);
|
||||
const result = await client.agentDocument.writeDocumentByPath.mutate({
|
||||
agentId: context.agentId,
|
||||
content,
|
||||
createMode: existing ? 'must-exist' : 'if-missing',
|
||||
path: resolved.path,
|
||||
topicId: context.topicId,
|
||||
});
|
||||
|
||||
return {
|
||||
action: existing ? ('updated' as const) : ('created' as const),
|
||||
path: result?.path ?? resolved.path,
|
||||
};
|
||||
}
|
||||
|
||||
async function mkdirFsPath(
|
||||
client: AgentFsClient,
|
||||
context: AgentFsContext,
|
||||
inputPath: string,
|
||||
options?: { recursive?: boolean },
|
||||
) {
|
||||
const resolved = resolveAgentFsPath(inputPath);
|
||||
|
||||
return client.agentDocument.mkdirDocumentByPath.mutate({
|
||||
agentId: context.agentId,
|
||||
path: resolved.path,
|
||||
recursive: options?.recursive,
|
||||
topicId: context.topicId,
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteFsPath(
|
||||
client: AgentFsClient,
|
||||
context: AgentFsContext,
|
||||
inputPath: string,
|
||||
options?: { force?: boolean; recursive?: boolean },
|
||||
) {
|
||||
const resolved = resolveAgentFsPath(inputPath);
|
||||
|
||||
return client.agentDocument.deleteDocumentByPath.mutate({
|
||||
agentId: context.agentId,
|
||||
force: options?.force,
|
||||
path: resolved.path,
|
||||
recursive: options?.recursive,
|
||||
topicId: context.topicId,
|
||||
});
|
||||
}
|
||||
|
||||
async function copyFsPath(
|
||||
client: AgentFsClient,
|
||||
context: AgentFsContext,
|
||||
source: string,
|
||||
destination: string,
|
||||
force?: boolean,
|
||||
) {
|
||||
const sourceResolved = resolveAgentFsPath(source);
|
||||
const destinationResolved = resolveAgentFsPath(destination);
|
||||
|
||||
return client.agentDocument.copyDocumentByPath.mutate({
|
||||
agentId: context.agentId,
|
||||
force,
|
||||
fromPath: sourceResolved.path,
|
||||
toPath: destinationResolved.path,
|
||||
topicId: context.topicId,
|
||||
});
|
||||
}
|
||||
|
||||
async function renameFsPath(
|
||||
client: AgentFsClient,
|
||||
context: AgentFsContext,
|
||||
source: string,
|
||||
destination: string,
|
||||
force?: boolean,
|
||||
) {
|
||||
const sourceResolved = resolveAgentFsPath(source);
|
||||
const destinationResolved = resolveAgentFsPath(destination);
|
||||
|
||||
return client.agentDocument.renameDocumentByPath.mutate({
|
||||
agentId: context.agentId,
|
||||
force,
|
||||
fromPath: sourceResolved.path,
|
||||
toPath: destinationResolved.path,
|
||||
topicId: context.topicId,
|
||||
});
|
||||
}
|
||||
|
||||
async function listTrashFsPath(client: AgentFsClient, context: AgentFsContext, inputPath?: string) {
|
||||
const resolved = resolveAgentFsPath(inputPath || 'agent:/');
|
||||
|
||||
return (await client.agentDocument.listTrashDocumentsByPath.query({
|
||||
agentId: context.agentId,
|
||||
path: resolved.path,
|
||||
topicId: context.topicId,
|
||||
})) as Array<Pick<AgentFsNode, 'path'>>;
|
||||
}
|
||||
|
||||
async function restoreTrashFsPath(
|
||||
client: AgentFsClient,
|
||||
context: AgentFsContext,
|
||||
inputPath: string,
|
||||
) {
|
||||
const resolved = resolveAgentFsPath(inputPath);
|
||||
|
||||
return client.agentDocument.restoreDocumentFromTrashByPath.mutate({
|
||||
agentId: context.agentId,
|
||||
path: resolved.path,
|
||||
topicId: context.topicId,
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteTrashFsPath(
|
||||
client: AgentFsClient,
|
||||
context: AgentFsContext,
|
||||
inputPath: string,
|
||||
options?: { force?: boolean; recursive?: boolean },
|
||||
) {
|
||||
const resolved = resolveAgentFsPath(inputPath);
|
||||
|
||||
return client.agentDocument.deleteDocumentPermanentlyByPath.mutate({
|
||||
agentId: context.agentId,
|
||||
force: options?.force,
|
||||
path: resolved.path,
|
||||
recursive: options?.recursive,
|
||||
topicId: context.topicId,
|
||||
});
|
||||
}
|
||||
|
||||
async function printFsTree(
|
||||
client: AgentFsClient,
|
||||
context: AgentFsContext,
|
||||
path: string,
|
||||
prefix = '',
|
||||
warnings: string[] = [],
|
||||
) {
|
||||
let nodes: AgentFsNode[];
|
||||
|
||||
try {
|
||||
nodes = (await client.agentDocument.listDocumentsByPath.query({
|
||||
agentId: context.agentId,
|
||||
path,
|
||||
topicId: context.topicId,
|
||||
})) as AgentFsNode[];
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'failed to list path';
|
||||
warnings.push(`${toDisplayPath(path)}: ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [index, node] of nodes.entries()) {
|
||||
const last = index === nodes.length - 1;
|
||||
console.log(`${prefix}${last ? '└── ' : '├── '}${formatFsNodeName(node)}`);
|
||||
|
||||
if (node.type === 'directory') {
|
||||
await printFsTree(client, context, node.path, `${prefix}${last ? ' ' : '│ '}`, warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function registerFsCommands(fsCommand: Command) {
|
||||
fsCommand
|
||||
.command('ls [path]')
|
||||
.description('List VFS entries')
|
||||
.option('-a, --all', 'Include hidden entries')
|
||||
.option('-l, --long', 'Use long listing format')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('--cursor <cursor>', 'Directory pagination cursor')
|
||||
.option('-L, --limit <n>', 'Maximum number of entries')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(
|
||||
async (
|
||||
inputPath: string | undefined,
|
||||
options: {
|
||||
agentId?: string;
|
||||
all?: boolean;
|
||||
cursor?: string;
|
||||
json?: string | boolean;
|
||||
limit?: string;
|
||||
long?: boolean;
|
||||
slug?: string;
|
||||
topicId?: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
const resolved = resolveAgentFsPath(inputPath || 'agent:/');
|
||||
|
||||
const nodes = ((await client.agentDocument.listDocumentsByPath.query({
|
||||
agentId: context.agentId,
|
||||
cursor: options.cursor,
|
||||
limit: parseOptionalPositiveInteger(options.limit),
|
||||
path: resolved.path,
|
||||
topicId: context.topicId,
|
||||
})) ?? []) as AgentFsNode[];
|
||||
const filtered = options.all ? nodes : nodes.filter((node) => !node.name.startsWith('.'));
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(filtered, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.long) {
|
||||
const longNodes = options.all
|
||||
? [
|
||||
createSyntheticListingNode('.', resolved.path),
|
||||
createSyntheticListingNode('..', getParentFsPath(resolved.path)),
|
||||
...filtered,
|
||||
]
|
||||
: filtered;
|
||||
formatFsLongListing(longNodes).forEach((line) => console.log(line));
|
||||
return;
|
||||
}
|
||||
|
||||
filtered.forEach((node) => console.log(formatFsNodeName(node)));
|
||||
},
|
||||
);
|
||||
|
||||
fsCommand
|
||||
.command('tree [path]')
|
||||
.description('Print a tree view of the VFS')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.action(
|
||||
async (
|
||||
inputPath: string | undefined,
|
||||
options: { agentId?: string; slug?: string; topicId?: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
const resolved = resolveAgentFsPath(inputPath || 'agent:/');
|
||||
|
||||
console.log(pc.bold(toDisplayPath(resolved.path)));
|
||||
const warnings: string[] = [];
|
||||
await printFsTree(client, context, resolved.path, '', warnings);
|
||||
|
||||
for (const warning of warnings) {
|
||||
log.warn(warning);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
fsCommand
|
||||
.command('cat <path>')
|
||||
.description('Read a VFS file')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.action(
|
||||
async (inputPath: string, options: { agentId?: string; slug?: string; topicId?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
const { node } = await readFsFile(client, context, inputPath);
|
||||
process.stdout.write(node.content ?? '');
|
||||
},
|
||||
);
|
||||
|
||||
fsCommand
|
||||
.command('stat <path>')
|
||||
.description('Show VFS node metadata')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(
|
||||
async (
|
||||
inputPath: string,
|
||||
options: {
|
||||
agentId?: string;
|
||||
json?: string | boolean;
|
||||
slug?: string;
|
||||
topicId?: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
const resolved = resolveAgentFsPath(inputPath);
|
||||
|
||||
const node = await getFsNode(client, context, resolved.path);
|
||||
|
||||
if (!node) {
|
||||
exitWithError(`Path not found: ${inputPath}`);
|
||||
}
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(node, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(node, null, 2));
|
||||
},
|
||||
);
|
||||
|
||||
fsCommand
|
||||
.command('touch <path>')
|
||||
.description('Create or update a VFS file')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-c, --content <content>', 'File content')
|
||||
.option('-F, --content-file <path>', 'Read content from a local file')
|
||||
.action(
|
||||
async (
|
||||
inputPath: string,
|
||||
options: {
|
||||
agentId?: string;
|
||||
content?: string;
|
||||
contentFile?: string;
|
||||
slug?: string;
|
||||
topicId?: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
const content = await readFsContentInput(options);
|
||||
const result = await writeFsFile(client, context, inputPath, content);
|
||||
console.log(`${pc.green('✓')} ${result.action} ${pc.bold(toDisplayPath(result.path))}`);
|
||||
},
|
||||
);
|
||||
|
||||
fsCommand
|
||||
.command('write <path>')
|
||||
.description('Write content to a VFS file')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-c, --content <content>', 'File content')
|
||||
.option('-F, --content-file <path>', 'Read content from a local file')
|
||||
.action(
|
||||
async (
|
||||
inputPath: string,
|
||||
options: {
|
||||
agentId?: string;
|
||||
content?: string;
|
||||
contentFile?: string;
|
||||
slug?: string;
|
||||
topicId?: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
const content = await readFsContentInput(options);
|
||||
const result = await writeFsFile(client, context, inputPath, content);
|
||||
console.log(`${pc.green('✓')} ${result.action} ${pc.bold(toDisplayPath(result.path))}`);
|
||||
},
|
||||
);
|
||||
|
||||
fsCommand
|
||||
.command('mkdir <path>')
|
||||
.description('Create a VFS directory')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-p, --parents', 'Create parent directories as needed')
|
||||
.action(
|
||||
async (
|
||||
inputPath: string,
|
||||
options: { agentId?: string; parents?: boolean; slug?: string; topicId?: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
const result = await mkdirFsPath(client, context, inputPath, {
|
||||
recursive: options.parents,
|
||||
});
|
||||
console.log(
|
||||
`${pc.green('✓')} created ${pc.bold(toDisplayPath(result?.path ?? inputPath))}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
fsCommand
|
||||
.command('rm <path>')
|
||||
.description('Delete a VFS node into trash')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-r, --recursive', 'Recursively delete a directory subtree')
|
||||
.option('-f, --force', 'Forward force semantics to the VFS delete primitive')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(
|
||||
async (
|
||||
inputPath: string,
|
||||
options: {
|
||||
agentId?: string;
|
||||
force?: boolean;
|
||||
recursive?: boolean;
|
||||
slug?: string;
|
||||
topicId?: string;
|
||||
yes?: boolean;
|
||||
},
|
||||
) => {
|
||||
if (!options.yes) {
|
||||
const confirmed = await confirm(`Delete ${inputPath}?`);
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
await deleteFsPath(client, context, inputPath, {
|
||||
force: options.force,
|
||||
recursive: options.recursive,
|
||||
});
|
||||
console.log(`${pc.green('✓')} deleted ${pc.bold(inputPath)}`);
|
||||
},
|
||||
);
|
||||
|
||||
fsCommand
|
||||
.command('cp <source> <destination>')
|
||||
.description('Copy a VFS node')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-f, --force', 'Overwrite the destination if it exists')
|
||||
.action(
|
||||
async (
|
||||
source: string,
|
||||
destination: string,
|
||||
options: { agentId?: string; force?: boolean; slug?: string; topicId?: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
const result = await copyFsPath(client, context, source, destination, options.force);
|
||||
console.log(
|
||||
`${pc.green('✓')} copied ${pc.bold(source)} → ${pc.bold(toDisplayPath(result?.path ?? destination))}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
fsCommand
|
||||
.command('mv <source> <destination>')
|
||||
.description('Move or rename a VFS node')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-f, --force', 'Overwrite the destination if it exists')
|
||||
.action(
|
||||
async (
|
||||
source: string,
|
||||
destination: string,
|
||||
options: { agentId?: string; force?: boolean; slug?: string; topicId?: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
const sourceResolved = resolveAgentFsPath(source);
|
||||
const destinationResolved = resolveAgentFsPath(destination);
|
||||
|
||||
if (sourceResolved.path === destinationResolved.path) {
|
||||
console.log(`${pc.yellow('!')} source and destination are the same.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await renameFsPath(client, context, source, destination, options.force);
|
||||
console.log(
|
||||
`${pc.green('✓')} moved ${pc.bold(source)} → ${pc.bold(toDisplayPath(result?.path ?? destination))}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const trashCommand = fsCommand.command('trash').description('Operate on soft-deleted VFS nodes');
|
||||
|
||||
trashCommand
|
||||
.command('ls [path]')
|
||||
.description('List trashed VFS nodes')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(
|
||||
async (
|
||||
inputPath: string | undefined,
|
||||
options: {
|
||||
agentId?: string;
|
||||
json?: string | boolean;
|
||||
slug?: string;
|
||||
topicId?: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
const nodes = await listTrashFsPath(client, context, inputPath);
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(nodes, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
console.log('Trash is empty.');
|
||||
return;
|
||||
}
|
||||
|
||||
nodes.forEach((node) => console.log(toDisplayPath(node.path)));
|
||||
},
|
||||
);
|
||||
|
||||
trashCommand
|
||||
.command('restore <path>')
|
||||
.description('Restore a soft-deleted VFS node')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.action(
|
||||
async (inputPath: string, options: { agentId?: string; slug?: string; topicId?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
const result = await restoreTrashFsPath(client, context, inputPath);
|
||||
console.log(
|
||||
`${pc.green('✓')} restored ${pc.bold(toDisplayPath(result?.path ?? inputPath))}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
trashCommand
|
||||
.command('rm <path>')
|
||||
.description('Permanently delete a trashed VFS node')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-r, --recursive', 'Recursively delete a directory subtree')
|
||||
.option('-f, --force', 'Forward force semantics to the permanent delete primitive')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(
|
||||
async (
|
||||
inputPath: string,
|
||||
options: {
|
||||
agentId?: string;
|
||||
force?: boolean;
|
||||
recursive?: boolean;
|
||||
slug?: string;
|
||||
topicId?: string;
|
||||
yes?: boolean;
|
||||
},
|
||||
) => {
|
||||
if (!options.yes) {
|
||||
const confirmed = await confirm(`Permanently delete ${inputPath}?`);
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
await deleteTrashFsPath(client, context, inputPath, {
|
||||
force: options.force,
|
||||
recursive: options.recursive,
|
||||
});
|
||||
console.log(`${pc.green('✓')} permanently deleted ${pc.bold(inputPath)}`);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register agent document VFS commands under `agent space fs`.
|
||||
*
|
||||
* Use when:
|
||||
* - The CLI should expose filesystem-like operations for an agent document space.
|
||||
* - Command registration should stay outside the core `agent` command file.
|
||||
*
|
||||
* Expects:
|
||||
* - `agentCommand` to be the existing `agent` command group.
|
||||
*
|
||||
* Returns:
|
||||
* - Registered Commander subcommands.
|
||||
*/
|
||||
export function registerAgentSpaceFsCommand(agentCommand: Command) {
|
||||
const spaceCommand = agentCommand.command('space').description('Manage agent document space');
|
||||
const fsCommand = spaceCommand.command('fs').description('Operate on the agent document VFS');
|
||||
registerFsCommands(fsCommand);
|
||||
}
|
||||
@@ -7,7 +7,54 @@ import { confirm, outputJson, printBoxTable, printTable, timeAgo } from '../util
|
||||
import { log } from '../utils/logger';
|
||||
import { registerBotMessageCommands } from './botMessage';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────
|
||||
// ── Access policy helpers ──────────────────────────────
|
||||
|
||||
const DM_POLICIES = ['open', 'allowlist', 'pairing', 'disabled'] as const;
|
||||
const GROUP_POLICIES = ['open', 'allowlist', 'disabled'] as const;
|
||||
type DmPolicy = (typeof DM_POLICIES)[number];
|
||||
type GroupPolicy = (typeof GROUP_POLICIES)[number];
|
||||
|
||||
interface AllowEntry {
|
||||
id: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an allow-list value into `{id, name?}[]`. Mirrors the server-side
|
||||
* back-compat parser — `settings.allowFrom` may be on disk as a comma-separated
|
||||
* string, a bare `string[]`, or the current `{id, name?}[]` shape. The CLI
|
||||
* needs the canonical form before push/filter operations and before sending
|
||||
* back to the server.
|
||||
*/
|
||||
function normalizeAllowList(raw: unknown): AllowEntry[] {
|
||||
if (typeof raw === 'string') {
|
||||
return raw
|
||||
.split(/[\s,]+/)
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean)
|
||||
.map((id) => ({ id }));
|
||||
}
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: AllowEntry[] = [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry === 'string') {
|
||||
const id = entry.trim();
|
||||
if (id) out.push({ id });
|
||||
continue;
|
||||
}
|
||||
if (entry && typeof entry === 'object' && 'id' in entry) {
|
||||
const id = (entry as { id?: unknown }).id;
|
||||
if (typeof id !== 'string' || !id.trim()) continue;
|
||||
const name = (entry as { name?: unknown }).name;
|
||||
out.push(
|
||||
typeof name === 'string' && name.trim()
|
||||
? { id: id.trim(), name: name.trim() }
|
||||
: { id: id.trim() },
|
||||
);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function maskValue(val: string): string {
|
||||
if (val.length > 8) return val.slice(0, 4) + '****' + val.slice(-4);
|
||||
@@ -78,6 +125,150 @@ async function resolvePlatform(client: TrpcClient, platformId: string) {
|
||||
return def;
|
||||
}
|
||||
|
||||
// ── Allowlist subcommand factory ────────────────────────
|
||||
|
||||
interface AllowlistGroupOptions {
|
||||
/** Description shown by `lh bot <name> --help`. */
|
||||
description: string;
|
||||
/** Settings field to mutate — `allowFrom` (user IDs) or `groupAllowFrom` (channel IDs). */
|
||||
fieldKey: 'allowFrom' | 'groupAllowFrom';
|
||||
/** Human-friendly description of what the `<id>` arg represents. */
|
||||
idLabel: string;
|
||||
/** Subcommand group name (`allowlist` or `group-allowlist`). */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `list / add / remove / clear` subcommand group around an
|
||||
* array-typed settings field (`allowFrom` or `groupAllowFrom`). All write
|
||||
* paths read existing settings first and merge — passing only a partial
|
||||
* `settings` object to the TRPC `update` would replace the whole JSONB
|
||||
* column and silently drop unrelated fields.
|
||||
*/
|
||||
function registerAllowlistCommand(bot: Command, opts: AllowlistGroupOptions) {
|
||||
const group = bot.command(opts.name).description(opts.description);
|
||||
|
||||
// Read the current entries off a freshly-fetched bot row.
|
||||
const readEntries = (bot: any): AllowEntry[] =>
|
||||
normalizeAllowList((bot.settings as Record<string, unknown> | null)?.[opts.fieldKey]);
|
||||
|
||||
// Build the next settings payload from existing settings + the new entries.
|
||||
const buildPayload = (bot: any, nextEntries: AllowEntry[]) => ({
|
||||
id: bot.id,
|
||||
settings: {
|
||||
...(bot.settings as Record<string, unknown>),
|
||||
[opts.fieldKey]: nextEntries,
|
||||
},
|
||||
});
|
||||
|
||||
group
|
||||
.command('list <botId>')
|
||||
.description(`List ${opts.fieldKey} entries`)
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (botId: string, options: { json?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const b = await findBot(client, botId);
|
||||
const entries = readEntries(b);
|
||||
|
||||
if (options.json) {
|
||||
outputJson(entries);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log(`${pc.dim(`No ${opts.fieldKey} entries.`)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
printTable(
|
||||
entries.map((e) => [e.id, e.name ?? pc.dim('-')]),
|
||||
['ID', 'NAME'],
|
||||
);
|
||||
});
|
||||
|
||||
group
|
||||
.command('add <botId> <id>')
|
||||
.description(`Add a ${opts.idLabel} to ${opts.fieldKey}`)
|
||||
.option('--name <name>', 'Optional human-friendly label so you can recognise the entry later')
|
||||
.action(async (botId: string, id: string, options: { name?: string }) => {
|
||||
const trimmedId = id.trim();
|
||||
if (!trimmedId) {
|
||||
log.error('ID cannot be empty.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const b = await findBot(client, botId);
|
||||
const entries = readEntries(b);
|
||||
|
||||
if (entries.some((e) => e.id === trimmedId)) {
|
||||
log.info(`${trimmedId} is already on the ${opts.fieldKey} list — nothing to do.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedName = options.name?.trim();
|
||||
const next = [
|
||||
...entries,
|
||||
trimmedName ? { id: trimmedId, name: trimmedName } : { id: trimmedId },
|
||||
];
|
||||
|
||||
await client.agentBotProvider.update.mutate(buildPayload(b, next) as any);
|
||||
console.log(
|
||||
`${pc.green('✓')} Added ${pc.bold(trimmedId)}${trimmedName ? ` (${trimmedName})` : ''} to ${opts.fieldKey} (now ${next.length} entr${next.length === 1 ? 'y' : 'ies'})`,
|
||||
);
|
||||
});
|
||||
|
||||
group
|
||||
.command('remove <botId> <id>')
|
||||
.description(`Remove a ${opts.idLabel} from ${opts.fieldKey}`)
|
||||
.action(async (botId: string, id: string) => {
|
||||
const trimmedId = id.trim();
|
||||
const client = await getTrpcClient();
|
||||
const b = await findBot(client, botId);
|
||||
const entries = readEntries(b);
|
||||
const next = entries.filter((e) => e.id !== trimmedId);
|
||||
|
||||
if (next.length === entries.length) {
|
||||
log.info(`${trimmedId} is not on the ${opts.fieldKey} list — nothing to do.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await client.agentBotProvider.update.mutate(buildPayload(b, next) as any);
|
||||
console.log(
|
||||
`${pc.green('✓')} Removed ${pc.bold(trimmedId)} from ${opts.fieldKey} (${next.length} entr${next.length === 1 ? 'y' : 'ies'} left)`,
|
||||
);
|
||||
});
|
||||
|
||||
group
|
||||
.command('clear <botId>')
|
||||
.description(`Clear all entries from ${opts.fieldKey}`)
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(async (botId: string, options: { yes?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const b = await findBot(client, botId);
|
||||
const entries = readEntries(b);
|
||||
|
||||
if (entries.length === 0) {
|
||||
log.info(`${opts.fieldKey} is already empty — nothing to do.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.yes) {
|
||||
const confirmed = await confirm(
|
||||
`Clear all ${entries.length} ${opts.fieldKey} entr${entries.length === 1 ? 'y' : 'ies'} from this bot?`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await client.agentBotProvider.update.mutate(buildPayload(b, []) as any);
|
||||
console.log(`${pc.green('✓')} Cleared ${opts.fieldKey} on bot ${pc.bold(botId)}`);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Command Registration ─────────────────────────────────
|
||||
|
||||
export function registerBotCommand(program: Command) {
|
||||
@@ -313,6 +504,16 @@ export function registerBotCommand(program: Command) {
|
||||
.option('--verification-token <token>', 'New verification token')
|
||||
.option('--app-id <appId>', 'New application ID')
|
||||
.option('--platform <platform>', 'New platform')
|
||||
.option(
|
||||
'--dm-policy <policy>',
|
||||
`DM access policy (${DM_POLICIES.join('|')}). 'pairing' requires --user-id.`,
|
||||
)
|
||||
.option('--group-policy <policy>', `Group/channel access policy (${GROUP_POLICIES.join('|')})`)
|
||||
.option(
|
||||
'--user-id <id>',
|
||||
"Owner's platform user ID (required for --dm-policy=pairing; auto-trusts the operator in the global allowlist)",
|
||||
)
|
||||
.option('--server-id <id>', 'Default server / guild / workspace ID for AI tool calls')
|
||||
.action(
|
||||
async (
|
||||
botId: string,
|
||||
@@ -321,11 +522,15 @@ export function registerBotCommand(program: Command) {
|
||||
appSecret?: string;
|
||||
botId?: string;
|
||||
botToken?: string;
|
||||
dmPolicy?: string;
|
||||
encryptKey?: string;
|
||||
groupPolicy?: string;
|
||||
platform?: string;
|
||||
publicKey?: string;
|
||||
secretToken?: string;
|
||||
serverId?: string;
|
||||
signingSecret?: string;
|
||||
userId?: string;
|
||||
verificationToken?: string;
|
||||
webhookProxyUrl?: string;
|
||||
},
|
||||
@@ -342,6 +547,40 @@ export function registerBotCommand(program: Command) {
|
||||
if (options.appId) input.applicationId = options.appId;
|
||||
if (options.platform) input.platform = options.platform;
|
||||
|
||||
// ── Settings (DM / group policy + identity fields) ────────────
|
||||
// Read-modify-write so we don't wipe `allowFrom`, `groupAllowFrom`,
|
||||
// or any other settings field the operator already configured.
|
||||
const settingsPatch: Record<string, unknown> = {};
|
||||
if (options.dmPolicy !== undefined) {
|
||||
if (!(DM_POLICIES as readonly string[]).includes(options.dmPolicy)) {
|
||||
log.error(
|
||||
`Invalid --dm-policy "${options.dmPolicy}". Must be one of: ${DM_POLICIES.join(', ')}`,
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
settingsPatch.dmPolicy = options.dmPolicy as DmPolicy;
|
||||
}
|
||||
if (options.groupPolicy !== undefined) {
|
||||
if (!(GROUP_POLICIES as readonly string[]).includes(options.groupPolicy)) {
|
||||
log.error(
|
||||
`Invalid --group-policy "${options.groupPolicy}". Must be one of: ${GROUP_POLICIES.join(', ')}`,
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
settingsPatch.groupPolicy = options.groupPolicy as GroupPolicy;
|
||||
}
|
||||
if (options.userId !== undefined) settingsPatch.userId = options.userId;
|
||||
if (options.serverId !== undefined) settingsPatch.serverId = options.serverId;
|
||||
|
||||
if (Object.keys(settingsPatch).length > 0) {
|
||||
input.settings = {
|
||||
...(existing.settings as Record<string, unknown>),
|
||||
...settingsPatch,
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.keys(input).length <= 1) {
|
||||
log.error('No changes specified.');
|
||||
process.exit(1);
|
||||
@@ -353,6 +592,22 @@ export function registerBotCommand(program: Command) {
|
||||
},
|
||||
);
|
||||
|
||||
// ── allowlist (DM / group user gate) ──────────────────
|
||||
|
||||
registerAllowlistCommand(bot, {
|
||||
description: 'Manage the global user allowlist (gates DMs and group @mentions)',
|
||||
fieldKey: 'allowFrom',
|
||||
idLabel: 'platform user ID',
|
||||
name: 'allowlist',
|
||||
});
|
||||
|
||||
registerAllowlistCommand(bot, {
|
||||
description: 'Manage the group/channel allowlist (used when groupPolicy=allowlist)',
|
||||
fieldKey: 'groupAllowFrom',
|
||||
idLabel: 'channel / group / thread ID',
|
||||
name: 'group-allowlist',
|
||||
});
|
||||
|
||||
// ── remove ────────────────────────────────────────────
|
||||
|
||||
bot
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../auth/refresh', () => ({
|
||||
getValidToken: vi.fn().mockResolvedValue({
|
||||
credentials: { accessToken: 'test-token', expiresAt: undefined, refreshToken: 'test-refresh' },
|
||||
}),
|
||||
}));
|
||||
vi.mock('../auth/resolveToken', () => ({
|
||||
resolveToken: vi.fn().mockResolvedValue({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
@@ -83,16 +88,21 @@ vi.mock('@lobechat/device-gateway-client', () => ({
|
||||
on: vi.fn().mockImplementation((event: string, handler: (...args: any[]) => any) => {
|
||||
clientEventHandlers[event] = handler;
|
||||
}),
|
||||
reconnect: vi.fn().mockResolvedValue(undefined),
|
||||
sendSystemInfoResponse: vi.fn().mockImplementation((data: any) => {
|
||||
lastSentSystemInfoResponse = data;
|
||||
}),
|
||||
sendToolCallResponse: vi.fn().mockImplementation((data: any) => {
|
||||
lastSentToolResponse = data;
|
||||
}),
|
||||
updateToken: vi.fn(),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { GatewayClient } from '@lobechat/device-gateway-client';
|
||||
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { resolveToken } from '../auth/resolveToken';
|
||||
// eslint-disable-next-line import-x/first
|
||||
@@ -242,13 +252,33 @@ describe('connect command', () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect']);
|
||||
|
||||
clientEventHandlers['auth_failed']?.('invalid token');
|
||||
await clientEventHandlers['auth_failed']?.('invalid token');
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Authentication failed'));
|
||||
expect(cleanupAllProcesses).toHaveBeenCalled();
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should retry auth_failed with token refresh when new token available', async () => {
|
||||
vi.mocked(resolveToken).mockResolvedValueOnce({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
token: 'refreshed-token',
|
||||
tokenType: 'jwt',
|
||||
userId: 'test-user',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect']);
|
||||
|
||||
const mockClient = vi.mocked(GatewayClient).mock.results[0].value;
|
||||
|
||||
await clientEventHandlers['auth_failed']?.('token expired');
|
||||
|
||||
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Token refreshed'));
|
||||
expect(mockClient.updateToken).toHaveBeenCalledWith('refreshed-token');
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle auth_expired', async () => {
|
||||
vi.mocked(resolveToken).mockResolvedValueOnce({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import { GatewayClient } from '@lobechat/device-gateway-client';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { getValidToken } from '../auth/refresh';
|
||||
import { resolveToken } from '../auth/resolveToken';
|
||||
import { CLI_API_KEY_ENV } from '../constants/auth';
|
||||
import { OFFICIAL_GATEWAY_URL } from '../constants/urls';
|
||||
@@ -284,8 +285,44 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
updateStatus('reconnecting');
|
||||
});
|
||||
|
||||
// Handle auth failed
|
||||
client.on('auth_failed', (reason) => {
|
||||
// Proactive token refresh — schedule before JWT expires
|
||||
const startProactiveRefresh = () =>
|
||||
scheduleProactiveRefresh(
|
||||
auth,
|
||||
(refreshed) => {
|
||||
client.updateToken(refreshed.token);
|
||||
auth = refreshed;
|
||||
// Schedule next refresh based on the new token
|
||||
cancelRefreshTimer = startProactiveRefresh();
|
||||
},
|
||||
info,
|
||||
error,
|
||||
);
|
||||
let cancelRefreshTimer = startProactiveRefresh();
|
||||
|
||||
// Handle auth failed — attempt token refresh once before giving up
|
||||
// (e.g., auto-reconnect may send an expired JWT before proactive refresh fires)
|
||||
let authFailedRefreshAttempted = false;
|
||||
client.on('auth_failed', async (reason) => {
|
||||
if (auth.tokenType === 'jwt' && !authFailedRefreshAttempted) {
|
||||
authFailedRefreshAttempted = true;
|
||||
info(`Authentication failed (${reason}). Attempting token refresh...`);
|
||||
try {
|
||||
const refreshed = await resolveToken({});
|
||||
if (refreshed && refreshed.token !== auth.token) {
|
||||
info('Token refreshed successfully. Reconnecting...');
|
||||
client.updateToken(refreshed.token);
|
||||
auth = refreshed;
|
||||
authFailedRefreshAttempted = false;
|
||||
cancelRefreshTimer = startProactiveRefresh();
|
||||
await client.reconnect();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
error(`Authentication failed: ${reason}`);
|
||||
error(
|
||||
`Run 'lh login', or set ${CLI_API_KEY_ENV} and run 'lh login --server <url>' to configure API key authentication.`,
|
||||
@@ -308,8 +345,8 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
if (refreshed) {
|
||||
info('Token refreshed successfully. Reconnecting...');
|
||||
client.updateToken(refreshed.token);
|
||||
// Update cached auth so subsequent refreshes use the latest token
|
||||
auth = refreshed;
|
||||
cancelRefreshTimer = startProactiveRefresh();
|
||||
await client.reconnect();
|
||||
return;
|
||||
}
|
||||
@@ -330,6 +367,7 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
// Graceful shutdown
|
||||
const cleanup = () => {
|
||||
info('Shutting down...');
|
||||
cancelRefreshTimer?.();
|
||||
cleanupAllProcesses();
|
||||
client.disconnect();
|
||||
removeStatus();
|
||||
@@ -374,6 +412,69 @@ function formatUptime(startedAt: Date): string {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
// How far before expiry to proactively refresh (1 hour)
|
||||
const PROACTIVE_REFRESH_BUFFER = 60 * 60;
|
||||
|
||||
/**
|
||||
* Parse the `exp` claim from a JWT without verifying the signature.
|
||||
*/
|
||||
function parseJwtExp(token: string): number | undefined {
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());
|
||||
return typeof payload.exp === 'number' ? payload.exp : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a proactive token refresh before the JWT expires.
|
||||
* Returns a cleanup function that cancels the scheduled timer.
|
||||
*/
|
||||
function scheduleProactiveRefresh(
|
||||
auth: { token: string; tokenType: string },
|
||||
onRefreshed: (newAuth: Awaited<ReturnType<typeof resolveToken>>) => void,
|
||||
info: (msg: string) => void,
|
||||
error: (msg: string) => void,
|
||||
): (() => void) | null {
|
||||
if (auth.tokenType !== 'jwt') return null;
|
||||
|
||||
const exp = parseJwtExp(auth.token);
|
||||
if (!exp) return null;
|
||||
|
||||
const refreshAt = (exp - PROACTIVE_REFRESH_BUFFER) * 1000;
|
||||
const delay = refreshAt - Date.now();
|
||||
|
||||
if (delay < 0) {
|
||||
// Already past the refresh window — refresh immediately on next tick
|
||||
void doRefresh();
|
||||
return null;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => void doRefresh(), delay);
|
||||
return () => clearTimeout(timer);
|
||||
|
||||
async function doRefresh() {
|
||||
try {
|
||||
// Use the same buffer so getValidToken actually triggers a refresh
|
||||
const result = await getValidToken(PROACTIVE_REFRESH_BUFFER);
|
||||
if (!result) {
|
||||
error('Proactive token refresh failed — no valid credentials.');
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshed = await resolveToken({});
|
||||
// Only notify if the token actually changed to avoid reschedule loops
|
||||
if (refreshed.token !== auth.token) {
|
||||
info('Proactively refreshed token.');
|
||||
onRefreshed(refreshed);
|
||||
}
|
||||
} catch {
|
||||
error('Proactive token refresh failed.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectSystemInfo(): DeviceSystemInfo {
|
||||
const home = os.homedir();
|
||||
const platform = process.platform;
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('cron command', () => {
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentCronJob.create.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1', name: 'My Job', schedule: '* * * * *' }),
|
||||
expect.objectContaining({ agentId: 'a1', cronPattern: '* * * * *', name: 'My Job' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,10 +125,10 @@ export function registerCronCommand(program: Command) {
|
||||
|
||||
const input: Record<string, any> = {
|
||||
agentId: options.agentId,
|
||||
schedule: options.schedule,
|
||||
cronPattern: options.schedule,
|
||||
};
|
||||
if (options.name) input.name = options.name;
|
||||
if (options.prompt) input.prompt = options.prompt;
|
||||
if (options.prompt) input.content = options.prompt;
|
||||
if (options.maxExecutions) input.maxExecutions = Number.parseInt(options.maxExecutions, 10);
|
||||
|
||||
const result = await client.agentCronJob.create.mutate(input as any);
|
||||
@@ -168,8 +168,8 @@ export function registerCronCommand(program: Command) {
|
||||
) => {
|
||||
const data: Record<string, any> = {};
|
||||
if (options.name) data.name = options.name;
|
||||
if (options.schedule) data.schedule = options.schedule;
|
||||
if (options.prompt) data.prompt = options.prompt;
|
||||
if (options.schedule) data.cronPattern = options.schedule;
|
||||
if (options.prompt) data.content = options.prompt;
|
||||
if (options.maxExecutions) data.maxExecutions = Number.parseInt(options.maxExecutions, 10);
|
||||
if (options.enable) data.enabled = true;
|
||||
if (options.disable) data.enabled = false;
|
||||
|
||||
@@ -9,6 +9,61 @@ import { registerTextCommand } from './text';
|
||||
import { registerTtsCommand } from './tts';
|
||||
import { registerVideoCommand } from './video';
|
||||
|
||||
/**
|
||||
* Parse a tRPC/server error and return a user-friendly message for gen status/download.
|
||||
*
|
||||
* getGenerationStatus throws NOT_FOUND in two distinct cases:
|
||||
* 1. "Async task not found" → asyncTaskId is wrong (user passed gen_xxx instead of UUID)
|
||||
* 2. "Generation not found" → generationId is wrong
|
||||
*
|
||||
* INTERNAL_SERVER_ERROR with a message mentioning "async_tasks" also indicates a bad asyncTaskId
|
||||
* (e.g. the server SQL query fails when a non-UUID is passed).
|
||||
*/
|
||||
function parseGenStatusError(
|
||||
err: any,
|
||||
generationId: string,
|
||||
asyncTaskId: string,
|
||||
command: 'status' | 'download',
|
||||
): string | null {
|
||||
const code = err?.data?.code || err?.shape?.data?.code;
|
||||
const message: string = err?.message || err?.shape?.message || '';
|
||||
|
||||
const isAsyncTaskNotFound =
|
||||
(code === 'NOT_FOUND' && message.includes('Async task not found')) ||
|
||||
(code === 'INTERNAL_SERVER_ERROR' && message.includes('async_tasks'));
|
||||
|
||||
const isGenerationNotFound = code === 'NOT_FOUND' && message.includes('Generation not found');
|
||||
|
||||
if (isAsyncTaskNotFound) {
|
||||
return (
|
||||
`${pc.red('✗')} Async task not found: ${pc.bold(asyncTaskId)}\n` +
|
||||
`\n` +
|
||||
` The second argument must be the ${pc.bold('asyncTaskId')} — the UUID printed after\n` +
|
||||
` "→ Task" in the video/image output, not the generation ID (gen_xxx).\n` +
|
||||
`\n` +
|
||||
` Example output from "lh gen video":\n` +
|
||||
` Generation ${pc.bold('gen_abc123')} → Task ${pc.dim('7ad0eb13-e9a5-4403-8070-1f7fe95b2f95')}\n` +
|
||||
`\n` +
|
||||
` Correct usage:\n` +
|
||||
` ${pc.cyan(`lh gen ${command} gen_abc123 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95`)}`
|
||||
);
|
||||
}
|
||||
|
||||
if (isGenerationNotFound) {
|
||||
return (
|
||||
`${pc.red('✗')} Generation not found: ${pc.bold(generationId)}\n` +
|
||||
`\n` +
|
||||
` The first argument must be the ${pc.bold('generationId')} (gen_xxx) from the\n` +
|
||||
` video/image output.\n` +
|
||||
`\n` +
|
||||
` Correct usage:\n` +
|
||||
` ${pc.cyan(`lh gen ${command} <generationId> <asyncTaskId>`)}`
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerGenerateCommand(program: Command) {
|
||||
const generate = program
|
||||
.command('generate')
|
||||
@@ -23,15 +78,26 @@ export function registerGenerateCommand(program: Command) {
|
||||
|
||||
// ── status ──────────────────────────────────────────
|
||||
generate
|
||||
.command('status <generationId> <taskId>')
|
||||
.command('status <generationId> <asyncTaskId>')
|
||||
.description('Check generation task status')
|
||||
.option('--json', 'Output raw JSON')
|
||||
.action(async (generationId: string, taskId: string, options: { json?: boolean }) => {
|
||||
.action(async (generationId: string, asyncTaskId: string, options: { json?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.generation.getGenerationStatus.query({
|
||||
asyncTaskId: taskId,
|
||||
generationId,
|
||||
});
|
||||
|
||||
let result: any;
|
||||
try {
|
||||
result = await client.generation.getGenerationStatus.query({
|
||||
asyncTaskId,
|
||||
generationId,
|
||||
});
|
||||
} catch (err: any) {
|
||||
const msg = parseGenStatusError(err, generationId, asyncTaskId, 'status');
|
||||
if (msg) {
|
||||
console.error(msg);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
@@ -53,7 +119,7 @@ export function registerGenerateCommand(program: Command) {
|
||||
|
||||
// ── download ──────────────────────────────────────────
|
||||
generate
|
||||
.command('download <generationId> <taskId>')
|
||||
.command('download <generationId> <asyncTaskId>')
|
||||
.description('Wait for generation to complete and download the result')
|
||||
.option('-o, --output <path>', 'Output file path (default: auto-detect from asset)')
|
||||
.option('--interval <sec>', 'Polling interval in seconds', '5')
|
||||
@@ -61,7 +127,7 @@ export function registerGenerateCommand(program: Command) {
|
||||
.action(
|
||||
async (
|
||||
generationId: string,
|
||||
taskId: string,
|
||||
asyncTaskId: string,
|
||||
options: { interval?: string; output?: string; timeout?: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
@@ -73,10 +139,20 @@ export function registerGenerateCommand(program: Command) {
|
||||
|
||||
// Poll for completion
|
||||
while (true) {
|
||||
const result = (await client.generation.getGenerationStatus.query({
|
||||
asyncTaskId: taskId,
|
||||
generationId,
|
||||
})) as any;
|
||||
let result: any;
|
||||
try {
|
||||
result = await client.generation.getGenerationStatus.query({
|
||||
asyncTaskId,
|
||||
generationId,
|
||||
});
|
||||
} catch (err: any) {
|
||||
const msg = parseGenStatusError(err, generationId, asyncTaskId, 'download');
|
||||
if (msg) {
|
||||
console.error(`\n${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (result.status === 'success' && result.generation) {
|
||||
const gen = result.generation;
|
||||
@@ -125,7 +201,7 @@ export function registerGenerateCommand(program: Command) {
|
||||
console.log(
|
||||
`${pc.red('✗')} Timed out after ${options.timeout}s. Task still ${result.status}.`,
|
||||
);
|
||||
console.log(pc.dim(`Run "lh gen status ${generationId} ${taskId}" to check later.`));
|
||||
console.log(pc.dim(`Run "lh gen status ${generationId} ${asyncTaskId}" to check later.`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { registerHeteroCommand } from './hetero';
|
||||
|
||||
const { mockSpawnAgent } = vi.hoisted(() => ({
|
||||
mockSpawnAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@lobechat/heterogeneous-agents/spawn', () => ({
|
||||
spawnAgent: mockSpawnAgent,
|
||||
}));
|
||||
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
setVerbose: vi.fn(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Build a Promise resolving to a fake `SpawnAgentHandle`. `spawnAgent` itself
|
||||
* is async, so test mocks return the handle wrapped — same iterable contract,
|
||||
* just behind one microtask. The async iterable yields `events` synchronously
|
||||
* and ends, so the command's `for await (const event of ...)` loop terminates
|
||||
* without hanging the test.
|
||||
*/
|
||||
const createFakeHandle = ({
|
||||
events = [] as any[],
|
||||
exitCode = 0,
|
||||
signal = null as NodeJS.Signals | null,
|
||||
stderrChunks = [] as string[],
|
||||
}: {
|
||||
events?: any[];
|
||||
exitCode?: number | null;
|
||||
signal?: NodeJS.Signals | null;
|
||||
stderrChunks?: string[];
|
||||
} = {}) => {
|
||||
const stderr = new PassThrough();
|
||||
setImmediate(() => {
|
||||
for (const c of stderrChunks) stderr.write(c);
|
||||
stderr.end();
|
||||
});
|
||||
|
||||
const eventsIter: AsyncIterable<any> = {
|
||||
[Symbol.asyncIterator]() {
|
||||
let i = 0;
|
||||
return {
|
||||
async next() {
|
||||
if (i < events.length) return { done: false, value: events[i++] };
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return Promise.resolve({
|
||||
events: eventsIter,
|
||||
exit: Promise.resolve({ code: exitCode, signal }),
|
||||
kill: vi.fn(),
|
||||
pid: 12_345,
|
||||
stderr,
|
||||
});
|
||||
};
|
||||
|
||||
describe('hetero exec command', () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let stdoutSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Stub `process.exit` so the test runner doesn't tear down — but THROW a
|
||||
// sentinel rather than return, mirroring `process.exit`'s `never` return
|
||||
// type in production. Without throwing, the command's code after an
|
||||
// `exit(2)` keeps running and crashes on `handle.stderr` (no spawn mock).
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
||||
throw new Error(`__exit__${code}`);
|
||||
}) as any);
|
||||
stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
||||
mockSpawnAgent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
stdoutSpy.mockRestore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/** Build a fresh program with the hetero command registered. */
|
||||
const buildProgram = () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerHeteroCommand(program);
|
||||
return program;
|
||||
};
|
||||
|
||||
/**
|
||||
* Run the parsed command. Swallows our `__exit__<code>` sentinel so tests
|
||||
* can inspect `exitSpy.mock.calls` afterwards instead of having to wrap
|
||||
* every `parseAsync` in `expect(...).rejects`. Real production exits stay
|
||||
* `process.exit` so this only affects the test path.
|
||||
*/
|
||||
const runCmd = async (argv: string[]) => {
|
||||
try {
|
||||
await buildProgram().parseAsync(argv, { from: 'user' });
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.startsWith('__exit__')) return;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
it('rejects unsupported agent types via process.exit(2)', async () => {
|
||||
await runCmd(['hetero', 'exec', '--type', 'kimi-cli', '--prompt', 'hi']);
|
||||
expect(exitSpy).toHaveBeenCalledWith(2);
|
||||
expect(mockSpawnAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects empty prompts via process.exit(2)', async () => {
|
||||
await runCmd(['hetero', 'exec', '--type', 'claude-code', '--prompt', ' ']);
|
||||
expect(exitSpy).toHaveBeenCalledWith(2);
|
||||
expect(mockSpawnAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes --type / --prompt / --resume / --cwd / --command through to spawnAgent', async () => {
|
||||
mockSpawnAgent.mockReturnValue(createFakeHandle());
|
||||
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'codex',
|
||||
'--prompt',
|
||||
'do thing',
|
||||
'--resume',
|
||||
'thread_abc',
|
||||
'--cwd',
|
||||
'/tmp/work',
|
||||
'--command',
|
||||
'/usr/local/bin/codex',
|
||||
]);
|
||||
|
||||
expect(mockSpawnAgent).toHaveBeenCalledTimes(1);
|
||||
const call = mockSpawnAgent.mock.calls[0][0];
|
||||
expect(call).toMatchObject({
|
||||
agentType: 'codex',
|
||||
command: '/usr/local/bin/codex',
|
||||
cwd: '/tmp/work',
|
||||
prompt: 'do thing',
|
||||
resumeSessionId: 'thread_abc',
|
||||
});
|
||||
// operationId auto-generated when omitted (uuid v4 shape)
|
||||
expect(call.operationId).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
});
|
||||
|
||||
it('uses the provided --operation-id verbatim', async () => {
|
||||
mockSpawnAgent.mockReturnValue(createFakeHandle());
|
||||
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'claude-code',
|
||||
'--prompt',
|
||||
'hi',
|
||||
'--operation-id',
|
||||
'op-server-allocated',
|
||||
]);
|
||||
|
||||
const call = mockSpawnAgent.mock.calls[0][0];
|
||||
expect(call.operationId).toBe('op-server-allocated');
|
||||
});
|
||||
|
||||
it('streams events to stdout as JSONL, one line per event', async () => {
|
||||
const events = [
|
||||
{ data: { foo: 1 }, operationId: 'op-1', stepIndex: 0, timestamp: 1, type: 'stream_start' },
|
||||
{
|
||||
data: { chunkType: 'text', content: 'hi' },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 2,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
];
|
||||
mockSpawnAgent.mockReturnValue(createFakeHandle({ events }));
|
||||
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'claude-code',
|
||||
'--prompt',
|
||||
'hi',
|
||||
'--operation-id',
|
||||
'op-1',
|
||||
]);
|
||||
|
||||
// Each event is one JSON line with a trailing \n.
|
||||
const lines = stdoutSpy.mock.calls.map((c) => c[0]).filter((s) => typeof s === 'string');
|
||||
expect(lines).toHaveLength(2);
|
||||
for (const line of lines as string[]) {
|
||||
expect(line.endsWith('\n')).toBe(true);
|
||||
const parsed = JSON.parse(line);
|
||||
expect(parsed.operationId).toBe('op-1');
|
||||
}
|
||||
});
|
||||
|
||||
it('passes the child exit code straight through', async () => {
|
||||
mockSpawnAgent.mockReturnValue(createFakeHandle({ exitCode: 7 }));
|
||||
|
||||
await runCmd(['hetero', 'exec', '--type', 'claude-code', '--prompt', 'hi']);
|
||||
expect(exitSpy).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it('maps SIGINT (code === null) to POSIX exit code 130', async () => {
|
||||
mockSpawnAgent.mockReturnValue(createFakeHandle({ exitCode: null, signal: 'SIGINT' }));
|
||||
|
||||
await runCmd(['hetero', 'exec', '--type', 'claude-code', '--prompt', 'hi']);
|
||||
expect(exitSpy).toHaveBeenCalledWith(130);
|
||||
});
|
||||
|
||||
it('combines --prompt + --image into mixed content blocks', async () => {
|
||||
mockSpawnAgent.mockReturnValue(createFakeHandle());
|
||||
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'claude-code',
|
||||
'--prompt',
|
||||
'describe',
|
||||
'--image',
|
||||
'./fixture-a.png',
|
||||
'--image',
|
||||
'https://cdn.example/fixture-b.png',
|
||||
]);
|
||||
|
||||
const call = mockSpawnAgent.mock.calls[0][0];
|
||||
expect(Array.isArray(call.prompt)).toBe(true);
|
||||
expect(call.prompt).toEqual([
|
||||
{ text: 'describe', type: 'text' },
|
||||
// Path is resolved against process.cwd() — match by suffix to be CI-portable.
|
||||
{
|
||||
source: expect.objectContaining({ type: 'path' }),
|
||||
type: 'image',
|
||||
},
|
||||
{
|
||||
source: { type: 'url', url: 'https://cdn.example/fixture-b.png' },
|
||||
type: 'image',
|
||||
},
|
||||
]);
|
||||
expect(call.prompt[1].source.path).toMatch(/fixture-a\.png$/);
|
||||
});
|
||||
|
||||
it('parses a data: URL --image into a base64 source', async () => {
|
||||
mockSpawnAgent.mockReturnValue(createFakeHandle());
|
||||
|
||||
const dataUrl = `data:image/png;base64,${Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64')}`;
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'claude-code',
|
||||
'--prompt',
|
||||
'see',
|
||||
'--image',
|
||||
dataUrl,
|
||||
]);
|
||||
|
||||
const call = mockSpawnAgent.mock.calls[0][0];
|
||||
expect(call.prompt[1]).toEqual({
|
||||
source: {
|
||||
data: Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64'),
|
||||
mediaType: 'image/png',
|
||||
type: 'base64',
|
||||
},
|
||||
type: 'image',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads multimodal content from --input-json <file>', async () => {
|
||||
const { mkdtemp, writeFile, rm } = await import('node:fs/promises');
|
||||
const { tmpdir } = await import('node:os');
|
||||
const path = await import('node:path');
|
||||
const dir = await mkdtemp(`${tmpdir()}/hetero-input-json-`);
|
||||
const file = path.join(dir, 'input.json');
|
||||
await writeFile(
|
||||
file,
|
||||
JSON.stringify([
|
||||
{ text: 'analyze', type: 'text' },
|
||||
{ source: { type: 'url', url: 'https://x/y.png' }, type: 'image' },
|
||||
]),
|
||||
);
|
||||
|
||||
mockSpawnAgent.mockReturnValue(createFakeHandle());
|
||||
try {
|
||||
await runCmd(['hetero', 'exec', '--type', 'claude-code', '--input-json', file]);
|
||||
} finally {
|
||||
await rm(dir, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
const call = mockSpawnAgent.mock.calls[0][0];
|
||||
expect(call.prompt).toEqual([
|
||||
{ text: 'analyze', type: 'text' },
|
||||
{ source: { type: 'url', url: 'https://x/y.png' }, type: 'image' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports spawnAgent rejections (e.g. missing --image path) as a clean error + exit(1)', async () => {
|
||||
// spawnAgent is now async and can reject during image normalization —
|
||||
// missing local --image paths, fetch failures, etc. The CLI must catch
|
||||
// these and exit with a friendly message instead of crashing on an
|
||||
// unhandled rejection.
|
||||
mockSpawnAgent.mockReturnValue(
|
||||
Promise.reject(new Error('ENOENT: no such file or directory, open /missing.png')),
|
||||
);
|
||||
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'claude-code',
|
||||
'--prompt',
|
||||
'see',
|
||||
'--image',
|
||||
'/missing.png',
|
||||
]);
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('rejects --prompt + --input-json (mutually exclusive)', async () => {
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'claude-code',
|
||||
'--prompt',
|
||||
'hi',
|
||||
'--input-json',
|
||||
'/tmp/bogus.json',
|
||||
]);
|
||||
expect(exitSpy).toHaveBeenCalledWith(2);
|
||||
expect(mockSpawnAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type {
|
||||
AgentContentBlock,
|
||||
AgentImageSource,
|
||||
AgentPromptInput,
|
||||
} from '@lobechat/heterogeneous-agents/spawn';
|
||||
import { spawnAgent } from '@lobechat/heterogeneous-agents/spawn';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
const SUPPORTED_AGENT_TYPES = new Set(['claude-code', 'codex']);
|
||||
|
||||
interface ExecOptions {
|
||||
command?: string;
|
||||
cwd?: string;
|
||||
image?: string[];
|
||||
inputJson?: string;
|
||||
operationId?: string;
|
||||
prompt?: string;
|
||||
resume?: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const collectImage = (value: string, previous: string[] = []): string[] => [...previous, value];
|
||||
|
||||
const readStdin = async (): Promise<string> => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : (chunk as Buffer));
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a raw `--input-json` argument: `'-'` (or empty) reads stdin, anything
|
||||
* else is treated as a filesystem path.
|
||||
*/
|
||||
const readInputJson = async (location: string): Promise<string> => {
|
||||
if (location === '-' || location === '') return readStdin();
|
||||
return readFile(location, 'utf8');
|
||||
};
|
||||
|
||||
const looksLikeJsonInput = (value: string): boolean => {
|
||||
const trimmed = value.trimStart();
|
||||
return trimmed.startsWith('{') || trimmed.startsWith('[');
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert an `--image <value>` argument into an image source. Recognized
|
||||
* shapes: `https?://...` URL, `data:` URL, otherwise a filesystem path
|
||||
* resolved relative to the CLI's cwd.
|
||||
*/
|
||||
const parseImageArg = (value: string): AgentImageSource => {
|
||||
if (/^https?:\/\//i.test(value)) return { type: 'url', url: value };
|
||||
if (value.startsWith('data:')) {
|
||||
const match = value.match(/^data:([^;,]+);base64,(.+)$/);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid data URL for --image: ${value.slice(0, 40)}…`);
|
||||
}
|
||||
return { data: match[2]!, mediaType: match[1]!, type: 'base64' };
|
||||
}
|
||||
return { path: path.resolve(process.cwd(), value), type: 'path' };
|
||||
};
|
||||
|
||||
/**
|
||||
* Best-effort coercion of a JSON-decoded value into an `AgentPromptInput`.
|
||||
* Accepts:
|
||||
* - `'plain text'` → single text block
|
||||
* - `[{ type: 'text', text }, { type: 'image', source }]` → content blocks
|
||||
* - `{ content: [...] }` (Anthropic message shape) → unwraps `content`
|
||||
* - `{ type: 'text', ... } | { type: 'image', ... }` → single block
|
||||
*/
|
||||
const coerceJsonPrompt = (parsed: unknown): AgentPromptInput => {
|
||||
if (typeof parsed === 'string') return parsed;
|
||||
if (Array.isArray(parsed)) return parsed as AgentContentBlock[];
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (Array.isArray(obj.content)) return obj.content as AgentContentBlock[];
|
||||
if (obj.type === 'text' || obj.type === 'image') return [obj as AgentContentBlock];
|
||||
}
|
||||
throw new Error(
|
||||
'Invalid --input-json shape: expected a string, array of content blocks, ' +
|
||||
'or `{ content: [...] }` envelope.',
|
||||
);
|
||||
};
|
||||
|
||||
interface ResolvedPrompt {
|
||||
/** Human-readable description for the empty-input check. */
|
||||
describe: () => string;
|
||||
prompt: AgentPromptInput;
|
||||
}
|
||||
|
||||
const buildPromptFromText = (text: string, images: string[]): ResolvedPrompt => {
|
||||
if (images.length === 0) {
|
||||
return { describe: () => text.trim(), prompt: text };
|
||||
}
|
||||
const blocks: AgentContentBlock[] = [];
|
||||
if (text.length > 0) blocks.push({ text, type: 'text' });
|
||||
for (const image of images) {
|
||||
blocks.push({ source: parseImageArg(image), type: 'image' });
|
||||
}
|
||||
return {
|
||||
describe: () =>
|
||||
blocks
|
||||
.map((b) => (b.type === 'text' ? b.text.trim() : '[image]'))
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim(),
|
||||
prompt: blocks,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Decide which input mode the user requested and produce a unified prompt.
|
||||
*
|
||||
* Mode resolution (mutually exclusive):
|
||||
* 1. `--input-json` → read JSON file or stdin, parse to content blocks
|
||||
* 2. `--prompt` (with optional `--image` flags) → text + images
|
||||
* 3. (default) read stdin: auto-detect JSON vs plain text by first char
|
||||
*/
|
||||
const resolvePrompt = async (options: ExecOptions): Promise<ResolvedPrompt> => {
|
||||
const images = options.image ?? [];
|
||||
|
||||
if (options.inputJson !== undefined) {
|
||||
if (options.prompt !== undefined) {
|
||||
throw new Error('--prompt and --input-json are mutually exclusive.');
|
||||
}
|
||||
if (images.length > 0) {
|
||||
throw new Error('--image cannot be combined with --input-json (put images in the JSON).');
|
||||
}
|
||||
const raw = await readInputJson(options.inputJson);
|
||||
return { describe: () => raw.trim(), prompt: coerceJsonPrompt(JSON.parse(raw)) };
|
||||
}
|
||||
|
||||
if (options.prompt !== undefined && options.prompt !== '-') {
|
||||
return buildPromptFromText(options.prompt, images);
|
||||
}
|
||||
|
||||
// No --prompt or --prompt -: read stdin and auto-detect.
|
||||
const raw = await readStdin();
|
||||
if (looksLikeJsonInput(raw)) {
|
||||
return { describe: () => raw.trim(), prompt: coerceJsonPrompt(JSON.parse(raw)) };
|
||||
}
|
||||
return buildPromptFromText(raw, images);
|
||||
};
|
||||
|
||||
const exec = async (options: ExecOptions): Promise<void> => {
|
||||
if (!SUPPORTED_AGENT_TYPES.has(options.type)) {
|
||||
log.error(
|
||||
`Unsupported --type "${options.type}". Supported: ${[...SUPPORTED_AGENT_TYPES].join(', ')}`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let resolved: ResolvedPrompt;
|
||||
try {
|
||||
resolved = await resolvePrompt(options);
|
||||
} catch (err) {
|
||||
log.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (!resolved.describe()) {
|
||||
log.error(
|
||||
'Empty prompt. Pass --prompt <text>, --image <path>, --input-json <file|->, or pipe content via stdin.',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Standalone (phase 1a): no server ingest, so the operationId is just an
|
||||
// identity stamp on the JSONL stream. Generate a fresh one if the caller
|
||||
// didn't provide --operation-id; phase 1b will require it as a real
|
||||
// server-allocated id.
|
||||
const operationId = options.operationId || randomUUID();
|
||||
|
||||
// `spawnAgent` is async and can reject DURING image normalization — fetch
|
||||
// failures, missing local --image paths, decode errors. Surface those as a
|
||||
// clean error + exit code instead of an unhandled promise rejection / stack
|
||||
// trace, mirroring the validation try/catch above.
|
||||
let handle: Awaited<ReturnType<typeof spawnAgent>>;
|
||||
try {
|
||||
handle = await spawnAgent({
|
||||
agentType: options.type,
|
||||
command: options.command,
|
||||
cwd: options.cwd || process.cwd(),
|
||||
operationId,
|
||||
prompt: resolved.prompt,
|
||||
resumeSessionId: options.resume,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error('Failed to start agent:', err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Forward the child's stderr to ours so users see CLI errors / warnings
|
||||
// (auth prompts, missing-binary errors, etc.) in the terminal.
|
||||
handle.stderr.pipe(process.stderr);
|
||||
|
||||
// Ctrl-C → SIGINT to the child's process group so the spawned CLI gets a
|
||||
// chance to clean up. Repeated Ctrl-C escalates to SIGKILL via the
|
||||
// standard "double-tap" pattern most CLIs implement themselves.
|
||||
let interrupted = false;
|
||||
const onSigint = () => {
|
||||
if (interrupted) {
|
||||
handle.kill('SIGKILL');
|
||||
return;
|
||||
}
|
||||
interrupted = true;
|
||||
handle.kill('SIGINT');
|
||||
};
|
||||
process.on('SIGINT', onSigint);
|
||||
process.on('SIGTERM', () => handle.kill('SIGTERM'));
|
||||
|
||||
// Stream events out as JSONL on stdout. Each line is one `AgentStreamEvent`.
|
||||
// Use raw write (not console.log) so we don't pull in console formatting
|
||||
// and JSONL stays parseable downstream.
|
||||
try {
|
||||
for await (const event of handle.events) {
|
||||
process.stdout.write(`${JSON.stringify(event)}\n`);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('Stream error from agent process:', err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
} finally {
|
||||
process.off('SIGINT', onSigint);
|
||||
}
|
||||
|
||||
// Pass the child's exit code through. Signal-induced exits (SIGINT etc.)
|
||||
// surface as `code === null` — map to 130 (POSIX convention for SIGINT).
|
||||
const { code, signal } = await handle.exit;
|
||||
if (code !== null) process.exit(code);
|
||||
if (signal === 'SIGINT') process.exit(130);
|
||||
if (signal === 'SIGTERM') process.exit(143);
|
||||
if (signal === 'SIGKILL') process.exit(137);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
export function registerHeteroCommand(program: Command) {
|
||||
const hetero = program
|
||||
.command('hetero')
|
||||
.description('Run heterogeneous agent CLIs (Claude Code / Codex) and stream their output');
|
||||
|
||||
hetero
|
||||
.command('exec')
|
||||
.description(
|
||||
'Spawn a heterogeneous agent CLI and stream its events as JSONL on stdout. Standalone mode (no server ingest).',
|
||||
)
|
||||
.requiredOption('-t, --type <type>', `Agent type: ${[...SUPPORTED_AGENT_TYPES].join(' | ')}`)
|
||||
.option('-p, --prompt [text]', 'Prompt text. Pass `-` (or omit the value) to read from stdin.')
|
||||
.option(
|
||||
'-i, --image <path|url>',
|
||||
'Attach an image (repeatable). Accepts a local path, http(s) URL, or data: URL.',
|
||||
collectImage,
|
||||
)
|
||||
.option(
|
||||
'--input-json <path>',
|
||||
'Read full multimodal prompt as JSON content blocks from a file. Use `-` for stdin.',
|
||||
)
|
||||
.option('-r, --resume <sessionId>', 'Resume an existing agent session by its native id')
|
||||
.option('-d, --cwd <path>', 'Working directory for the spawned agent (default: process.cwd())')
|
||||
.option(
|
||||
'-c, --command <bin>',
|
||||
'Override the agent CLI binary name (default: `claude` or `codex`)',
|
||||
)
|
||||
.option(
|
||||
'--operation-id <id>',
|
||||
'Operation id stamped onto every emitted event. Generated as a uuid if omitted (phase 1a).',
|
||||
)
|
||||
.action(exec);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { registerDocCommand } from './commands/doc';
|
||||
import { registerEvalCommand } from './commands/eval';
|
||||
import { registerFileCommand } from './commands/file';
|
||||
import { registerGenerateCommand } from './commands/generate';
|
||||
import { registerHeteroCommand } from './commands/hetero';
|
||||
import { registerKbCommand } from './commands/kb';
|
||||
import { registerLoginCommand } from './commands/login';
|
||||
import { registerLogoutCommand } from './commands/logout';
|
||||
@@ -62,6 +63,7 @@ export function createProgram() {
|
||||
registerCronCommand(program);
|
||||
registerGenerateCommand(program);
|
||||
registerFileCommand(program);
|
||||
registerHeteroCommand(program);
|
||||
registerSkillCommand(program);
|
||||
registerSessionGroupCommand(program);
|
||||
registerTaskCommand(program);
|
||||
|
||||
@@ -27,22 +27,22 @@ describe('executeToolCall', () => {
|
||||
fs.rmSync(tmpDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
it('should dispatch readLocalFile', async () => {
|
||||
it('should dispatch readFile', async () => {
|
||||
const filePath = path.join(tmpDir, 'test.txt');
|
||||
await writeFile(filePath, 'hello world');
|
||||
|
||||
const result = await executeToolCall('readLocalFile', JSON.stringify({ path: filePath }));
|
||||
const result = await executeToolCall('readFile', JSON.stringify({ path: filePath }));
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const parsed = JSON.parse(result.content);
|
||||
expect(parsed.content).toContain('hello world');
|
||||
});
|
||||
|
||||
it('should dispatch writeLocalFile', async () => {
|
||||
it('should dispatch writeFile', async () => {
|
||||
const filePath = path.join(tmpDir, 'new.txt');
|
||||
|
||||
const result = await executeToolCall(
|
||||
'writeLocalFile',
|
||||
'writeFile',
|
||||
JSON.stringify({ content: 'written', path: filePath }),
|
||||
);
|
||||
|
||||
@@ -50,6 +50,17 @@ describe('executeToolCall', () => {
|
||||
expect(fs.readFileSync(filePath, 'utf8')).toBe('written');
|
||||
});
|
||||
|
||||
it('should dispatch legacy alias readLocalFile', async () => {
|
||||
const filePath = path.join(tmpDir, 'legacy.txt');
|
||||
await writeFile(filePath, 'legacy hello');
|
||||
|
||||
const result = await executeToolCall('readLocalFile', JSON.stringify({ path: filePath }));
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const parsed = JSON.parse(result.content);
|
||||
expect(parsed.content).toContain('legacy hello');
|
||||
});
|
||||
|
||||
it('should dispatch runCommand', async () => {
|
||||
const result = await executeToolCall(
|
||||
'runCommand',
|
||||
@@ -61,21 +72,21 @@ describe('executeToolCall', () => {
|
||||
expect(parsed.stdout).toContain('dispatched');
|
||||
});
|
||||
|
||||
it('should dispatch listLocalFiles', async () => {
|
||||
it('should dispatch listFiles', async () => {
|
||||
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
|
||||
|
||||
const result = await executeToolCall('listLocalFiles', JSON.stringify({ path: tmpDir }));
|
||||
const result = await executeToolCall('listFiles', JSON.stringify({ path: tmpDir }));
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const parsed = JSON.parse(result.content);
|
||||
expect(parsed.totalCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should dispatch globLocalFiles', async () => {
|
||||
it('should dispatch globFiles', async () => {
|
||||
await writeFile(path.join(tmpDir, 'test.ts'), 'code');
|
||||
|
||||
const result = await executeToolCall(
|
||||
'globLocalFiles',
|
||||
'globFiles',
|
||||
JSON.stringify({ cwd: tmpDir, pattern: '*.ts' }),
|
||||
);
|
||||
|
||||
@@ -84,12 +95,12 @@ describe('executeToolCall', () => {
|
||||
expect(parsed.files).toContain('test.ts');
|
||||
});
|
||||
|
||||
it('should dispatch editLocalFile', async () => {
|
||||
it('should dispatch editFile', async () => {
|
||||
const filePath = path.join(tmpDir, 'edit.txt');
|
||||
await writeFile(filePath, 'old content');
|
||||
|
||||
const result = await executeToolCall(
|
||||
'editLocalFile',
|
||||
'editFile',
|
||||
JSON.stringify({
|
||||
file_path: filePath,
|
||||
new_string: 'new content',
|
||||
@@ -116,7 +127,7 @@ describe('executeToolCall', () => {
|
||||
const filePath = path.join(tmpDir, 'str.txt');
|
||||
await writeFile(filePath, 'content');
|
||||
|
||||
const result = await executeToolCall('readLocalFile', JSON.stringify({ path: filePath }));
|
||||
const result = await executeToolCall('readFile', JSON.stringify({ path: filePath }));
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Result should be valid JSON
|
||||
@@ -124,7 +135,7 @@ describe('executeToolCall', () => {
|
||||
});
|
||||
|
||||
it('should return error for invalid JSON arguments', async () => {
|
||||
const result = await executeToolCall('readLocalFile', 'not-json');
|
||||
const result = await executeToolCall('readFile', 'not-json');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
@@ -141,11 +152,11 @@ describe('executeToolCall', () => {
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should dispatch searchLocalFiles', async () => {
|
||||
it('should dispatch searchFiles', async () => {
|
||||
await writeFile(path.join(tmpDir, 'search_target.txt'), 'found');
|
||||
|
||||
const result = await executeToolCall(
|
||||
'searchLocalFiles',
|
||||
'searchFiles',
|
||||
JSON.stringify({ directory: tmpDir, keywords: 'search_target' }),
|
||||
);
|
||||
|
||||
|
||||
@@ -11,14 +11,22 @@ import {
|
||||
import { getCommandOutput, killCommand, runCommand } from './shell';
|
||||
|
||||
const methodMap: Record<string, (args: any) => Promise<unknown>> = {
|
||||
editLocalFile,
|
||||
editFile: editLocalFile,
|
||||
getCommandOutput,
|
||||
globLocalFiles,
|
||||
globFiles: globLocalFiles,
|
||||
grepContent,
|
||||
killCommand,
|
||||
listFiles: listLocalFiles,
|
||||
readFile: readLocalFile,
|
||||
runCommand,
|
||||
searchFiles: searchLocalFiles,
|
||||
writeFile: writeLocalFile,
|
||||
|
||||
// Legacy aliases — older Gateway versions may still send the long form
|
||||
editLocalFile,
|
||||
globLocalFiles,
|
||||
listLocalFiles,
|
||||
readLocalFile,
|
||||
runCommand,
|
||||
searchLocalFiles,
|
||||
writeLocalFile,
|
||||
};
|
||||
|
||||
@@ -188,6 +188,7 @@ export default defineConfig({
|
||||
],
|
||||
resolve: {
|
||||
dedupe: ['react', 'react-dom'],
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"@lobechat/electron-client-ipc": "workspace:*",
|
||||
"@lobechat/electron-server-ipc": "workspace:*",
|
||||
"@lobechat/file-loaders": "workspace:*",
|
||||
"@lobechat/heterogeneous-agents": "workspace:*",
|
||||
"@lobechat/local-file-shell": "workspace:*",
|
||||
"@lobehub/i18n-cli": "^1.25.1",
|
||||
"@modelcontextprotocol/sdk": "^1.24.3",
|
||||
@@ -74,7 +75,7 @@
|
||||
"cookie": "^1.1.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"diff": "^8.0.4",
|
||||
"electron": "41.1.0",
|
||||
"electron": "41.3.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"electron-devtools-installer": "4.0.0",
|
||||
"electron-is": "^3.0.0",
|
||||
@@ -107,7 +108,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"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
packages:
|
||||
- '../cli'
|
||||
- '../../packages/agent-gateway-client'
|
||||
- '../../packages/heterogeneous-agents'
|
||||
- '../../packages/const'
|
||||
- '../../packages/electron-server-ipc'
|
||||
- '../../packages/electron-client-ipc'
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "خدمات",
|
||||
"macOS.unhide": "إظهار الكل",
|
||||
"tray.open": "فتح {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "خروج",
|
||||
"tray.show": "عرض {{appName}}",
|
||||
"view.forceReload": "إعادة تحميل قسري",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "تصغير",
|
||||
"window.title": "نافذة",
|
||||
"window.toggleFullscreen": "تبديل وضع ملء الشاشة",
|
||||
"window.zoom": "تكبير",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "تكبير"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Услуги",
|
||||
"macOS.unhide": "Покажи всичко",
|
||||
"tray.open": "Отвори {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Изход",
|
||||
"tray.show": "Покажи {{appName}}",
|
||||
"view.forceReload": "Принудително презареждане",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Минимизирай",
|
||||
"window.title": "Прозорец",
|
||||
"window.toggleFullscreen": "Превключи на цял екран",
|
||||
"window.zoom": "Мащаб",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Мащаб"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Dienste",
|
||||
"macOS.unhide": "Alle anzeigen",
|
||||
"tray.open": "{{appName}} öffnen",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Beenden",
|
||||
"tray.show": "{{appName}} anzeigen",
|
||||
"view.forceReload": "Erzwinge Neuladen",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Minimieren",
|
||||
"window.title": "Fenster",
|
||||
"window.toggleFullscreen": "Vollbild umschalten",
|
||||
"window.zoom": "Zoom",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Zoom"
|
||||
}
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Servicios",
|
||||
"macOS.unhide": "Mostrar todo",
|
||||
"tray.open": "Abrir {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Salir",
|
||||
"tray.show": "Mostrar {{appName}}",
|
||||
"view.forceReload": "Recargar forzosamente",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Minimizar",
|
||||
"window.title": "Ventana",
|
||||
"window.toggleFullscreen": "Alternar pantalla completa",
|
||||
"window.zoom": "Zoom",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Zoom"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "خدمات",
|
||||
"macOS.unhide": "نمایش همه",
|
||||
"tray.open": "باز کردن {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "خروج",
|
||||
"tray.show": "نمایش {{appName}}",
|
||||
"view.forceReload": "بارگذاری اجباری",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "کوچک کردن",
|
||||
"window.title": "پنجره",
|
||||
"window.toggleFullscreen": "تغییر به حالت تمام صفحه",
|
||||
"window.zoom": "زوم",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "زوم"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Services",
|
||||
"macOS.unhide": "Tout afficher",
|
||||
"tray.open": "Ouvrir {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Quitter",
|
||||
"tray.show": "Afficher {{appName}}",
|
||||
"view.forceReload": "Recharger de force",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Réduire",
|
||||
"window.title": "Fenêtre",
|
||||
"window.toggleFullscreen": "Basculer en plein écran",
|
||||
"window.zoom": "Zoom",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Zoom"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Servizi",
|
||||
"macOS.unhide": "Mostra tutto",
|
||||
"tray.open": "Apri {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Esci",
|
||||
"tray.show": "Mostra {{appName}}",
|
||||
"view.forceReload": "Ricarica forzata",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Minimizza",
|
||||
"window.title": "Finestra",
|
||||
"window.toggleFullscreen": "Attiva/disattiva schermo intero",
|
||||
"window.zoom": "Zoom",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Zoom"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "サービス",
|
||||
"macOS.unhide": "すべて表示",
|
||||
"tray.open": "{{appName}} を開く",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "終了",
|
||||
"tray.show": "{{appName}} を表示",
|
||||
"view.forceReload": "強制再読み込み",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "最小化",
|
||||
"window.title": "ウィンドウ",
|
||||
"window.toggleFullscreen": "フルスクリーン切替",
|
||||
"window.zoom": "ズーム",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "ズーム"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "서비스",
|
||||
"macOS.unhide": "모두 표시",
|
||||
"tray.open": "{{appName}} 열기",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "종료",
|
||||
"tray.show": "{{appName}} 표시",
|
||||
"view.forceReload": "강제 새로 고침",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "최소화",
|
||||
"window.title": "창",
|
||||
"window.toggleFullscreen": "전체 화면 전환",
|
||||
"window.zoom": "줌",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "줌"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Diensten",
|
||||
"macOS.unhide": "Toon alles",
|
||||
"tray.open": "Open {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Afsluiten",
|
||||
"tray.show": "Toon {{appName}}",
|
||||
"view.forceReload": "Forceer herladen",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Minimaliseren",
|
||||
"window.title": "Venster",
|
||||
"window.toggleFullscreen": "Schakel volledig scherm in/uit",
|
||||
"window.zoom": "Inzoomen",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Inzoomen"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Usługi",
|
||||
"macOS.unhide": "Pokaż wszystko",
|
||||
"tray.open": "Otwórz {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Zakończ",
|
||||
"tray.show": "Pokaż {{appName}}",
|
||||
"view.forceReload": "Wymuś ponowne załadowanie",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Zminimalizuj",
|
||||
"window.title": "Okno",
|
||||
"window.toggleFullscreen": "Przełącz tryb pełnoekranowy",
|
||||
"window.zoom": "Powiększenie",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Powiększenie"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Serviços",
|
||||
"macOS.unhide": "Mostrar Todos",
|
||||
"tray.open": "Abrir {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Sair",
|
||||
"tray.show": "Mostrar {{appName}}",
|
||||
"view.forceReload": "Recarregar Forçadamente",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Minimizar",
|
||||
"window.title": "Janela",
|
||||
"window.toggleFullscreen": "Alternar Tela Cheia",
|
||||
"window.zoom": "Zoom",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Zoom"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Сервисы",
|
||||
"macOS.unhide": "Показать все",
|
||||
"tray.open": "Открыть {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Выйти",
|
||||
"tray.show": "Показать {{appName}}",
|
||||
"view.forceReload": "Принудительная перезагрузка",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Свернуть",
|
||||
"window.title": "Окно",
|
||||
"window.toggleFullscreen": "Переключить полноэкранный режим",
|
||||
"window.zoom": "Масштаб",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Масштаб"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Hizmetler",
|
||||
"macOS.unhide": "Hepsini Göster",
|
||||
"tray.open": "{{appName}}'i Aç",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Çık",
|
||||
"tray.show": "{{appName}}'i Göster",
|
||||
"view.forceReload": "Zorla Yenile",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Küçült",
|
||||
"window.title": "Pencere",
|
||||
"window.toggleFullscreen": "Tam Ekrana Geç",
|
||||
"window.zoom": "Yakınlaştır",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Yakınlaştır"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Dịch vụ",
|
||||
"macOS.unhide": "Hiện tất cả",
|
||||
"tray.open": "Mở {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Thoát",
|
||||
"tray.show": "Hiện {{appName}}",
|
||||
"view.forceReload": "Tải lại cưỡng bức",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Thu nhỏ",
|
||||
"window.title": "Cửa sổ",
|
||||
"window.toggleFullscreen": "Chuyển đổi toàn màn hình",
|
||||
"window.zoom": "Thu phóng",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Thu phóng"
|
||||
}
|
||||
|
||||
@@ -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": "已下载更新。现在安装吗?",
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "服務",
|
||||
"macOS.unhide": "全部顯示",
|
||||
"tray.open": "打開 {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "退出",
|
||||
"tray.show": "顯示 {{appName}}",
|
||||
"view.forceReload": "強制重新載入",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "最小化",
|
||||
"window.title": "視窗",
|
||||
"window.toggleFullscreen": "切換全螢幕",
|
||||
"window.zoom": "縮放",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "縮放"
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export const defaultProxySettings: NetworkProxySettings = {
|
||||
* Storage default values
|
||||
*/
|
||||
export const STORE_DEFAULTS: ElectronMainStore = {
|
||||
appTrayVisible: true,
|
||||
dataSyncConfig: { storageMode: 'cloud' },
|
||||
encryptedTokens: {},
|
||||
gatewayDeviceDescription: '',
|
||||
|
||||
@@ -111,20 +111,39 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
// ─── Tool Call Routing ───
|
||||
|
||||
private async executeToolCall(apiName: string, args: any): Promise<unknown> {
|
||||
const editFile = () => this.localFileCtr.handleEditFile(args);
|
||||
const globFiles = () => this.localFileCtr.handleGlobFiles(args);
|
||||
const listFiles = () => this.localFileCtr.listLocalFiles(args);
|
||||
const moveFiles = () => this.localFileCtr.handleMoveFiles(args);
|
||||
const readFile = () => this.localFileCtr.readFile(args);
|
||||
const searchFiles = () => this.localFileCtr.handleLocalFilesSearch(args);
|
||||
const writeFile = () => this.localFileCtr.handleWriteFile(args);
|
||||
|
||||
const methodMap: Record<string, () => Promise<unknown>> = {
|
||||
editLocalFile: () => this.localFileCtr.handleEditFile(args),
|
||||
globLocalFiles: () => this.localFileCtr.handleGlobFiles(args),
|
||||
editFile,
|
||||
globFiles,
|
||||
grepContent: () => this.localFileCtr.handleGrepContent(args),
|
||||
listLocalFiles: () => this.localFileCtr.listLocalFiles(args),
|
||||
moveLocalFiles: () => this.localFileCtr.handleMoveFiles(args),
|
||||
readLocalFile: () => this.localFileCtr.readFile(args),
|
||||
renameLocalFile: () => this.localFileCtr.handleRenameFile(args),
|
||||
searchLocalFiles: () => this.localFileCtr.handleLocalFilesSearch(args),
|
||||
writeLocalFile: () => this.localFileCtr.handleWriteFile(args),
|
||||
listFiles,
|
||||
moveFiles,
|
||||
readFile,
|
||||
searchFiles,
|
||||
writeFile,
|
||||
|
||||
getCommandOutput: () => this.shellCommandCtr.handleGetCommandOutput(args),
|
||||
killCommand: () => this.shellCommandCtr.handleKillCommand(args),
|
||||
runCommand: () => this.shellCommandCtr.handleRunCommand(args),
|
||||
|
||||
// Legacy aliases — keep these so older Gateway versions sending the long
|
||||
// names continue to route correctly. `renameLocalFile` is also kept even
|
||||
// though the new surface drops rename (it's now handled by `moveFiles`).
|
||||
editLocalFile: editFile,
|
||||
globLocalFiles: globFiles,
|
||||
listLocalFiles: listFiles,
|
||||
moveLocalFiles: moveFiles,
|
||||
readLocalFile: readFile,
|
||||
renameLocalFile: () => this.localFileCtr.handleRenameFile(args),
|
||||
searchLocalFiles: searchFiles,
|
||||
writeLocalFile: writeFile,
|
||||
};
|
||||
|
||||
const handler = methodMap[apiName];
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import type {
|
||||
GetGitBranchDiffPayload,
|
||||
GitAheadBehind,
|
||||
GitBranchDiffPatches,
|
||||
GitBranchInfo,
|
||||
GitBranchListItem,
|
||||
GitCheckoutResult,
|
||||
GitFileDiffStatus,
|
||||
GitLinkedPullRequestResult,
|
||||
GitPullResult,
|
||||
GitPushResult,
|
||||
GitRemoteBranchListItem,
|
||||
GitWorkingTreeFiles,
|
||||
GitWorkingTreePatch,
|
||||
GitWorkingTreePatches,
|
||||
GitWorkingTreeStatus,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
|
||||
@@ -22,6 +28,412 @@ import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
const logger = createLogger('controllers:GitCtr');
|
||||
|
||||
interface DirtyEntry {
|
||||
filePath: string;
|
||||
status: GitFileDiffStatus;
|
||||
}
|
||||
|
||||
interface DiffBlock {
|
||||
isBinary: boolean;
|
||||
patch: string;
|
||||
/** Destination path (or source path for deleted files). */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the output of `git diff HEAD --` into one block per file. Each block
|
||||
* starts at a `^diff --git ` line and runs to just before the next one (or
|
||||
* EOF). Path comes from the `+++ b/<path>` line, falling back to `--- a/<path>`
|
||||
* when the destination is `/dev/null` (deletion). Quoted paths (spaces /
|
||||
* non-ASCII when `core.quotepath` is on) are minimally de-escaped.
|
||||
*/
|
||||
const splitBulkDiff = (diffText: string): DiffBlock[] => {
|
||||
if (!diffText) return [];
|
||||
const blocks: DiffBlock[] = [];
|
||||
const headerRe = /^diff --git /gm;
|
||||
const starts: number[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = headerRe.exec(diffText)) !== null) starts.push(m.index);
|
||||
for (let i = 0; i < starts.length; i++) {
|
||||
const start = starts[i];
|
||||
const end = i + 1 < starts.length ? starts[i + 1] : diffText.length;
|
||||
const block = diffText.slice(start, end);
|
||||
const filePath = extractPathFromDiffBlock(block);
|
||||
if (!filePath) continue;
|
||||
blocks.push({
|
||||
isBinary: /^Binary files .* differ$/m.test(block),
|
||||
path: filePath,
|
||||
patch: block,
|
||||
});
|
||||
}
|
||||
return blocks;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pull the file path out of a per-file diff block. Looks at the `+++ b/<path>`
|
||||
* line first (covers add/modify); falls back to `--- a/<path>` for deletes
|
||||
* where `+++` is `/dev/null`; final fallback is the `diff --git a/x b/y`
|
||||
* header line.
|
||||
*/
|
||||
const extractPathFromDiffBlock = (block: string): string | null => {
|
||||
let plusPath: string | null = null;
|
||||
let minusPath: string | null = null;
|
||||
for (const line of block.split('\n')) {
|
||||
if (line.startsWith('+++ ')) {
|
||||
plusPath = parseDiffPathLine(line.slice(4), 'b/');
|
||||
} else if (line.startsWith('--- ')) {
|
||||
minusPath = parseDiffPathLine(line.slice(4), 'a/');
|
||||
}
|
||||
// The file headers always come before the first hunk / binary marker;
|
||||
// bail once we hit either to avoid scanning huge diff bodies.
|
||||
if (line.startsWith('@@') || line.startsWith('Binary files ')) break;
|
||||
}
|
||||
if (plusPath) return plusPath;
|
||||
if (minusPath) return minusPath;
|
||||
// Last-resort: parse the `diff --git a/x b/y` header itself.
|
||||
const header = block.split('\n', 1)[0];
|
||||
const match = /^diff --git a\/.+? b\/(.+)$/.exec(header);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip the `a/` or `b/` prefix off a `+++` / `---` line, drop the optional
|
||||
* trailing tab+timestamp, and de-quote git's C-style escaping. Returns null
|
||||
* for `/dev/null` (which means the other side of the diff is the real path).
|
||||
*/
|
||||
const parseDiffPathLine = (raw: string, prefix: 'a/' | 'b/'): string | null => {
|
||||
const tabIdx = raw.indexOf('\t');
|
||||
let p = tabIdx >= 0 ? raw.slice(0, tabIdx) : raw;
|
||||
if (p === '/dev/null') return null;
|
||||
// Quoted form: "b/path with spaces"
|
||||
if (p.startsWith('"') && p.endsWith('"')) {
|
||||
p = dequoteGitPath(p.slice(1, -1));
|
||||
}
|
||||
return p.startsWith(prefix) ? p.slice(prefix.length) : p;
|
||||
};
|
||||
|
||||
export const dequoteGitPath = (s: string): string =>
|
||||
s.replaceAll(/\\(["\\trn]|[0-7]{3})/g, (_, esc: string) => {
|
||||
if (esc === '"') return '"';
|
||||
if (esc === '\\') return '\\';
|
||||
if (esc === 't') return '\t';
|
||||
if (esc === 'r') return '\r';
|
||||
if (esc === 'n') return '\n';
|
||||
return String.fromCodePoint(Number.parseInt(esc, 8));
|
||||
});
|
||||
|
||||
/**
|
||||
* Inverse of {@link dequoteGitPath} — returns either `<prefix><path>` (when
|
||||
* no escaping is needed) or git's C-style quoted form `"<prefix><escaped>"`
|
||||
* (when the path contains TAB / LF / CR / quote / backslash / control bytes).
|
||||
* The prefix lives *inside* the quotes so the output matches what real `git
|
||||
* diff` would emit, e.g. `"a/file\twith tab.txt"` rather than `a/"file\twith
|
||||
* tab.txt"`. Plain spaces are not quoted (git tolerates them; the trailing
|
||||
* ` b/<path>` marker on the diff header is enough to delimit the source).
|
||||
*/
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const NEEDS_QUOTING = /["\\\x00-\x1F\x7F]/;
|
||||
export const quoteGitPath = (prefix: 'a/' | 'b/', filePath: string): string => {
|
||||
const combined = prefix + filePath;
|
||||
if (!NEEDS_QUOTING.test(combined)) return combined;
|
||||
let out = '"';
|
||||
for (const ch of combined) {
|
||||
if (ch === '\\') out += '\\\\';
|
||||
else if (ch === '"') out += '\\"';
|
||||
else if (ch === '\t') out += '\\t';
|
||||
else if (ch === '\n') out += '\\n';
|
||||
else if (ch === '\r') out += '\\r';
|
||||
else {
|
||||
const code = ch.codePointAt(0)!;
|
||||
if (code < 0x20 || code === 0x7f) {
|
||||
out += '\\' + code.toString(8).padStart(3, '0');
|
||||
} else {
|
||||
out += ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out + '"';
|
||||
};
|
||||
|
||||
/**
|
||||
* Status from a single diff block's preamble: `new file mode` → added,
|
||||
* `deleted file mode` → deleted, otherwise modified. Used by branch-diff mode
|
||||
* where there's no `git status` to consult — the diff itself is the source.
|
||||
*/
|
||||
const detectDiffBlockStatus = (block: string): GitFileDiffStatus => {
|
||||
// Only scan up to the first hunk / binary marker so huge bodies aren't walked.
|
||||
for (const line of block.split('\n')) {
|
||||
if (line.startsWith('new file mode ')) return 'added';
|
||||
if (line.startsWith('deleted file mode ')) return 'deleted';
|
||||
if (line.startsWith('@@') || line.startsWith('Binary files ')) break;
|
||||
}
|
||||
return 'modified';
|
||||
};
|
||||
|
||||
/** Walk a patch counting `+`/`-` lines while skipping `+++`/`---` headers. */
|
||||
const countAddDel = (patch: string): { additions: number; deletions: number } => {
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
for (const line of patch.split('\n')) {
|
||||
if (line.startsWith('+++') || line.startsWith('---')) continue;
|
||||
if (line.startsWith('+')) additions++;
|
||||
else if (line.startsWith('-')) deletions++;
|
||||
}
|
||||
return { additions, deletions };
|
||||
};
|
||||
|
||||
const emptyPatch = (entry: DirtyEntry): GitWorkingTreePatch => ({
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
filePath: entry.filePath,
|
||||
isBinary: false,
|
||||
patch: '',
|
||||
status: entry.status,
|
||||
truncated: false,
|
||||
});
|
||||
|
||||
const buildTrackedPatch = (
|
||||
entry: DirtyEntry,
|
||||
block: DiffBlock,
|
||||
maxBytes: number,
|
||||
): GitWorkingTreePatch => {
|
||||
if (block.isBinary) {
|
||||
return { ...emptyPatch(entry), isBinary: true };
|
||||
}
|
||||
if (block.patch.length > maxBytes) {
|
||||
return { ...emptyPatch(entry), truncated: true };
|
||||
}
|
||||
const { additions, deletions } = countAddDel(block.patch);
|
||||
return {
|
||||
additions,
|
||||
deletions,
|
||||
filePath: entry.filePath,
|
||||
isBinary: false,
|
||||
patch: block.patch,
|
||||
status: entry.status,
|
||||
truncated: false,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a synthetic add-only patch for an untracked file by reading it from
|
||||
* disk — replaces the per-file `git diff --no-index /dev/null <file>` fork.
|
||||
* Binary detection uses a NUL-byte sniff over the first 8 KB (matches what
|
||||
* git itself does internally).
|
||||
*/
|
||||
const readUntrackedAsPatch = async (
|
||||
cwd: string,
|
||||
entry: DirtyEntry,
|
||||
maxBytes: number,
|
||||
): Promise<GitWorkingTreePatch> => {
|
||||
const absolute = path.resolve(cwd, entry.filePath);
|
||||
let size: number;
|
||||
try {
|
||||
const s = await stat(absolute);
|
||||
if (!s.isFile()) return emptyPatch(entry);
|
||||
size = s.size;
|
||||
} catch (error: any) {
|
||||
logger.debug('[readUntrackedAsPatch] stat failed', {
|
||||
filePath: entry.filePath,
|
||||
message: error?.message,
|
||||
});
|
||||
return emptyPatch(entry);
|
||||
}
|
||||
// Pre-quote so the path is C-style escaped wherever it lands in the synthetic
|
||||
// patch — raw `entry.filePath` interpolation would emit malformed `diff --git`
|
||||
// / `+++` lines for filenames containing TAB / LF / quote / backslash.
|
||||
const aPath = quoteGitPath('a/', entry.filePath);
|
||||
const bPath = quoteGitPath('b/', entry.filePath);
|
||||
if (size === 0) {
|
||||
return {
|
||||
...emptyPatch(entry),
|
||||
patch:
|
||||
[
|
||||
`diff --git ${aPath} ${bPath}`,
|
||||
'new file mode 100644',
|
||||
'--- /dev/null',
|
||||
`+++ ${bPath}`,
|
||||
].join('\n') + '\n',
|
||||
};
|
||||
}
|
||||
// Cap the synthesized patch by *file* size, not patch size — a 200 KB file
|
||||
// produces a ~200 KB patch (one `+` per line). Close enough.
|
||||
if (size > maxBytes) {
|
||||
return { ...emptyPatch(entry), truncated: true };
|
||||
}
|
||||
let buf: Buffer;
|
||||
try {
|
||||
buf = await readFile(absolute);
|
||||
} catch (error: any) {
|
||||
logger.debug('[readUntrackedAsPatch] read failed', {
|
||||
filePath: entry.filePath,
|
||||
message: error?.message,
|
||||
});
|
||||
return emptyPatch(entry);
|
||||
}
|
||||
const sniffEnd = Math.min(buf.length, 8192);
|
||||
for (let i = 0; i < sniffEnd; i++) {
|
||||
if (buf[i] === 0) return { ...emptyPatch(entry), isBinary: true };
|
||||
}
|
||||
const text = buf.toString('utf8');
|
||||
// text.split('\n') leaves a trailing '' when the file ends with '\n';
|
||||
// exclude it so the hunk header line count matches git's own output.
|
||||
const rawLines = text.split('\n');
|
||||
const trailingEmpty = rawLines.length > 0 && rawLines.at(-1) === '';
|
||||
const lineCount = trailingEmpty ? rawLines.length - 1 : rawLines.length;
|
||||
if (lineCount === 0) {
|
||||
return { ...emptyPatch(entry), patch: '' };
|
||||
}
|
||||
const body = rawLines
|
||||
.slice(0, lineCount)
|
||||
.map((line) => '+' + line)
|
||||
.join('\n');
|
||||
// Mirror `git diff --no-index`'s "no newline at end of file" footer when the
|
||||
// source had no trailing newline — keeps PatchDiff's hunk parser happy.
|
||||
const noNewlineFooter = trailingEmpty ? '' : '\n\\ No newline at end of file';
|
||||
const patch =
|
||||
[
|
||||
`diff --git ${aPath} ${bPath}`,
|
||||
'new file mode 100644',
|
||||
'--- /dev/null',
|
||||
`+++ ${bPath}`,
|
||||
`@@ -0,0 +1,${lineCount} @@`,
|
||||
body,
|
||||
].join('\n') +
|
||||
noNewlineFooter +
|
||||
'\n';
|
||||
return {
|
||||
additions: lineCount,
|
||||
deletions: 0,
|
||||
filePath: entry.filePath,
|
||||
isBinary: false,
|
||||
patch,
|
||||
status: entry.status,
|
||||
truncated: false,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Stream a git invocation's stdout via `spawn` instead of `execFile`'s
|
||||
* fixed-size buffer. Replaces the bulk-diff caller's old 64 MB `maxBuffer`
|
||||
* cap — pipe-buffer-sized chunks accumulate in memory until the process
|
||||
* exits, with no hard ceiling. SIGTERM on timeout. Resolves with the full
|
||||
* stdout string; rejects with an Error carrying `stderr` and `partialStdout`
|
||||
* fields so callers can salvage partial output (or fall back) on failure.
|
||||
*/
|
||||
const runGitCaptureStream = (cwd: string, args: string[], timeoutMs: number): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn('git', args, { cwd });
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
let stderrBuf = '';
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill('SIGTERM');
|
||||
}, timeoutMs);
|
||||
child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderrBuf += chunk.toString('utf8');
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(Object.assign(err, { stderr: stderrBuf }));
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
const stdout = Buffer.concat(stdoutChunks).toString('utf8');
|
||||
if (timedOut) {
|
||||
const err: any = new Error('git command timed out');
|
||||
err.stderr = stderrBuf;
|
||||
err.partialStdout = stdout;
|
||||
return reject(err);
|
||||
}
|
||||
// `git diff HEAD` (without --exit-code) exits 0 even when there are
|
||||
// diffs; non-zero is therefore a real error.
|
||||
if (code !== 0) {
|
||||
const err: any = new Error(`git exited with code ${code}`);
|
||||
err.code = code;
|
||||
err.stderr = stderrBuf;
|
||||
err.partialStdout = stdout;
|
||||
return reject(err);
|
||||
}
|
||||
resolve(stdout);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Last-resort per-file diff for tracked entries the bulk diff didn't cover —
|
||||
* either because the bulk command failed entirely or because git emitted no
|
||||
* patch for a path the status step listed (rare race with concurrent writes).
|
||||
* Mirrors the original per-file behavior so individual files keep their
|
||||
* patches even when the bulk fast-path is unavailable.
|
||||
*/
|
||||
const fetchTrackedPatchPerFile = async (
|
||||
cwd: string,
|
||||
entry: DirtyEntry,
|
||||
maxBytes: number,
|
||||
): Promise<GitWorkingTreePatch> => {
|
||||
const execFileAsync = promisify(execFile);
|
||||
let text: string;
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
['-c', 'core.quotepath=off', 'diff', '--no-color', 'HEAD', '--', entry.filePath],
|
||||
{
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: maxBytes * 4,
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
text = stdout as string;
|
||||
} catch (error: any) {
|
||||
logger.debug('[fetchTrackedPatchPerFile] diff failed', {
|
||||
filePath: entry.filePath,
|
||||
stderr: error?.stderr?.toString?.() ?? error?.stderr,
|
||||
});
|
||||
return emptyPatch(entry);
|
||||
}
|
||||
if (text.length > maxBytes) return { ...emptyPatch(entry), truncated: true };
|
||||
if (/^Binary files .* differ$/m.test(text)) return { ...emptyPatch(entry), isBinary: true };
|
||||
if (!text) return emptyPatch(entry);
|
||||
const { additions, deletions } = countAddDel(text);
|
||||
return {
|
||||
additions,
|
||||
deletions,
|
||||
filePath: entry.filePath,
|
||||
isBinary: false,
|
||||
patch: text,
|
||||
status: entry.status,
|
||||
truncated: false,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Bounded `Promise.all` — runs at most `limit` async tasks at a time. Used
|
||||
* for the per-file fallback so we cap fork pressure at a small constant
|
||||
* instead of replaying the original 200-parallel `git diff` storm.
|
||||
*/
|
||||
const mapWithConcurrency = async <T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>,
|
||||
): Promise<R[]> => {
|
||||
const results: R[] = Array.from({ length: items.length });
|
||||
let cursor = 0;
|
||||
const workerCount = Math.min(limit, items.length);
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (true) {
|
||||
const idx = cursor++;
|
||||
if (idx >= items.length) return;
|
||||
results[idx] = await fn(items[idx]);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
};
|
||||
|
||||
export default class GitController extends ControllerModule {
|
||||
static override readonly groupName = 'git';
|
||||
|
||||
@@ -162,6 +574,54 @@ export default class GitController extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List remote branches under `refs/remotes/origin/*`, ordered by most
|
||||
* recent commit. The `HEAD` symref is filtered out and the resolved
|
||||
* default branch is flagged via `isDefault` so the UI can render it
|
||||
* with a marker. Used by the Review panel's branch-compare picker.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async listGitRemoteBranches(dirPath: string): Promise<GitRemoteBranchListItem[]> {
|
||||
const execFileAsync = promisify(execFile);
|
||||
let defaultRef: string | undefined;
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'],
|
||||
{ cwd: dirPath, timeout: 5000 },
|
||||
);
|
||||
defaultRef = stdout.trim() || undefined;
|
||||
} catch {
|
||||
defaultRef = undefined;
|
||||
}
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
[
|
||||
'for-each-ref',
|
||||
'--sort=-committerdate',
|
||||
'--format=%(refname:short)',
|
||||
'refs/remotes/origin',
|
||||
],
|
||||
{ cwd: dirPath, timeout: 5000 },
|
||||
);
|
||||
return stdout
|
||||
.replaceAll('\r', '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((name) => name.length > 0 && name !== 'origin/HEAD' && !name.endsWith('/HEAD'))
|
||||
.map((name) => ({ isDefault: name === defaultRef, name }));
|
||||
} catch (error: any) {
|
||||
logger.warn('[listGitRemoteBranches] git command failed', {
|
||||
code: error?.code,
|
||||
cwd: dirPath,
|
||||
message: error?.message,
|
||||
stderr: error?.stderr?.toString?.() ?? error?.stderr,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket dirty files into added / modified / deleted via `git status --porcelain -z`.
|
||||
* Each file is counted once: untracked (`??`) and staged-add (`A`) → added,
|
||||
@@ -261,6 +721,235 @@ export default class GitController extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull every dirty file's unified diff in one shot — one IPC call returns
|
||||
* the patches the renderer needs to render `<PatchDiff />` per file.
|
||||
*
|
||||
* Tracked changes (modified / deleted / staged-A) all come from a *single*
|
||||
* `git diff HEAD --` invocation that we split per-file in JS — fork-bombing
|
||||
* the main process with N parallel `git diff` subprocesses was costing us
|
||||
* ~5–10ms × N in fork overhead plus `.git/index` lock contention, and the
|
||||
* libuv worker pool stayed busy while other IPC handlers queued. One
|
||||
* subprocess instead of N keeps the freeze invisible.
|
||||
*
|
||||
* Untracked files are read directly with `fs.readFile` and a synthetic
|
||||
* `--- /dev/null / +++ b/<path>` patch is built in Node — no `git diff`
|
||||
* subprocess at all.
|
||||
*
|
||||
* Per-file patches are capped at 256 KB; oversized or binary entries get an
|
||||
* empty `patch` string and a flag the renderer can use for a placeholder.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async getGitWorkingTreePatches(dirPath: string): Promise<GitWorkingTreePatches> {
|
||||
const MAX_PATCH_BYTES = 256 * 1024;
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface Entry {
|
||||
filePath: string;
|
||||
isUntracked: boolean;
|
||||
status: GitFileDiffStatus;
|
||||
}
|
||||
|
||||
// Step 1 — classify every dirty path. Mirrors getGitWorkingTreeFiles but
|
||||
// also distinguishes untracked (`??`) from staged-add (`A`) so we can pick
|
||||
// the right path (git diff vs raw read) per entry.
|
||||
const entries: Entry[] = [];
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-z'], {
|
||||
cwd: dirPath,
|
||||
timeout: 5000,
|
||||
});
|
||||
const tokens = stdout.split('\0');
|
||||
let i = 0;
|
||||
while (i < tokens.length) {
|
||||
const entry = tokens[i];
|
||||
i++;
|
||||
if (entry.length < 3) continue;
|
||||
const x = entry[0];
|
||||
const y = entry[1];
|
||||
const filePath = entry.slice(3);
|
||||
// R/C entries carry an extra source-path token we must consume.
|
||||
if (x === 'R' || x === 'C') i++;
|
||||
if (!filePath) continue;
|
||||
if (x === '?' && y === '?') {
|
||||
entries.push({ filePath, isUntracked: true, status: 'added' });
|
||||
} else if (x === '!' && y === '!') {
|
||||
// ignored
|
||||
} else if (x === 'D' || y === 'D') {
|
||||
entries.push({ filePath, isUntracked: false, status: 'deleted' });
|
||||
} else if (x === 'A' || y === 'A') {
|
||||
entries.push({ filePath, isUntracked: false, status: 'added' });
|
||||
} else {
|
||||
entries.push({ filePath, isUntracked: false, status: 'modified' });
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.warn('[getGitWorkingTreePatches] status failed', {
|
||||
cwd: dirPath,
|
||||
stderr: error?.stderr?.toString?.() ?? error?.stderr,
|
||||
});
|
||||
return { patches: [] };
|
||||
}
|
||||
|
||||
// Step 2a — single bulk `git diff HEAD` for every tracked dirty path,
|
||||
// then split per-file in JS. We pass paths explicitly (not all) so a
|
||||
// huge unrelated working tree doesn't pull extra patches into the
|
||||
// stream. Output is streamed via spawn so there's no maxBuffer ceiling
|
||||
// — even a multi-hundred-MB combined diff lands intact, and any partial
|
||||
// output recovered from a failed run still feeds the per-file fallback.
|
||||
const trackedEntries = entries.filter((e) => !e.isUntracked);
|
||||
const trackedByPath = new Map(trackedEntries.map((e) => [e.filePath, e]));
|
||||
const trackedPatches = new Map<string, GitWorkingTreePatch>();
|
||||
if (trackedEntries.length > 0) {
|
||||
let bulkDiff = '';
|
||||
try {
|
||||
bulkDiff = await runGitCaptureStream(
|
||||
dirPath,
|
||||
[
|
||||
'-c',
|
||||
'core.quotepath=off',
|
||||
'diff',
|
||||
'--no-color',
|
||||
'HEAD',
|
||||
'--',
|
||||
...trackedEntries.map((e) => e.filePath),
|
||||
],
|
||||
30_000,
|
||||
);
|
||||
} catch (error: any) {
|
||||
logger.warn('[getGitWorkingTreePatches] bulk diff failed; per-file fallback', {
|
||||
cwd: dirPath,
|
||||
stderr: error?.stderr?.toString?.() ?? error?.stderr,
|
||||
});
|
||||
// Salvage any patches that did stream through before the failure —
|
||||
// the per-file fallback below only retries the stragglers.
|
||||
if (typeof error?.partialStdout === 'string') bulkDiff = error.partialStdout;
|
||||
}
|
||||
for (const block of splitBulkDiff(bulkDiff)) {
|
||||
const entry = trackedByPath.get(block.path);
|
||||
if (!entry) continue;
|
||||
trackedPatches.set(entry.filePath, buildTrackedPatch(entry, block, MAX_PATCH_BYTES));
|
||||
}
|
||||
// Anything the bulk diff didn't cover (bulk crashed, race-with-write,
|
||||
// or git emitted no patch for a path status flagged dirty) gets a
|
||||
// per-file retry. Concurrency-capped to avoid the original fork storm.
|
||||
const stragglers = trackedEntries.filter((e) => !trackedPatches.has(e.filePath));
|
||||
if (stragglers.length > 0) {
|
||||
const recovered = await mapWithConcurrency(stragglers, 8, (entry) =>
|
||||
fetchTrackedPatchPerFile(dirPath, entry, MAX_PATCH_BYTES),
|
||||
);
|
||||
for (const patch of recovered) trackedPatches.set(patch.filePath, patch);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2b — read untracked files directly in Node. fs.readFile is bounded
|
||||
// by libuv's thread pool (4 by default) so unbounded Promise.all is fine.
|
||||
const untrackedEntries = entries.filter((e) => e.isUntracked);
|
||||
const untrackedPatches = await Promise.all(
|
||||
untrackedEntries.map((entry) => readUntrackedAsPatch(dirPath, entry, MAX_PATCH_BYTES)),
|
||||
);
|
||||
|
||||
// Step 3 — combine + sort to match the working-tree popover order.
|
||||
const order: Record<GitFileDiffStatus, number> = { added: 0, modified: 1, deleted: 2 };
|
||||
const allPatches: GitWorkingTreePatch[] = [...trackedPatches.values(), ...untrackedPatches];
|
||||
allPatches.sort((a, b) => order[a.status] - order[b.status]);
|
||||
|
||||
return { patches: allPatches };
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff every changed file between the current HEAD and the remote default
|
||||
* branch (resolved via `refs/remotes/origin/HEAD` — typically `origin/main`
|
||||
* or `origin/canary`). Uses `<base>...HEAD` so the result is "what this
|
||||
* branch added since it forked", ignoring upstream-only commits.
|
||||
*
|
||||
* Best-effort `git fetch` first so the comparison reflects the latest
|
||||
* remote state; fetch failures (offline / no creds / no `origin`) are
|
||||
* swallowed and we fall back to whatever cached refs exist. Returns
|
||||
* `baseRef: undefined` + empty patches when no remote default is set —
|
||||
* the renderer surfaces a "noBaseRef" hint in that case.
|
||||
*
|
||||
* Patch parsing reuses the same bulk-split + size-cap path as the working
|
||||
* tree variant; status comes from each diff block's preamble (no `git
|
||||
* status` cross-reference needed since every block is from history).
|
||||
*/
|
||||
@IpcMethod()
|
||||
async getGitBranchDiff(payload: GetGitBranchDiffPayload): Promise<GitBranchDiffPatches> {
|
||||
const { path: dirPath, baseRef: baseRefOverride } = payload;
|
||||
const MAX_PATCH_BYTES = 256 * 1024;
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Step 1 — best-effort fetch so origin/<default> reflects remote HEAD.
|
||||
try {
|
||||
await execFileAsync('git', ['fetch', '--no-tags', '--quiet', 'origin'], {
|
||||
cwd: dirPath,
|
||||
timeout: 10_000,
|
||||
});
|
||||
} catch {
|
||||
// swallow — fall through to cached refs
|
||||
}
|
||||
|
||||
// Step 2 — pick the comparison base. When the caller passes an explicit
|
||||
// override (e.g. user picked a non-default branch in the UI) we trust it;
|
||||
// otherwise we resolve `refs/remotes/origin/HEAD`. The default may be
|
||||
// missing on repos cloned with --no-checkout or after a remote rename —
|
||||
// surface a "noBaseRef" empty state in that case so the user can run
|
||||
// `git remote set-head origin --auto` themselves.
|
||||
let baseRef: string | undefined = baseRefOverride;
|
||||
if (!baseRef) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'],
|
||||
{ cwd: dirPath, timeout: 5000 },
|
||||
);
|
||||
baseRef = stdout.trim() || undefined;
|
||||
} catch {
|
||||
baseRef = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// headRef populated even when baseRef is missing so the UI can still
|
||||
// surface "fix/foo ← ?" instead of going completely blank.
|
||||
const headRef = (await this.getGitBranch(dirPath)).branch;
|
||||
|
||||
if (!baseRef) {
|
||||
return { headRef, patches: [] };
|
||||
}
|
||||
|
||||
// Step 3 — single bulk diff against the merge base. Three-dot semantics
|
||||
// (`base...HEAD`) ignore commits added to base after the branch forked,
|
||||
// matching what users expect from "compare branch" UI on GitHub. Stream
|
||||
// capture mirrors the working-tree path so multi-MB diffs land intact.
|
||||
let bulkDiff = '';
|
||||
try {
|
||||
bulkDiff = await runGitCaptureStream(
|
||||
dirPath,
|
||||
['-c', 'core.quotepath=off', 'diff', '--no-color', `${baseRef}...HEAD`],
|
||||
30_000,
|
||||
);
|
||||
} catch (error: any) {
|
||||
logger.warn('[getGitBranchDiff] diff failed', {
|
||||
baseRef,
|
||||
cwd: dirPath,
|
||||
stderr: error?.stderr?.toString?.() ?? error?.stderr,
|
||||
});
|
||||
if (typeof error?.partialStdout === 'string') bulkDiff = error.partialStdout;
|
||||
}
|
||||
|
||||
// Step 4 — split + classify per-file from the diff preamble alone.
|
||||
const patches: GitWorkingTreePatch[] = [];
|
||||
for (const block of splitBulkDiff(bulkDiff)) {
|
||||
const status = detectDiffBlockStatus(block.patch);
|
||||
patches.push(buildTrackedPatch({ filePath: block.path, status }, block, MAX_PATCH_BYTES));
|
||||
}
|
||||
|
||||
const order: Record<GitFileDiffStatus, number> = { added: 0, modified: 1, deleted: 2 };
|
||||
patches.sort((a, b) => order[a.status] - order[b.status]);
|
||||
|
||||
return { baseRef, headRef, patches };
|
||||
}
|
||||
|
||||
/**
|
||||
* Count commits HEAD is ahead/behind its upstream tracking ref.
|
||||
* Returns `hasUpstream: false` when the branch has no upstream configured
|
||||
|
||||
@@ -1,52 +1,65 @@
|
||||
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 { randomUUID } from 'node:crypto';
|
||||
import { access, appendFile, mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { Readable, Writable } from 'node:stream';
|
||||
import { finished as streamFinished } from 'node:stream/promises';
|
||||
|
||||
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 type { AgentContentBlock } from '@lobechat/heterogeneous-agents/spawn';
|
||||
import {
|
||||
AgentStreamPipeline,
|
||||
buildAgentInput,
|
||||
materializeImageToPath,
|
||||
normalizeImage,
|
||||
} from '@lobechat/heterogeneous-agents/spawn';
|
||||
import { app as electronApp, BrowserWindow } from 'electron';
|
||||
|
||||
import { getHeterogeneousAgentDriver } from '@/modules/heterogeneousAgent';
|
||||
import type { HeterogeneousAgentImageAttachment } 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' },
|
||||
};
|
||||
const CLI_TRACE_DIR = '.heerogeneous-tracing';
|
||||
const CODEX_STDERR_STATUS_LINE = 'Reading prompt from stdin...';
|
||||
const CODEX_WARN_LOG_PATTERN = /^\d{4}-\d{2}-\d{2}T\S+\s+WARN\s+/;
|
||||
const CODEX_LOG_PATTERN = /^\d{4}-\d{2}-\d{2}T\S+\s+(?:DEBUG|ERROR|INFO|TRACE|WARN)\s+/;
|
||||
const CLI_ERROR_LINE_PATTERN = /^(?:error:|Error:|Usage:)/;
|
||||
|
||||
// ─── IPC types ───
|
||||
|
||||
@@ -69,14 +82,15 @@ 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[];
|
||||
/**
|
||||
* Renderer-side operation id stamped onto every emitted `AgentStreamEvent`.
|
||||
* Required: producer-side conversion is the V3 contract — by the time events
|
||||
* reach the renderer they must already carry the operation they belong to.
|
||||
*/
|
||||
operationId: string;
|
||||
prompt: string;
|
||||
sessionId: string;
|
||||
}
|
||||
@@ -115,23 +129,434 @@ interface AgentSession {
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
process?: ChildProcess;
|
||||
resumeSessionId?: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
type SessionErrorPayload = HeterogeneousAgentSessionError | string;
|
||||
|
||||
interface CliTraceSession {
|
||||
dir: string;
|
||||
writeQueue: Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Lifecycle: startSession → sendPrompt → (heteroAgentEvent broadcasts) → stopSession
|
||||
*/
|
||||
export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
static override readonly groupName = 'heterogeneousAgent';
|
||||
|
||||
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 getRelevantCodexStderr(stderr: string): string {
|
||||
const keptLines: string[] = [];
|
||||
let droppingWarnBlock = false;
|
||||
|
||||
for (const line of stderr.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed === CODEX_STDERR_STATUS_LINE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (CODEX_WARN_LOG_PATTERN.test(trimmed)) {
|
||||
droppingWarnBlock = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (CODEX_LOG_PATTERN.test(trimmed)) {
|
||||
droppingWarnBlock = false;
|
||||
keptLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (droppingWarnBlock && !CLI_ERROR_LINE_PATTERN.test(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
droppingWarnBlock = false;
|
||||
keptLines.push(line);
|
||||
}
|
||||
|
||||
return keptLines.join('\n').trim();
|
||||
}
|
||||
|
||||
private getExitErrorMessage(
|
||||
code: number | null,
|
||||
session: AgentSession,
|
||||
stderrOutput: string,
|
||||
): string {
|
||||
const relevantStderr =
|
||||
session.agentType === 'codex' ? this.getRelevantCodexStderr(stderrOutput) : stderrOutput;
|
||||
|
||||
return relevantStderr || `Agent exited with code ${code}`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private get shouldTraceCliOutput(): boolean {
|
||||
return process.env.NODE_ENV !== 'test' && !electronApp.isPackaged;
|
||||
}
|
||||
|
||||
private formatTraceTimestamp(date: Date): string {
|
||||
const pad = (value: number) => value.toString().padStart(2, '0');
|
||||
|
||||
return [
|
||||
date.getFullYear(),
|
||||
pad(date.getMonth() + 1),
|
||||
pad(date.getDate()),
|
||||
'-',
|
||||
pad(date.getHours()),
|
||||
pad(date.getMinutes()),
|
||||
pad(date.getSeconds()),
|
||||
].join('');
|
||||
}
|
||||
|
||||
private sanitizeTracePathSegment(value: string): string {
|
||||
const sanitized = value
|
||||
.replaceAll(path.sep, '-')
|
||||
.replaceAll(/[^\w.-]+/g, '-')
|
||||
.replaceAll(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
|
||||
return sanitized || 'unknown';
|
||||
}
|
||||
|
||||
private getAttachmentTraceSummary(image: HeterogeneousAgentImageAttachment) {
|
||||
let urlKind = 'unknown';
|
||||
|
||||
try {
|
||||
urlKind = new URL(image.url).protocol.replace(/:$/, '') || urlKind;
|
||||
} catch {
|
||||
urlKind = image.url.startsWith('data:') ? 'data' : 'unknown';
|
||||
}
|
||||
|
||||
return {
|
||||
id: image.id,
|
||||
urlKind,
|
||||
};
|
||||
}
|
||||
|
||||
private async createCliTraceSession({
|
||||
cliArgs,
|
||||
cwd,
|
||||
imageList,
|
||||
session,
|
||||
stdinPayload,
|
||||
}: {
|
||||
cliArgs: string[];
|
||||
cwd: string;
|
||||
imageList: HeterogeneousAgentImageAttachment[];
|
||||
session: AgentSession;
|
||||
stdinPayload?: string;
|
||||
}): Promise<CliTraceSession | undefined> {
|
||||
if (!this.shouldTraceCliOutput) return;
|
||||
|
||||
// Don't materialize the cwd via mkdir — if the caller passed a stale or
|
||||
// typo'd path, we want spawn() to fail loudly instead of silently running
|
||||
// the agent in an empty auto-created directory.
|
||||
try {
|
||||
await access(cwd);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const createdAt = new Date();
|
||||
const rootDir = path.join(cwd, CLI_TRACE_DIR);
|
||||
const agentDir = path.join(rootDir, this.sanitizeTracePathSegment(session.agentType));
|
||||
const traceId = `${this.formatTraceTimestamp(createdAt)}-${this.sanitizeTracePathSegment(
|
||||
session.sessionId,
|
||||
)}`;
|
||||
const dir = path.join(agentDir, traceId);
|
||||
|
||||
try {
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(path.join(rootDir, '.last-live-trace'), `${dir}\n`);
|
||||
await writeFile(path.join(dir, 'stdout.jsonl'), '');
|
||||
await writeFile(path.join(dir, 'stderr.log'), '');
|
||||
if (stdinPayload !== undefined) {
|
||||
await writeFile(path.join(dir, 'stdin.txt'), '');
|
||||
}
|
||||
await writeFile(
|
||||
path.join(dir, 'meta.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
agentSessionId: session.agentSessionId,
|
||||
agentType: session.agentType,
|
||||
args: cliArgs,
|
||||
attachments: imageList.map((image) => this.getAttachmentTraceSummary(image)),
|
||||
command: session.command,
|
||||
createdAt: createdAt.toISOString(),
|
||||
cwd,
|
||||
envKeys: session.env ? Object.keys(session.env).sort() : [],
|
||||
resumeSessionId: session.resumeSessionId,
|
||||
sessionId: session.sessionId,
|
||||
stdinBytes: stdinPayload === undefined ? 0 : Buffer.byteLength(stdinPayload),
|
||||
stdinFile: stdinPayload === undefined ? undefined : 'stdin.txt',
|
||||
stderrFile: 'stderr.log',
|
||||
stdoutFile: 'stdout.jsonl',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
return { dir, writeQueue: Promise.resolve() };
|
||||
} catch (error) {
|
||||
logger.warn('Failed to initialize CLI trace directory:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private queueCliTraceWrite(
|
||||
trace: CliTraceSession | undefined,
|
||||
write: () => Promise<void>,
|
||||
): Promise<void> | undefined {
|
||||
if (!trace) return;
|
||||
|
||||
trace.writeQueue = trace.writeQueue.then(write).catch((error) => {
|
||||
logger.warn('Failed to write CLI trace file:', error);
|
||||
});
|
||||
|
||||
return trace.writeQueue;
|
||||
}
|
||||
|
||||
private appendCliTraceFile(
|
||||
trace: CliTraceSession | undefined,
|
||||
fileName: string,
|
||||
data: Buffer | string,
|
||||
): Promise<void> | undefined {
|
||||
if (!trace) return;
|
||||
|
||||
const filePath = path.join(trace.dir, fileName);
|
||||
|
||||
return this.queueCliTraceWrite(trace, () => appendFile(filePath, data));
|
||||
}
|
||||
|
||||
private writeCliTraceFile(
|
||||
trace: CliTraceSession | undefined,
|
||||
fileName: string,
|
||||
data: string,
|
||||
): Promise<void> | undefined {
|
||||
if (!trace) return;
|
||||
|
||||
const filePath = path.join(trace.dir, fileName);
|
||||
|
||||
return this.queueCliTraceWrite(trace, () => writeFile(filePath, data));
|
||||
}
|
||||
|
||||
private writeCliTraceJson(
|
||||
trace: CliTraceSession | undefined,
|
||||
fileName: string,
|
||||
payload: unknown,
|
||||
): Promise<void> | undefined {
|
||||
return this.writeCliTraceFile(trace, fileName, `${JSON.stringify(payload, null, 2)}\n`);
|
||||
}
|
||||
|
||||
private async flushCliTrace(trace: CliTraceSession | undefined): Promise<void> {
|
||||
await trace?.writeQueue;
|
||||
}
|
||||
|
||||
// ─── Broadcast ───
|
||||
|
||||
private broadcast<T>(channel: string, data: T) {
|
||||
@@ -149,87 +574,79 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a filesystem-safe cache key for attachments.
|
||||
*
|
||||
* Never use the raw image id as a path segment — upstream callers can persist
|
||||
* arbitrary ids and path.join would treat traversal sequences as real
|
||||
* directories. A stable hash preserves cache hits without trusting the id as a
|
||||
* filename.
|
||||
* Convert a desktop image attachment list into shared content blocks. Each
|
||||
* attachment's id is preserved as the cache key so repeated prompts hit the
|
||||
* same on-disk entries.
|
||||
*/
|
||||
private getImageCacheKey(imageId: string): string {
|
||||
return createHash('sha256').update(imageId).digest('hex');
|
||||
private toImageContentBlocks(
|
||||
imageList: HeterogeneousAgentImageAttachment[],
|
||||
): AgentContentBlock[] {
|
||||
return imageList.map((image) => ({
|
||||
source: { id: image.id, type: 'url', url: image.url },
|
||||
type: 'image',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an image by URL, with local disk cache keyed by id.
|
||||
*/
|
||||
private async resolveImage(
|
||||
image: ImageAttachment,
|
||||
): Promise<{ buffer: Buffer; mimeType: string }> {
|
||||
const cacheDir = this.fileCacheDir;
|
||||
const cacheKey = this.getImageCacheKey(image.id);
|
||||
const metaPath = path.join(cacheDir, `${cacheKey}.meta`);
|
||||
const dataPath = path.join(cacheDir, cacheKey);
|
||||
|
||||
// Check cache first
|
||||
try {
|
||||
const metaRaw = await readFile(metaPath, 'utf8');
|
||||
const meta = JSON.parse(metaRaw);
|
||||
const buffer = await readFile(dataPath);
|
||||
logger.debug('Image cache hit:', image.id);
|
||||
return { buffer, mimeType: meta.mimeType || 'image/png' };
|
||||
} catch {
|
||||
// Cache miss — download
|
||||
}
|
||||
|
||||
logger.info('Downloading image:', image.id);
|
||||
|
||||
const res = await fetch(image.url);
|
||||
if (!res.ok)
|
||||
throw new Error(`Failed to download image ${image.id}: ${res.status} ${res.statusText}`);
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const mimeType = res.headers.get('content-type') || 'image/png';
|
||||
|
||||
// Write to cache
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
await writeFile(dataPath, buffer);
|
||||
await writeFile(metaPath, JSON.stringify({ id: image.id, mimeType }));
|
||||
logger.debug('Image cached:', image.id, `${buffer.length} bytes`);
|
||||
|
||||
return { buffer, mimeType };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stream-json user message with text + optional image content blocks.
|
||||
* Build a Claude Code stream-json user message with text + base64 images.
|
||||
* Delegates to the shared `buildAgentInput`; the desktop wrapper exists only
|
||||
* to preserve the helper signature consumed by existing drivers.
|
||||
*/
|
||||
private async buildStreamJsonInput(
|
||||
prompt: string,
|
||||
imageList: ImageAttachment[] = [],
|
||||
imageList: HeterogeneousAgentImageAttachment[] = [],
|
||||
): Promise<string> {
|
||||
const content: any[] = [{ text: prompt, type: 'text' }];
|
||||
const blocks: AgentContentBlock[] = [];
|
||||
if (prompt && prompt.length > 0) blocks.push({ text: prompt, type: 'text' });
|
||||
blocks.push(...this.toImageContentBlocks(imageList));
|
||||
|
||||
for (const image of imageList) {
|
||||
try {
|
||||
const { buffer, mimeType } = await this.resolveImage(image);
|
||||
content.push({
|
||||
source: {
|
||||
data: buffer.toString('base64'),
|
||||
media_type: mimeType,
|
||||
type: 'base64',
|
||||
},
|
||||
type: 'image',
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`Failed to resolve image ${image.id}:`, err);
|
||||
const plan = await buildAgentInput('claude-code', blocks, { cacheDir: this.fileCacheDir });
|
||||
return plan.stdin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize image attachments into stable filesystem paths for path-mode
|
||||
* agents (Codex `--image <file>`). Fails the prompt if any image cannot be
|
||||
* fetched / decoded — partially-attached prompts confuse the agent more
|
||||
* than they help.
|
||||
*/
|
||||
private async resolveCliImagePaths(
|
||||
imageList: HeterogeneousAgentImageAttachment[] = [],
|
||||
): Promise<string[]> {
|
||||
if (imageList.length === 0) return [];
|
||||
|
||||
const cacheDir = this.fileCacheDir;
|
||||
const results = await Promise.allSettled(
|
||||
imageList.map(async (image) => {
|
||||
const normalized = await normalizeImage(
|
||||
{ id: image.id, type: 'url', url: image.url },
|
||||
{ cacheDir },
|
||||
);
|
||||
return materializeImageToPath(normalized, cacheDir);
|
||||
}),
|
||||
);
|
||||
|
||||
const imagePaths: string[] = [];
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const [index, result] of results.entries()) {
|
||||
const imageId = imageList[index]?.id ?? `image-${index + 1}`;
|
||||
|
||||
if (result.status === 'fulfilled') {
|
||||
imagePaths.push(result.value);
|
||||
continue;
|
||||
}
|
||||
|
||||
const message = this.getErrorMessage(result.reason) || 'Unknown error';
|
||||
logger.error(`Failed to materialize image ${imageId} for CLI:`, result.reason);
|
||||
failures.push(`${imageId}: ${message}`);
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
message: { content, role: 'user' },
|
||||
type: 'user',
|
||||
});
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`Failed to attach image(s) to CLI: ${failures.join('; ')}`);
|
||||
}
|
||||
|
||||
return imagePaths;
|
||||
}
|
||||
|
||||
// ─── IPC methods ───
|
||||
@@ -241,6 +658,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 +669,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
sessionId,
|
||||
resumeSessionId: params.resumeSessionId,
|
||||
});
|
||||
|
||||
logger.info('Session created:', { agentType, sessionId });
|
||||
@@ -260,45 +679,51 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
/**
|
||||
* Send a prompt to an agent session.
|
||||
*
|
||||
* Spawns the CLI process with preset flags. Broadcasts each stdout line
|
||||
* as an `heteroAgentRawLine` event — Renderer side parses and adapts.
|
||||
* Spawns the CLI process with preset flags. Pipes each stdout chunk through
|
||||
* the shared `AgentStreamPipeline` (JSONL → adapter → toStreamEvent) and
|
||||
* broadcasts the resulting `AgentStreamEvent`s on `heteroAgentEvent`.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async sendPrompt(params: SendPromptParams): Promise<void> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) throw new Error(`Session not found: ${params.sessionId}`);
|
||||
|
||||
const preset = CLI_PRESETS[session.agentType];
|
||||
if (!preset) throw new Error(`Unknown agent type: ${session.agentType}`);
|
||||
|
||||
const 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);
|
||||
}
|
||||
|
||||
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;
|
||||
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).
|
||||
const cwd = session.cwd || electronApp.getPath('desktop');
|
||||
const traceSession = await this.createCliTraceSession({
|
||||
cliArgs,
|
||||
cwd,
|
||||
imageList: params.imageList ?? [],
|
||||
session,
|
||||
stdinPayload: spawnPlan.stdinPayload,
|
||||
});
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
// Build CLI args: base preset + resume + user args
|
||||
const cliArgs = [
|
||||
...preset.baseArgs,
|
||||
...(session.agentSessionId && preset.resumeArgs
|
||||
? preset.resumeArgs(session.agentSessionId)
|
||||
: []),
|
||||
...session.args,
|
||||
];
|
||||
|
||||
if (!useStdin && preset.promptMode === 'positional') {
|
||||
// Positional mode: append prompt as a CLI arg (legacy / non-CC presets).
|
||||
cliArgs.push(params.prompt);
|
||||
}
|
||||
|
||||
// Fall back to the user's Desktop so the process never inherits
|
||||
// the Electron parent's cwd (which is `/` when launched from Finder).
|
||||
const cwd = session.cwd || electronApp.getPath('desktop');
|
||||
|
||||
logger.info('Spawning agent:', session.command, cliArgs.join(' '), `(cwd: ${cwd})`);
|
||||
|
||||
// `detached: true` on Unix puts the child in a new process group so we
|
||||
@@ -318,90 +743,138 @@ 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) {
|
||||
void this.writeCliTraceFile(traceSession, 'stdin.txt', spawnPlan.stdinPayload);
|
||||
const stdin = proc.stdin as Writable;
|
||||
stdin.write(stdinPayload + '\n', () => {
|
||||
stdin.write(spawnPlan.stdinPayload, () => {
|
||||
stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
session.process = proc;
|
||||
let buffer = '';
|
||||
|
||||
// Stream stdout lines as raw events to Renderer
|
||||
// Producer-side conversion (V3 contract): JSONL framing + adapter +
|
||||
// toStreamEvent all run inside the shared pipeline, so renderer + future
|
||||
// server `heteroIngest` see the same `AgentStreamEvent` wire shape with
|
||||
// no per-consumer adapter. The pipeline auto-wires the Codex
|
||||
// file-change line-stat tracker when `agentType === 'codex'`, so this
|
||||
// controller stays agent-agnostic.
|
||||
const pipeline = new AgentStreamPipeline({
|
||||
agentType: session.agentType,
|
||||
operationId: params.operationId,
|
||||
});
|
||||
let stdoutBroadcastQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
const broadcastPipelineBatch = (produce: () => ReturnType<AgentStreamPipeline['push']>) => {
|
||||
stdoutBroadcastQueue = stdoutBroadcastQueue
|
||||
.then(async () => {
|
||||
const events = await produce();
|
||||
// Adapter-extracted CC/Codex session id powers `--resume` on the
|
||||
// next prompt; surface it through the existing `getSessionInfo`
|
||||
// IPC by mirroring the freshest value onto the session record.
|
||||
if (pipeline.sessionId && pipeline.sessionId !== session.agentSessionId) {
|
||||
session.agentSessionId = pipeline.sessionId;
|
||||
}
|
||||
for (const event of events) {
|
||||
this.broadcast('heteroAgentEvent', {
|
||||
event,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Failed to broadcast agent stream batch:', error);
|
||||
});
|
||||
};
|
||||
|
||||
// Stream stdout events through the producer pipeline.
|
||||
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
|
||||
}
|
||||
}
|
||||
void this.appendCliTraceFile(traceSession, 'stdout.jsonl', chunk);
|
||||
broadcastPipelineBatch(() => pipeline.push(chunk));
|
||||
});
|
||||
stdout.on('end', () => {
|
||||
broadcastPipelineBatch(() => pipeline.flush());
|
||||
});
|
||||
|
||||
// Capture stderr
|
||||
const stderrChunks: string[] = [];
|
||||
const stderr = proc.stderr as Readable;
|
||||
stderr.on('data', (chunk: Buffer) => {
|
||||
void this.appendCliTraceFile(traceSession, 'stderr.log', chunk);
|
||||
stderrChunks.push(chunk.toString('utf8'));
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
logger.error('Agent process error:', err);
|
||||
void this.writeCliTraceJson(traceSession, 'process-error.json', {
|
||||
message: err.message,
|
||||
name: err.name,
|
||||
});
|
||||
void this.flushCliTrace(traceSession);
|
||||
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;
|
||||
// Node may emit `'exit'` BEFORE stdio finishes draining (documented:
|
||||
// child_process docs note "stdio streams might still be open" at exit
|
||||
// time). Wait for stdout to fully end/close so the `stdout.on('end')`
|
||||
// handler has scheduled `pipeline.flush()` onto `stdoutBroadcastQueue`,
|
||||
// THEN wait for the queue itself to settle. Without this two-step
|
||||
// gate, trailing flushed events (final synthesized tool_end /
|
||||
// tool_result) would race against — and lose to — the
|
||||
// `heteroAgentSessionComplete` broadcast, leaving renderer-side
|
||||
// persistence to finalize on incomplete state.
|
||||
const stdoutDrained = streamFinished(stdout, { writable: false }).catch(() => {
|
||||
/* end / close / error are all "done"; we still want to settle. */
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
void stdoutDrained
|
||||
.then(() => stdoutBroadcastQueue)
|
||||
.finally(async () => {
|
||||
void this.writeCliTraceJson(traceSession, 'exit.json', {
|
||||
code,
|
||||
finishedAt: new Date().toISOString(),
|
||||
signal,
|
||||
});
|
||||
await this.flushCliTrace(traceSession);
|
||||
|
||||
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,
|
||||
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 (code === 0) {
|
||||
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
|
||||
resolve();
|
||||
} else {
|
||||
const stderrOutput = stderrChunks.join('').trim();
|
||||
const errorMsg = this.getExitErrorMessage(code, session, stderrOutput);
|
||||
const sessionError = this.getSessionErrorPayload(errorMsg, session);
|
||||
this.broadcast('heteroAgentSessionError', {
|
||||
error: sessionError,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
reject(
|
||||
new Error(typeof sessionError === 'string' ? sessionError : sessionError.message),
|
||||
);
|
||||
}
|
||||
});
|
||||
reject(new Error(errorMsg));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { access, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises';
|
||||
import { access, mkdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
@@ -24,6 +24,9 @@ import {
|
||||
type PickFileResult,
|
||||
type PrepareSkillDirectoryParams,
|
||||
type PrepareSkillDirectoryResult,
|
||||
type ProjectFileIndexEntry,
|
||||
type ProjectFileIndexParams,
|
||||
type ProjectFileIndexResult,
|
||||
type RenameLocalFileResult,
|
||||
type ResolveSkillResourcePathParams,
|
||||
type ResolveSkillResourcePathResult,
|
||||
@@ -35,6 +38,7 @@ import {
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import {
|
||||
editLocalFile,
|
||||
expandTilde,
|
||||
listLocalFiles,
|
||||
moveLocalFiles,
|
||||
readLocalFile,
|
||||
@@ -42,6 +46,7 @@ import {
|
||||
writeLocalFile,
|
||||
} from '@lobechat/local-file-shell';
|
||||
import { dialog, shell } from 'electron';
|
||||
import { execa } from 'execa';
|
||||
import { unzipSync } from 'fflate';
|
||||
|
||||
import { type FileResult, type SearchOptions } from '@/modules/fileSearch';
|
||||
@@ -81,6 +86,50 @@ const resolveNearestExistingRealPath = async (targetPath: string): Promise<strin
|
||||
}
|
||||
};
|
||||
|
||||
const toPosixRelativePath = (filePath: string) => filePath.split(path.sep).join('/');
|
||||
|
||||
const createProjectFileEntry = (
|
||||
root: string,
|
||||
absolutePath: string,
|
||||
isDirectory: boolean,
|
||||
): ProjectFileIndexEntry => {
|
||||
const relativePath = toPosixRelativePath(path.relative(root, absolutePath));
|
||||
|
||||
return {
|
||||
isDirectory,
|
||||
name: path.basename(absolutePath),
|
||||
path: absolutePath,
|
||||
relativePath: isDirectory ? `${relativePath}/` : relativePath,
|
||||
};
|
||||
};
|
||||
|
||||
const collectProjectDirectories = (files: string[], root: string): ProjectFileIndexEntry[] => {
|
||||
const directories = new Set<string>();
|
||||
|
||||
for (const filePath of files) {
|
||||
let current = path.dirname(filePath);
|
||||
while (current && current !== root && current.startsWith(`${root}${path.sep}`)) {
|
||||
if (directories.has(current)) break;
|
||||
directories.add(current);
|
||||
current = path.dirname(current);
|
||||
}
|
||||
}
|
||||
|
||||
return [...directories].map((directory) => createProjectFileEntry(root, directory, true));
|
||||
};
|
||||
|
||||
const createDetectedProjectFileEntry = async (
|
||||
root: string,
|
||||
absolutePath: string,
|
||||
): Promise<ProjectFileIndexEntry> => {
|
||||
try {
|
||||
const stats = await stat(absolutePath);
|
||||
return createProjectFileEntry(root, absolutePath, stats.isDirectory());
|
||||
} catch {
|
||||
return createProjectFileEntry(root, absolutePath, false);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveSafePathRealPrefixes = async (): Promise<string[]> => {
|
||||
const prefixes = new Set<string>(SAFE_PATH_PREFIXES);
|
||||
|
||||
@@ -413,14 +462,127 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
|
||||
// ==================== Search & Find ====================
|
||||
|
||||
@IpcMethod()
|
||||
async getProjectFileIndex(params: ProjectFileIndexParams = {}): Promise<ProjectFileIndexResult> {
|
||||
const requestedScope = params.scope || process.cwd();
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const rootResult = await execa(
|
||||
'git',
|
||||
['-C', requestedScope, 'rev-parse', '--show-toplevel'],
|
||||
{
|
||||
reject: false,
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
const root = rootResult.exitCode === 0 ? rootResult.stdout.trim() : requestedScope;
|
||||
|
||||
if (rootResult.exitCode === 0) {
|
||||
const [trackedResult, untrackedResult] = await Promise.all([
|
||||
execa(
|
||||
'git',
|
||||
['-C', root, '-c', 'core.quotepath=false', 'ls-files', '--recurse-submodules'],
|
||||
{
|
||||
reject: false,
|
||||
timeout: 10_000,
|
||||
},
|
||||
),
|
||||
execa(
|
||||
'git',
|
||||
[
|
||||
'-C',
|
||||
root,
|
||||
'-c',
|
||||
'core.quotepath=false',
|
||||
'ls-files',
|
||||
'--others',
|
||||
'--exclude-standard',
|
||||
],
|
||||
{ reject: false, timeout: 10_000 },
|
||||
),
|
||||
]);
|
||||
|
||||
if (trackedResult.exitCode !== 0) {
|
||||
throw new Error(trackedResult.stderr || 'git ls-files failed');
|
||||
}
|
||||
|
||||
const files = [
|
||||
...trackedResult.stdout.split('\n'),
|
||||
...(untrackedResult.exitCode === 0 ? untrackedResult.stdout.split('\n') : []),
|
||||
]
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.map((relativePath) => path.resolve(root, relativePath));
|
||||
|
||||
const seen = new Set<string>();
|
||||
const fileEntries = files
|
||||
.filter((filePath) => {
|
||||
if (seen.has(filePath)) return false;
|
||||
seen.add(filePath);
|
||||
return true;
|
||||
})
|
||||
.map((filePath) => createProjectFileEntry(root, filePath, false));
|
||||
|
||||
const entries = [...collectProjectDirectories(files, root), ...fileEntries];
|
||||
logger.debug('Project file index built from git', {
|
||||
duration: Date.now() - startedAt,
|
||||
entries: entries.length,
|
||||
files: fileEntries.length,
|
||||
requestedScope,
|
||||
root,
|
||||
});
|
||||
|
||||
return {
|
||||
entries,
|
||||
indexedAt: new Date().toISOString(),
|
||||
root,
|
||||
source: 'git',
|
||||
totalCount: entries.length,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Git project file index failed, falling back to glob', {
|
||||
error,
|
||||
requestedScope,
|
||||
});
|
||||
}
|
||||
|
||||
const fallback = await this.searchService.glob({ pattern: '**/*', scope: requestedScope });
|
||||
const files = fallback.files.map((filePath) => path.resolve(filePath));
|
||||
const entries = await Promise.all(
|
||||
files.map((filePath) => createDetectedProjectFileEntry(requestedScope, filePath)),
|
||||
);
|
||||
|
||||
logger.debug('Project file index built from glob', {
|
||||
duration: Date.now() - startedAt,
|
||||
entries: entries.length,
|
||||
engine: fallback.engine,
|
||||
requestedScope,
|
||||
});
|
||||
|
||||
return {
|
||||
entries,
|
||||
indexedAt: new Date().toISOString(),
|
||||
root: requestedScope,
|
||||
source: 'glob',
|
||||
totalCount: entries.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle IPC event for local file search
|
||||
*/
|
||||
@IpcMethod()
|
||||
async handleLocalFilesSearch(params: LocalSearchFilesParams): Promise<FileResult[]> {
|
||||
const effectiveDirectory = expandTilde(params.directory ?? params.scope);
|
||||
|
||||
logger.debug('Received file search request:', {
|
||||
directory: params.directory,
|
||||
effectiveDirectory,
|
||||
limit: params.limit,
|
||||
keywords: params.keywords,
|
||||
scope: params.scope,
|
||||
});
|
||||
|
||||
// Build search options from params, mapping directory to onlyIn
|
||||
@@ -436,7 +598,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
liveUpdate: params.liveUpdate,
|
||||
modifiedAfter: params.modifiedAfter ? new Date(params.modifiedAfter) : undefined,
|
||||
modifiedBefore: params.modifiedBefore ? new Date(params.modifiedBefore) : undefined,
|
||||
onlyIn: params.directory, // Map directory param to onlyIn option
|
||||
onlyIn: effectiveDirectory,
|
||||
sortBy: params.sortBy,
|
||||
sortDirection: params.sortDirection,
|
||||
};
|
||||
@@ -446,6 +608,14 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
logger.debug('File search completed', {
|
||||
count: results.length,
|
||||
directory: params.directory,
|
||||
effectiveDirectory,
|
||||
results: results.slice(0, 5).map((result) => ({
|
||||
engine: result.engine,
|
||||
isDirectory: result.isDirectory,
|
||||
name: result.name,
|
||||
path: result.path,
|
||||
})),
|
||||
scope: params.scope,
|
||||
});
|
||||
return results;
|
||||
} catch (error) {
|
||||
|
||||
@@ -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,
|
||||
@@ -149,6 +155,9 @@ export default class NotificationCtr extends ControllerModule {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.browserWindow.focus();
|
||||
if (params.navigate?.path) {
|
||||
mainWindow.broadcast('navigate', params.navigate);
|
||||
}
|
||||
});
|
||||
|
||||
notification.on('close', () => {
|
||||
@@ -178,6 +187,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);
|
||||
|
||||
@@ -19,6 +19,26 @@ export default class TrayMenuCtr extends ControllerModule {
|
||||
mainWindow.toggleVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether the application tray is visible.
|
||||
*/
|
||||
@IpcMethod()
|
||||
getAppTrayVisible(): boolean {
|
||||
return this.app.storeManager.get('appTrayVisible', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist and apply application tray visibility.
|
||||
*/
|
||||
@IpcMethod()
|
||||
setAppTrayVisible(visible: boolean) {
|
||||
logger.debug(`Set app tray visibility: ${visible}`);
|
||||
this.app.storeManager.set('appTrayVisible', visible);
|
||||
this.app.trayManager.setAppTrayVisible(visible);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Show tray balloon notification
|
||||
* @param options Balloon options
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { UploadFileParams } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import FileService from '@/services/fileSrv';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
export default class UploadFileCtr extends ControllerModule {
|
||||
static override readonly groupName = 'upload';
|
||||
private get fileService() {
|
||||
return this.app.getService(FileService);
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async uploadFile(params: UploadFileParams) {
|
||||
return this.fileService.uploadFile(params);
|
||||
}
|
||||
}
|
||||
@@ -433,18 +433,23 @@ describe('GatewayConnectionCtr', () => {
|
||||
}
|
||||
|
||||
it.each([
|
||||
['readLocalFile', 'readFile', mockLocalFileCtr],
|
||||
['listLocalFiles', 'listLocalFiles', mockLocalFileCtr],
|
||||
['moveLocalFiles', 'handleMoveFiles', mockLocalFileCtr],
|
||||
['renameLocalFile', 'handleRenameFile', mockLocalFileCtr],
|
||||
['searchLocalFiles', 'handleLocalFilesSearch', mockLocalFileCtr],
|
||||
['writeLocalFile', 'handleWriteFile', mockLocalFileCtr],
|
||||
['editLocalFile', 'handleEditFile', mockLocalFileCtr],
|
||||
['globLocalFiles', 'handleGlobFiles', mockLocalFileCtr],
|
||||
['readFile', 'readFile', mockLocalFileCtr],
|
||||
['listFiles', 'listLocalFiles', mockLocalFileCtr],
|
||||
['moveFiles', 'handleMoveFiles', mockLocalFileCtr],
|
||||
['searchFiles', 'handleLocalFilesSearch', mockLocalFileCtr],
|
||||
['writeFile', 'handleWriteFile', mockLocalFileCtr],
|
||||
['editFile', 'handleEditFile', mockLocalFileCtr],
|
||||
['globFiles', 'handleGlobFiles', mockLocalFileCtr],
|
||||
['grepContent', 'handleGrepContent', mockLocalFileCtr],
|
||||
['runCommand', 'handleRunCommand', mockShellCommandCtr],
|
||||
['getCommandOutput', 'handleGetCommandOutput', mockShellCommandCtr],
|
||||
['killCommand', 'handleKillCommand', mockShellCommandCtr],
|
||||
// Legacy aliases — older Gateway versions may still send the long form.
|
||||
// `renameLocalFile` is kept even though the new surface drops rename.
|
||||
['readLocalFile', 'readFile', mockLocalFileCtr],
|
||||
['listLocalFiles', 'listLocalFiles', mockLocalFileCtr],
|
||||
['writeLocalFile', 'handleWriteFile', mockLocalFileCtr],
|
||||
['renameLocalFile', 'handleRenameFile', mockLocalFileCtr],
|
||||
] as const)('should route %s to %s', async (apiName, methodName, controller) => {
|
||||
const client = await connectAndOpen();
|
||||
const args = { test: 'arg' };
|
||||
@@ -470,7 +475,7 @@ describe('GatewayConnectionCtr', () => {
|
||||
});
|
||||
const client = await connectAndOpen();
|
||||
|
||||
client.simulateToolCallRequest('readLocalFile', { path: '/a.txt' }, 'req-42');
|
||||
client.simulateToolCallRequest('readFile', { path: '/a.txt' }, 'req-42');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
|
||||
@@ -497,7 +502,7 @@ describe('GatewayConnectionCtr', () => {
|
||||
vi.mocked(mockLocalFileCtr.readFile).mockRejectedValueOnce(new Error('File not found'));
|
||||
const client = await connectAndOpen();
|
||||
|
||||
client.simulateToolCallRequest('readLocalFile', { path: '/missing' }, 'req-err');
|
||||
client.simulateToolCallRequest('readFile', { path: '/missing' }, 'req-err');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { dequoteGitPath, quoteGitPath } from '../GitCtr';
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('quoteGitPath', () => {
|
||||
it('leaves plain ASCII paths unquoted (including spaces)', () => {
|
||||
expect(quoteGitPath('a/', 'src/foo.ts')).toBe('a/src/foo.ts');
|
||||
expect(quoteGitPath('b/', 'src/foo bar.ts')).toBe('b/src/foo bar.ts');
|
||||
expect(quoteGitPath('a/', 'with-dash_and.underscore')).toBe('a/with-dash_and.underscore');
|
||||
});
|
||||
|
||||
it('C-style escapes TAB / LF / CR / quote / backslash', () => {
|
||||
expect(quoteGitPath('b/', 'with\ttab.txt')).toBe('"b/with\\ttab.txt"');
|
||||
expect(quoteGitPath('b/', 'with\nlf.txt')).toBe('"b/with\\nlf.txt"');
|
||||
expect(quoteGitPath('b/', 'with\rcr.txt')).toBe('"b/with\\rcr.txt"');
|
||||
expect(quoteGitPath('b/', 'with"quote.txt')).toBe('"b/with\\"quote.txt"');
|
||||
expect(quoteGitPath('b/', 'with\\backslash.txt')).toBe('"b/with\\\\backslash.txt"');
|
||||
});
|
||||
|
||||
it('octal-escapes other control bytes (NUL, 0x1F, DEL)', () => {
|
||||
expect(quoteGitPath('a/', 'nul\x00here')).toBe('"a/nul\\000here"');
|
||||
expect(quoteGitPath('a/', 'unit\x1Fsep')).toBe('"a/unit\\037sep"');
|
||||
expect(quoteGitPath('a/', 'del\x7Fchar')).toBe('"a/del\\177char"');
|
||||
});
|
||||
|
||||
it('puts the prefix inside the quotes', () => {
|
||||
// Real git output for `git diff` of a tab-containing file:
|
||||
// diff --git "a/with\there" "b/with\there"
|
||||
expect(quoteGitPath('a/', 'with\there')).toBe('"a/with\\there"');
|
||||
expect(quoteGitPath('b/', 'with\there')).toBe('"b/with\\there"');
|
||||
});
|
||||
|
||||
it('round-trips through dequoteGitPath for problem characters', () => {
|
||||
const cases = [
|
||||
'with\ttab.txt',
|
||||
'with\nlf.txt',
|
||||
'with\rcr.txt',
|
||||
'with"quote.txt',
|
||||
'with\\backslash.txt',
|
||||
'nul\x00inside',
|
||||
'mix\t"of\\everything\n',
|
||||
];
|
||||
for (const original of cases) {
|
||||
const quoted = quoteGitPath('b/', original);
|
||||
// Strip the surrounding quotes + b/ prefix, then de-escape.
|
||||
expect(quoted.startsWith('"b/')).toBe(true);
|
||||
expect(quoted.endsWith('"')).toBe(true);
|
||||
const stripped = quoted.slice(1, -1).slice('b/'.length);
|
||||
expect(dequoteGitPath(stripped)).toBe(original);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('dequoteGitPath', () => {
|
||||
it('decodes named C-style escapes', () => {
|
||||
expect(dequoteGitPath('with\\ttab')).toBe('with\ttab');
|
||||
expect(dequoteGitPath('with\\nlf')).toBe('with\nlf');
|
||||
expect(dequoteGitPath('with\\rcr')).toBe('with\rcr');
|
||||
expect(dequoteGitPath('with\\"quote')).toBe('with"quote');
|
||||
expect(dequoteGitPath('with\\\\bs')).toBe('with\\bs');
|
||||
});
|
||||
|
||||
it('decodes 3-digit octal escapes', () => {
|
||||
expect(dequoteGitPath('nul\\000here')).toBe('nul\x00here');
|
||||
expect(dequoteGitPath('unit\\037sep')).toBe('unit\x1Fsep');
|
||||
expect(dequoteGitPath('del\\177char')).toBe('del\x7Fchar');
|
||||
});
|
||||
|
||||
it('leaves unescaped chars alone', () => {
|
||||
expect(dequoteGitPath('plain ascii here')).toBe('plain ascii here');
|
||||
});
|
||||
});
|
||||
@@ -4,16 +4,22 @@ 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';
|
||||
|
||||
const FAKE_DESKTOP_PATH = '/Users/fake/Desktop';
|
||||
|
||||
const { mockGetAllWindows } = vi.hoisted(() => ({
|
||||
mockGetAllWindows: vi.fn<() => any[]>(() => []),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: { getAllWindows: () => [] },
|
||||
BrowserWindow: { getAllWindows: () => mockGetAllWindows() },
|
||||
app: {
|
||||
getPath: vi.fn((name: string) => (name === 'desktop' ? FAKE_DESKTOP_PATH : `/fake/${name}`)),
|
||||
isPackaged: false,
|
||||
on: vi.fn(),
|
||||
},
|
||||
ipcMain: { handle: vi.fn() },
|
||||
@@ -32,18 +38,36 @@ 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,
|
||||
stderrLines = [],
|
||||
stdoutLines = [],
|
||||
}: {
|
||||
exitCode?: number;
|
||||
stderrLines?: string[];
|
||||
stdoutLines?: string[];
|
||||
} = {}) => {
|
||||
const proc = new EventEmitter() as any;
|
||||
const stdout = new PassThrough();
|
||||
const stderr = new PassThrough();
|
||||
@@ -60,15 +84,29 @@ 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);
|
||||
}
|
||||
for (const line of stderrLines) {
|
||||
stderr.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;
|
||||
|
||||
@@ -80,13 +118,24 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
await rm(appStoragePath, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
describe('resolveImage', () => {
|
||||
describe('image cache (delegates to shared `normalizeImage`)', () => {
|
||||
// Image fetch + cache moved to `@lobechat/heterogeneous-agents/spawn`'s
|
||||
// `normalizeImage`. The desktop controller passes its own cacheDir so the
|
||||
// path-traversal invariant — id segments like `../../foo` MUST be hashed,
|
||||
// never used as path segments — is enforced by the shared helper. Verify
|
||||
// that invariant against the same cacheDir the controller would use.
|
||||
const fixtureCacheDir = (storage: string) => path.join(storage, 'heteroAgent/files');
|
||||
const importNormalize = async () => {
|
||||
const { mkdir } = await import('node:fs/promises');
|
||||
const mod = await import('@lobechat/heterogeneous-agents/spawn');
|
||||
return { mkdir, normalizeImage: mod.normalizeImage };
|
||||
};
|
||||
|
||||
it('stores traversal-looking ids inside the cache root via a stable hash key', async () => {
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
} as any);
|
||||
const cacheDir = path.join(appStoragePath, 'heteroAgent/files');
|
||||
const { mkdir, normalizeImage } = await importNormalize();
|
||||
const cacheDir = fixtureCacheDir(appStoragePath);
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
|
||||
const escapedTargetName = `${path.basename(appStoragePath)}-outside-storage`;
|
||||
const escapePath = path.join(cacheDir, `../../../${escapedTargetName}`);
|
||||
|
||||
@@ -96,10 +145,14 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
// best-effort cleanup
|
||||
}
|
||||
|
||||
await (ctr as any).resolveImage({
|
||||
id: `../../../${escapedTargetName}`,
|
||||
url: 'data:text/plain;base64,T1VUU0lERQ==',
|
||||
});
|
||||
await normalizeImage(
|
||||
{
|
||||
id: `../../../${escapedTargetName}`,
|
||||
type: 'url',
|
||||
url: 'data:text/plain;base64,T1VUU0lERQ==',
|
||||
},
|
||||
{ cacheDir, fetcher: (async () => new Response('OUTSIDE', { status: 200 })) as any },
|
||||
);
|
||||
|
||||
const cacheEntries = await readdir(cacheDir);
|
||||
|
||||
@@ -115,11 +168,10 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
});
|
||||
|
||||
it('does not trust pre-seeded out-of-root traversal cache files as cache hits', async () => {
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
} as any);
|
||||
const cacheDir = path.join(appStoragePath, 'heteroAgent/files');
|
||||
const { mkdir, normalizeImage } = await importNormalize();
|
||||
const cacheDir = fixtureCacheDir(appStoragePath);
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
|
||||
const traversalId = '../../preexisting-secret';
|
||||
const outOfRootDataPath = path.join(cacheDir, traversalId);
|
||||
const outOfRootMetaPath = path.join(cacheDir, `${traversalId}.meta`);
|
||||
@@ -130,13 +182,20 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
JSON.stringify({ id: traversalId, mimeType: 'text/plain' }),
|
||||
);
|
||||
|
||||
const result = await (ctr as any).resolveImage({
|
||||
id: traversalId,
|
||||
url: 'data:text/plain;base64,SUdOT1JFRA==',
|
||||
});
|
||||
const result = await normalizeImage(
|
||||
{ id: traversalId, type: 'url', url: 'data:text/plain;base64,SUdOT1JFRA==' },
|
||||
{
|
||||
cacheDir,
|
||||
fetcher: (async () =>
|
||||
new Response('IGNORED', {
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
status: 200,
|
||||
})) as any,
|
||||
},
|
||||
);
|
||||
|
||||
expect(Buffer.from(result.buffer).toString('utf8')).toBe('IGNORED');
|
||||
expect(result.mimeType).toBe('text/plain');
|
||||
expect(result.mediaType).toBe('text/plain');
|
||||
await expect(readFile(outOfRootDataPath, 'utf8')).resolves.toBe('SECRET');
|
||||
});
|
||||
});
|
||||
@@ -144,10 +203,16 @@ 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[] = [],
|
||||
sendPromptOverrides: Partial<{ imageList: Array<{ id: string; url: string }> }> = {},
|
||||
) => {
|
||||
const { proc, writes } = createFakeProc({ stdoutLines });
|
||||
nextFakeProc = proc;
|
||||
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
@@ -159,10 +224,10 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
command: 'claude',
|
||||
...sessionOverrides,
|
||||
});
|
||||
await ctr.sendPrompt({ prompt, sessionId });
|
||||
await ctr.sendPrompt({ operationId: 'op-test', prompt, sessionId, ...sendPromptOverrides });
|
||||
|
||||
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 +286,520 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
|
||||
expect(options.cwd).toBe(explicitCwd);
|
||||
});
|
||||
|
||||
it('omits the empty text block when only images are attached', async () => {
|
||||
const { writes } = await runSendPrompt('', {}, [], {
|
||||
imageList: [{ id: 'image-1', url: 'data:image/png;base64,UE5HX1RFU1Q=' }],
|
||||
});
|
||||
|
||||
expect(writes).toHaveLength(1);
|
||||
const msg = JSON.parse(writes[0].trimEnd());
|
||||
// Anthropic rejects `{ text: '', type: 'text' }` with
|
||||
// "messages: text content blocks must be non-empty".
|
||||
expect(msg.message.content).toEqual([
|
||||
{
|
||||
source: { data: 'UE5HX1RFU1Q=', media_type: 'image/png', type: 'base64' },
|
||||
type: 'image',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
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({ operationId: 'op-test', 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({ operationId: 'op-test', 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({ operationId: 'op-test', 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({ operationId: 'op-test', 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(cliArgs).not.toContain('-');
|
||||
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).not.toContain('-');
|
||||
expect(cliArgs.filter((arg) => arg === '--image')).toHaveLength(2);
|
||||
expect(imagePaths).toHaveLength(2);
|
||||
expect(imagePaths).not.toContain('-');
|
||||
expect(cliArgs.at(-1)).toBe(imagePaths[1]);
|
||||
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('normalizes parameterized image MIME types before choosing the CLI file extension', async () => {
|
||||
const imageList = [
|
||||
{ id: 'image-with-params', url: 'data:image/png;charset=utf-8;base64,UE5HX1RFU1Q=' },
|
||||
];
|
||||
const { cliArgs } = await runSendPrompt('describe this screenshot', {}, [], { imageList });
|
||||
|
||||
const imagePaths = getFlagValues(cliArgs, '--image');
|
||||
|
||||
expect(imagePaths).toHaveLength(1);
|
||||
expect(imagePaths[0]).toMatch(/\.png$/);
|
||||
await expect(readFile(imagePaths[0], 'utf8')).resolves.toBe('PNG_TEST');
|
||||
});
|
||||
|
||||
it('sniffs image bytes when MIME and URL do not expose a usable extension', async () => {
|
||||
const pngBytes = Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
Buffer.from('PNG_TEST'),
|
||||
]);
|
||||
const imageList = [
|
||||
{
|
||||
id: 'image-octet',
|
||||
url: `data:application/octet-stream;base64,${pngBytes.toString('base64')}`,
|
||||
},
|
||||
];
|
||||
const { cliArgs } = await runSendPrompt('describe this screenshot', {}, [], { imageList });
|
||||
|
||||
const imagePaths = getFlagValues(cliArgs, '--image');
|
||||
|
||||
expect(imagePaths).toHaveLength(1);
|
||||
expect(imagePaths[0]).toMatch(/\.png$/);
|
||||
await expect(readFile(imagePaths[0])).resolves.toEqual(pngBytes);
|
||||
});
|
||||
|
||||
it('fails before spawning Codex when any image cannot be materialized', async () => {
|
||||
const imageList = [
|
||||
{ id: 'good-image', url: 'data:image/png;base64,VkFMSURfSU1BR0U=' },
|
||||
{ id: 'bad-image', url: 'bad://broken-image' },
|
||||
];
|
||||
const { proc } = createFakeProc();
|
||||
nextFakeProc = proc;
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
} as any);
|
||||
const { sessionId } = await ctr.startSession({
|
||||
agentType: 'codex',
|
||||
command: 'codex',
|
||||
});
|
||||
|
||||
await expect(
|
||||
ctr.sendPrompt({
|
||||
imageList,
|
||||
operationId: 'op-test',
|
||||
prompt: 'inspect the screenshots',
|
||||
sessionId,
|
||||
}),
|
||||
).rejects.toThrow('Failed to attach image(s) to CLI');
|
||||
expect(spawnCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not surface Codex stderr status and warn logs as the terminal error', async () => {
|
||||
const { proc } = createFakeProc({
|
||||
exitCode: 1,
|
||||
stderrLines: [
|
||||
'Reading prompt from stdin...\n',
|
||||
'2026-04-25T09:24:08.165782Z WARN codex_core::session_startup_prewarm: startup websocket prewarm setup failed\n',
|
||||
'<html>\n',
|
||||
' <body>challenge page</body>\n',
|
||||
'</html>\n',
|
||||
],
|
||||
stdoutLines: [
|
||||
`${JSON.stringify({ thread_id: 'thread_codex_123', type: 'thread.started' })}\n`,
|
||||
`${JSON.stringify({ type: 'turn.started' })}\n`,
|
||||
`${JSON.stringify({ message: 'real Codex JSONL error', type: 'error' })}\n`,
|
||||
],
|
||||
});
|
||||
nextFakeProc = proc;
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
} as any);
|
||||
const { sessionId } = await ctr.startSession({
|
||||
agentType: 'codex',
|
||||
command: 'codex',
|
||||
});
|
||||
|
||||
await expect(
|
||||
ctr.sendPrompt({ operationId: 'op-test', prompt: 'hello', sessionId }),
|
||||
).rejects.toThrow('Agent exited with code 1');
|
||||
});
|
||||
|
||||
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(-2)).toBe('thread_abc');
|
||||
expect(cliArgs.at(-1)).toBe('-');
|
||||
});
|
||||
|
||||
it('writes raw CLI streams to a dev trace directory grouped by agent type', async () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
||||
try {
|
||||
const prompt = 'trace this run';
|
||||
const rawLine = `${JSON.stringify({
|
||||
thread_id: 'thread_codex_trace',
|
||||
type: 'thread.started',
|
||||
})}\n`;
|
||||
const { sessionId } = await runSendPrompt(prompt, { cwd: appStoragePath }, [rawLine], {
|
||||
imageList: [{ id: 'image-1', url: 'data:image/png;base64,UE5HX1RFU1Q=' }],
|
||||
});
|
||||
const traceRoot = path.join(appStoragePath, '.heerogeneous-tracing');
|
||||
const agentTraceRoot = path.join(traceRoot, 'codex');
|
||||
const traceDirs = await readdir(agentTraceRoot);
|
||||
|
||||
expect(traceDirs).toHaveLength(1);
|
||||
|
||||
const traceDir = path.join(agentTraceRoot, traceDirs[0]);
|
||||
|
||||
await expect(readFile(path.join(traceRoot, '.last-live-trace'), 'utf8')).resolves.toBe(
|
||||
`${traceDir}\n`,
|
||||
);
|
||||
await expect(readFile(path.join(traceDir, 'stdin.txt'), 'utf8')).resolves.toBe(prompt);
|
||||
await expect(readFile(path.join(traceDir, 'stdout.jsonl'), 'utf8')).resolves.toBe(rawLine);
|
||||
await expect(readFile(path.join(traceDir, 'stderr.log'), 'utf8')).resolves.toBe('');
|
||||
await expect(readFile(path.join(traceDir, 'exit.json'), 'utf8')).resolves.toContain(
|
||||
'"code": 0',
|
||||
);
|
||||
|
||||
const meta = JSON.parse(await readFile(path.join(traceDir, 'meta.json'), 'utf8'));
|
||||
|
||||
expect(meta).toMatchObject({
|
||||
agentType: 'codex',
|
||||
command: 'codex',
|
||||
cwd: appStoragePath,
|
||||
sessionId,
|
||||
stdinBytes: Buffer.byteLength(prompt),
|
||||
stdoutFile: 'stdout.jsonl',
|
||||
});
|
||||
expect(meta.args).not.toContain('-');
|
||||
expect(meta.attachments).toEqual([{ id: 'image-1', urlKind: 'data' }]);
|
||||
} finally {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it('skips trace creation (and never auto-creates the cwd) when the cwd is missing', async () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
||||
const missingCwd = path.join(appStoragePath, 'does-not-exist');
|
||||
|
||||
try {
|
||||
await runSendPrompt('trace this run', { cwd: missingCwd });
|
||||
|
||||
await expect(access(missingCwd)).rejects.toThrow();
|
||||
} finally {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
}
|
||||
});
|
||||
|
||||
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"}}',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Node may emit `proc.on('exit')` BEFORE stdout fully drains (documented in
|
||||
* child_process docs as "stdio streams might still be open"). The phase 0
|
||||
* refactor moved adapter ownership to main, so renderer no longer flushes
|
||||
* its own adapter on session-complete — meaning trailing events from
|
||||
* `pipeline.flush()` (e.g. Codex's synthesized `tool_end` for unfinished
|
||||
* tool calls) would race against — and lose to — the
|
||||
* `heteroAgentSessionComplete` broadcast without an explicit gate.
|
||||
*
|
||||
* The fix in `proc.on('exit')` is to await stdout `'end'/'close'` (so the
|
||||
* `stdout.on('end')` handler can schedule `pipeline.flush()` onto the
|
||||
* broadcast queue), then drain the queue, then broadcast complete.
|
||||
*/
|
||||
describe('exit-before-end ordering (LOBE-8516 phase 0 race)', () => {
|
||||
let broadcasts: Array<{ channel: string; data: any }>;
|
||||
|
||||
beforeEach(() => {
|
||||
spawnCalls.length = 0;
|
||||
execFileMock.mockReset();
|
||||
broadcasts = [];
|
||||
mockGetAllWindows.mockImplementation(() => [
|
||||
{
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
send: (channel: string, data: any) => broadcasts.push({ channel, data }),
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockGetAllWindows.mockReset();
|
||||
mockGetAllWindows.mockReturnValue([]);
|
||||
});
|
||||
|
||||
it('delivers pipeline.flush() events BEFORE heteroAgentSessionComplete even when proc exit precedes stdout end', async () => {
|
||||
// Codex `item.started` for a tool — adapter buffers it as a pending
|
||||
// tool call. On flush, adapter synthesizes a trailing `tool_end`. This
|
||||
// is exactly the kind of event the race would lose against complete.
|
||||
const itemStarted = `${JSON.stringify({
|
||||
item: {
|
||||
aggregated_output: '',
|
||||
command: 'echo hi',
|
||||
id: 'cmd-1',
|
||||
status: 'in_progress',
|
||||
type: 'command_execution',
|
||||
},
|
||||
type: 'item.started',
|
||||
})}\n`;
|
||||
const threadStarted = `${JSON.stringify({ thread_id: 't1', type: 'thread.started' })}\n`;
|
||||
|
||||
const proc = new EventEmitter() as any;
|
||||
const stdout = new PassThrough();
|
||||
const stderr = new PassThrough();
|
||||
proc.stdout = stdout;
|
||||
proc.stderr = stderr;
|
||||
proc.stdin = {
|
||||
end: vi.fn(),
|
||||
write: vi.fn((_chunk: any, cb?: () => void) => {
|
||||
cb?.();
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
proc.kill = vi.fn();
|
||||
proc.killed = false;
|
||||
proc.__start = () => {
|
||||
setImmediate(() => {
|
||||
stdout.write(threadStarted);
|
||||
stdout.write(itemStarted);
|
||||
stderr.end();
|
||||
// ⚠️ Reproduce the documented Node race: emit exit BEFORE stdout
|
||||
// ends. Without the streamFinished gate in the controller, the
|
||||
// broadcast queue settles immediately (no flush queued yet) and
|
||||
// complete fires before the trailing tool_end ever broadcasts.
|
||||
proc.emit('exit', 0);
|
||||
setImmediate(() => stdout.end());
|
||||
});
|
||||
};
|
||||
nextFakeProc = proc;
|
||||
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
} as any);
|
||||
const { sessionId } = await ctr.startSession({ agentType: 'codex', command: 'codex' });
|
||||
await ctr.sendPrompt({ operationId: 'op-test', prompt: 'hello', sessionId });
|
||||
|
||||
const events = broadcasts.filter((b) => b.channel === 'heteroAgentEvent');
|
||||
const completeIdx = broadcasts.findIndex((b) => b.channel === 'heteroAgentSessionComplete');
|
||||
const lastEventIdx = broadcasts.findLastIndex((b) => b.channel === 'heteroAgentEvent');
|
||||
|
||||
expect(completeIdx).toBeGreaterThan(-1);
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
// Every stream event must land before complete — no trailing events
|
||||
// sneak in after the renderer has been told the session is done.
|
||||
expect(lastEventIdx).toBeLessThan(completeIdx);
|
||||
|
||||
// Specifically: the synthesized tool_end for the pending command
|
||||
// execution (emitted only by adapter.flush()) is in the broadcast.
|
||||
const toolEnds = events.filter((b) => (b.data as any)?.event?.type === 'tool_end');
|
||||
expect(toolEnds.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,8 @@ import { type App } from '@/core/App';
|
||||
|
||||
import LocalFileCtr from '../LocalFileCtr';
|
||||
|
||||
const { ipcMainHandleMock, fetchMock } = vi.hoisted(() => ({
|
||||
const { execaMock, ipcMainHandleMock, fetchMock } = vi.hoisted(() => ({
|
||||
execaMock: vi.fn(),
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
fetchMock: vi.fn(),
|
||||
}));
|
||||
@@ -14,6 +15,10 @@ vi.mock('@/utils/net-fetch', () => ({
|
||||
netFetch: fetchMock,
|
||||
}));
|
||||
|
||||
vi.mock('execa', () => ({
|
||||
execa: execaMock,
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
@@ -535,6 +540,18 @@ describe('LocalFileCtr', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should use scope as the default search directory', async () => {
|
||||
mockSearchService.search.mockResolvedValue([]);
|
||||
|
||||
await localFileCtr.handleLocalFilesSearch({ keywords: 'src', scope: '/workspace/project' });
|
||||
|
||||
expect(mockSearchService.search).toHaveBeenCalledWith('src', {
|
||||
keywords: 'src',
|
||||
limit: 30,
|
||||
onlyIn: '/workspace/project',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array on search error', async () => {
|
||||
mockSearchService.search.mockRejectedValue(new Error('Search failed'));
|
||||
|
||||
@@ -544,6 +561,94 @@ describe('LocalFileCtr', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProjectFileIndex', () => {
|
||||
it('should build a project file index from git files', async () => {
|
||||
execaMock
|
||||
.mockResolvedValueOnce({ exitCode: 0, stdout: '/workspace/project' })
|
||||
.mockResolvedValueOnce({
|
||||
exitCode: 0,
|
||||
stdout: 'src/index.ts\nsrc/components/Button.tsx',
|
||||
})
|
||||
.mockResolvedValueOnce({ exitCode: 0, stdout: 'tmp/local.ts' });
|
||||
|
||||
const result = await localFileCtr.getProjectFileIndex({ scope: '/workspace/project' });
|
||||
|
||||
expect(result.source).toBe('git');
|
||||
expect(result.root).toBe('/workspace/project');
|
||||
expect(result.entries).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
isDirectory: true,
|
||||
path: '/workspace/project/src',
|
||||
relativePath: 'src/',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
isDirectory: false,
|
||||
path: '/workspace/project/src/index.ts',
|
||||
relativePath: 'src/index.ts',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
isDirectory: false,
|
||||
path: '/workspace/project/tmp/local.ts',
|
||||
relativePath: 'tmp/local.ts',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(result.totalCount).toBe(result.entries.length);
|
||||
});
|
||||
|
||||
it('should fall back to glob when git indexing fails', async () => {
|
||||
execaMock.mockResolvedValueOnce({ exitCode: 1, stdout: '' });
|
||||
mockSearchService.glob.mockResolvedValue({
|
||||
engine: 'fast-glob',
|
||||
files: ['/workspace/project/src', '/workspace/project/src/index.ts'],
|
||||
success: true,
|
||||
total_files: 2,
|
||||
});
|
||||
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath: string) => ({
|
||||
isDirectory: () => filePath === '/workspace/project/src',
|
||||
}));
|
||||
|
||||
const result = await localFileCtr.getProjectFileIndex({ scope: '/workspace/project' });
|
||||
|
||||
expect(result.source).toBe('glob');
|
||||
expect(result.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
isDirectory: true,
|
||||
path: '/workspace/project/src',
|
||||
relativePath: 'src/',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
isDirectory: false,
|
||||
path: '/workspace/project/src/index.ts',
|
||||
relativePath: 'src/index.ts',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should mark glob entries as files when stat fails', async () => {
|
||||
execaMock.mockResolvedValueOnce({ exitCode: 1, stdout: '' });
|
||||
mockSearchService.glob.mockResolvedValue({
|
||||
engine: 'fast-glob',
|
||||
files: ['/workspace/project/src/index.ts'],
|
||||
success: true,
|
||||
total_files: 1,
|
||||
});
|
||||
vi.mocked(mockFsPromises.stat).mockRejectedValue(new Error('missing'));
|
||||
|
||||
const result = await localFileCtr.getProjectFileIndex({ scope: '/workspace/project' });
|
||||
|
||||
expect(result.source).toBe('glob');
|
||||
expect(result.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
isDirectory: false,
|
||||
path: '/workspace/project/src/index.ts',
|
||||
relativePath: 'src/index.ts',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleGlobFiles', () => {
|
||||
it('should glob files successfully', async () => {
|
||||
const mockResult = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -40,13 +40,21 @@ const mockDisplayBalloon = vi.fn();
|
||||
const mockUpdateIcon = vi.fn();
|
||||
const mockUpdateTooltip = vi.fn();
|
||||
const mockGetMainTray = vi.fn();
|
||||
const mockSetAppTrayVisible = vi.fn();
|
||||
const mockStoreGet = vi.fn(() => true);
|
||||
const mockStoreSet = vi.fn();
|
||||
|
||||
const mockApp = {
|
||||
browserManager: {
|
||||
getMainWindow: mockGetMainWindow,
|
||||
},
|
||||
storeManager: {
|
||||
get: mockStoreGet,
|
||||
set: mockStoreSet,
|
||||
},
|
||||
trayManager: {
|
||||
getMainTray: mockGetMainTray,
|
||||
setAppTrayVisible: mockSetAppTrayVisible,
|
||||
},
|
||||
} as unknown as App;
|
||||
|
||||
@@ -58,9 +66,31 @@ describe('TrayMenuCtr', () => {
|
||||
ipcMainHandleMock.mockClear();
|
||||
// Reset mockedTray for each test
|
||||
mockGetMainTray.mockReset();
|
||||
mockStoreGet.mockReturnValue(true);
|
||||
trayMenuCtr = new TrayMenuCtr(mockApp);
|
||||
});
|
||||
|
||||
describe('getAppTrayVisible', () => {
|
||||
it('should return stored app tray visibility', () => {
|
||||
mockStoreGet.mockReturnValue(false);
|
||||
|
||||
const result = trayMenuCtr.getAppTrayVisible();
|
||||
|
||||
expect(mockStoreGet).toHaveBeenCalledWith('appTrayVisible', true);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAppTrayVisible', () => {
|
||||
it('should persist and apply app tray visibility', () => {
|
||||
const result = trayMenuCtr.setAppTrayVisible(false);
|
||||
|
||||
expect(mockStoreSet).toHaveBeenCalledWith('appTrayVisible', false);
|
||||
expect(mockSetAppTrayVisible).toHaveBeenCalledWith(false);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// Restore platform settings after all tests complete
|
||||
afterAll(() => {
|
||||
// Restore the original platform
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
import { IpcHandler } from '@/utils/ipc/base';
|
||||
|
||||
import UploadFileCtr from '../UploadFileCtr';
|
||||
|
||||
const { ipcHandlers, ipcMainHandleMock } = vi.hoisted(() => {
|
||||
const handlers = new Map<string, (event: any, ...args: any[]) => any>();
|
||||
const handle = vi.fn((channel: string, handler: any) => {
|
||||
handlers.set(channel, handler);
|
||||
});
|
||||
return { ipcHandlers: handlers, ipcMainHandleMock: handle };
|
||||
});
|
||||
|
||||
const invokeIpc = async <T = any>(channel: string, payload?: any): Promise<T> => {
|
||||
const handler = ipcHandlers.get(channel);
|
||||
if (!handler) throw new Error(`IPC handler for ${channel} not found`);
|
||||
|
||||
const fakeEvent = { sender: { id: 'test' } as any };
|
||||
if (payload === undefined) return handler(fakeEvent);
|
||||
return handler(fakeEvent, payload);
|
||||
};
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock FileService module to prevent electron dependency issues
|
||||
vi.mock('@/services/fileSrv', () => ({
|
||||
default: class MockFileService {},
|
||||
}));
|
||||
|
||||
// Mock FileService instance methods
|
||||
const mockFileService = {
|
||||
uploadFile: vi.fn(),
|
||||
};
|
||||
|
||||
const mockApp = {
|
||||
getService: vi.fn(() => mockFileService),
|
||||
} as unknown as App;
|
||||
|
||||
describe('UploadFileCtr', () => {
|
||||
let _controller: UploadFileCtr;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcHandlers.clear();
|
||||
ipcMainHandleMock.mockClear();
|
||||
(IpcHandler.getInstance() as any).registeredChannels?.clear();
|
||||
_controller = new UploadFileCtr(mockApp);
|
||||
});
|
||||
|
||||
describe('uploadFile', () => {
|
||||
it('should upload file successfully', async () => {
|
||||
const params = {
|
||||
hash: 'abc123',
|
||||
path: '/test/file.txt',
|
||||
content: new ArrayBuffer(16),
|
||||
filename: 'file.txt',
|
||||
type: 'text/plain',
|
||||
};
|
||||
const expectedResult = { id: 'file-id-123', url: '/files/file-id-123' };
|
||||
mockFileService.uploadFile.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await invokeIpc('upload.uploadFile', params);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(mockFileService.uploadFile).toHaveBeenCalledWith(params);
|
||||
});
|
||||
|
||||
it('should handle upload error', async () => {
|
||||
const params = {
|
||||
hash: 'abc123',
|
||||
path: '/test/file.txt',
|
||||
content: new ArrayBuffer(16),
|
||||
filename: 'file.txt',
|
||||
type: 'text/plain',
|
||||
};
|
||||
const error = new Error('Upload failed');
|
||||
mockFileService.uploadFile.mockRejectedValue(error);
|
||||
|
||||
await expect(invokeIpc('upload.uploadFile', params)).rejects.toThrow('Upload failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,6 @@ import SystemController from './SystemCtr';
|
||||
import ToolDetectorCtr from './ToolDetectorCtr';
|
||||
import TrayMenuCtr from './TrayMenuCtr';
|
||||
import UpdaterCtr from './UpdaterCtr';
|
||||
import UploadFileCtr from './UploadFileCtr';
|
||||
|
||||
export const controllerIpcConstructors = [
|
||||
HeterogeneousAgentCtr,
|
||||
@@ -47,7 +46,6 @@ export const controllerIpcConstructors = [
|
||||
ToolDetectorCtr,
|
||||
TrayMenuCtr,
|
||||
UpdaterCtr,
|
||||
UploadFileCtr,
|
||||
] as const satisfies readonly IpcServiceConstructor[];
|
||||
|
||||
type DesktopControllerIpcConstructors = typeof controllerIpcConstructors;
|
||||
|
||||
@@ -39,6 +39,12 @@ export class TrayManager {
|
||||
initializeTrays() {
|
||||
logger.debug('Initialize application tray');
|
||||
|
||||
if (!this.app.storeManager.get('appTrayVisible', true)) {
|
||||
logger.debug('Application tray is disabled by user settings');
|
||||
this.destroyAll();
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize main tray
|
||||
const mainTray = this.initializeMainTray();
|
||||
|
||||
@@ -58,6 +64,19 @@ export class TrayManager {
|
||||
return this.retrieveByIdentifier('main');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the application tray at runtime.
|
||||
*/
|
||||
setAppTrayVisible(visible: boolean) {
|
||||
logger.debug(`Set application tray visible: ${visible}`);
|
||||
|
||||
if (visible) {
|
||||
this.initializeTrays();
|
||||
} else {
|
||||
this.destroyAll();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize main tray. On macOS we ship a template image (black + alpha)
|
||||
* so the system recolors it automatically for light / dark menu bars.
|
||||
|
||||
@@ -55,6 +55,9 @@ describe('TrayManager', () => {
|
||||
menuManager: {
|
||||
buildTrayMenu: vi.fn(() => ({ _mockMenu: true }) as any),
|
||||
},
|
||||
storeManager: {
|
||||
get: vi.fn(() => true),
|
||||
},
|
||||
} as unknown as App;
|
||||
|
||||
// Mock Tray constructor
|
||||
@@ -93,6 +96,15 @@ describe('TrayManager', () => {
|
||||
expect(mockApp.menuManager.buildTrayMenu).toHaveBeenCalled();
|
||||
expect(mockTray.setMenu).toHaveBeenCalledWith({ _mockMenu: true });
|
||||
});
|
||||
|
||||
it('should skip tray initialization when app tray is disabled', () => {
|
||||
vi.mocked(mockApp.storeManager.get).mockReturnValue(false);
|
||||
|
||||
trayManager.initializeTrays();
|
||||
|
||||
expect(Tray).not.toHaveBeenCalled();
|
||||
expect(trayManager.trays.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeMainTray', () => {
|
||||
@@ -273,6 +285,24 @@ describe('TrayManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAppTrayVisible', () => {
|
||||
it('should initialize trays when visible is true', () => {
|
||||
trayManager.setAppTrayVisible(true);
|
||||
|
||||
expect(Tray).toHaveBeenCalled();
|
||||
expect(trayManager.trays.has('main')).toBe(true);
|
||||
});
|
||||
|
||||
it('should destroy all trays when visible is false', () => {
|
||||
trayManager.initializeTrays();
|
||||
|
||||
trayManager.setAppTrayVisible(false);
|
||||
|
||||
expect(mockTray.destroy).toHaveBeenCalled();
|
||||
expect(trayManager.trays.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retrieveOrInitialize (private method)', () => {
|
||||
it('should create new tray when it does not exist', () => {
|
||||
const options = {
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -75,6 +75,7 @@ const menu = {
|
||||
'tray.open': 'Open {{appName}}',
|
||||
'tray.quickChat': 'Quick Chat',
|
||||
'tray.quit': 'Quit',
|
||||
'tray.settings': 'Settings',
|
||||
'tray.show': 'Show {{appName}}',
|
||||
'view.forceReload': 'Force Reload',
|
||||
'view.reload': 'Reload',
|
||||
|
||||
@@ -63,7 +63,9 @@ const createMockApp = () => {
|
||||
'dev.devPanel': 'Dev Panel',
|
||||
'tray.openMiniToolbar': 'Quick Composer',
|
||||
'tray.open': `Open ${params?.appName || 'App'}`,
|
||||
'tray.quickChat': 'Quick Chat',
|
||||
'tray.quit': 'Quit',
|
||||
'tray.settings': 'Settings',
|
||||
};
|
||||
return translations[key] || key;
|
||||
});
|
||||
@@ -197,6 +199,7 @@ describe('LinuxMenu', () => {
|
||||
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
|
||||
expect(template.length).toBeGreaterThan(0);
|
||||
expect(template.some((item: any) => item.label?.includes('Open'))).toBe(true);
|
||||
expect(template.some((item: any) => item.label === 'Settings')).toBe(true);
|
||||
expect(template.some((item: any) => item.label === 'Quit')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -466,7 +466,7 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
{ type: 'separator' },
|
||||
{
|
||||
click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
|
||||
label: t('file.preferences'),
|
||||
label: t('tray.settings'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ label: t('tray.quit'), role: 'quit' },
|
||||
|
||||
@@ -31,6 +31,19 @@ vi.mock('electron', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('electron-is', () => ({
|
||||
macOS: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock isDev
|
||||
vi.mock('@/const/env', () => ({
|
||||
isDev: false,
|
||||
@@ -177,6 +190,7 @@ describe('MacOSMenu', () => {
|
||||
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
|
||||
expect(template.length).toBeGreaterThan(0);
|
||||
expect(template.some((item: any) => item.label?.includes('Show'))).toBe(true);
|
||||
expect(template.some((item: any) => item.label === 'Settings')).toBe(true);
|
||||
expect(template.some((item: any) => item.label === 'Quit')).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -694,7 +694,7 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
mainWindow.show();
|
||||
mainWindow.broadcast('navigate', { path: '/settings' });
|
||||
},
|
||||
label: t('file.preferences'),
|
||||
label: t('tray.settings'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ label: t('tray.quit'), role: 'quit' },
|
||||
|
||||
@@ -58,7 +58,9 @@ const createMockApp = () => {
|
||||
'dev.devPanel': 'Dev Panel',
|
||||
'tray.openMiniToolbar': 'Quick Composer',
|
||||
'tray.open': `Open ${params?.appName || 'App'}`,
|
||||
'tray.quickChat': 'Quick Chat',
|
||||
'tray.quit': 'Quit',
|
||||
'tray.settings': 'Settings',
|
||||
};
|
||||
return translations[key] || key;
|
||||
});
|
||||
@@ -179,6 +181,7 @@ describe('WindowsMenu', () => {
|
||||
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
|
||||
expect(template.length).toBeGreaterThan(0);
|
||||
expect(template.some((item: any) => item.label?.includes('Open'))).toBe(true);
|
||||
expect(template.some((item: any) => item.label === 'Settings')).toBe(true);
|
||||
expect(template.some((item: any) => item.label === 'Quit')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -473,7 +473,7 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
{ type: 'separator' },
|
||||
{
|
||||
click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
|
||||
label: t('file.preferences'),
|
||||
label: t('tray.settings'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ label: t('tray.quit'), role: 'quit' },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user