diff --git a/AGENTS.md b/AGENTS.md index 2df137bd..0271de79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,6 +80,35 @@ Coding Code 是 AI 编程助手。 - 修改过程中发现错误,如果是本次范围就修改(包括测试),否则要在最后指出 - 仅允许使用简短注释 +## 测试规则 + +**唯一判据**:改生产代码的逻辑,测试必须失败;改不影响行为的文案、命名、注释、格式,测试不应失败。不满足此判据的用例一律不写。 + +**该怎么写**: + +- 只断言可观察的行为:给定输入 → 返回值、副作用、状态变化、抛出的错误。断言对象是生产代码的结果,不是测试里自造的数据。 +- 采用 Arrange-Act-Assert:一段准备、一段触发、一段断言,一个用例只验证一个概念。 +- 用例名描述行为与预期,不描述实现细节。 +- 覆盖边界与失败路径(边界值、空集合、错误分支),这里才是缺陷高发区。 +- 按层级选择:纯函数用单元测试;跨模块链路用集成测试(mock 掉外部依赖如 LLM),验证真实装配后的端到端行为。 + +**禁止写的类型**: + +1. 文案 / 提示词断言——断言提示词、系统说明、注释、UI 文案包含或不再包含某个字符串。提示词是产品内容,其变更由人工评审守护,不写成测试。 +2. 类型形状断言——运行时断言某字面量有哪些字段、字段是什么类型。类型正确性由 `tsc` 保证,这类断言恒真且零信息量;确需钉住类型时用编译期的 `@ts-expect-error` / `expectTypeOf`,不要写成运行时 `expect`。 +3. 存在性 / 导出断言——断言文件存在、类 / 函数 / layer 已导出、方法存在。真断裂时编译与上层用例会同时失败,此类用例不提供额外信息。 +4. 常量钉死——断言常量等于它自身的字面量。需要守护的是行为,不是常量的副本。 +5. 测试内重新实现逻辑——在测试里重写一遍待测算法再断言自己写的副本,只证明写法一致,发现不了实现缺陷。 +6. 源码文本 / 结构扫描——用字符串或正则解析源码断言导入、布局或写法。这属于静态分析:类型与导入可解析性由 `tsc` 保证,无需重复;架构分层等 `tsc` 无法表达的约束,改用 ESLint 规则或 AST 解析,而不是正则匹配源码文本(现有 `test/architecture/boundaries.test.ts`、`test/hooks/points-coverage.test.ts` 应按此方向迁移)。 +7. 占位 / 恒真断言——`expect(true).toBe(true)`、只 `toBeDefined()` 不校验值、以及无论如何都会通过的断言。 +8. 重复用例——与已有用例覆盖同一行为的副本;发现重复应合并,不要并存。 + +**其他**: + +- 测试必须独立、确定、可任意顺序执行,不依赖执行顺序或共享可变状态(每个用例自备数据并自行清理)。 +- 不为「将来可能的变化」预留用例,只覆盖当前真实存在的行为。 +- 新增 / 删除测试前先取得用户许可(见「开发规则」)。 + ## 其他规则 - 用户要求回答问题时,必须清晰回答每一点问题,不得遗漏 diff --git a/CLAUDE.md b/CLAUDE.md index 2df137bd..0271de79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,6 +80,35 @@ Coding Code 是 AI 编程助手。 - 修改过程中发现错误,如果是本次范围就修改(包括测试),否则要在最后指出 - 仅允许使用简短注释 +## 测试规则 + +**唯一判据**:改生产代码的逻辑,测试必须失败;改不影响行为的文案、命名、注释、格式,测试不应失败。不满足此判据的用例一律不写。 + +**该怎么写**: + +- 只断言可观察的行为:给定输入 → 返回值、副作用、状态变化、抛出的错误。断言对象是生产代码的结果,不是测试里自造的数据。 +- 采用 Arrange-Act-Assert:一段准备、一段触发、一段断言,一个用例只验证一个概念。 +- 用例名描述行为与预期,不描述实现细节。 +- 覆盖边界与失败路径(边界值、空集合、错误分支),这里才是缺陷高发区。 +- 按层级选择:纯函数用单元测试;跨模块链路用集成测试(mock 掉外部依赖如 LLM),验证真实装配后的端到端行为。 + +**禁止写的类型**: + +1. 文案 / 提示词断言——断言提示词、系统说明、注释、UI 文案包含或不再包含某个字符串。提示词是产品内容,其变更由人工评审守护,不写成测试。 +2. 类型形状断言——运行时断言某字面量有哪些字段、字段是什么类型。类型正确性由 `tsc` 保证,这类断言恒真且零信息量;确需钉住类型时用编译期的 `@ts-expect-error` / `expectTypeOf`,不要写成运行时 `expect`。 +3. 存在性 / 导出断言——断言文件存在、类 / 函数 / layer 已导出、方法存在。真断裂时编译与上层用例会同时失败,此类用例不提供额外信息。 +4. 常量钉死——断言常量等于它自身的字面量。需要守护的是行为,不是常量的副本。 +5. 测试内重新实现逻辑——在测试里重写一遍待测算法再断言自己写的副本,只证明写法一致,发现不了实现缺陷。 +6. 源码文本 / 结构扫描——用字符串或正则解析源码断言导入、布局或写法。这属于静态分析:类型与导入可解析性由 `tsc` 保证,无需重复;架构分层等 `tsc` 无法表达的约束,改用 ESLint 规则或 AST 解析,而不是正则匹配源码文本(现有 `test/architecture/boundaries.test.ts`、`test/hooks/points-coverage.test.ts` 应按此方向迁移)。 +7. 占位 / 恒真断言——`expect(true).toBe(true)`、只 `toBeDefined()` 不校验值、以及无论如何都会通过的断言。 +8. 重复用例——与已有用例覆盖同一行为的副本;发现重复应合并,不要并存。 + +**其他**: + +- 测试必须独立、确定、可任意顺序执行,不依赖执行顺序或共享可变状态(每个用例自备数据并自行清理)。 +- 不为「将来可能的变化」预留用例,只覆盖当前真实存在的行为。 +- 新增 / 删除测试前先取得用户许可(见「开发规则」)。 + ## 其他规则 - 用户要求回答问题时,必须清晰回答每一点问题,不得遗漏 diff --git a/packages/codingcode/test/agent/agent-cache-stability.test.ts b/packages/codingcode/test/agent/agent-cache-stability.test.ts deleted file mode 100644 index 6e83c82a..00000000 --- a/packages/codingcode/test/agent/agent-cache-stability.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { makeState, runAgentTurn, llmStream, pEnd } from '../helpers/agent-harness.js'; - -vi.mock('../../src/infra/config.js', () => ({ - loadConfig: () => ({ - maxSteps: 5, - maxStopContinuations: 2, - context: { - compactionModel: '', - }, - memory: { - enabled: false, - model: '', - maxBytes: 16384, - promptMaxBytes: 8192, - }, - server: { port: 8080 }, - }), -})); - -const mockState = makeState({ - sessionId: 'cache-test-sid', - cwd: '/tmp/cache-test', - title: 'cache-stability', -}); - -function makeCapturingLlm() { - const captured: { system?: string } = {}; - const llm = { - completeStream: vi.fn((params: any) => { - captured.system = params.system; - return llmStream(pEnd()); - }), - modelInfo: { maxTokens: 1000 }, - } as any; - return { llm, captured }; -} - -async function runOnce(llm: any) { - return runAgentTurn( - { llm, state: mockState }, - { sessionId: 'cache-test-sid', cwd: '/tmp/cache-test' } - ); -} - -describe('LLM prompt cache stability', () => { - it('system prompt does not include deferred tools catalog', async () => { - const { llm, captured } = makeCapturingLlm(); - await runOnce(llm); - expect(captured.system).toBeDefined(); - expect(captured.system).not.toContain(''); - expect(captured.system).not.toContain(''); - }); - - it('system prompt is byte-identical across consecutive turns', async () => { - const { llm, captured } = makeCapturingLlm(); - await runOnce(llm); - const first = captured.system; - expect(first).toBeDefined(); - await runOnce(llm); - const second = captured.system; - expect(second).toBe(first); - }); -}); diff --git a/packages/codingcode/test/agent/agent-profile-filter.test.ts b/packages/codingcode/test/agent/agent-profile-filter.test.ts deleted file mode 100644 index 3b162944..00000000 --- a/packages/codingcode/test/agent/agent-profile-filter.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { buildSystemPrompt } from '../../src/agent/prompt.js'; - -describe('system prompt', () => { - it('does not advertise a subagent catalog', () => { - const prompt = buildSystemPrompt({ - cwd: '/x', - platform: 'linux', - shell: 'bash', - }); - expect(prompt).not.toContain('Available Subagents'); - }); -}); diff --git a/packages/codingcode/test/agent/build-system-prompt.test.ts b/packages/codingcode/test/agent/build-system-prompt.test.ts deleted file mode 100644 index 24e83b2c..00000000 --- a/packages/codingcode/test/agent/build-system-prompt.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { BUILD_PROMPT, PLAN_PROMPT, PLAN_PROFILE } from '../../src/agent/profile.js'; -import { buildSystemPrompt } from '../../src/agent/prompt.js'; - -describe('buildSystemPrompt', () => { - it('uses the build prompt when profileSystemPrompt is not provided', () => { - const prompt = buildSystemPrompt({ - cwd: '/test', - platform: 'linux', - shell: 'bash', - }); - expect(prompt).toContain(BUILD_PROMPT); - expect(prompt).toContain('## How you work'); - expect(prompt).toContain('## Environment'); - expect(prompt).toContain('Working directory: /test'); - }); - - it('uses the plan prompt when profileSystemPrompt is provided', () => { - const prompt = buildSystemPrompt({ - cwd: '/test', - platform: 'linux', - shell: 'bash', - profileSystemPrompt: PLAN_PROMPT, - }); - expect(prompt).toContain('You are a planning agent'); - expect(prompt).toContain('## Environment'); - expect(prompt).toContain('Working directory: /test'); - expect(prompt).not.toContain('You are a coding assistant'); - expect(prompt).not.toContain('## How you work'); - }); - - it('emits env segment with cwd/platform/shell replaced', () => { - const prompt = buildSystemPrompt({ - cwd: '/projects/foo', - platform: 'darwin', - shell: 'zsh', - }); - expect(prompt).toContain('Working directory: /projects/foo'); - expect(prompt).toContain('Operating system: darwin'); - expect(prompt).toContain('Shell: zsh'); - expect(prompt).not.toContain('{{cwd}}'); - expect(prompt).not.toContain('{{platform}}'); - expect(prompt).not.toContain('{{shell}}'); - }); - - it('appends user-defined rules when provided', () => { - const prompt = buildSystemPrompt({ - cwd: '/x', - platform: 'linux', - shell: 'bash', - rules: 'Always use TypeScript strict mode.', - }); - expect(prompt).toContain('## User-defined Rules'); - expect(prompt).toContain('Always use TypeScript strict mode.'); - }); - - it('plan profile prompt limits implementation work to submit_plan', () => { - const prompt = buildSystemPrompt({ - cwd: '/x', - platform: 'linux', - shell: 'bash', - profileSystemPrompt: PLAN_PROFILE.systemPrompt, - }); - expect(prompt).toContain('submit_plan'); - expect(prompt).toContain('write_file / edit_file / execute_command are denied'); - }); -}); diff --git a/packages/codingcode/test/agent/memory-snapshot.test.ts b/packages/codingcode/test/agent/memory-snapshot.test.ts index 6a46d531..14e2f0d3 100644 --- a/packages/codingcode/test/agent/memory-snapshot.test.ts +++ b/packages/codingcode/test/agent/memory-snapshot.test.ts @@ -25,11 +25,10 @@ function makeStateForMemory() { } function makeCapturingLlm() { - const captured: { system?: string; messages?: any[] } = {}; + const captured: { system?: string } = {}; const llm = { completeStream: vi.fn((params: any) => { captured.system = params.system; - captured.messages = params.messages; return llmStream(pEnd()); }), modelInfo: { maxTokens: 1000 }, @@ -45,31 +44,9 @@ async function runOnce(llm: any, memorySnapshot: string = '') { } describe('Memory snapshot semantics', () => { - it('loads memory via MemoryPort and includes it in the system prompt', async () => { + it('injects the provided memory snapshot into the system prompt', async () => { const { llm, captured } = makeCapturingLlm(); await runOnce(llm, MEMORY); - expect(captured.system).toContain('## Session Memory'); expect(captured.system).toContain('Frozen content'); }); - - it('system prompt is byte-identical across consecutive turns with the same memory snapshot', async () => { - const { llm, captured } = makeCapturingLlm(); - await runOnce(llm, MEMORY); - const first = captured.system; - expect(first).toBeDefined(); - await runOnce(llm, MEMORY); - const second = captured.system; - expect(second).toBe(first); - }); - - it('appends memory verbatim and does not inject into messages', async () => { - const { llm, captured } = makeCapturingLlm(); - await runOnce(llm, MEMORY); - // memory 块原样拼在 "## Session Memory" 标题之后,中间无注入的 reminder 包装 - expect(captured.system).toContain('## Session Memory\n\n## Long-term Memory\n\nFrozen content'); - const allContents = (captured.messages ?? []) - .map((m: any) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content))) - .join('\n'); - expect(allContents).not.toContain(''); - }); }); diff --git a/packages/codingcode/test/agent/message-tool-calls.test.ts b/packages/codingcode/test/agent/message-tool-calls.test.ts deleted file mode 100644 index 43fd5091..00000000 --- a/packages/codingcode/test/agent/message-tool-calls.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import type { Message } from '../../src/contracts/types.js'; - -describe('Message tool_calls assignment', () => { - it('should allow direct assignment without as any cast', () => { - const toolCalls = [{ id: 'tc1', name: 'read_file', arguments: { path: '/foo.ts' } }]; - const assistantMsg: Message = { role: 'assistant', content: 'Reading file' }; - - // This must compile and work without `as any` because Message already - // declares the optional `tool_calls` field. - assistantMsg.tool_calls = toolCalls; - - expect(assistantMsg.tool_calls).toBe(toolCalls); - expect(assistantMsg.tool_calls!).toHaveLength(1); - expect(assistantMsg.tool_calls![0]!.name).toBe('read_file'); - }); - - it('should keep tool_calls undefined when not assigned', () => { - const assistantMsg: Message = { role: 'assistant', content: 'Hello' }; - expect(assistantMsg.tool_calls).toBeUndefined(); - }); - - it('should round-trip through messages array', () => { - const messages: Message[] = []; - const assistantMsg: Message = { role: 'assistant', content: '' }; - assistantMsg.tool_calls = [ - { id: 'a', name: 'tool1', arguments: {} }, - { id: 'b', name: 'tool2', arguments: { x: 1 } }, - ]; - messages.push(assistantMsg); - - expect(messages[0]!.tool_calls!).toHaveLength(2); - expect(messages[0]!.tool_calls![1]!.id).toBe('b'); - }); -}); diff --git a/packages/codingcode/test/agent/stop-decision-type.test.ts b/packages/codingcode/test/agent/stop-decision-type.test.ts deleted file mode 100644 index 702abf37..00000000 --- a/packages/codingcode/test/agent/stop-decision-type.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -// 必须排在 hooks.js 前面:vi.mock 工厂要引用 fakeSpawn -import { resetFakeSpawn, whenCommand, fakeSpawn } from '../hooks/fake-spawn.js'; -import { Effect } from 'effect'; -import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; -import { join, resolve } from 'path'; -import { tmpdir } from 'os'; -import { HookService } from '../../src/hooks/port.js'; -import { HookLayer } from '../../src/hooks/hooks.js'; -import { useTempHome } from '../helpers/temp-home.js'; - -vi.mock('child_process', () => ({ - spawn: (command: string, args: string[]) => fakeSpawn(command, args), -})); - -describe('agent.turn.stop 的决策类型推断', () => { - const testDir = resolve(tmpdir(), 'codingcode-test-stop-decision'); - // 全局层落在临时 home 里,避免读到开发机上的 ~/.codingcode(全局配置目录不可指定) - useTempHome('codingcode-test-stop-decision-'); - - beforeEach(() => { - resetFakeSpawn(); - if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); - mkdirSync(join(testDir, '.codingcode'), { recursive: true }); - }); - - afterEach(() => { - if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); - }); - - it('emitDecision 直接给出 HookDecision,读 decision / injection 不需要 any', async () => { - writeFileSync( - join(testDir, '.codingcode', 'hooks.yaml'), - 'hooks:\n - name: loop\n point: agent.turn.stop\n type: decision\n command: cmd-loop\n args: []\n' - ); - whenCommand('cmd-loop', { stdout: '{"decision":"continue","injection":"(test continue)"}' }); - - const program = Effect.gen(function* () { - const hooks = yield* HookService; - yield* hooks.reloadUserHooks(testDir); - const stopDecision = yield* hooks.emitDecision('agent.turn.stop', { - projectPath: testDir, - sessionId: 'test-sid', - content: 'hello', - turnId: 1, - }); - // 类型级检查:stopDecision 是 HookDecision | null,下面两行必须无 `as any` 通过编译 - return stopDecision?.decision === 'continue' ? stopDecision.injection : null; - }); - - const result = await Effect.runPromise(program.pipe(Effect.provide(HookLayer) as any)); - expect(result).toBe('(test continue)'); - }); -}); diff --git a/packages/codingcode/test/checkpoint/checkpoint-diff.test.ts b/packages/codingcode/test/checkpoint/checkpoint-diff.test.ts index e09f64a9..4e09da0f 100644 --- a/packages/codingcode/test/checkpoint/checkpoint-diff.test.ts +++ b/packages/codingcode/test/checkpoint/checkpoint-diff.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect } from 'vitest'; import { useTempProjectBase } from '../helpers/project-base.js'; -import { CheckpointLayer } from '../../src/checkpoint/checkpoint.js'; useTempProjectBase(); @@ -18,37 +17,3 @@ describe('toGitPath', () => { }); }); -describe('CheckpointService class', () => { - it('CheckpointService class is exported', async () => { - const mod = await import('../../src/checkpoint/port.js'); - expect(mod.CheckpointService).toBeDefined(); - }, 60000); -}); - -describe('CheckpointDiff type with insertions/deletions', () => { - it('CheckpointDiff type includes insertions and deletions fields', async () => { - // Verify the type structure by creating a mock object - const diff: import('../../src/checkpoint/types.js').CheckpointDiff = { - turnId: 1, - files: [ - { - path: 'test.ts', - status: 'M', - diff: '--- a/test.ts\n+++ b/test.ts\n@@ -1 +1 @@\n-old\n+new', - insertions: 1, - deletions: 1, - }, - ], - }; - expect(diff.files[0]!.insertions).toBe(1); - expect(diff.files[0]!.deletions).toBe(1); - }); -}); - -describe('CheckpointService', () => { - it('should export a Default layer', async () => { - const { CheckpointService } = await import('../../src/checkpoint/port.js'); - expect(CheckpointService).toBeDefined(); - expect((CheckpointLayer as any)).toBeDefined(); - }); -}); diff --git a/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts b/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts index 6ce9f093..fceda626 100644 --- a/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts +++ b/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect } from 'vitest'; import { useTempProjectBase } from '../helpers/project-base.js'; -import { CheckpointLayer } from '../../src/checkpoint/checkpoint.js'; useTempProjectBase(); @@ -47,10 +46,3 @@ describe('toGitPath preserves original casing for git paths', () => { }); }); -describe('CheckpointService', () => { - it('should export a Default layer', async () => { - const { CheckpointService } = await import('../../src/checkpoint/port.js'); - expect(CheckpointService).toBeDefined(); - expect((CheckpointLayer as any)).toBeDefined(); - }); -}); diff --git a/packages/codingcode/test/context/compressor/l5-prompt.test.ts b/packages/codingcode/test/context/compressor/l5-prompt.test.ts deleted file mode 100644 index e076353f..00000000 --- a/packages/codingcode/test/context/compressor/l5-prompt.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { COMPACTION_SYSTEM_PROMPT } from '../../../src/context/compaction-prompt.js'; - -// 断言直接读真实提示词常量,避免测试与实现各写一份而悄悄漂移。 -describe('L5 compaction prompt contract', () => { - const SECTIONS = [ - '## 1. Primary Request and Intent', - '## 2. Key Technical Concepts', - '## 3. Files and Code Sections', - '## 4. Errors and Fixes', - '## 5. Problem Solving', - '## 6. Decision Rationale and Rejected Approaches', - '## 7. All User Messages', - '## 8. Pending Tasks', - '## 9. Current Work', - '## 10. Optional Next Step', - ]; - - it('requests all ten sections in order', () => { - let cursor = -1; - for (const section of SECTIONS) { - const at = COMPACTION_SYSTEM_PROMPT.indexOf(section); - expect(at, `missing or out-of-order section: ${section}`).toBeGreaterThan(cursor); - cursor = at; - } - }); - - it('no longer asks for a separate analysis block', () => { - expect(COMPACTION_SYSTEM_PROMPT).not.toMatch(/<\/?analysis>/); - }); - - it('no longer wraps the summary in tags', () => { - expect(COMPACTION_SYSTEM_PROMPT).not.toMatch(/<\/?summary>/); - }); -}); diff --git a/packages/codingcode/test/context/organizer.test.ts b/packages/codingcode/test/context/organizer.test.ts deleted file mode 100644 index c712c4bb..00000000 --- a/packages/codingcode/test/context/organizer.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { Effect, Layer } from 'effect'; -import { ContextService } from '../../src/context/port.js'; -import { SessionService } from '../../src/session/port.js'; -import { SessionLayer } from '../../src/session/session.js'; -import { LLMService } from '../../src/llm/port.js'; -import type { SessionEvent, ToolResultEvent } from '../../src/contracts/session.js'; -import { ContextLayer } from '../../src/context/context.js'; - -const baseConfig = { - compactionModel: '', -}; - -function makeUserEvent(content: string, turnId: number): SessionEvent { - return { type: 'user', content, turnId }; -} - -function makeAssistant(content: string, turnId: number): SessionEvent { - return { - type: 'assistant', - content, - turnId, - toolCalls: [], - }; -} - -function makeToolResult( - toolName: string, - output: string, - turnId: number, - toolCallId: string -): ToolResultEvent { - return { - type: 'tool_result', - toolName, - toolCallId, - output, - turnId, - }; -} - -const TestLayer = Layer.merge( - SessionLayer, - Layer.succeed(LLMService, { - complete: () => Effect.fail(new Error('no llm')), - completeStream: () => (async function* () {})(), - } as any) -); - -describe('assemblePayload', () => { - it('is importable and exists as a method on ContextService', async () => { - const svc = await Effect.runPromise( - Effect.gen(function* () { - const ctx = yield* ContextService; - return ctx; - }).pipe(Effect.provide(ContextLayer), Effect.provide(TestLayer)) - ); - expect(typeof svc.assemblePayload).toBe('function'); - }); -}); diff --git a/packages/codingcode/test/core/path.test.ts b/packages/codingcode/test/core/path.test.ts index 33db2804..d0454d59 100644 --- a/packages/codingcode/test/core/path.test.ts +++ b/packages/codingcode/test/core/path.test.ts @@ -1,12 +1,7 @@ import { describe, it, expect } from 'vitest'; import { join } from 'path'; import { homedir, tmpdir } from 'os'; -import { - normalizePath, - encodeProjectPath, - CODINGCODE_DIRNAME, - getGlobalDir, -} from '../../src/core/path.js'; +import { normalizePath, encodeProjectPath, getGlobalDir } from '../../src/core/path.js'; import { ShadowGit } from '../../src/checkpoint/shadow-git.js'; import { setFakeHome, restoreHome } from '../helpers/temp-home.js'; @@ -41,11 +36,7 @@ describe('core/path:纯格式化函数', () => { }); }); -describe('core/path:唯一被允许的常量', () => { - it('目录名字面量被钉住:改它必须显式改这里', () => { - expect(CODINGCODE_DIRNAME).toBe('.codingcode'); - }); - +describe('core/path:全局目录', () => { it('全局目录落在用户目录下', () => { expect(getGlobalDir()).toBe(join(homedir(), '.codingcode')); }); diff --git a/packages/codingcode/test/plan/is-plan-profile.test.ts b/packages/codingcode/test/plan/is-plan-profile.test.ts index 77c2cdfd..6633c3d5 100644 --- a/packages/codingcode/test/plan/is-plan-profile.test.ts +++ b/packages/codingcode/test/plan/is-plan-profile.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { isPlanProfile, PLAN_PROFILE_NAME, BUILD_PROFILE_NAME } from '../../src/agent/profile.js'; +import { isPlanProfile } from '../../src/agent/profile.js'; describe('isPlanProfile', () => { it('returns true for a profile named "plan"', () => { @@ -19,8 +19,4 @@ describe('isPlanProfile', () => { expect(isPlanProfile(undefined)).toBe(false); }); - it('exposes the canonical plan/build profile name constants', () => { - expect(PLAN_PROFILE_NAME).toBe('plan'); - expect(BUILD_PROFILE_NAME).toBe('build'); - }); }); diff --git a/packages/codingcode/test/plan/policy.test.ts b/packages/codingcode/test/plan/policy.test.ts index 2e09926b..6c38c4eb 100644 --- a/packages/codingcode/test/plan/policy.test.ts +++ b/packages/codingcode/test/plan/policy.test.ts @@ -2,12 +2,6 @@ import { describe, expect, it } from 'vitest'; import { PLAN_ALLOWED_TOOLS } from '../../src/contracts/permission.js'; describe('PLAN_ALLOWED_TOOLS', () => { - it('contains only read tools and submit_plan', () => { - expect(PLAN_ALLOWED_TOOLS).toEqual( - new Set(['read_file', 'search_files', 'search_code', 'fetch_url', 'submit_plan']) - ); - }); - it('does not expose write tools', () => { expect(PLAN_ALLOWED_TOOLS.has('write_file')).toBe(false); expect(PLAN_ALLOWED_TOOLS.has('edit_file')).toBe(false); diff --git a/packages/codingcode/test/prompts/system-prompt.test.ts b/packages/codingcode/test/prompts/system-prompt.test.ts deleted file mode 100644 index 671eaffe..00000000 --- a/packages/codingcode/test/prompts/system-prompt.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { buildSystemPrompt, SYSTEM_NOTES } from '../../src/agent/prompt.js'; - -const baseOpts = { cwd: '/test', platform: 'linux', shell: 'bash' }; - -describe('buildSystemPrompt', () => { - it('replaces cwd, platform, shell placeholders', () => { - const prompt = buildSystemPrompt({ cwd: '/my/proj', platform: 'darwin', shell: 'zsh' }); - expect(prompt).toContain('/my/proj'); - expect(prompt).toContain('darwin'); - expect(prompt).toContain('zsh'); - }); - - it('includes identity definition', () => { - const prompt = buildSystemPrompt(baseOpts); - expect(prompt).toContain('coding assistant'); - expect(prompt).toContain('software engineering tasks'); - }); - - it('includes How you work section', () => { - const prompt = buildSystemPrompt(baseOpts); - expect(prompt).toContain('How you work'); - expect(prompt).toContain('permission system'); - expect(prompt).toContain('system-reminder'); - }); - - it('Rule 7 guides assessment-first then optional delegation', () => { - const prompt = buildSystemPrompt(baseOpts); - expect(prompt).toContain('assess the task scope'); - expect(prompt).toContain('dispatch_agent'); - }); - - it('Rule 7 delegation does not point at a removed subagent catalog', () => { - const prompt = buildSystemPrompt(baseOpts); - expect(prompt).not.toContain('runtime-configured subagent'); - expect(prompt).not.toContain('Available Subagents'); - expect(prompt).toContain('short agentName'); - expect(prompt).toContain('keep the delegated write set disjoint'); - }); - - it('includes Using your tools section', () => { - const prompt = buildSystemPrompt(baseOpts); - expect(prompt).toContain('Using your tools'); - expect(prompt).toContain('Prefer dedicated tools over shell commands'); - expect(prompt).toContain('Call multiple tools in parallel'); - expect(prompt).toContain('read_file instead of cat'); - }); - - it('includes Executing actions with care section', () => { - const prompt = buildSystemPrompt(baseOpts); - expect(prompt).toContain('Executing actions with care'); - expect(prompt).toContain('reversibility and blast radius'); - expect(prompt).toContain('destructive commands'); - expect(prompt).toContain('rm -rf'); - }); - - it('includes Git operations section', () => { - const prompt = buildSystemPrompt(baseOpts); - expect(prompt).toContain('Git operations'); - expect(prompt).toContain('Do NOT commit changes unless the user explicitly asks'); - expect(prompt).toContain('git reset --hard'); - expect(prompt).toContain('git push --force'); - }); - - it('includes Professional objectivity section', () => { - const prompt = buildSystemPrompt(baseOpts); - expect(prompt).toContain('Professional objectivity'); - expect(prompt).toContain('technical accuracy'); - expect(prompt).toContain('Do not begin responses with conversational interjections'); - }); - - it('includes Follow existing conventions section with expanded guidance', () => { - const prompt = buildSystemPrompt(baseOpts); - expect(prompt).toContain('Follow existing conventions'); - expect(prompt).toContain('Never assume a library is available'); - expect(prompt).toContain('package.json'); - expect(prompt).toContain('Comments'); - expect(prompt).toContain('WHY is non-obvious'); - }); - - it('includes Code references section', () => { - const prompt = buildSystemPrompt(baseOpts); - expect(prompt).toContain('Code references'); - expect(prompt).toContain('file_path:line_number'); - }); - - it('includes Output efficiency section', () => { - const prompt = buildSystemPrompt(baseOpts); - expect(prompt).toContain('Output efficiency'); - expect(prompt).toContain('Lead with the answer'); - expect(prompt).toContain('one-to-two sentence summary'); - expect(prompt).toContain('Match the response to the question'); - }); - - it('SYSTEM_NOTES explains compression, memory, and todo', () => { - expect(SYSTEM_NOTES).toContain('automatically compressed'); - expect(SYSTEM_NOTES).toContain('Session Memory'); - expect(SYSTEM_NOTES).toContain('todo_write'); - }); - - it('includes SYSTEM_NOTES in prompt', () => { - const prompt = buildSystemPrompt(baseOpts); - expect(prompt).toContain('System Notes'); - expect(prompt).toContain('automatically compressed'); - }); - - it('includes user-defined rules section when rules exist', () => { - const prompt = buildSystemPrompt({ ...baseOpts, rules: 'Always use TypeScript strict mode' }); - expect(prompt).toContain('User-defined Rules'); - expect(prompt).toContain('Always use TypeScript strict mode'); - }); - - it('omits user-defined rules section when rules is undefined', () => { - const prompt = buildSystemPrompt(baseOpts); - expect(prompt).not.toContain('User-defined Rules'); - }); - - it('omits available subagents section when no profiles are provided', () => { - const prompt = buildSystemPrompt(baseOpts); - expect(prompt).not.toContain('Available Subagents'); - }); - - it('does not contain old Rule 3 (verify with read_file after writing)', () => { - const prompt = buildSystemPrompt(baseOpts); - expect(prompt).not.toContain('verify with read_file'); - }); - - it('does not contain DEFERRED_TOOLS_GUIDELINES as separate section', () => { - const prompt = buildSystemPrompt(baseOpts); - expect(prompt).not.toContain('Deferred tools'); - }); -}); diff --git a/packages/codingcode/test/session/facade-surface.test.ts b/packages/codingcode/test/session/facade-surface.test.ts index b4593489..ea099dbe 100644 --- a/packages/codingcode/test/session/facade-surface.test.ts +++ b/packages/codingcode/test/session/facade-surface.test.ts @@ -4,36 +4,6 @@ import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; describe('session service surface', () => { - it('service shape exposes exactly the contract method set', async () => { - const service = await Effect.runPromise( - Effect.gen(function* () { - return yield* SessionService; - }).pipe(Effect.provide(SessionLayer)) - ); - const methods = Object.keys(service).sort(); - expect(methods).toEqual([ - 'appendEvent', - 'appendSummary', - 'create', - 'deleteSession', - 'forkSession', - 'listSessions', - 'load', - 'readEvents', - 'readHistory', - 'readUITurns', - 'recordAssistant', - 'recordSystem', - 'recordToolResult', - 'recordUser', - 'renameSession', - 'rollbackToTurn', - 'setActiveProfile', - 'setModel', - 'setPermissionMode', - ]); - }); - it('does not leak file-level operations through the service', async () => { const service = await Effect.runPromise( Effect.gen(function* () { diff --git a/packages/codingcode/test/session/types-export.test.ts b/packages/codingcode/test/session/types-export.test.ts deleted file mode 100644 index cf9b248e..00000000 --- a/packages/codingcode/test/session/types-export.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { SessionStoreState } from '../../src/contracts/session.js'; - -describe('SessionStoreState export', () => { - it('contains only cwd as its path source', () => { - const state: SessionStoreState = { - type: 'session_meta', - sessionId: 'test-sid', - cwd: '/tmp', - createdAt: '2026-01-01T00:00:00.000Z', - model: 'gpt-4', - title: '', - activeProfile: 'build', - permissionMode: 'ask', - currentTurnId: 0, - usage: undefined, - memorySnapshot: '', - }; - - expect(state.cwd).toBe('/tmp'); - expect(state).not.toHaveProperty('projectPath'); - expect(state).not.toHaveProperty('transcriptPath'); - expect(state).not.toHaveProperty('indexPath'); - expect(state).not.toHaveProperty('mode'); - }); -}); diff --git a/packages/codingcode/test/subagent/builtin-profiles.test.ts b/packages/codingcode/test/subagent/builtin-profiles.test.ts deleted file mode 100644 index 06b5c233..00000000 --- a/packages/codingcode/test/subagent/builtin-profiles.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { PLAN_PROFILE, BUILD_PROFILE, PLAN_TOOL_NAMES } from '../../src/agent/profile.js'; - -describe('built-in subagent profiles', () => { - it('keeps only build and plan as built-in names', () => { - expect([BUILD_PROFILE.name, PLAN_PROFILE.name].sort()).toEqual(['build', 'plan']); - }); - - it('keeps plan tools independent from profile tool lists', () => { - expect('tools' in PLAN_PROFILE).toBe(false); - expect('tools' in BUILD_PROFILE).toBe(false); - expect(PLAN_TOOL_NAMES).toEqual([ - 'read_file', - 'search_files', - 'search_code', - 'fetch_url', - 'submit_plan', - ]); - }); -}); diff --git a/packages/codingcode/test/types/type-collapse.test.ts b/packages/codingcode/test/types/type-collapse.test.ts index bdf24e20..0f4dee8f 100644 --- a/packages/codingcode/test/types/type-collapse.test.ts +++ b/packages/codingcode/test/types/type-collapse.test.ts @@ -1,20 +1,13 @@ -import { describe, expect, it } from 'vitest'; +import { describe, it } from 'vitest'; import type { UITurn, UITurnItem } from '../../src/contracts/session.js'; -import type { TodoItem, TokenUsage } from '../../src/contracts/types.js'; - -type AssertNotAny = 0 extends 1 & T ? never : T; - -type _UITurnNotAny = AssertNotAny; -type _TodoNotAny = AssertNotAny; -type _UsageNotAny = AssertNotAny; +import type { TodoItem } from '../../src/contracts/types.js'; +// 本文件只做编译期类型约束(tsc 执行 @ts-expect-error 校验),不含运行时断言。 describe('类型收口', () => { - it('UITurn.status 不再退化为 string', () => { - const turn: UITurn = { id: '1', items: [], status: 'completed' }; - expect(turn.status).toBe('completed'); + it('UITurn.status 不接受任意字符串', () => { // @ts-expect-error status 只能是三个字面量之一 const bad: UITurn = { id: '1', items: [], status: 'nope' }; - expect(bad).toBeDefined(); + void bad; }); it('UITurnItem 覆盖 session 产出的全部变体', () => { @@ -26,21 +19,12 @@ describe('类型收口', () => { { id: 'e', type: 'reasoning', content: 'r', isVisible: false }, { id: 'f', type: 'error', message: 'e' }, ]; - expect(items.map((i) => i.type)).toEqual([ - 'message', - 'tool_call', - 'tool_result', - 'summary', - 'reasoning', - 'error', - ]); + void items; }); it('TodoItem.status 是字面量联合而非 string', () => { - const todo: TodoItem = { step: 'do it', status: 'in_progress' }; - expect(todo.status).toBe('in_progress'); // @ts-expect-error status 只能是 pending / in_progress / completed const bad: TodoItem = { step: 'x', status: 'whatever' }; - expect(bad).toBeDefined(); + void bad; }); }); diff --git a/packages/desktop/test/diff-and-store.test.ts b/packages/desktop/test/diff-and-store.test.ts new file mode 100644 index 00000000..7c64e73d --- /dev/null +++ b/packages/desktop/test/diff-and-store.test.ts @@ -0,0 +1,234 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { computeDiff } from '../src/lib/diff-compute'; +import { useAgentStore } from '../src/stores/agent.store'; +import type { Item } from '../shared/types'; + +// ─── diff-compute: large file protection ───────────────────────────────── + +describe('computeDiff - large file protection', () => { + it('uses LCS diff for files under 500 lines', () => { + const oldContent = Array(100).fill('line').join('\n'); + const newContent = Array(100).fill('line').join('\n'); + newContent.replace('line', 'changed'); + + const result = computeDiff(oldContent, newContent); + // LCS diff should produce contextual diff, not all-delete+all-insert + expect(result.diff).toContain(' line'); + }); + + it('falls back to simplified diff for files over 500 lines', () => { + const oldLines = Array(600).fill('old line'); + const newLines = Array(600).fill('new line'); + // Only change 1 line — LCS would show 1 deletion + 1 insertion + oldLines[300] = 'changed'; + newLines[300] = 'changed'; + + const result = computeDiff(oldLines.join('\n'), newLines.join('\n')); + // Simplified diff shows ALL old lines as deletions, ALL new lines as insertions + expect(result.deletions).toBe(600); + expect(result.insertions).toBe(600); + }); + + it('simplified diff still produces valid diff output', () => { + const oldContent = Array(501).fill('a').join('\n'); + const newContent = Array(501).fill('b').join('\n'); + + const result = computeDiff(oldContent, newContent); + expect(result.diff).toContain('-a'); + expect(result.diff).toContain('+b'); + expect(result.insertions).toBe(501); + expect(result.deletions).toBe(501); + }); + + it('new file shortcut still works regardless of line count', () => { + const newContent = Array(1000).fill('line').join('\n'); + const result = computeDiff('', newContent); + expect(result.insertions).toBe(1000); + expect(result.deletions).toBe(0); + expect(result.diff).toContain('+line'); + }); +}); + +// ─── agent.store: applyChunk tool_result 定位与插入位置 ─────────────────── + +describe('global store - applyChunk tool_result searches current turn first', () => { + beforeEach(() => { + useAgentStore.setState({ + currentThreadId: null, + threads: {}, + profile: 'build', permissionMode: 'ask', + model: '', + models: [], + contextUsage: null, + todoByThreadId: {}, + pendingInput: null, + usageByThreadId: {}, + isCompressing: false, + }); + }); + + it('finds tool_call in current turn first', () => { + const threadId = 't1'; + + // Turn 1 with a tool_call + useAgentStore.getState().startTurn(threadId, { + id: 'turn-1', + items: [ + { id: 'call-1', type: 'tool_call', name: 'read_file', args: {}, status: 'running' } as Item, + ], + status: 'completed', + }); + useAgentStore.getState().completeTurn(threadId, 'turn-1', 'completed'); + + // Turn 2 with a tool_call of same name but different id + useAgentStore.getState().startTurn(threadId, { + id: 'turn-2', + items: [ + { id: 'call-2', type: 'tool_call', name: 'read_file', args: {}, status: 'running' } as Item, + ], + status: 'running', + }); + + // Apply tool_result for call-2 (should find it in turn-2 first) + useAgentStore.getState().applyChunk(threadId, 'turn-2', { + id: 'res-2', + type: 'tool_result', + callId: 'call-2', + name: 'read_file', + output: 'ok', + exitCode: 0, + } as Item); + + const turn2 = useAgentStore.getState().threads[threadId].turns[1]; + const call = turn2.items.find((i) => i.id === 'call-2') as any; + expect(call.status).toBe('approved'); + expect(turn2.items).toHaveLength(2); // call + result + }); + + it('falls back to other turns when callId not in current turn', () => { + const threadId = 't1'; + + // Turn 1 with tool_call + useAgentStore.getState().startTurn(threadId, { + id: 'turn-1', + items: [ + { id: 'call-1', type: 'tool_call', name: 'read_file', args: {}, status: 'running' } as Item, + ], + status: 'completed', + }); + useAgentStore.getState().completeTurn(threadId, 'turn-1', 'completed'); + + // Turn 2 with no tool_call + useAgentStore.getState().startTurn(threadId, { + id: 'turn-2', + items: [], + status: 'running', + }); + + // Apply tool_result for call-1 (should find it in turn-1 via fallback) + useAgentStore.getState().applyChunk(threadId, 'turn-2', { + id: 'res-1', + type: 'tool_result', + callId: 'call-1', + name: 'read_file', + output: 'ok', + exitCode: 0, + } as Item); + + const turn1 = useAgentStore.getState().threads[threadId].turns[0]; + const call = turn1.items.find((i) => i.id === 'call-1') as any; + expect(call.status).toBe('approved'); + }); +}); + +describe('global store - applyChunk tool_result uses push', () => { + beforeEach(() => { + useAgentStore.setState({ + currentThreadId: null, + threads: {}, + profile: 'build', permissionMode: 'ask', + model: '', + models: [], + contextUsage: null, + todoByThreadId: {}, + pendingInput: null, + usageByThreadId: {}, + isCompressing: false, + }); + }); + + it('tool_result is pushed to end, not spliced after tool_call', () => { + const threadId = 't1'; + + useAgentStore.getState().startTurn(threadId, { + id: 'turn-1', + items: [ + { id: 'msg-1', type: 'message', role: 'user', content: 'hi' } as Item, + { id: 'call-1', type: 'tool_call', name: 'read_file', args: {}, status: 'running' } as Item, + { + id: 'msg-2', + type: 'message', + role: 'assistant', + content: 'done', + partial: false, + } as Item, + ], + status: 'running', + }); + + // Apply tool_result for call-1 + useAgentStore.getState().applyChunk(threadId, 'turn-1', { + id: 'res-1', + type: 'tool_result', + callId: 'call-1', + name: 'read_file', + output: 'ok', + exitCode: 0, + } as Item); + + const turn = useAgentStore.getState().threads[threadId].turns[0]; + // tool_result should be at the end, not between call-1 and msg-2 + const lastItem = turn.items[turn.items.length - 1]; + expect(lastItem.type).toBe('tool_result'); + // msg-2 should still be at index 2 (not shifted) + expect(turn.items[2].id).toBe('msg-2'); + }); + + it('existing item indices are not shifted when tool_result is pushed', () => { + const threadId = 't1'; + + useAgentStore.getState().startTurn(threadId, { + id: 'turn-1', + items: [ + { id: 'call-1', type: 'tool_call', name: 'edit', args: {}, status: 'running' } as Item, + { + id: 'msg-1', + type: 'message', + role: 'assistant', + content: 'editing', + partial: true, + } as Item, + ], + status: 'running', + }); + + // Record index of msg-1 before tool_result + const beforeTurn = useAgentStore.getState().threads[threadId].turns[0]; + const msgIndexBefore = beforeTurn.items.findIndex((i) => i.id === 'msg-1'); + expect(msgIndexBefore).toBe(1); + + useAgentStore.getState().applyChunk(threadId, 'turn-1', { + id: 'res-1', + type: 'tool_result', + callId: 'call-1', + name: 'edit', + output: 'ok', + exitCode: 0, + } as Item); + + const afterTurn = useAgentStore.getState().threads[threadId].turns[0]; + const msgIndexAfter = afterTurn.items.findIndex((i) => i.id === 'msg-1'); + // msg-1 should still be at the same index + expect(msgIndexAfter).toBe(msgIndexBefore); + }); +}); diff --git a/packages/desktop/test/global.store.test.ts b/packages/desktop/test/global.store.test.ts deleted file mode 100644 index a1d2ae6b..00000000 --- a/packages/desktop/test/global.store.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { create } from 'zustand'; -import { immer } from 'zustand/middleware/immer'; - -// Minimal store for testing core logic without persist -interface TestThread { - id: string; - projectId: string; - title: string; - cwd: string; - turns: any[]; - createdAt: number; - updatedAt: number; -} - -interface TestState { - threads: Record; - currentThreadId: string | null; -} - -interface TestActions { - upsertThread: (thread: TestThread) => void; - loadThreads: (threads: TestThread[]) => void; - setCurrentThread: (id: string | null) => void; - startTurn: (threadId: string, turn: any, meta?: { cwd?: string; title?: string }) => void; -} - -const testStore = create()( - immer((set) => ({ - threads: {}, - currentThreadId: null, - upsertThread: (thread) => - set((s) => { - s.threads[thread.id] = thread; - }), - loadThreads: (threads) => - set((s) => { - const next: Record = {}; - for (const t of threads) { - const existing = s.threads[t.id]; - next[t.id] = existing ? { ...t, turns: existing.turns } : t; - } - s.threads = next; - }), - setCurrentThread: (id) => - set((s) => { - s.currentThreadId = id; - }), - startTurn: (threadId, turn, meta) => - set((s) => { - if (!s.threads[threadId]) { - s.threads[threadId] = { - id: threadId, - projectId: '', - title: meta?.title ?? 'New Conversation', - cwd: meta?.cwd ?? '', - turns: [turn], - createdAt: Date.now(), - updatedAt: Date.now(), - }; - } else { - s.threads[threadId].turns.push(turn); - s.threads[threadId].updatedAt = Date.now(); - } - }), - })) -); - -function resetStore() { - testStore.setState({ threads: {}, currentThreadId: null }); -} - -describe('loadThreads simplified', () => { - beforeEach(resetStore); - - it('replaces threads and preserves existing turns', () => { - testStore.getState().upsertThread({ - id: 'sess-1', - projectId: '', - title: 'Old Title', - cwd: '/path', - turns: [{ id: 'turn-1', items: [], status: 'completed' }], - createdAt: 1000, - updatedAt: 1000, - }); - - testStore.getState().loadThreads([ - { - id: 'sess-1', - projectId: '', - title: 'New Title', - cwd: '/new-path', - turns: [], - createdAt: 2000, - updatedAt: 2000, - }, - ]); - - const thread = testStore.getState().threads['sess-1']; - expect(thread).toBeDefined(); - expect(thread!.title).toBe('New Title'); - expect(thread!.cwd).toBe('/new-path'); - expect(thread!.turns).toHaveLength(1); - expect(thread!.turns[0]!.id).toBe('turn-1'); - }); - - it('does not preserve orphan threads', () => { - testStore.getState().upsertThread({ - id: 'orphan-1', - projectId: '', - title: 'Orphan', - cwd: '/path', - turns: [{ id: 't1', items: [], status: 'running' }], - createdAt: 1000, - updatedAt: 1000, - }); - - testStore.getState().loadThreads([ - { - id: 'sess-1', - projectId: '', - title: 'Only Session', - cwd: '/path', - turns: [], - createdAt: 2000, - updatedAt: 2000, - }, - ]); - - expect(testStore.getState().threads['orphan-1']).toBeUndefined(); - expect(testStore.getState().threads['sess-1']).toBeDefined(); - }); - - it('creates new threads for unknown ids', () => { - testStore.getState().loadThreads([ - { - id: 'sess-new', - projectId: '', - title: 'New', - cwd: '/path', - turns: [], - createdAt: 3000, - updatedAt: 3000, - }, - ]); - - expect(testStore.getState().threads['sess-new']).toBeDefined(); - expect(testStore.getState().threads['sess-new']!.title).toBe('New'); - }); -}); - -describe('Thread type without backendSessionId', () => { - it('backendSessionId is not present on Thread', () => { - const thread = { - id: 'sess-1', - projectId: '', - title: 'Test', - cwd: '/', - turns: [], - createdAt: 0, - updatedAt: 0, - }; - expect(thread).toBeDefined(); - }); -}); - -describe('sendMessage uses sessionId as threadId', () => { - beforeEach(resetStore); - - it('startTurn creates a thread with given threadId', () => { - testStore.getState().startTurn('sess-abc', { - id: 'turn-1', - items: [{ id: 'u1', type: 'message', role: 'user', content: 'hi' }], - status: 'running', - }); - - expect(testStore.getState().threads['sess-abc']).toBeDefined(); - expect(testStore.getState().threads['sess-abc']!.id).toBe('sess-abc'); - }); -}); diff --git a/packages/desktop/test/performance-optimization.test.ts b/packages/desktop/test/performance-optimization.test.ts deleted file mode 100644 index 5952959b..00000000 --- a/packages/desktop/test/performance-optimization.test.ts +++ /dev/null @@ -1,508 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { computeDiff } from '../src/lib/diff-compute'; -import { parseUnifiedDiff } from '../src/lib/diff-parser'; -import { useAgentStore } from '../src/stores/agent.store'; -import type { Item, Turn } from '../shared/types'; - -// ─── diff-compute: large file protection ───────────────────────────────── - -describe('computeDiff - large file protection', () => { - it('uses LCS diff for files under 500 lines', () => { - const oldContent = Array(100).fill('line').join('\n'); - const newContent = Array(100).fill('line').join('\n'); - newContent.replace('line', 'changed'); - - const result = computeDiff(oldContent, newContent); - // LCS diff should produce contextual diff, not all-delete+all-insert - expect(result.diff).toContain(' line'); - }); - - it('falls back to simplified diff for files over 500 lines', () => { - const oldLines = Array(600).fill('old line'); - const newLines = Array(600).fill('new line'); - // Only change 1 line — LCS would show 1 deletion + 1 insertion - oldLines[300] = 'changed'; - newLines[300] = 'changed'; - - const result = computeDiff(oldLines.join('\n'), newLines.join('\n')); - // Simplified diff shows ALL old lines as deletions, ALL new lines as insertions - expect(result.deletions).toBe(600); - expect(result.insertions).toBe(600); - }); - - it('simplified diff still produces valid diff output', () => { - const oldContent = Array(501).fill('a').join('\n'); - const newContent = Array(501).fill('b').join('\n'); - - const result = computeDiff(oldContent, newContent); - expect(result.diff).toContain('-a'); - expect(result.diff).toContain('+b'); - expect(result.insertions).toBe(501); - expect(result.deletions).toBe(501); - }); - - it('new file shortcut still works regardless of line count', () => { - const newContent = Array(1000).fill('line').join('\n'); - const result = computeDiff('', newContent); - expect(result.insertions).toBe(1000); - expect(result.deletions).toBe(0); - expect(result.diff).toContain('+line'); - }); -}); - -// ─── global.store: applyChunk tool_result priority ────────────────────── - -describe('global store - applyChunk tool_result searches current turn first', () => { - beforeEach(() => { - useAgentStore.setState({ - currentThreadId: null, - threads: {}, - profile: 'build', permissionMode: 'ask', - model: '', - models: [], - contextUsage: null, - todoByThreadId: {}, - pendingInput: null, - usageByThreadId: {}, - isCompressing: false, - }); - }); - - it('finds tool_call in current turn first', () => { - const threadId = 't1'; - - // Turn 1 with a tool_call - useAgentStore.getState().startTurn(threadId, { - id: 'turn-1', - items: [ - { id: 'call-1', type: 'tool_call', name: 'read_file', args: {}, status: 'running' } as Item, - ], - status: 'completed', - }); - useAgentStore.getState().completeTurn(threadId, 'turn-1', 'completed'); - - // Turn 2 with a tool_call of same name but different id - useAgentStore.getState().startTurn(threadId, { - id: 'turn-2', - items: [ - { id: 'call-2', type: 'tool_call', name: 'read_file', args: {}, status: 'running' } as Item, - ], - status: 'running', - }); - - // Apply tool_result for call-2 (should find it in turn-2 first) - useAgentStore.getState().applyChunk(threadId, 'turn-2', { - id: 'res-2', - type: 'tool_result', - callId: 'call-2', - name: 'read_file', - output: 'ok', - exitCode: 0, - } as Item); - - const turn2 = useAgentStore.getState().threads[threadId].turns[1]; - const call = turn2.items.find((i) => i.id === 'call-2') as any; - expect(call.status).toBe('approved'); - expect(turn2.items).toHaveLength(2); // call + result - }); - - it('falls back to other turns when callId not in current turn', () => { - const threadId = 't1'; - - // Turn 1 with tool_call - useAgentStore.getState().startTurn(threadId, { - id: 'turn-1', - items: [ - { id: 'call-1', type: 'tool_call', name: 'read_file', args: {}, status: 'running' } as Item, - ], - status: 'completed', - }); - useAgentStore.getState().completeTurn(threadId, 'turn-1', 'completed'); - - // Turn 2 with no tool_call - useAgentStore.getState().startTurn(threadId, { - id: 'turn-2', - items: [], - status: 'running', - }); - - // Apply tool_result for call-1 (should find it in turn-1 via fallback) - useAgentStore.getState().applyChunk(threadId, 'turn-2', { - id: 'res-1', - type: 'tool_result', - callId: 'call-1', - name: 'read_file', - output: 'ok', - exitCode: 0, - } as Item); - - const turn1 = useAgentStore.getState().threads[threadId].turns[0]; - const call = turn1.items.find((i) => i.id === 'call-1') as any; - expect(call.status).toBe('approved'); - }); -}); - -// ─── agent.store: 不再挂 persist ───────────────────────────────────────── - -describe('agent store - 不持久化任何状态', () => { - it('has no persist middleware attached', () => { - // 模型、会话、用量、新建会话默认值全部以服务端为准,前端不留副本到磁盘 - const store: any = useAgentStore; - expect(store.persist).toBeUndefined(); - }); -}); - -// ─── App.tsx: event listener cleanup ───────────────────────────────────── - -describe('App.tsx - event listener cleanup', () => { - it('handler reference is preserved for removeEventListener', () => { - // Verify the pattern: store handler ref, pass to both add and remove - const handlers: EventListener[] = []; - const fakeAdd = (type: string, handler: EventListener) => { - handlers.push(handler); - }; - const fakeRemove = (type: string, handler: EventListener) => { - const idx = handlers.indexOf(handler); - expect(idx).toBeGreaterThanOrEqual(0); - handlers.splice(idx, 1); - }; - - const handler = (() => {}) as EventListener; - fakeAdd('menu:switchProfile', handler); - expect(handlers).toHaveLength(1); - fakeRemove('menu:switchProfile', handler); - expect(handlers).toHaveLength(0); - }); -}); - -// ─── main.ts: stopPolling on window close ──────────────────────────────── - -describe('main.ts - resource cleanup', () => { - it('stopPolling is exported from git.service', async () => { - const { stopPolling } = await import('../electron/core/git.service'); - expect(typeof stopPolling).toBe('function'); - }); -}); - -// ─── tool_result push instead of splice ────────────────────────────────── - -describe('global store - applyChunk tool_result uses push', () => { - beforeEach(() => { - useAgentStore.setState({ - currentThreadId: null, - threads: {}, - profile: 'build', permissionMode: 'ask', - model: '', - models: [], - contextUsage: null, - todoByThreadId: {}, - pendingInput: null, - usageByThreadId: {}, - isCompressing: false, - }); - }); - - it('tool_result is pushed to end, not spliced after tool_call', () => { - const threadId = 't1'; - - useAgentStore.getState().startTurn(threadId, { - id: 'turn-1', - items: [ - { id: 'msg-1', type: 'message', role: 'user', content: 'hi' } as Item, - { id: 'call-1', type: 'tool_call', name: 'read_file', args: {}, status: 'running' } as Item, - { - id: 'msg-2', - type: 'message', - role: 'assistant', - content: 'done', - partial: false, - } as Item, - ], - status: 'running', - }); - - // Apply tool_result for call-1 - useAgentStore.getState().applyChunk(threadId, 'turn-1', { - id: 'res-1', - type: 'tool_result', - callId: 'call-1', - name: 'read_file', - output: 'ok', - exitCode: 0, - } as Item); - - const turn = useAgentStore.getState().threads[threadId].turns[0]; - // tool_result should be at the end, not between call-1 and msg-2 - const lastItem = turn.items[turn.items.length - 1]; - expect(lastItem.type).toBe('tool_result'); - // msg-2 should still be at index 2 (not shifted) - expect(turn.items[2].id).toBe('msg-2'); - }); - - it('existing item indices are not shifted when tool_result is pushed', () => { - const threadId = 't1'; - - useAgentStore.getState().startTurn(threadId, { - id: 'turn-1', - items: [ - { id: 'call-1', type: 'tool_call', name: 'edit', args: {}, status: 'running' } as Item, - { - id: 'msg-1', - type: 'message', - role: 'assistant', - content: 'editing', - partial: true, - } as Item, - ], - status: 'running', - }); - - // Record index of msg-1 before tool_result - const beforeTurn = useAgentStore.getState().threads[threadId].turns[0]; - const msgIndexBefore = beforeTurn.items.findIndex((i) => i.id === 'msg-1'); - expect(msgIndexBefore).toBe(1); - - useAgentStore.getState().applyChunk(threadId, 'turn-1', { - id: 'res-1', - type: 'tool_result', - callId: 'call-1', - name: 'edit', - output: 'ok', - exitCode: 0, - } as Item); - - const afterTurn = useAgentStore.getState().threads[threadId].turns[0]; - const msgIndexAfter = afterTurn.items.findIndex((i) => i.id === 'msg-1'); - // msg-1 should still be at the same index - expect(msgIndexAfter).toBe(msgIndexBefore); - }); -}); - -// ─── turnsStructureKey stability ───────────────────────────────────────── - -describe('turnsStructureKey - content changes do not affect structure', () => { - it('structure key includes item types and ids but not content', () => { - const turns1 = [ - { - id: 'turn-1', - status: 'running', - items: [{ id: 'msg-1', type: 'message', role: 'assistant', content: 'hello' }], - }, - ]; - const turns2 = [ - { - id: 'turn-1', - status: 'running', - items: [ - { id: 'msg-1', type: 'message', role: 'assistant', content: 'hello world more text' }, - ], - }, - ]; - - const buildKey = (turns: any[]) => - turns - .map( - (t) => - `${t.id}:${t.status}:${t.items.length}:${t.items.map((i: any) => `${i.type}:${i.id}`).join(',')}` - ) - .join('|'); - - // Same structure: same id, status, item count, item types and ids - expect(buildKey(turns1)).toBe(buildKey(turns2)); - }); - - it('structure key changes when item is added', () => { - const turns1 = [ - { - id: 'turn-1', - status: 'running', - items: [{ id: 'msg-1', type: 'message', role: 'assistant', content: 'hello' }], - }, - ]; - const turns2 = [ - { - id: 'turn-1', - status: 'running', - items: [ - { id: 'msg-1', type: 'message', role: 'assistant', content: 'hello' }, - { id: 'call-1', type: 'tool_call', name: 'edit', args: {}, status: 'running' }, - ], - }, - ]; - - const buildKey = (turns: any[]) => - turns - .map( - (t) => - `${t.id}:${t.status}:${t.items.length}:${t.items.map((i: any) => `${i.type}:${i.id}`).join(',')}` - ) - .join('|'); - - expect(buildKey(turns1)).not.toBe(buildKey(turns2)); - }); - - it('structure key changes when turn status changes', () => { - const turns1 = [{ id: 'turn-1', status: 'running', items: [{ id: 'msg-1', type: 'message' }] }]; - const turns2 = [ - { id: 'turn-1', status: 'completed', items: [{ id: 'msg-1', type: 'message' }] }, - ]; - - const buildKey = (turns: any[]) => - turns - .map( - (t) => - `${t.id}:${t.status}:${t.items.length}:${t.items.map((i: any) => `${i.type}:${i.id}`).join(',')}` - ) - .join('|'); - - expect(buildKey(turns1)).not.toBe(buildKey(turns2)); - }); -}); - -// ─── entryCountByTurnId correctness ────────────────────────────────────── - -describe('entryCountByTurnId - correct counts for multiple turns', () => { - it('counts entries per turn correctly', () => { - const turns = [ - { - id: 'turn-1', - items: [ - { id: 'msg-1', type: 'message', role: 'user', content: 'hi' }, - { id: 'msg-2', type: 'message', role: 'assistant', content: 'hello' }, - ], - }, - { - id: 'turn-2', - items: [ - { id: 'msg-3', type: 'message', role: 'user', content: 'do it' }, - { id: 'call-1', type: 'tool_call', name: 'edit', args: {}, status: 'approved' }, - { - id: 'res-1', - type: 'tool_result', - callId: 'call-1', - name: 'edit', - output: 'ok', - exitCode: 0, - }, - { id: 'msg-4', type: 'message', role: 'assistant', content: 'done' }, - ], - }, - ]; - - // Simulate renderEntries logic: skip tool_result, count per turn - const countMap = new Map(); - for (const turn of turns) { - for (const item of turn.items) { - if (item.type === 'tool_result') continue; - countMap.set(turn.id, (countMap.get(turn.id) ?? 0) + 1); - } - } - - // turn-1: msg-1, msg-2 = 2 entries - expect(countMap.get('turn-1')).toBe(2); - // turn-2: msg-3, call-1 (with toolResult), msg-4 = 3 entries (tool_result skipped) - expect(countMap.get('turn-2')).toBe(3); - }); -}); - -// ─── turnById Map correctness ──────────────────────────────────────────── - -describe('turnById - O(1) lookup', () => { - it('finds turn by id in Map', () => { - const turns = [ - { id: 'turn-1', status: 'completed', items: [] }, - { id: 'turn-2', status: 'running', items: [] }, - { id: 'turn-3', status: 'error', items: [] }, - ]; - - const turnById = new Map(turns.map((t) => [t.id, t])); - expect(turnById.get('turn-2')?.status).toBe('running'); - expect(turnById.get('turn-99')).toBeUndefined(); - }); -}); - -// ─── ApprovalPanel pendingKey stability ────────────────────────────────── - -describe('ApprovalPanel - pendingKey stability', () => { - it('pendingKey only includes pending tool_call IDs', () => { - const thread = { - id: 't1', - turns: [ - { - id: 'turn-1', - items: [ - { id: 'msg-1', type: 'message', role: 'user', content: 'hi' }, - { id: 'call-1', type: 'tool_call', name: 'edit', args: {}, status: 'approved' }, - { id: 'call-2', type: 'tool_call', name: 'read', args: {}, status: 'pending' }, - { id: 'msg-2', type: 'message', role: 'assistant', content: 'working...' }, - ], - }, - ], - }; - - const pendingIds = thread.turns - .flatMap((t: any) => t.items) - .filter((i: any) => i.type === 'tool_call' && i.status === 'pending') - .map((i: any) => i.id) - .join(','); - - expect(pendingIds).toBe('call-2'); - }); - - it('pendingKey is empty string when no pending items', () => { - const thread = { - id: 't1', - turns: [ - { - id: 'turn-1', - items: [{ id: 'call-1', type: 'tool_call', name: 'edit', args: {}, status: 'approved' }], - }, - ], - }; - - const pendingIds = thread.turns - .flatMap((t: any) => t.items) - .filter((i: any) => i.type === 'tool_call' && i.status === 'pending') - .map((i: any) => i.id) - .join(','); - - expect(pendingIds).toBe(''); - }); -}); - -// ─── parseUnifiedDiff useMemo caching ──────────────────────────────────── - -describe('parseUnifiedDiff - caching behavior', () => { - it('parseUnifiedDiff returns consistent results for same input', () => { - const diff = `diff --git a/foo.ts b/foo.ts ---- a/foo.ts -+++ b/foo.ts -@@ -1,3 +1,3 @@ - line1 --old -+new - line3`; - - const result1 = parseUnifiedDiff(diff); - const result2 = parseUnifiedDiff(diff); - expect(result1).toEqual(result2); - expect(result1).toHaveLength(1); - expect(result1[0].fileName).toBe('foo.ts'); - }); -}); - -// ─── Virtuoso unified path ─────────────────────────────────────────────── - -describe('Virtuoso unified path - no isLargeList threshold', () => { - it('rendering path does not switch based on item count', () => { - // The old code had: isLargeList = totalCount > 100 - // The new code always uses Virtuoso regardless of count - // This test verifies the threshold is removed - const totalCounts = [1, 50, 99, 100, 101, 500]; - for (const count of totalCounts) { - // All counts should use the same rendering path (Virtuoso) - // No isLargeList check needed - expect(true).toBe(true); // Placeholder — actual behavior verified by typecheck - } - }); -});