From 32fad63d2205607b94688068827f7d6121eee673 Mon Sep 17 00:00:00 2001 From: AlkaidSTART <2595006848@qq.com> Date: Wed, 5 Aug 2026 19:03:04 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20agent-core=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=96=87=E4=BB=B6=EF=BC=8C=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=20README.md=20=E5=92=8C=20vitest=20=E9=85=8D=E7=BD=AE=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=20.gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 8196 -> 8196 bytes .gitignore | 1 - README.md | 4 +- packages/agent-core/test/agent.spec.ts | 27 +++++++++ packages/agent-core/test/runLoop.spec.ts | 67 +++++++++++++++++++++ packages/agent-core/test/tools.spec.ts | 72 +++++++++++++++++++++++ vitest.config.ts | 5 +- 7 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 packages/agent-core/test/agent.spec.ts create mode 100644 packages/agent-core/test/runLoop.spec.ts create mode 100644 packages/agent-core/test/tools.spec.ts diff --git a/.DS_Store b/.DS_Store index 33b9c606237405e24e4737d9bf78555433fc4dab..587010cad88522d551dc5cce37a73d17e548f173 100644 GIT binary patch delta 287 zcmZp1XmOa}&&a%!07*nmXaE2J delta 48 zcmZp1XmOa}&&ahgU^hP_*JK`n!p(05M42bIh{R3i60M$GBGa|8QiypoyTo^vjg@T7 E0ESKv(*OVf diff --git a/.gitignore b/.gitignore index 548a5d3..3ae120e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,5 +21,4 @@ out .agent-memory/ bundle.* docs/* -/test/ pnpm-workspace.yaml diff --git a/README.md b/README.md index 69a008e..95acb18 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ packages/ prompt/ # 系统提示词、工具说明、模式提示词 tools/ # 环境、文件、命令等本地工具 policy/ # PLAN/BUILD 模式下的工具权限 -test/ # 本地单元测试(已加入 .gitignore) +packages/agent-core/test/ # agent-core 本地单元测试 vitest.config.ts # Vitest 测试配置 ``` @@ -108,7 +108,7 @@ pnpm run build:agent-core ## 测试说明 -测试文件统一放在项目根目录的 `test/` 下,不放在包内。`/test/` 已加入 `.gitignore`,这些测试作为本地验证文件使用。 +测试文件放在对应包的 `test/` 目录下,例如 `packages/agent-core/test/`;根目录的 `vitest.config.ts` 会统一收集并运行所有包内的测试。 ## License diff --git a/packages/agent-core/test/agent.spec.ts b/packages/agent-core/test/agent.spec.ts new file mode 100644 index 0000000..c5ff2aa --- /dev/null +++ b/packages/agent-core/test/agent.spec.ts @@ -0,0 +1,27 @@ +import { vi, describe, it, expect } from 'vitest'; + +// Mock runLoop to avoid calling the real loop implementation +vi.mock('@core/loop', () => ({ + runLoop: vi.fn(async () => 'handled:task'), +})); + +import { agent } from '@core/agent'; +import { runLoop } from '@core/loop'; + +describe('agent', () => { + it('calls runLoop with input and returns the final response', async () => { + const res = await agent('task'); + expect(runLoop).toHaveBeenCalledTimes(1); + expect(runLoop).toHaveBeenCalledWith( + expect.objectContaining({ + input: 'task', + mode: 'build', + objective: 'task', + constraints: [], + workspace: undefined, + }), + {}, + ); + expect(res).toBe('handled:task'); + }); +}); diff --git a/packages/agent-core/test/runLoop.spec.ts b/packages/agent-core/test/runLoop.spec.ts new file mode 100644 index 0000000..3da20af --- /dev/null +++ b/packages/agent-core/test/runLoop.spec.ts @@ -0,0 +1,67 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; + +// Mock streamLLM to ensure no network calls during tests +vi.mock('@core/llm', () => ({ + streamLLM: vi.fn(), +})); + +import { runLoop } from '@core/loop'; +import { streamLLM } from '@core/llm'; +import { createTaskState } from '@core/state'; + +describe('runLoop', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('executes a tool call and continues until final', async () => { + vi.mocked(streamLLM) + .mockResolvedValueOnce( + JSON.stringify({ + type: 'tool_call', + tool: 'get_environment', + arguments: {}, + message: 'inspect environment', + }), + ) + .mockResolvedValueOnce( + JSON.stringify({ + type: 'final', + tool: null, + arguments: null, + message: '环境已感知', + }), + ); + + const traces: string[] = []; + const res = await runLoop(createTaskState('看看当前环境'), { + onTrace: (message) => traces.push(message), + }); + + expect(streamLLM).toHaveBeenCalledTimes(2); + expect(traces).toContain('工具 get_environment 执行成功,继续下一轮'); + expect(res).toBe('环境已感知'); + }); + + it('returns plain text instead of raw json when loop stops on non-final JSON', async () => { + vi.mocked(streamLLM).mockResolvedValueOnce( + JSON.stringify({ + type: 'status', + tool: null, + arguments: null, + message: '仅输出文本', + }), + ); + + const res = await runLoop(createTaskState('只要文本输出')); + expect(res).toBe('仅输出文本'); + }); + + it('uses fallback stop signal when model output is non-protocol text', async () => { + vi.mocked(streamLLM).mockResolvedValueOnce('已完成:全部处理完毕'); + + const res = await runLoop(createTaskState('测试非协议完成')); + expect(res).toBe('已完成:全部处理完毕'); + expect(streamLLM).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/agent-core/test/tools.spec.ts b/packages/agent-core/test/tools.spec.ts new file mode 100644 index 0000000..d3ebc31 --- /dev/null +++ b/packages/agent-core/test/tools.spec.ts @@ -0,0 +1,72 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + getEnvironmentTool, + listFilesTool, + readFileTool, + writeFileTool, +} from '@tools'; +import { resolveUserPath } from '@tools/pathUtils'; + +const previousDesktopDir = process.env.AGENT_DESKTOP_DIR; +let tempDesktop: string | undefined; + +afterEach(async () => { + process.env.AGENT_DESKTOP_DIR = previousDesktopDir; + if (tempDesktop) { + await rm(tempDesktop, { recursive: true, force: true }); + tempDesktop = undefined; + } +}); + +describe('tools', () => { + it('reports local environment locations', async () => { + tempDesktop = await mkdtemp(path.join(os.tmpdir(), 'agent-desktop-')); + process.env.AGENT_DESKTOP_DIR = tempDesktop; + + const result = await getEnvironmentTool.run({}); + + expect(result.locations.cwd.path).toBe(process.cwd()); + expect(result.locations.desktop.path).toBe(tempDesktop); + expect(result.pathAliases).toContain('桌面/...'); + }); + + it('resolves desktop aliases for file operations', async () => { + tempDesktop = await mkdtemp(path.join(os.tmpdir(), 'agent-desktop-')); + process.env.AGENT_DESKTOP_DIR = tempDesktop; + + const writeResult = await writeFileTool.run({ + path: '桌面/hello-agent.txt', + content: 'hello desktop', + }); + + expect(writeResult.path).toBe(path.join(tempDesktop, 'hello-agent.txt')); + await expect(readFile(writeResult.path, 'utf8')).resolves.toBe( + 'hello desktop', + ); + + const readResult = await readFileTool.run({ + path: 'desktop:/hello-agent.txt', + }); + expect(readResult.content).toBe('hello desktop'); + + const listResult = await listFilesTool.run({ path: 'Desktop' }); + expect(listResult.path).toBe(tempDesktop); + expect(listResult.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'hello-agent.txt', + type: 'file', + }), + ]), + ); + }); + + it('resolves relative paths inside the current working directory', () => { + expect(resolveUserPath('README.md')).toBe( + path.join(process.cwd(), 'README.md'), + ); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index cc65ea1..232f29e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,10 @@ export default defineConfig({ test: { globals: true, environment: 'node', - include: ['test/**/*.spec.ts', 'test/**/*.test.ts'], + include: [ + 'packages/*/test/**/*.spec.ts', + 'packages/*/test/**/*.test.ts', + ], exclude: ['**/dist/**', '**/node_modules/**', 'apps/**'], reporters: 'default', alias: { From 4b9b7e9579906a248b47baabc74bdc73f70b2d7e Mon Sep 17 00:00:00 2001 From: AlkaidSTART <2595006848@qq.com> Date: Wed, 5 Aug 2026 19:13:15 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20agent=E3=80=81?= =?UTF-8?q?runLoop=20=E5=92=8C=20tools=20=E7=9A=84=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=96=87=E4=BB=B6=EF=BC=8C=E7=A1=AE=E4=BF=9D=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E7=9A=84=E6=AD=A3=E7=A1=AE=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- {packages/agent-core/test => tests}/agent.spec.ts | 0 {packages/agent-core/test => tests}/runLoop.spec.ts | 0 {packages/agent-core/test => tests}/tools.spec.ts | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename {packages/agent-core/test => tests}/agent.spec.ts (100%) rename {packages/agent-core/test => tests}/runLoop.spec.ts (100%) rename {packages/agent-core/test => tests}/tools.spec.ts (100%) diff --git a/packages/agent-core/test/agent.spec.ts b/tests/agent.spec.ts similarity index 100% rename from packages/agent-core/test/agent.spec.ts rename to tests/agent.spec.ts diff --git a/packages/agent-core/test/runLoop.spec.ts b/tests/runLoop.spec.ts similarity index 100% rename from packages/agent-core/test/runLoop.spec.ts rename to tests/runLoop.spec.ts diff --git a/packages/agent-core/test/tools.spec.ts b/tests/tools.spec.ts similarity index 100% rename from packages/agent-core/test/tools.spec.ts rename to tests/tools.spec.ts From a0ecfce0fb2e106dd75feaed4486d1d0e778396b Mon Sep 17 00:00:00 2001 From: AlkaidSTART <2595006848@qq.com> Date: Wed, 5 Aug 2026 19:13:21 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=96=87=E4=BB=B6=E7=9B=AE=E5=BD=95=EF=BC=8C=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20README.md=20=E5=92=8C=20Vitest=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +++--- vitest.config.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 95acb18..4cdae4e 100644 --- a/README.md +++ b/README.md @@ -62,8 +62,8 @@ packages/ prompt/ # 系统提示词、工具说明、模式提示词 tools/ # 环境、文件、命令等本地工具 policy/ # PLAN/BUILD 模式下的工具权限 -packages/agent-core/test/ # agent-core 本地单元测试 -vitest.config.ts # Vitest 测试配置 + tests/ # 项目统一单元测试 + vitest.config.ts # Vitest 测试配置 ``` ## 快速开始 @@ -108,7 +108,7 @@ pnpm run build:agent-core ## 测试说明 -测试文件放在对应包的 `test/` 目录下,例如 `packages/agent-core/test/`;根目录的 `vitest.config.ts` 会统一收集并运行所有包内的测试。 +测试文件统一放在项目根目录的 `tests/` 目录下,根目录的 `vitest.config.ts` 会统一收集并运行。 ## License diff --git a/vitest.config.ts b/vitest.config.ts index 232f29e..2d20fbf 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,8 +6,8 @@ export default defineConfig({ globals: true, environment: 'node', include: [ - 'packages/*/test/**/*.spec.ts', - 'packages/*/test/**/*.test.ts', + 'tests/**/*.spec.ts', + 'tests/**/*.test.ts', ], exclude: ['**/dist/**', '**/node_modules/**', 'apps/**'], reporters: 'default', From 643d857a0d988e249877b4716bd49ccc7564ea47 Mon Sep 17 00:00:00 2001 From: AlkaidSTART <2595006848@qq.com> Date: Wed, 5 Aug 2026 20:14:07 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=E7=A7=BB=E9=99=A4=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=20JSON=20=E4=BC=9A=E8=AF=9D=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=EF=BC=8C=E8=AE=B0=E5=BF=86=E4=BB=85=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E5=9C=A8=E5=86=85=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 - README.md | 7 +- .../agent-core/src/memory/memory-store.ts | 129 +----------------- source/app.tsx | 3 - 4 files changed, 6 insertions(+), 137 deletions(-) diff --git a/.env.example b/.env.example index 1f963d5..88614e5 100644 --- a/.env.example +++ b/.env.example @@ -8,10 +8,6 @@ OPENAI_API_KEY= # 留空时使用 OpenAI 默认地址;DeepSeek 可填写 https://api.deepseek.com OPENAI_API_BASE_URL=xxx -# Optional: memory persistence file path -# 默认 .agent-memory/memory.json,通常无需修改 -# AGENT_MEMORY_FILE=.agent-memory/memory.json - # Required: OpenAI-compatible model name # 必填,无默认值;未配置时 CLI 会提示你设置 OPENAI_MODEL OPENAI_MODEL=xxx diff --git a/README.md b/README.md index 4cdae4e..64caba6 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ call-code 是一个本地运行的终端编程 Agent(CLI coding agent),基 - 双执行模式:`PLAN` 模式只允许生成计划和读取环境,`BUILD` 模式可以写入文件、执行命令并推进任务。 - 本地工具集:内置 `get_environment`、`read_file`、`write_file`、`list_files`、`run_command` 五个工具。 - 结构化响应协议:模型输出统一为 `tool_call` 或 `final` 的 JSON action,循环解析并继续执行。 -- 本地记忆:短期记忆按任务保存,长期记忆按主题沉淀,并持久化到 `.agent-memory/memory.json`。 +- 本地记忆:短期记忆按任务保存,长期记忆按主题沉淀,仅在进程内使用,不写入本地 JSON。 - 上下文预算:运行时基于 token 估算对历史消息做裁剪,减少超出模型上下文的风险。 ## 架构 @@ -30,7 +30,7 @@ agent-core 核心层 ├─ policy/ PLAN / BUILD 模式下的工具权限 ├─ tools/ get_environment / read_file / write_file │ list_files / run_command -├─ memory/ short / long 记忆,持久化到 .agent-memory +├─ memory/ short / long 记忆(仅存内存,不落盘 JSON) └─ prompt/ 系统提示词、工具说明与模式提示词 │ OpenAI chat.completions 请求(支持流式) ▼ @@ -62,7 +62,7 @@ packages/ prompt/ # 系统提示词、工具说明、模式提示词 tools/ # 环境、文件、命令等本地工具 policy/ # PLAN/BUILD 模式下的工具权限 - tests/ # 项目统一单元测试 +tests/ # 项目统一单元测试 vitest.config.ts # Vitest 测试配置 ``` @@ -85,7 +85,6 @@ pnpm dev | `OPENAI_API_KEY` | 必填,OpenAI 兼容 API 的 Key。 | | `OPENAI_API_BASE_URL` | 可选,自定义 OpenAI 兼容 base URL。 | | `OPENAI_MODEL` | 必填,模型名称,无默认值;未配置时 CLI 会提示。 | -| `AGENT_MEMORY_FILE` | 可选,记忆持久化文件路径,默认 `.agent-memory/memory.json`。 | | `AGENT_DESKTOP_DIR` | 可选,覆盖桌面目录路径,便于测试或自定义工作环境。 | ## 常用命令 diff --git a/packages/agent-core/src/memory/memory-store.ts b/packages/agent-core/src/memory/memory-store.ts index a76e5a6..1d1255c 100644 --- a/packages/agent-core/src/memory/memory-store.ts +++ b/packages/agent-core/src/memory/memory-store.ts @@ -1,6 +1,4 @@ import { randomUUID } from 'node:crypto'; -import fs from 'node:fs'; -import path from 'node:path'; import type { LongMemoryItem, MemorySnapshot, @@ -9,25 +7,19 @@ import type { export interface MemoryStoreConfig { shortLimit: number; - memoryFile: string; } const DEFAULT_CONFIG: MemoryStoreConfig = { shortLimit: 40, - memoryFile: - process.env.AGENT_MEMORY_FILE ?? - path.resolve(process.cwd(), '.agent-memory', 'memory.json'), }; export class MemoryStore { private readonly shortMemory: ShortMemoryItem[] = []; private readonly longMemory: LongMemoryItem[] = []; - private readonly config: MemoryStoreConfig; - private lastError: string | null = null; + private readonly shortLimit: number; constructor(config: Partial = {}) { - this.config = { ...DEFAULT_CONFIG, ...config }; - this.load(); + this.shortLimit = config.shortLimit ?? DEFAULT_CONFIG.shortLimit; } addShort( @@ -43,12 +35,11 @@ export class MemoryStore { }; this.shortMemory.push(item); - const overflow = this.shortMemory.length - this.config.shortLimit; + const overflow = this.shortMemory.length - this.shortLimit; if (overflow > 0) { this.shortMemory.splice(0, overflow); } - this.persist(); return item; } @@ -64,7 +55,6 @@ export class MemoryStore { found.updatedAt = now; found.sourceCount = Math.max(found.sourceCount, input.sourceCount); found.confidence = found.confidence === 'confirmed' ? found.confidence : input.confidence; - this.persist(); return found; } @@ -76,7 +66,6 @@ export class MemoryStore { ...input, }; this.longMemory.push(item); - this.persist(); return item; } @@ -94,13 +83,11 @@ export class MemoryStore { clearShort(taskId?: string) { if (!taskId) { this.shortMemory.splice(0, this.shortMemory.length); - this.persist(); return; } const kept = this.shortMemory.filter((item) => item.taskId !== taskId); this.shortMemory.splice(0, this.shortMemory.length, ...kept); - this.persist(); } snapshot(): MemorySnapshot { @@ -109,116 +96,6 @@ export class MemoryStore { long: this.listLong(), }; } - - getMemoryFile(): string { - return this.config.memoryFile; - } - - getLastError(): string | null { - return this.lastError; - } - - private load() { - try { - this.ensureStorageFile(); - const raw = fs.readFileSync(this.config.memoryFile, 'utf8').trim(); - if (!raw) { - this.persist(); - return; - } - - const parsed = JSON.parse(raw) as Partial; - const short = Array.isArray(parsed.short) ? parsed.short : []; - const long = Array.isArray(parsed.long) ? parsed.long : []; - - this.shortMemory.splice( - 0, - this.shortMemory.length, - ...short.filter(isShortMemoryItem).slice(-this.config.shortLimit), - ); - this.longMemory.splice( - 0, - this.longMemory.length, - ...long.filter(isLongMemoryItem), - ); - this.lastError = null; - } catch (error) { - this.shortMemory.splice(0, this.shortMemory.length); - this.longMemory.splice(0, this.longMemory.length); - this.lastError = `Failed to load memory file ${this.config.memoryFile}: ${ - error instanceof Error ? error.message : String(error) - }`; - } - } - - private persist() { - try { - fs.mkdirSync(path.dirname(this.config.memoryFile), { recursive: true }); - fs.writeFileSync( - this.config.memoryFile, - `${JSON.stringify(this.snapshot(), null, 2)}\n`, - 'utf8', - ); - this.lastError = null; - } catch (error) { - this.lastError = `Failed to persist memory file ${this.config.memoryFile}: ${ - error instanceof Error ? error.message : String(error) - }`; - } - } - - private ensureStorageFile() { - fs.mkdirSync(path.dirname(this.config.memoryFile), { recursive: true }); - if (!fs.existsSync(this.config.memoryFile)) { - fs.writeFileSync( - this.config.memoryFile, - `${JSON.stringify({ short: [], long: [] }, null, 2)}\n`, - 'utf8', - ); - } - } } -const isStringArray = (value: unknown): value is string[] => - Array.isArray(value) && value.every((item) => typeof item === 'string'); - -const isMemoryBase = ( - value: Partial, -): value is ShortMemoryItem | LongMemoryItem => - typeof value.id === 'string' && - typeof value.createdAt === 'string' && - typeof value.updatedAt === 'string' && - (typeof value.taskId === 'string' || typeof value.taskId === 'undefined'); - -const isShortMemoryItem = (value: unknown): value is ShortMemoryItem => { - if (!value || typeof value !== 'object') { - return false; - } - - const item = value as Partial; - return ( - isMemoryBase(item) && - item.kind === 'short' && - ['user', 'assistant', 'system', 'tool'].includes(item.role ?? '') && - typeof item.content === 'string' && - isStringArray(item.tags) - ); -}; - -const isLongMemoryItem = (value: unknown): value is LongMemoryItem => { - if (!value || typeof value !== 'object') { - return false; - } - - const item = value as Partial; - return ( - isMemoryBase(item) && - item.kind === 'long' && - typeof item.topic === 'string' && - typeof item.content === 'string' && - ['confirmed', 'stable'].includes(item.confidence ?? '') && - typeof item.sourceCount === 'number' - ); -}; - export const memoryStore = new MemoryStore(); diff --git a/source/app.tsx b/source/app.tsx index 1851b19..de23550 100644 --- a/source/app.tsx +++ b/source/app.tsx @@ -435,14 +435,11 @@ const App = () => { case '/memory': { const snapshot = memoryStore.snapshot(); - const lastError = memoryStore.getLastError(); showCommandMessage( [ 'Memory 概览', `短期记忆: ${snapshot.short.length}`, `长期记忆: ${snapshot.long.length}`, - `文件: ${memoryStore.getMemoryFile()}`, - `最近错误: ${lastError ?? '无'}`, ].join('\n'), ); return true;