mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-08 16:43:59 +08:00
### What this PR does Before this PR: Cherry Studio only supports `claude-code` as an agent type. Agents have no autonomous scheduling, no IM channel integration, and no soul/personality system. After this PR: Introduces **CherryClaw** — a new autonomous agent type with: - **Soul-driven personality**: Markdown-based soul files with mtime-cached reading - **Task-based scheduler**: Poll-loop scheduler with drift-resistant interval computation, tasks as first-class DB entities (nanoclaw-inspired) - **Internal claw MCP server**: `cron` tool (add/list/remove) auto-injected into CherryClaw sessions for autonomous task management - **Channel abstraction layer**: Pluggable adapter pattern with Telegram as the first implementation (grammY, long polling, streaming drafts, typing indicators) - **Headless message persistence**: Channel and scheduler messages now persist to the agent SQLite DB - **Basic sandbox mode**: PreToolUse hook path enforcement + OS-level sandbox toggle - **Full UI**: Agent creation modal with type selector, settings tabs (soul, tasks, channels, advanced), task management CRUD, channel catalog with inline config - **53 unit tests** across 8 test files covering all new services <!-- Fixes # --> ### Why we need it and why it was done in this way CherryClaw enables Cherry Studio agents to operate autonomously — executing scheduled tasks and responding to IM messages without user interaction. This is the foundation for "always-on" AI assistants. The following tradeoffs were made: - **Poll-loop scheduler over timer-based**: DB is the source of truth; no timer state to restore on restart. Simpler, more robust at the cost of up to 60s latency. - **AgentServiceRegistry pattern**: Replaced hardcoded `ClaudeCodeService` in `SessionMessageService` with a registry mapping `AgentType` → service. Extensible for future agent types. - **Internal MCP server for cron**: Rather than extending the SDK's tool system, the `cron` tool is served as a standard MCP server at `/v1/claw/:agentId/claw-mcp`. This lets the agent discover and use it naturally. - **Channel abstraction over direct Telegram integration**: `ChannelAdapter` + factory registration enables future Discord/Slack adapters without touching core routing logic. - **Basic sandbox (not security boundary)**: PreToolUse hook + OS sandbox provides best-effort restriction for well-behaved agents. Known bypass vectors documented; hardening deferred. The following alternatives were considered: - cron-based OS scheduling (rejected: harder to manage lifecycle, no DB integration) - Direct Telegram bot API calls (rejected: grammY provides typed API, connection management, and middleware) - Modifying SDK builtin tools (rejected: internal MCP server is cleaner separation) ### Breaking changes None. This is a new agent type (`cherry-claw`) alongside the existing `claude-code` type. No existing behavior is modified. ### Special notes for your reviewer - **New DB migration**: `0003_wise_meltdown.sql` adds `scheduled_tasks` and `task_run_logs` tables (agents DB only, not IndexedDB) - **New dependencies**: `cron-parser` ^5.5.0, `grammy` ^1.41 - **Placeholder avatar**: `cherry-claw.png` is currently a copy of `claude.png` — needs a proper distinct image - **74 files changed, ~7400 lines added** — large PR, recommend reviewing by phase (type system → backend services → MCP → channels → UI → tests) - **Sandbox is basic only**: The PreToolUse path checking has known bypasses (relative paths, variable expansion). Documented in handoff.md. Hardening is follow-up work. - The `handoff.md` file in the repo root contains full architectural context and decisions ### Checklist - [x] PR: The PR description is expressive enough and will help future contributors - [x] Code: [Write code that humans can understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans) and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle) - [x] Refactor: You have [left the code cleaner than you found it (Boy Scout Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html) - [ ] Upgrade: Impact of this change on upgrade flows was considered and addressed if required - [ ] Documentation: A [user-guide update](https://docs.cherry-ai.com) was considered and is present (link) or not required. Check this only when the PR introduces or changes a user-facing feature or behavior. - [ ] Self-review: I have reviewed my own code (e.g., via [`/gh-pr-review`](/.claude/skills/gh-pr-review/SKILL.md), `gh pr diff`, or GitHub UI) before requesting review from others ### Release note ```release-note New CherryClaw agent type: autonomous agents with soul-driven personality, task-based scheduling (cron/interval/one-time), internal cron MCP tool for self-managed tasks, Telegram channel integration with streaming responses, and basic sandbox mode for filesystem restriction. ``` --------- Signed-off-by: Vaayne <liu.vaayne@gmail.com> Signed-off-by: suyao <sy20010504@gmail.com> Signed-off-by: Siin Xu <31815270+SiinXu@users.noreply.github.com> Signed-off-by: zhangjiadi225 <625013594@qq.com> Signed-off-by: greycheng255 <greycheng255@gmail.com> Co-authored-by: kangfenmao <kangfenmao@qq.com> Co-authored-by: suyao <sy20010504@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Siin Xu <31815270+SiinXu@users.noreply.github.com> Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com> Co-authored-by: zhangjiadi225 <625013594@qq.com>
175 lines
3.9 KiB
TypeScript
175 lines
3.9 KiB
TypeScript
import { vi } from 'vitest'
|
|
|
|
// Mock LoggerService globally for main process tests
|
|
vi.mock('@logger', async () => {
|
|
const { MockMainLoggerService, mockMainLoggerService } = await import('./__mocks__/MainLoggerService')
|
|
return {
|
|
LoggerService: MockMainLoggerService,
|
|
loggerService: mockMainLoggerService
|
|
}
|
|
})
|
|
|
|
// Mock electron modules that are commonly used in main process
|
|
vi.mock('electron', () => {
|
|
const mock = {
|
|
app: {
|
|
getPath: vi.fn((key: string) => {
|
|
switch (key) {
|
|
case 'userData':
|
|
return '/mock/userData'
|
|
case 'temp':
|
|
return '/mock/temp'
|
|
case 'logs':
|
|
return '/mock/logs'
|
|
default:
|
|
return '/mock/unknown'
|
|
}
|
|
}),
|
|
getVersion: vi.fn(() => '1.0.0')
|
|
},
|
|
ipcMain: {
|
|
handle: vi.fn(),
|
|
on: vi.fn(),
|
|
once: vi.fn(),
|
|
removeHandler: vi.fn(),
|
|
removeAllListeners: vi.fn()
|
|
},
|
|
BrowserWindow: vi.fn(),
|
|
dialog: {
|
|
showErrorBox: vi.fn(),
|
|
showMessageBox: vi.fn(),
|
|
showOpenDialog: vi.fn(),
|
|
showSaveDialog: vi.fn()
|
|
},
|
|
shell: {
|
|
openExternal: vi.fn(),
|
|
showItemInFolder: vi.fn()
|
|
},
|
|
session: {
|
|
defaultSession: {
|
|
clearCache: vi.fn(),
|
|
clearStorageData: vi.fn()
|
|
}
|
|
},
|
|
webContents: {
|
|
getAllWebContents: vi.fn(() => [])
|
|
},
|
|
systemPreferences: {
|
|
getMediaAccessStatus: vi.fn(),
|
|
askForMediaAccess: vi.fn()
|
|
},
|
|
nativeTheme: {
|
|
themeSource: 'system',
|
|
shouldUseDarkColors: false,
|
|
on: vi.fn(),
|
|
removeListener: vi.fn()
|
|
},
|
|
screen: {
|
|
getPrimaryDisplay: vi.fn(),
|
|
getAllDisplays: vi.fn()
|
|
},
|
|
Notification: vi.fn(),
|
|
net: {
|
|
fetch: vi.fn()
|
|
}
|
|
}
|
|
|
|
return { __esModule: true, ...mock, default: mock }
|
|
})
|
|
|
|
// Mock Winston for LoggerService dependencies
|
|
vi.mock('winston', () => ({
|
|
createLogger: vi.fn(() => ({
|
|
log: vi.fn(),
|
|
error: vi.fn(),
|
|
warn: vi.fn(),
|
|
info: vi.fn(),
|
|
debug: vi.fn(),
|
|
level: 'info',
|
|
on: vi.fn(),
|
|
end: vi.fn()
|
|
})),
|
|
format: {
|
|
combine: vi.fn(),
|
|
splat: vi.fn(),
|
|
timestamp: vi.fn(),
|
|
errors: vi.fn(),
|
|
json: vi.fn()
|
|
},
|
|
transports: {
|
|
Console: vi.fn(),
|
|
File: vi.fn()
|
|
}
|
|
}))
|
|
|
|
// Mock winston-daily-rotate-file
|
|
vi.mock('winston-daily-rotate-file', () => {
|
|
return vi.fn().mockImplementation(() => ({
|
|
on: vi.fn(),
|
|
log: vi.fn()
|
|
}))
|
|
})
|
|
|
|
// Mock electron-store to avoid file system operations
|
|
vi.mock('electron-store', () => {
|
|
return {
|
|
default: vi.fn().mockImplementation(() => ({
|
|
get: vi.fn((key: string, defaultValue?: unknown) => defaultValue),
|
|
set: vi.fn(),
|
|
delete: vi.fn(),
|
|
clear: vi.fn(),
|
|
has: vi.fn(() => false),
|
|
store: {}
|
|
}))
|
|
}
|
|
})
|
|
|
|
// Mock Node.js modules
|
|
vi.mock('node:os', () => {
|
|
const mock = {
|
|
platform: vi.fn(() => 'darwin'),
|
|
arch: vi.fn(() => 'x64'),
|
|
version: vi.fn(() => '20.0.0'),
|
|
cpus: vi.fn(() => [{ model: 'Mock CPU' }]),
|
|
homedir: vi.fn(() => '/mock/home'),
|
|
totalmem: vi.fn(() => 8 * 1024 * 1024 * 1024) // 8GB
|
|
}
|
|
return { ...mock, default: mock }
|
|
})
|
|
|
|
vi.mock('node:path', async () => {
|
|
const actual = await vi.importActual('node:path')
|
|
return {
|
|
...actual,
|
|
join: vi.fn((...args: string[]) => args.join('/')),
|
|
resolve: vi.fn((...args: string[]) => args.join('/'))
|
|
}
|
|
})
|
|
|
|
vi.mock('node:fs', () => {
|
|
const mock = {
|
|
promises: {
|
|
access: vi.fn(),
|
|
readFile: vi.fn(),
|
|
writeFile: vi.fn(),
|
|
mkdir: vi.fn(),
|
|
readdir: vi.fn(),
|
|
stat: vi.fn(),
|
|
unlink: vi.fn(),
|
|
rmdir: vi.fn()
|
|
},
|
|
existsSync: vi.fn(),
|
|
readFileSync: vi.fn(),
|
|
writeFileSync: vi.fn(),
|
|
mkdirSync: vi.fn(),
|
|
readdirSync: vi.fn(),
|
|
statSync: vi.fn(),
|
|
unlinkSync: vi.fn(),
|
|
rmdirSync: vi.fn(),
|
|
createReadStream: vi.fn(),
|
|
createWriteStream: vi.fn()
|
|
}
|
|
|
|
return { ...mock, default: mock }
|
|
})
|