From 38cc3aa1f08bbd44f441728a97c5308cc0aea719 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Wed, 10 Jun 2026 23:56:14 +0800 Subject: [PATCH 01/11] Optimize code structure --- AGENTS.md | 3 +- CLAUDE.md | 4 +- packages/codingcode/src/agent/agent.ts | 4 +- packages/codingcode/src/agent/build-tools.ts | 39 ----- .../codingcode/src/approval/async-confirm.ts | 26 ++-- .../codingcode/src/approval/confirmation.ts | 4 +- packages/codingcode/src/approval/index.ts | 8 +- packages/codingcode/src/approval/pipeline.ts | 16 +- packages/codingcode/src/approval/presets.ts | 2 +- .../codingcode/src/checkpoint/bootstrap.ts | 24 +-- .../src/checkpoint/checkpoint-service.ts | 137 ++++++------------ .../codingcode/src/checkpoint/shadow-git.ts | 2 +- packages/codingcode/src/context/organizer.ts | 4 +- packages/codingcode/src/core/error.ts | 2 +- packages/codingcode/src/core/workspace.ts | 14 +- packages/codingcode/src/hooks/config.ts | 98 ++----------- packages/codingcode/src/layer.ts | 4 +- packages/codingcode/src/llm/factory.ts | 5 +- .../codingcode/src/llm/providers/deepseek.ts | 81 +---------- .../codingcode/src/llm/providers/openai.ts | 83 +---------- .../codingcode/src/llm/providers/shared.ts | 80 ++++++++++ packages/codingcode/src/mcp/config.ts | 98 ++----------- .../codingcode/src/runtime/project-runtime.ts | 34 +++-- packages/codingcode/src/server/adapter.ts | 2 +- packages/codingcode/src/session/io.ts | 8 +- packages/codingcode/src/session/messages.ts | 4 +- packages/codingcode/src/session/store.ts | 6 +- packages/codingcode/src/skills/config.ts | 103 ++----------- packages/codingcode/src/subagent/registry.ts | 4 + .../src/tools/domains/self/tool-search.ts | 2 + packages/codingcode/src/tools/executor.ts | 24 +-- .../src/tools/tool-search-service.ts | 6 +- .../codingcode/test/approval/presets.test.ts | 6 +- .../checkpoint-project-path.test.ts | 6 +- .../codingcode/test/core/workspace.test.ts | 10 +- .../test/llm/openai-provider.test.ts | 7 +- .../domains/bash/bash-project-path.test.ts | 2 +- .../domains/fs/tool-project-path.test.ts | 2 +- .../codingcode/test/tools/tool-search.test.ts | 6 +- packages/infra/package.json | 3 +- packages/infra/src/disabled-store.ts | 123 ++++++++++++++++ 41 files changed, 411 insertions(+), 685 deletions(-) delete mode 100644 packages/codingcode/src/agent/build-tools.ts create mode 100644 packages/codingcode/src/llm/providers/shared.ts create mode 100644 packages/infra/src/disabled-store.ts diff --git a/AGENTS.md b/AGENTS.md index 5ed3829..672677c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,5 @@ 不许兼容、兜底旧代码 每次执行完以后都要补充测试文件确保实际行为与预期相符 所有的测试文件只能写在现有的test文件夹下 -修改过程中发现错误,如果是本次范围就修改,否则要在最后指出 -当前的设计不能假设单会话的,而应该假设多会话场景 +修改过程中发现错误,如果是本次范围就修改(包括测试),否则要在最后指出 在用户的最新的一条消息除非有显式命令(执行方案、修改代码等)要求修改代码,否则绝对不改代码,之前要求修改的指令全部不算数,别再根据之前的上下文或者当前不确定的指令猜是不是要直接修改代码了 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 5ed3829..dabd7a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,8 @@ 不允许假设“这是未来需要扩展的”,所以现在就不做,应该贴合用户的实际要求 +禁止局部短视实现:不允许仅为了“当前调用能跑通”而写死临时逻辑、硬编码、破坏原有接口契约、或绕过已有模块 不允许总是有阶段性计划,分阶段完成很容易导致过程产生一堆没用的死代码 不许兼容、兜底旧代码 每次执行完以后都要补充测试文件确保实际行为与预期相符 所有的测试文件只能写在现有的test文件夹下 -修改过程中发现错误,如果是本次范围就修改,否则要在最后指出 -当前的设计不能假设单会话的,而应该假设多会话场景 +修改过程中发现错误,如果是本次范围就修改(包括测试),否则要在最后指出 在用户的最新的一条消息除非有显式命令(执行方案、修改代码等)要求修改代码,否则绝对不改代码,之前要求修改的指令全部不算数,别再根据之前的上下文或者当前不确定的指令猜是不是要直接修改代码了 \ No newline at end of file diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index e6241aa..bb9fd80 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -465,7 +465,7 @@ export async function* runReActLoop( if (stopContinuations >= maxStopContinuations) { yield { _tag: 'Error', - error: new AgentError('STOP_LOOP', 'max stop continuations exceeded'), + error: new AgentError('AGENT_LOOP_DETECTED', 'max stop continuations exceeded'), }; await Effect.runPromise( hooks.emit('agent.turn.end', { @@ -477,7 +477,7 @@ export async function* runReActLoop( flushSessionToMemory(state.sessionId, llm).catch((e) => logger.error('memory flush failed:', e) ); - return Result.err(new AgentError('STOP_LOOP', 'max stop continuations exceeded')); + return Result.err(new AgentError('AGENT_LOOP_DETECTED', 'max stop continuations exceeded')); } stopContinuations++; const injection = stopDecision.injection ?? '(continue)'; diff --git a/packages/codingcode/src/agent/build-tools.ts b/packages/codingcode/src/agent/build-tools.ts deleted file mode 100644 index 5110648..0000000 --- a/packages/codingcode/src/agent/build-tools.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { ToolDescription } from '../tools/types.js'; -import type { AgentProfile } from '../subagent/registry.js'; -import type { ToolVisibilityPolicy } from '../tools/types.js'; -import type { ToolSearchService } from '../tools/tool-search-service.js'; - -export function buildToolsForAgent( - resolveTools: (input: { - projectPath: string; - sessionId: string; - profile: AgentProfile; - policy: ToolVisibilityPolicy; - }) => ToolDescription[], - params: { - projectPath: string; - sessionId: string; - profile: AgentProfile; - policy: ToolVisibilityPolicy; - } -): ToolDescription[] { - return resolveTools(params); -} - -export function buildDeferredCatalogContent( - toolSearch: ToolSearchService, - sessionId: string, - policy?: ToolVisibilityPolicy -): string | null { - const unloaded = toolSearch.listUnloadedDeferred(sessionId, policy); - if (unloaded.length === 0) return null; - const lines = unloaded.map( - (t) => `- ${t.name}: ${t.shortDescription ?? t.description.slice(0, 80)}` - ); - return [ - '', - 'These tools are not yet loaded. Call tool_search with relevant keywords to load them before use.', - ...lines, - '', - ].join('\n'); -} diff --git a/packages/codingcode/src/approval/async-confirm.ts b/packages/codingcode/src/approval/async-confirm.ts index 197ba82..cc83e73 100644 --- a/packages/codingcode/src/approval/async-confirm.ts +++ b/packages/codingcode/src/approval/async-confirm.ts @@ -6,8 +6,8 @@ interface PendingEntry { sessionId: string; } -const pending = new Map(); -const emitters = new Map< +const pendingConfirmations = new Map(); +const approvalEmitters = new Map< string, (id: string, tool: string, args: Record) => void >(); @@ -16,22 +16,22 @@ export function registerEmitter( sessionId: string, fn: (id: string, tool: string, args: Record) => void ): void { - emitters.set(sessionId, fn); + approvalEmitters.set(sessionId, fn); } export function delegateEmitter(childSessionId: string, parentSessionId: string): void { - const parentFn = emitters.get(parentSessionId); + const parentFn = approvalEmitters.get(parentSessionId); if (parentFn) { - emitters.set(childSessionId, parentFn); + approvalEmitters.set(childSessionId, parentFn); } } export function unregisterEmitter(sessionId: string): void { - emitters.delete(sessionId); + approvalEmitters.delete(sessionId); } export function hasEmitter(sessionId: string): boolean { - return emitters.has(sessionId); + return approvalEmitters.has(sessionId); } export class ApprovalWaitService extends Effect.Service()('ApprovalWait', { @@ -39,7 +39,7 @@ export class ApprovalWaitService extends Effect.Service()(' waitForConfirm: (id: string, sessionId: string): Effect.Effect => Effect.gen(function* () { const d = yield* Deferred.make(); - pending.set(id, { deferred: d, sessionId }); + pendingConfirmations.set(id, { deferred: d, sessionId }); return yield* Deferred.await(d); }), @@ -49,9 +49,9 @@ export class ApprovalWaitService extends Effect.Service()(' result: ConfirmResult ): Effect.Effect => Effect.sync(() => { - const entry = pending.get(id); + const entry = pendingConfirmations.get(id); if (!entry) return false; - pending.delete(id); + pendingConfirmations.delete(id); Deferred.unsafeDone(entry.deferred, Effect.succeed(result)); return true; }), @@ -59,11 +59,11 @@ export class ApprovalWaitService extends Effect.Service()(' getPending: (sessionId?: string): Effect.Effect => Effect.sync(() => { if (sessionId) { - return Array.from(pending.entries()) + return Array.from(pendingConfirmations.entries()) .filter(([_, e]) => e.sessionId === sessionId) .map(([id]) => id); } - return Array.from(pending.keys()); + return Array.from(pendingConfirmations.keys()); }), emitApprovalRequest: ( @@ -73,7 +73,7 @@ export class ApprovalWaitService extends Effect.Service()(' args: Record ): Effect.Effect => Effect.sync(() => { - emitters.get(sessionId)?.(id, tool, args); + approvalEmitters.get(sessionId)?.(id, tool, args); }), }), }) {} diff --git a/packages/codingcode/src/approval/confirmation.ts b/packages/codingcode/src/approval/confirmation.ts index 0dfaa89..f1af59c 100644 --- a/packages/codingcode/src/approval/confirmation.ts +++ b/packages/codingcode/src/approval/confirmation.ts @@ -13,10 +13,10 @@ export function userConfirmAsync( args: Record, waitSvc: ApprovalWaitService, sessionId: string, - callId?: string + callId: string ): Effect.Effect { return Effect.gen(function* () { - const id = callId!; + const id = callId; yield* waitSvc.emitApprovalRequest(sessionId, id, tool, args); diff --git a/packages/codingcode/src/approval/index.ts b/packages/codingcode/src/approval/index.ts index 9a70929..1d1c42f 100644 --- a/packages/codingcode/src/approval/index.ts +++ b/packages/codingcode/src/approval/index.ts @@ -2,7 +2,7 @@ import { Effect } from 'effect'; import { HookService } from '../hooks/registry.js'; import type { PermissionMode, PermissionRule, ApprovalDecision } from './types.js'; import { createRuleEngine, type RuleEngine } from './rule-engine.js'; -import { DEFAULT_DENY_RULES, READONLY_TOOL_NAMES, DESTRUCTIVE_TOOL_NAMES } from './presets.js'; +import { DEFAULT_DENY_RULES, READONLY_TOOL_NAMES, DANGEROUS_TOOL_NAMES } from './presets.js'; import { runPipeline, type PipelineHooks } from './pipeline.js'; import { ApprovalWaitService, hasEmitter } from './async-confirm.js'; @@ -22,8 +22,8 @@ export class ApprovalService extends Effect.Service()('Approval const hooks = yield* HookService; const approvalWait = yield* ApprovalWaitService; const ruleEngine: RuleEngine = createRuleEngine(DEFAULT_DENY_RULES); + const destructiveTools = new Set(DANGEROUS_TOOL_NAMES); const readonlyTools = new Set(READONLY_TOOL_NAMES); - const destructiveTools = new Set(DESTRUCTIVE_TOOL_NAMES); function buildPipelineHooks(): PipelineHooks { return { @@ -100,7 +100,7 @@ export class ApprovalService extends Effect.Service()('Approval } } if (opts?.readonly) { - for (const toolName of DESTRUCTIVE_TOOL_NAMES) { + for (const toolName of DANGEROUS_TOOL_NAMES) { nextEngine.addRule({ id: `readonly-${toolName}`, action: 'deny' as const, @@ -176,7 +176,7 @@ export class ApprovalService extends Effect.Service()('Approval } } if (opts?.readonly) { - const denyRules: PermissionRule[] = DESTRUCTIVE_TOOL_NAMES.map((toolName) => ({ + const denyRules: PermissionRule[] = DANGEROUS_TOOL_NAMES.map((toolName) => ({ id: `readonly-${toolName}`, action: 'deny' as const, toolPattern: toolName, diff --git a/packages/codingcode/src/approval/pipeline.ts b/packages/codingcode/src/approval/pipeline.ts index f3da50b..3773aba 100644 --- a/packages/codingcode/src/approval/pipeline.ts +++ b/packages/codingcode/src/approval/pipeline.ts @@ -61,7 +61,7 @@ export function runPipeline( const result = opts.ruleEngine.evaluate(request.tool, request.input); if (result) { layers.push(LAYER_NAMES[0]); - const final = yield* layer6Audit(request, result, layers, opts); + const final = yield* recordAuditAndReturn(request, result, layers, opts); return final; } } @@ -74,7 +74,7 @@ export function runPipeline( source: 'readonly-whitelist', }; layers.push(LAYER_NAMES[1]); - const final = yield* layer6Audit(request, result, layers, opts); + const final = yield* recordAuditAndReturn(request, result, layers, opts); return final; } } @@ -89,7 +89,7 @@ export function runPipeline( ); if (modeResult) { layers.push(LAYER_NAMES[2]); - const final = yield* layer6Audit(request, modeResult, layers, opts); + const final = yield* recordAuditAndReturn(request, modeResult, layers, opts); return final; } } @@ -108,12 +108,12 @@ export function runPipeline( reason: hookResult.reason ?? 'Denied by PreToolUse hook', source: 'hook', }; - const final = yield* layer6Audit(request, result, layers, opts); + const final = yield* recordAuditAndReturn(request, result, layers, opts); return final; } if (hookResult.decision === 'allow') { const result: ApprovalDecision = { type: 'allow', source: 'hook' }; - const final = yield* layer6Audit(request, result, layers, opts); + const final = yield* recordAuditAndReturn(request, result, layers, opts); return final; } // 'ask' or no decision → continue to user confirmation @@ -133,7 +133,7 @@ export function runPipeline( reason: 'Approval required but no UI available', source: 'system', }; - const final = yield* layer6Audit(request, result, layers, opts); + const final = yield* recordAuditAndReturn(request, result, layers, opts); return final; } @@ -163,7 +163,7 @@ export function runPipeline( break; } - const final = yield* layer6Audit(request, result, layers, opts); + const final = yield* recordAuditAndReturn(request, result, layers, opts); return final; } }); @@ -204,7 +204,7 @@ function applyPermissionMode( } } -function layer6Audit( +function recordAuditAndReturn( request: ToolCallRequest, decision: ApprovalDecision, passedLayers: string[], diff --git a/packages/codingcode/src/approval/presets.ts b/packages/codingcode/src/approval/presets.ts index aa908d1..c611e4f 100644 --- a/packages/codingcode/src/approval/presets.ts +++ b/packages/codingcode/src/approval/presets.ts @@ -94,4 +94,4 @@ export const READONLY_TOOL_NAMES: string[] = [ 'todo_write', ]; -export const DESTRUCTIVE_TOOL_NAMES: string[] = ['execute_command']; +export const DANGEROUS_TOOL_NAMES: string[] = ['execute_command']; diff --git a/packages/codingcode/src/checkpoint/bootstrap.ts b/packages/codingcode/src/checkpoint/bootstrap.ts index 5ffea9b..b189817 100644 --- a/packages/codingcode/src/checkpoint/bootstrap.ts +++ b/packages/codingcode/src/checkpoint/bootstrap.ts @@ -8,24 +8,16 @@ import { encodeProjectPath } from '../core/path.js'; import { Ledger } from './ledger.js'; -/** Cache ledger instances by checkpoint directory path. */ -const ledgerCache = new Map(); - /** * Carry file hash from tool.execute.before to tool.execute.after. * Keyed by execId (unique per tool execution) to avoid parallel race conditions. */ -const pendingHash = new Map(); +const hashBeforeEdit = new Map(); function getLedger(projectPath: string): Ledger { const encoded = encodeProjectPath(projectPath); const checkpointDir = join(homedir(), '.codingcode', 'project', encoded, 'checkpoint'); - let ledger = ledgerCache.get(checkpointDir); - if (!ledger) { - ledger = new Ledger(checkpointDir); - ledgerCache.set(checkpointDir, ledger); - } - return ledger; + return new Ledger(checkpointDir); } /** @@ -33,7 +25,7 @@ function getLedger(projectPath: string): Ledger { * Uses source: 'system' so these hooks survive reloadUserHooks() calls. * Idempotent — safe to call multiple times with the same HookService. */ -export function bootstrapCheckpoint(hooks: HookService): void { +export function registerCheckpointHooks(hooks: HookService): void { // Pre-execution: record file hash before modification Effect.runSync( hooks.register( @@ -50,7 +42,7 @@ export function bootstrapCheckpoint(hooks: HookService): void { const resolvedPath = resolve(base, rawPath); const callId = payload.callId as string; if (callId) { - pendingHash.set(callId, fileHash(resolvedPath)); + hashBeforeEdit.set(callId, sha256Truncated(resolvedPath)); } }, { source: 'system' } @@ -79,12 +71,12 @@ export function bootstrapCheckpoint(hooks: HookService): void { const resolvedPath = resolve(base, rawPath); const callId = payload.callId as string; - const hashBefore = callId ? (pendingHash.get(callId) ?? '') : ''; + const hashBefore = callId ? (hashBeforeEdit.get(callId) ?? '') : ''; if (callId) { - pendingHash.delete(callId); + hashBeforeEdit.delete(callId); } - const hashAfter = fileHash(resolvedPath); + const hashAfter = sha256Truncated(resolvedPath); getLedger(projectPath).record({ turnId, @@ -101,7 +93,7 @@ export function bootstrapCheckpoint(hooks: HookService): void { ); } -function fileHash(filePath: string): string { +function sha256Truncated(filePath: string): string { try { if (!existsSync(filePath)) return ''; const content = readFileSync(filePath); diff --git a/packages/codingcode/src/checkpoint/checkpoint-service.ts b/packages/codingcode/src/checkpoint/checkpoint-service.ts index 2214e67..aacd081 100644 --- a/packages/codingcode/src/checkpoint/checkpoint-service.ts +++ b/packages/codingcode/src/checkpoint/checkpoint-service.ts @@ -4,7 +4,7 @@ import { join, dirname, resolve } from 'path'; import { ShadowGit } from './shadow-git.js'; import { normalizePath } from '../core/path.js'; import { Ledger } from './ledger.js'; -import { bootstrapCheckpoint } from './bootstrap.js'; +import { registerCheckpointHooks } from './bootstrap.js'; import { HookService } from '../hooks/registry.js'; import { createHash } from 'crypto'; @@ -65,6 +65,17 @@ export interface CodeRestoreEntry { // ---- Module-level helpers ---- +function emptyRollbackResult(turnId: number, baseTurnId: number | null = null): CodeRollbackResult { + return { + reverted: false, + throughTurnId: turnId, + baseTurnId, + affectedTurns: [], + selectedFiles: [], + restoreEntry: null, + }; +} + function shortSid(sessionId: string): string { return createHash('sha256').update(sessionId).digest('hex').slice(0, 8); } @@ -160,15 +171,15 @@ function parseDiffStats(diffText: string): { insertions: number; deletions: numb } function getCompletedTurnsFor(sg: ShadowGit, sessionId: string): number[] { - const ids: number[] = []; - const prefix = `turn-${shortSid(sessionId)}-`; - for (let i = 1; i <= 10000; i++) { - const b = sg.findCommitByMessage(`${prefix}${i}-baseline`); - const f = sg.findCommitByMessage(`${prefix}${i}-final`); - if (b && f) ids.push(i); - if (!b && !f) break; + const short = shortSid(sessionId); + const result = sg.git('log', '--all', '--format=%s'); + const ids = new Set(); + const re = new RegExp(`^turn-${short}-(\\d+)-final$`); + for (const line of result.stdout.trim().split('\n')) { + const m = line.match(re); + if (m) ids.add(Number(m[1])); } - return ids; + return [...ids].sort((a, b) => a - b); } interface RestorePlan { @@ -292,7 +303,7 @@ function revertFilesImpl( export class CheckpointService extends Effect.Service()('Checkpoint', { effect: Effect.gen(function* () { const hooks = yield* HookService; - bootstrapCheckpoint(hooks); + registerCheckpointHooks(hooks); const shadowGitByProject = new Map(); const ledgerByProject = new Map(); @@ -480,17 +491,10 @@ export class CheckpointService extends Effect.Service()('Chec ): CodeRollbackResult => { const sg = ensure(projectPath); const plan = getTurnRestorePlan(sg, sessionId, turnId); - if (!plan) - return { - reverted: false, - throughTurnId: turnId, - baseTurnId: turnId, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; + if (!plan) { + return emptyRollbackResult(turnId, turnId); + } return revertFilesImpl(projectPath, sessionId, plan, [file], 'checkpoint-file', sg); - }, revertCheckpointFiles: ( projectPath: string, @@ -500,15 +504,9 @@ export class CheckpointService extends Effect.Service()('Chec ): CodeRollbackResult => { const sg = ensure(projectPath); const plan = getTurnRestorePlan(sg, sessionId, turnId); - if (!plan) - return { - reverted: false, - throughTurnId: turnId, - baseTurnId: turnId, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; + if (!plan) { + return emptyRollbackResult(turnId, turnId); + } return revertFilesImpl(projectPath, sessionId, plan, files, 'checkpoint-files', sg); }, @@ -522,34 +520,16 @@ export class CheckpointService extends Effect.Service()('Chec const sg = ensure(projectPath); const l = ledger(sg); const changes = classifySafe(projectPath, sessionId, turnId, sg, l); - if (!changes) - return { - reverted: false, - throughTurnId: turnId, - baseTurnId: null, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - if (changes.agentModified.length === 0) - return { - reverted: false, - throughTurnId: turnId, - baseTurnId: null, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; + if (!changes) { + return emptyRollbackResult(turnId); + } + if (changes.agentModified.length === 0) { + return emptyRollbackResult(turnId); + } const plan = getTurnRestorePlan(sg, sessionId, turnId); - if (!plan) - return { - reverted: false, - throughTurnId: turnId, - baseTurnId: null, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; + if (!plan) { + return emptyRollbackResult(turnId); + } return revertFilesImpl( projectPath, sessionId, @@ -568,35 +548,17 @@ export class CheckpointService extends Effect.Service()('Chec const sg = ensure(projectPath); const l = ledger(sg); const changes = classifySafe(projectPath, sessionId, turnId, sg, l); - if (!changes) - return { - reverted: false, - throughTurnId: turnId, - baseTurnId: null, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; + if (!changes) { + return emptyRollbackResult(turnId); + } const all = [...changes.agentModified, ...changes.unknownSource]; - if (all.length === 0) - return { - reverted: false, - throughTurnId: turnId, - baseTurnId: null, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; + if (all.length === 0) { + return emptyRollbackResult(turnId); + } const plan = getTurnRestorePlan(sg, sessionId, turnId); - if (!plan) - return { - reverted: false, - throughTurnId: turnId, - baseTurnId: null, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; + if (!plan) { + return emptyRollbackResult(turnId); + } return revertFilesImpl(projectPath, sessionId, plan, all, 'checkpoint-all', sg); }, @@ -632,14 +594,7 @@ export class CheckpointService extends Effect.Service()('Chec const sg = ensure(projectPath); const plan = getRollbackToTurnPlan(sg, sessionId, throughTurnId); if (!plan) { - return { - reverted: false, - throughTurnId, - baseTurnId: null, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; + return emptyRollbackResult(throughTurnId); } const diffResult = sg.git('diff', '--name-only', plan.baseline); diff --git a/packages/codingcode/src/checkpoint/shadow-git.ts b/packages/codingcode/src/checkpoint/shadow-git.ts index 3fca9fa..984387d 100644 --- a/packages/codingcode/src/checkpoint/shadow-git.ts +++ b/packages/codingcode/src/checkpoint/shadow-git.ts @@ -128,7 +128,7 @@ export class ShadowGit { return this.run(...args); } - shouldFallback(): boolean { + isTooLargeForSnapshot(): boolean { const result = this.run('ls-files', '-m', '-o', '--exclude-standard'); const files = result.stdout.trim().split('\n').filter(Boolean); if (files.length > FILE_COUNT_CAP) return true; diff --git a/packages/codingcode/src/context/organizer.ts b/packages/codingcode/src/context/organizer.ts index 0b231d7..06afdcf 100644 --- a/packages/codingcode/src/context/organizer.ts +++ b/packages/codingcode/src/context/organizer.ts @@ -7,7 +7,7 @@ import { join } from 'path'; import { randomUUID } from 'crypto'; import type { SessionEvent, ToolResultEvent, CompactEvent } from '../session/types.js'; -const COMPACTIBLE_TOOLS = new Set([ +const COMPACTABLE_TOOLS = new Set([ 'read_file', 'execute_command', 'search_code', @@ -101,7 +101,7 @@ function applyOldTurnCompaction( if (ev.type !== 'tool_result') continue; if (ev.turnId >= currentTurnId - 1) continue; if (compactedTurnIds.has(ev.turnId)) continue; - if (!COMPACTIBLE_TOOLS.has(ev.toolName.toLowerCase())) continue; + if (!COMPACTABLE_TOOLS.has(ev.toolName.toLowerCase())) continue; if (ev.output.length <= config.microCompactMinChars) continue; oldResults.push(ev); } diff --git a/packages/codingcode/src/core/error.ts b/packages/codingcode/src/core/error.ts index 41280df..0863abd 100644 --- a/packages/codingcode/src/core/error.ts +++ b/packages/codingcode/src/core/error.ts @@ -14,7 +14,7 @@ export type ErrorCode = | 'SESSION_NOT_FOUND' | 'SESSION_WORKSPACE_MISMATCH' | 'AGENT_ABORTED' - | 'STOP_LOOP' + | 'AGENT_LOOP_DETECTED' | 'SESSION_IO_ERROR'; export class AlreadyExistsError extends Error { diff --git a/packages/codingcode/src/core/workspace.ts b/packages/codingcode/src/core/workspace.ts index 4c79283..3859a75 100644 --- a/packages/codingcode/src/core/workspace.ts +++ b/packages/codingcode/src/core/workspace.ts @@ -4,14 +4,14 @@ import { AgentError } from './error.js'; import { encodeProjectPath } from './path.js'; import { type AppConfig, DEFAULT_CONFIG } from '@codingcode/infra/config'; -let installRoot = process.cwd(); +let processRoot = process.cwd(); let workspaceCwd = process.cwd(); let _config: AppConfig = DEFAULT_CONFIG; export interface WorkspaceInit { /** Directory where config/models.json lives (default: cwd at process start). */ - installRoot?: string; - /** Agent working directory (default: installRoot). Set via --cwd. */ + processRoot?: string; + /** Agent working directory (default: processRoot). Set via --cwd. */ workspaceCwd?: string; /** Pre-loaded app config. Hosts must load config before calling initWorkspace. */ config?: AppConfig; @@ -40,8 +40,8 @@ export function parseWorkspaceArgs(argv: string[]): { workspaceCwd?: string; arg } export function initWorkspace(opts: WorkspaceInit = {}): void { - installRoot = resolve(opts.installRoot ?? process.cwd()); - const raw = opts.workspaceCwd ?? installRoot; + processRoot = resolve(opts.processRoot ?? process.cwd()); + const raw = opts.workspaceCwd ?? processRoot; workspaceCwd = resolve(raw); if (!existsSync(workspaceCwd)) { throw new AgentError('CONFIG_INVALID', `Workspace directory does not exist: ${workspaceCwd}`); @@ -53,8 +53,8 @@ export function initWorkspace(opts: WorkspaceInit = {}): void { } /** Config / models.json root (where `npm start` was run). */ -export function getInstallRoot(): string { - return installRoot; +export function getProcessRoot(): string { + return processRoot; } /** Agent working directory for tools, sessions, checkpoints, AGENTS.md. */ diff --git a/packages/codingcode/src/hooks/config.ts b/packages/codingcode/src/hooks/config.ts index 7cd14bd..a740b1b 100644 --- a/packages/codingcode/src/hooks/config.ts +++ b/packages/codingcode/src/hooks/config.ts @@ -3,6 +3,7 @@ import { join } from 'path'; import { homedir } from 'os'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import type { HookPoint } from './registry.js'; +import { createDisabledStore } from '@codingcode/infra/disabled-store'; export interface UserHookConfig { name: string; @@ -89,93 +90,12 @@ export function resolveHookConfigs(projectRoot: string): UserHookConfig[] { return mergeByName(globalHooks, projectHooks); } -// ---- 全局级 Hook disabled 状态:持久化到 ~/.codingcode/config.yaml ---- +// ---- Hook disabled state ---- -export function getGlobalHookDisabledState(hookName: string): boolean { - try { - const p = join(getGlobalConfigDir(), 'config.yaml'); - if (!existsSync(p)) return false; - const raw = readFileSync(p, 'utf8'); - const config = parseYaml(raw) as any; - const disabled = config.hooks?.disabledHooks as Record; - return disabled?.[hookName] ?? false; - } catch { - return false; - } -} - -export function setGlobalHookDisabledState(hookName: string, disabled: boolean): void { - const dir = getGlobalConfigDir(); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const p = join(dir, 'config.yaml'); - const existing: Record = existsSync(p) - ? (parseYaml(readFileSync(p, 'utf8')) as Record) - : {}; - const hooks = (existing.hooks as Record) ?? {}; - const disabledHooks = (hooks.disabledHooks as Record) ?? {}; - disabledHooks[hookName] = disabled; - hooks.disabledHooks = disabledHooks; - existing.hooks = hooks; - writeFileSync(p, stringifyYaml(existing), 'utf8'); -} - -// ---- 项目级 Hook disabled 状态:持久化到 .codingcode/config.yaml ---- - -export function getProjectHookDisabledState( - projectRoot: string, - hookName: string -): boolean | undefined { - const p = join(projectRoot, '.codingcode', 'config.yaml'); - if (!existsSync(p)) return undefined; - try { - const raw = readFileSync(p, 'utf8'); - const config = parseYaml(raw) as any; - const disabled = config.hooks?.disabledHooks as Record; - return disabled?.[hookName]; - } catch { - return undefined; - } -} - -export function setProjectHookDisabledState( - projectRoot: string, - hookName: string, - disabled: boolean -): void { - const dir = join(projectRoot, '.codingcode'); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const p = join(dir, 'config.yaml'); - const existing: Record = existsSync(p) - ? (parseYaml(readFileSync(p, 'utf8')) as Record) - : {}; - const hooks = (existing.hooks as Record) ?? {}; - const disabledHooks = (hooks.disabledHooks as Record) ?? {}; - disabledHooks[hookName] = disabled; - hooks.disabledHooks = disabledHooks; - existing.hooks = hooks; - writeFileSync(p, stringifyYaml(existing), 'utf8'); -} - -export function resetProjectHookDisabledState(projectRoot: string, hookName: string): void { - const p = join(projectRoot, '.codingcode', 'config.yaml'); - if (!existsSync(p)) return; - const existing: Record = parseYaml(readFileSync(p, 'utf8')) as Record< - string, - unknown - >; - const hooks = (existing.hooks as Record) ?? {}; - const disabledHooks = hooks.disabledHooks as Record; - if (disabledHooks) { - delete disabledHooks[hookName]; - hooks.disabledHooks = disabledHooks; - } - existing.hooks = hooks; - writeFileSync(p, stringifyYaml(existing), 'utf8'); -} - -// 解析最终生效的 Hook disabled 状态:项目级 > 全局级 -export function resolveHookDisabled(projectRoot: string, hookName: string): boolean { - const projectVal = getProjectHookDisabledState(projectRoot, hookName); - if (projectVal !== undefined) return projectVal; - return getGlobalHookDisabledState(hookName); -} +const hookDisabledStore = createDisabledStore({ globalKeyPath: ['hooks', 'disabledHooks'], getGlobalConfigDir }); +export const getGlobalHookDisabledState = hookDisabledStore.getGlobal; +export const setGlobalHookDisabledState = hookDisabledStore.setGlobal; +export const getProjectHookDisabledState = hookDisabledStore.getProject; +export const setProjectHookDisabledState = hookDisabledStore.setProject; +export const resetProjectHookDisabledState = hookDisabledStore.resetProject; +export const resolveHookDisabled = hookDisabledStore.resolve; diff --git a/packages/codingcode/src/layer.ts b/packages/codingcode/src/layer.ts index be8bc2b..32a23f5 100644 --- a/packages/codingcode/src/layer.ts +++ b/packages/codingcode/src/layer.ts @@ -26,8 +26,8 @@ export const ApprovalLayer = ApprovalService.Default.pipe( Layer.provide(Layer.mergeAll(HookLayer, ApprovalWaitLayer)) ); -/** ProjectRuntime depends on HookService + McpService. */ -const ProjectRuntimeDeps = Layer.mergeAll(HookLayer, McpLayer); +/** ProjectRuntime depends on HookService + McpService + SubagentRegistry. */ +const ProjectRuntimeDeps = Layer.mergeAll(HookLayer, McpLayer, SubagentRegistryLayer); export const ProjectRuntimeLayer = ProjectRuntimeService.Default.pipe( Layer.provide(ProjectRuntimeDeps) ); diff --git a/packages/codingcode/src/llm/factory.ts b/packages/codingcode/src/llm/factory.ts index bfe48b3..6ea9966 100644 --- a/packages/codingcode/src/llm/factory.ts +++ b/packages/codingcode/src/llm/factory.ts @@ -1,7 +1,7 @@ import { readFileSync, existsSync } from 'fs'; import { resolve } from 'path'; import { AgentError } from '../core/error.js'; -import { getInstallRoot, getConfig } from '../core/workspace.js'; +import { getProcessRoot, getConfig } from '../core/workspace.js'; import { Result } from '../core/result.js'; import type { LLMClient } from './client.js'; import { OpenAIProvider } from './providers/openai.js'; @@ -36,10 +36,11 @@ export interface SelectableModel { base_url: string; api_key_env: string; context_window: number; + streamSupportsTools?: boolean; } function modelsFile(): string { - return resolve(getInstallRoot(), 'config/models.json'); + return resolve(getProcessRoot(), 'config/models.json'); } let catalog: ProviderCatalog | null = null; diff --git a/packages/codingcode/src/llm/providers/deepseek.ts b/packages/codingcode/src/llm/providers/deepseek.ts index 6b5ab2c..97a3a1f 100644 --- a/packages/codingcode/src/llm/providers/deepseek.ts +++ b/packages/codingcode/src/llm/providers/deepseek.ts @@ -1,4 +1,4 @@ -import { generateText, streamText, stepCountIs, jsonSchema, type ModelMessage } from 'ai'; +import { generateText, streamText, stepCountIs, type ModelMessage } from 'ai'; import type { LanguageModelV3 } from '@ai-sdk/provider'; import { Result } from '../../core/result.js'; import { AgentError } from '../../core/error.js'; @@ -6,84 +6,7 @@ import { mapLlmError } from '../errors.js'; import type { LLMClient } from '../client.js'; import type { LLMRequest, LLMResponse } from '../types.js'; import type { SelectableModel } from '../factory.js'; - -function convertMessages( - messages: Array<{ role: string; content: string; tool_calls?: unknown[]; tool_call_id?: string }> -): ModelMessage[] { - return messages.map((m) => { - if (m.role === 'assistant' && m.tool_calls && Array.isArray(m.tool_calls)) { - const content: any[] = [{ type: 'text', text: m.content }]; - for (const tc of m.tool_calls) { - content.push({ - type: 'tool-call', - toolCallId: (tc as any).id ?? 'unknown', - toolName: (tc as any).name ?? 'unknown', - input: (tc as any).arguments ?? {}, - }); - } - return { role: 'assistant', content } as ModelMessage; - } - if (m.role === 'tool' && m.tool_call_id) { - return { - role: 'tool', - content: [ - { - type: 'tool-result', - toolCallId: m.tool_call_id, - toolName: (m as any).tool_name || '', - output: { type: 'text', value: m.content }, - }, - ], - } as unknown as ModelMessage; - } - return { role: m.role as any, content: m.content } as ModelMessage; - }); -} - -function convertTools( - tools?: Array<{ name: string; description: string; parameters: Record }> -): Record | undefined { - if (!tools || tools.length === 0) return undefined; - const result: Record = {}; - for (const t of tools) { - result[t.name] = { - description: t.description, - inputSchema: jsonSchema(t.parameters as any), - }; - } - return result; -} - -function parseResponseMessages(responseMessages: ModelMessage[]): LLMResponse { - const lastAssistant = [...responseMessages].reverse().find((m) => m.role === 'assistant'); - if (!lastAssistant) { - return { content: '', finishReason: 'stop' }; - } - - let content = ''; - const toolCalls: LLMResponse['toolCalls'] = []; - - if (typeof lastAssistant.content === 'string') { - content = lastAssistant.content; - } else if (Array.isArray(lastAssistant.content)) { - for (const part of lastAssistant.content as any[]) { - if (part.type === 'text') content += part.text ?? ''; - if (part.type === 'tool-call') { - toolCalls.push({ - id: part.toolCallId ?? 'unknown', - name: part.toolName ?? 'unknown', - arguments: part.input ?? {}, - }); - } - } - } - - return { - content, - toolCalls: toolCalls.length > 0 ? toolCalls : undefined, - finishReason: toolCalls.length > 0 ? 'tool_calls' : 'stop', - }; -} +import { convertMessages, convertTools, parseResponseMessages } from './shared.js'; export class DeepSeekProvider implements LLMClient { constructor( diff --git a/packages/codingcode/src/llm/providers/openai.ts b/packages/codingcode/src/llm/providers/openai.ts index cfc0c41..9425295 100644 --- a/packages/codingcode/src/llm/providers/openai.ts +++ b/packages/codingcode/src/llm/providers/openai.ts @@ -1,4 +1,4 @@ -import { generateText, streamText, stepCountIs, jsonSchema, type ModelMessage } from 'ai'; +import { generateText, streamText, stepCountIs, type ModelMessage } from 'ai'; import type { LanguageModelV3 } from '@ai-sdk/provider'; import { Result } from '../../core/result.js'; import { AgentError } from '../../core/error.js'; @@ -6,84 +6,7 @@ import { mapLlmError } from '../errors.js'; import type { LLMClient } from '../client.js'; import type { LLMRequest, LLMResponse } from '../types.js'; import type { SelectableModel } from '../factory.js'; - -function convertMessages( - messages: Array<{ role: string; content: string; tool_calls?: unknown[]; tool_call_id?: string }> -): ModelMessage[] { - return messages.map((m) => { - if (m.role === 'assistant' && m.tool_calls && Array.isArray(m.tool_calls)) { - const content: any[] = [{ type: 'text', text: m.content }]; - for (const tc of m.tool_calls) { - content.push({ - type: 'tool-call', - toolCallId: (tc as any).id ?? 'unknown', - toolName: (tc as any).name ?? 'unknown', - input: (tc as any).arguments ?? {}, - }); - } - return { role: 'assistant', content } as ModelMessage; - } - if (m.role === 'tool' && m.tool_call_id) { - return { - role: 'tool', - content: [ - { - type: 'tool-result', - toolCallId: m.tool_call_id, - toolName: (m as any).tool_name || '', - output: { type: 'text', value: m.content }, - }, - ], - } as unknown as ModelMessage; - } - return { role: m.role as any, content: m.content } as ModelMessage; - }); -} - -function convertTools( - tools?: Array<{ name: string; description: string; parameters: Record }> -): Record | undefined { - if (!tools || tools.length === 0) return undefined; - const result: Record = {}; - for (const t of tools) { - result[t.name] = { - description: t.description, - inputSchema: jsonSchema(t.parameters as any), - }; - } - return result; -} - -function parseResponseMessages(responseMessages: ModelMessage[]): LLMResponse { - const lastAssistant = [...responseMessages].reverse().find((m) => m.role === 'assistant'); - if (!lastAssistant) { - return { content: '', finishReason: 'stop' }; - } - - let content = ''; - const toolCalls: LLMResponse['toolCalls'] = []; - - if (typeof lastAssistant.content === 'string') { - content = lastAssistant.content; - } else if (Array.isArray(lastAssistant.content)) { - for (const part of lastAssistant.content as any[]) { - if (part.type === 'text') content += part.text ?? ''; - if (part.type === 'tool-call') { - toolCalls.push({ - id: part.toolCallId ?? 'unknown', - name: part.toolName ?? 'unknown', - arguments: part.input ?? {}, - }); - } - } - } - - return { - content, - toolCalls: toolCalls.length > 0 ? toolCalls : undefined, - finishReason: toolCalls.length > 0 ? 'tool_calls' : 'stop', - }; -} +import { convertMessages, convertTools, parseResponseMessages } from './shared.js'; export class OpenAIProvider implements LLMClient { constructor( @@ -128,7 +51,7 @@ export class OpenAIProvider implements LLMClient { } completeStream(req: LLMRequest, signal?: AbortSignal): import('../client.js').StreamResult { - if (this.entry.provider === 'sansen' && req.tools && req.tools.length > 0) { + if (this.entry.streamSupportsTools === false && req.tools && req.tools.length > 0) { const response = this.complete(req, signal); const stream = (async function* () { const result = await response; diff --git a/packages/codingcode/src/llm/providers/shared.ts b/packages/codingcode/src/llm/providers/shared.ts new file mode 100644 index 0000000..5a88547 --- /dev/null +++ b/packages/codingcode/src/llm/providers/shared.ts @@ -0,0 +1,80 @@ +import { jsonSchema, type ModelMessage } from 'ai'; +import type { LLMResponse } from '../types.js'; + +export function convertMessages( + messages: Array<{ role: string; content: string; tool_calls?: unknown[]; tool_call_id?: string }> +): ModelMessage[] { + return messages.map((m) => { + if (m.role === 'assistant' && m.tool_calls && Array.isArray(m.tool_calls)) { + const content: any[] = [{ type: 'text', text: m.content }]; + for (const tc of m.tool_calls) { + content.push({ + type: 'tool-call', + toolCallId: (tc as any).id ?? 'unknown', + toolName: (tc as any).name ?? 'unknown', + input: (tc as any).arguments ?? {}, + }); + } + return { role: 'assistant', content } as ModelMessage; + } + if (m.role === 'tool' && m.tool_call_id) { + return { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: m.tool_call_id, + toolName: (m as any).tool_name || '', + output: { type: 'text', value: m.content }, + }, + ], + } as unknown as ModelMessage; + } + return { role: m.role as any, content: m.content } as ModelMessage; + }); +} + +export function convertTools( + tools?: Array<{ name: string; description: string; parameters: Record }> +): Record | undefined { + if (!tools || tools.length === 0) return undefined; + const result: Record = {}; + for (const t of tools) { + result[t.name] = { + description: t.description, + inputSchema: jsonSchema(t.parameters as any), + }; + } + return result; +} + +export function parseResponseMessages(responseMessages: ModelMessage[]): LLMResponse { + const lastAssistant = [...responseMessages].reverse().find((m) => m.role === 'assistant'); + if (!lastAssistant) { + return { content: '', finishReason: 'stop' }; + } + + let content = ''; + const toolCalls: LLMResponse['toolCalls'] = []; + + if (typeof lastAssistant.content === 'string') { + content = lastAssistant.content; + } else if (Array.isArray(lastAssistant.content)) { + for (const part of lastAssistant.content as any[]) { + if (part.type === 'text') content += part.text ?? ''; + if (part.type === 'tool-call') { + toolCalls.push({ + id: part.toolCallId ?? 'unknown', + name: part.toolName ?? 'unknown', + arguments: part.input ?? {}, + }); + } + } + } + + return { + content, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + finishReason: toolCalls.length > 0 ? 'tool_calls' : 'stop', + }; +} diff --git a/packages/codingcode/src/mcp/config.ts b/packages/codingcode/src/mcp/config.ts index 399a4d7..7eea0e7 100644 --- a/packages/codingcode/src/mcp/config.ts +++ b/packages/codingcode/src/mcp/config.ts @@ -3,6 +3,7 @@ import { join } from 'path'; import { homedir } from 'os'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import type { McpServerConfig } from './types.js'; +import { createDisabledStore } from '@codingcode/infra/disabled-store'; function resolveEnvVars(value: unknown): unknown { if (typeof value === 'string') { @@ -90,93 +91,12 @@ export function resolveMcpConfig(projectRoot: string): McpServerConfig[] { return mergeByName(globalServers, projectServers); } -// ---- 全局级 MCP disabled 状态:持久化到 ~/.codingcode/config.yaml ---- +// ---- MCP disabled state ---- -export function getGlobalMcpDisabledState(serverName: string): boolean { - try { - const p = join(getGlobalConfigDir(), 'config.yaml'); - if (!existsSync(p)) return false; - const raw = readFileSync(p, 'utf8'); - const config = parseYaml(raw) as any; - const disabled = config.mcp?.disabledServers as Record; - return disabled?.[serverName] ?? false; - } catch { - return false; - } -} - -export function setGlobalMcpDisabledState(serverName: string, disabled: boolean): void { - const dir = getGlobalConfigDir(); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const p = join(dir, 'config.yaml'); - const existing: Record = existsSync(p) - ? (parseYaml(readFileSync(p, 'utf8')) as Record) - : {}; - const mcp = (existing.mcp as Record) ?? {}; - const disabledServers = (mcp.disabledServers as Record) ?? {}; - disabledServers[serverName] = disabled; - mcp.disabledServers = disabledServers; - existing.mcp = mcp; - writeFileSync(p, stringifyYaml(existing), 'utf8'); -} - -// ---- 项目级 MCP disabled 状态:持久化到 .codingcode/config.yaml ---- - -export function getProjectMcpDisabledState( - projectRoot: string, - serverName: string -): boolean | undefined { - const p = join(projectRoot, '.codingcode', 'config.yaml'); - if (!existsSync(p)) return undefined; - try { - const raw = readFileSync(p, 'utf8'); - const config = parseYaml(raw) as any; - const disabled = config.mcp?.disabledServers as Record; - return disabled?.[serverName]; - } catch { - return undefined; - } -} - -export function setProjectMcpDisabledState( - projectRoot: string, - serverName: string, - disabled: boolean -): void { - const dir = join(projectRoot, '.codingcode'); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const p = join(dir, 'config.yaml'); - const existing: Record = existsSync(p) - ? (parseYaml(readFileSync(p, 'utf8')) as Record) - : {}; - const mcp = (existing.mcp as Record) ?? {}; - const disabledServers = (mcp.disabledServers as Record) ?? {}; - disabledServers[serverName] = disabled; - mcp.disabledServers = disabledServers; - existing.mcp = mcp; - writeFileSync(p, stringifyYaml(existing), 'utf8'); -} - -export function resetProjectMcpDisabledState(projectRoot: string, serverName: string): void { - const p = join(projectRoot, '.codingcode', 'config.yaml'); - if (!existsSync(p)) return; - const existing: Record = parseYaml(readFileSync(p, 'utf8')) as Record< - string, - unknown - >; - const mcp = (existing.mcp as Record) ?? {}; - const disabledServers = mcp.disabledServers as Record; - if (disabledServers) { - delete disabledServers[serverName]; - mcp.disabledServers = disabledServers; - } - existing.mcp = mcp; - writeFileSync(p, stringifyYaml(existing), 'utf8'); -} - -// 解析最终生效的 MCP disabled 状态:项目级 > 全局级 -export function resolveMcpDisabled(projectRoot: string, serverName: string): boolean { - const projectVal = getProjectMcpDisabledState(projectRoot, serverName); - if (projectVal !== undefined) return projectVal; - return getGlobalMcpDisabledState(serverName); -} +const mcpDisabledStore = createDisabledStore({ globalKeyPath: ['mcp', 'disabledServers'], getGlobalConfigDir }); +export const getGlobalMcpDisabledState = mcpDisabledStore.getGlobal; +export const setGlobalMcpDisabledState = mcpDisabledStore.setGlobal; +export const getProjectMcpDisabledState = mcpDisabledStore.getProject; +export const setProjectMcpDisabledState = mcpDisabledStore.setProject; +export const resetProjectMcpDisabledState = mcpDisabledStore.resetProject; +export const resolveMcpDisabled = mcpDisabledStore.resolve; diff --git a/packages/codingcode/src/runtime/project-runtime.ts b/packages/codingcode/src/runtime/project-runtime.ts index 54c925a..5912276 100644 --- a/packages/codingcode/src/runtime/project-runtime.ts +++ b/packages/codingcode/src/runtime/project-runtime.ts @@ -1,6 +1,6 @@ import { Effect } from 'effect'; import type { AgentProfile } from '../subagent/registry.js'; -import { EXPLORE_PROFILE, PLAN_PROFILE } from '../subagent/registry.js'; +import { EXPLORE_PROFILE, PLAN_PROFILE, SubagentRegistry } from '../subagent/registry.js'; import * as agentLoader from '../subagent/loader.js'; import type { ToolVisibilityPolicy } from '../tools/types.js'; import { HookService } from '../hooks/registry.js'; @@ -14,9 +14,9 @@ export class ProjectRuntimeService extends Effect.Service effect: Effect.gen(function* () { const hooks = yield* HookService; const mcp = yield* McpService; + const subagentRegistry = yield* SubagentRegistry; const sessionAgentProfiles = new Map(); - const cachedSubagentProfiles = new Map(); const prepared = new Set(); function buildProfiles(projectPath: string): AgentProfile[] { @@ -49,7 +49,9 @@ export class ProjectRuntimeService extends Effect.Service evictProjectRules(norm); yield* hooks.reloadUserHooks(norm).pipe(Effect.catchAll(() => Effect.void)); yield* mcp.syncConnections(norm).pipe(Effect.catchAll(() => Effect.void)); - cachedSubagentProfiles.set(norm, buildProfiles(norm)); + const profiles = buildProfiles(norm); + subagentRegistry.reset(); + subagentRegistry.registerAll(profiles); }), resolveMainAgentProfile: ( @@ -61,17 +63,27 @@ export class ProjectRuntimeService extends Effect.Service return agentLoader.loadMainAgentProfile(projectPath); }, - resolveSubagentProfile: (projectPath: string, name: string): AgentProfile | undefined => { - const norm = normalizePath(projectPath); - const cached = cachedSubagentProfiles.get(norm); - const profiles = cached ?? buildProfiles(norm); - return profiles.find((p) => p.name === name); + resolveSubagentProfile: (_projectPath: string, name: string): AgentProfile | undefined => { + // First check if not yet prepared (lazy init) + const cached = subagentRegistry.get(name); + if (cached) return cached; + // Lazy init: build profiles and register if not yet populated + const norm = normalizePath(_projectPath); + if (!prepared.has(norm)) { + const profiles = buildProfiles(norm); + subagentRegistry.registerAll(profiles); + } + return subagentRegistry.get(name); }, listAgentProfiles: (projectPath: string): AgentProfile[] => { const normalized = normalizePath(projectPath); - const cached = cachedSubagentProfiles.get(normalized); - return cached ? [...cached] : buildProfiles(normalized); + if (!prepared.has(normalized)) { + const profiles = buildProfiles(normalized); + subagentRegistry.registerAll(profiles); + prepared.add(normalized); + } + return subagentRegistry.list(); }, getToolPolicy: (profile: AgentProfile | undefined): ToolVisibilityPolicy => ({ @@ -97,7 +109,7 @@ export class ProjectRuntimeService extends Effect.Service Effect.sync(() => { const norm = normalizePath(projectPath); prepared.delete(norm); - cachedSubagentProfiles.delete(norm); + subagentRegistry.reset(); evictProjectRules(norm); }), }; diff --git a/packages/codingcode/src/server/adapter.ts b/packages/codingcode/src/server/adapter.ts index 0ac06c2..abb154c 100644 --- a/packages/codingcode/src/server/adapter.ts +++ b/packages/codingcode/src/server/adapter.ts @@ -1,6 +1,6 @@ import type { AgentEvent } from '../agent/agent.js'; -export type SseEvent = Record; +export type SseEvent = { type: string; [k: string]: unknown }; export function agentEventToSseEvent(event: AgentEvent): SseEvent | null { switch (event._tag) { diff --git a/packages/codingcode/src/session/io.ts b/packages/codingcode/src/session/io.ts index 1e1eb8d..2edae00 100644 --- a/packages/codingcode/src/session/io.ts +++ b/packages/codingcode/src/session/io.ts @@ -26,8 +26,8 @@ const logger = createLogger(); const CODINGCODE_DIR = join(homedir(), '.codingcode'); const PROJECT_BASE = join(CODINGCODE_DIR, 'project'); -export function projectSessionsDir(encoded: string): string { - return join(PROJECT_BASE, encoded, 'sessions'); +export function projectSessionsDir(encodedProjectPath: string): string { + return join(PROJECT_BASE, encodedProjectPath, 'sessions'); } export function resolveSessionDir(sessionId: string): string | null { @@ -86,7 +86,7 @@ export function findFirstUserContent(history: SessionEvent[]): string | null { return null; } -export function makeTitle(content: string): string { +export function truncateTitle(content: string): string { const cleaned = content.replace(/\n/g, ' ').trim(); if (cleaned.length <= 30) return cleaned; return cleaned.slice(0, 30) + '...'; @@ -106,7 +106,7 @@ function buildIndexFromMeta(meta: SessionMetaEvent, history: SessionEvent[]): Se createdAt: meta.createdAt, updatedAt: meta.createdAt, messageCount: countNonMetaEvents(history), - title: firstUser ? makeTitle(firstUser) : meta.sessionId.slice(0, 8), + title: firstUser ? truncateTitle(firstUser) : meta.sessionId.slice(0, 8), currentTurnId: 0, usage: undefined, promptEstimate: 0, diff --git a/packages/codingcode/src/session/messages.ts b/packages/codingcode/src/session/messages.ts index efc8feb..93393cd 100644 --- a/packages/codingcode/src/session/messages.ts +++ b/packages/codingcode/src/session/messages.ts @@ -4,7 +4,7 @@ import type { SessionEvent, AssistantEvent, TokenUsage } from './types.js'; import { readHistory, resolveSessionDir } from './io.js'; import { getContextConfig } from '../context/config.js'; -const COMPACTIBLE_TOOLS = new Set([ +const COMPACTABLE_TOOLS = new Set([ 'read_file', 'execute_command', 'search_code', @@ -104,7 +104,7 @@ export function buildMessagesFromEvents(events: SessionEvent[]): Message[] { let output = event.output; if ( compactedTurnIds.has(event.turnId) && - COMPACTIBLE_TOOLS.has(event.toolName.toLowerCase()) && + COMPACTABLE_TOOLS.has(event.toolName.toLowerCase()) && event.output.length > getContextConfig().microCompactMinChars ) { output = `[Earlier: used ${event.toolName}]`; diff --git a/packages/codingcode/src/session/store.ts b/packages/codingcode/src/session/store.ts index 9f26d3f..30aa96b 100644 --- a/packages/codingcode/src/session/store.ts +++ b/packages/codingcode/src/session/store.ts @@ -34,7 +34,7 @@ import { enqueueWrite, readCurrentIndex, countNonMetaEvents, - makeTitle, + truncateTitle, findFirstUserContent, } from './io.js'; import { buildMessages, findLastVisibleAssistantUsage } from './messages.js'; @@ -88,7 +88,7 @@ export class SessionService extends Effect.Service()('Session', state.messageCount = history.filter((e) => e.type !== 'session_meta').length; } const firstUser = findFirstUserContent(history); - if (firstUser) state.title = makeTitle(firstUser); + if (firstUser) state.title = truncateTitle(firstUser); return state; } @@ -130,7 +130,7 @@ export class SessionService extends Effect.Service()('Session', timestamp: new Date().toISOString(), }; if (state.title === state.sessionId.slice(0, 8)) { - state.title = makeTitle(content); + state.title = truncateTitle(content); } appendLine(state.transcriptPath, event); state.messageCount++; diff --git a/packages/codingcode/src/skills/config.ts b/packages/codingcode/src/skills/config.ts index a5cb98d..35c9104 100644 --- a/packages/codingcode/src/skills/config.ts +++ b/packages/codingcode/src/skills/config.ts @@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, mkdirSy import { join, basename } from 'path'; import { homedir } from 'os'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { createDisabledStore } from '@codingcode/infra/disabled-store'; interface SkillFrontMatter { name: string; @@ -106,96 +107,18 @@ export function getMimeType(filePath: string): string { return map[ext ?? ''] ?? 'application/octet-stream'; } -// ---- 全局级 Skill disabled 状态:持久化到 ~/.codingcode/config.yaml ---- - -export function getGlobalSkillDisabledState(skillName: string): boolean { - try { - const p = join(homedir(), '.codingcode', 'config.yaml'); - if (!existsSync(p)) return false; - const raw = readFileSync(p, 'utf8'); - const config = parseYaml(raw) as any; - const disabled = config.skills?.disabledSkills as Record; - return disabled?.[skillName] ?? false; - } catch { - return false; - } -} - -export function setGlobalSkillDisabledState(skillName: string, disabled: boolean): void { - const dir = join(homedir(), '.codingcode'); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const p = join(dir, 'config.yaml'); - const existing: Record = existsSync(p) - ? (parseYaml(readFileSync(p, 'utf8')) as Record) - : {}; - const skills = (existing.skills as Record) ?? {}; - const disabledSkills = (skills.disabledSkills as Record) ?? {}; - disabledSkills[skillName] = disabled; - skills.disabledSkills = disabledSkills; - existing.skills = skills; - writeFileSync(p, stringifyYaml(existing), 'utf8'); -} - -// ---- 项目级 Skill disabled 状态:持久化到 .codingcode/config.yaml ---- - -export function getProjectSkillDisabledState( - projectRoot: string, - skillName: string -): boolean | undefined { - const p = join(projectRoot, '.codingcode', 'config.yaml'); - if (!existsSync(p)) return undefined; - try { - const raw = readFileSync(p, 'utf8'); - const config = parseYaml(raw) as any; - const disabled = config.skills?.disabledSkills as Record; - return disabled?.[skillName]; - } catch { - return undefined; - } -} - -export function setProjectSkillDisabledState( - projectRoot: string, - skillName: string, - disabled: boolean -): void { - const dir = join(projectRoot, '.codingcode'); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const p = join(dir, 'config.yaml'); - const existing: Record = existsSync(p) - ? (parseYaml(readFileSync(p, 'utf8')) as Record) - : {}; - const skills = (existing.skills as Record) ?? {}; - const disabledSkills = (skills.disabledSkills as Record) ?? {}; - disabledSkills[skillName] = disabled; - skills.disabledSkills = disabledSkills; - existing.skills = skills; - writeFileSync(p, stringifyYaml(existing), 'utf8'); -} - -export function resetProjectSkillDisabledState(projectRoot: string, skillName: string): void { - const p = join(projectRoot, '.codingcode', 'config.yaml'); - if (!existsSync(p)) return; - const existing: Record = parseYaml(readFileSync(p, 'utf8')) as Record< - string, - unknown - >; - const skills = (existing.skills as Record) ?? {}; - const disabledSkills = skills.disabledSkills as Record; - if (disabledSkills) { - delete disabledSkills[skillName]; - skills.disabledSkills = disabledSkills; - } - existing.skills = skills; - writeFileSync(p, stringifyYaml(existing), 'utf8'); -} - -// 解析最终生效的 Skill disabled 状态:项目级 > 全局级 -export function resolveSkillDisabled(projectRoot: string, skillName: string): boolean { - const projectVal = getProjectSkillDisabledState(projectRoot, skillName); - if (projectVal !== undefined) return projectVal; - return getGlobalSkillDisabledState(skillName); -} +// ---- Skill disabled state ---- + +const skillDisabledStore = createDisabledStore({ + globalKeyPath: ['skills', 'disabledSkills'], + getGlobalConfigDir: () => join(homedir(), '.codingcode'), +}); +export const getGlobalSkillDisabledState = skillDisabledStore.getGlobal; +export const setGlobalSkillDisabledState = skillDisabledStore.setGlobal; +export const getProjectSkillDisabledState = skillDisabledStore.getProject; +export const setProjectSkillDisabledState = skillDisabledStore.setProject; +export const resetProjectSkillDisabledState = skillDisabledStore.resetProject; +export const resolveSkillDisabled = skillDisabledStore.resolve; // ---- 辅助函数:分别获取全局/项目级 Skill 目录 ---- diff --git a/packages/codingcode/src/subagent/registry.ts b/packages/codingcode/src/subagent/registry.ts index f4a119c..232ccf3 100644 --- a/packages/codingcode/src/subagent/registry.ts +++ b/packages/codingcode/src/subagent/registry.ts @@ -188,6 +188,10 @@ export class SubagentRegistry extends Effect.Service()('Subage map.set(profile.name, profile); }, + registerAll: (profiles: AgentProfile[]): void => { + for (const p of profiles) map.set(p.name, p); + }, + get: (name: string): AgentProfile | undefined => { return map.get(name); }, diff --git a/packages/codingcode/src/tools/domains/self/tool-search.ts b/packages/codingcode/src/tools/domains/self/tool-search.ts index 73c6dc4..e52d374 100644 --- a/packages/codingcode/src/tools/domains/self/tool-search.ts +++ b/packages/codingcode/src/tools/domains/self/tool-search.ts @@ -9,6 +9,7 @@ export interface ToolSearchApi { query: string, policy?: ToolVisibilityPolicy ) => Array<{ name: string; shortDescription?: string }>; + markLoaded: (sessionId: string, toolNames: string[]) => void; } export function createToolSearchTool( @@ -32,6 +33,7 @@ export function createToolSearchTool( const { query } = args as { query: string }; const hits = svc.search(sessionId, query, policy); if (hits.length === 0) return `No deferred tools matched "${query}".`; + svc.markLoaded(sessionId, hits.map((h) => h.name)); return [ `Loaded ${hits.length} tool(s). Their full schemas are now available next turn:`, ...hits.map((h) => `- ${h.name}: ${h.shortDescription ?? ''}`), diff --git a/packages/codingcode/src/tools/executor.ts b/packages/codingcode/src/tools/executor.ts index d89b246..1bb4eb8 100644 --- a/packages/codingcode/src/tools/executor.ts +++ b/packages/codingcode/src/tools/executor.ts @@ -61,29 +61,7 @@ export class ToolExecutorService extends Effect.Service()(' let finalArgs: Record = decision.type === 'modified' ? decision.input : (args as Record); - // 2. Hook PreToolUse - const hookDecision = yield* hooks.emitDecision('tool.approval.pre', { - toolName: name, - args: finalArgs, - }); - - if (hookDecision?.decision === 'deny') { - yield* hooks.emit('tool.execute.denied', { - toolName: name, - args: finalArgs, - reason: hookDecision.reason ?? 'denied by hook', - source: 'hook', - }); - return yield* Effect.fail( - new AgentError('TOOL_NOT_ALLOWED', hookDecision.reason ?? 'denied by hook') - ); - } - - if (hookDecision?.modifiedInput) { - finalArgs = hookDecision.modifiedInput; - } - - // 3. Notification hook — use callId for consistent pairing + // 2. Notification hook — use callId for consistent pairing const callId = opts?.callId; yield* hooks.emit('tool.execute.before', { toolName: name, diff --git a/packages/codingcode/src/tools/tool-search-service.ts b/packages/codingcode/src/tools/tool-search-service.ts index 5e576b6..9c4d6f3 100644 --- a/packages/codingcode/src/tools/tool-search-service.ts +++ b/packages/codingcode/src/tools/tool-search-service.ts @@ -61,10 +61,14 @@ export class ToolSearchService extends Effect.Service()('Tool const haystack = `${t.name} ${t.shortDescription ?? ''} ${t.description}`.toLowerCase(); return tokens.every((tok) => haystack.includes(tok)); }); - for (const t of hits) set.add(t.name); return hits.map((t) => ({ name: t.name, shortDescription: t.shortDescription })); }, + markLoaded: (sessionId: string, toolNames: string[]): void => { + const set = getSet(sessionId); + for (const name of toolNames) set.add(name); + }, + reset: (): void => loaded.clear(), disposeSession: (sessionId: string): void => { diff --git a/packages/codingcode/test/approval/presets.test.ts b/packages/codingcode/test/approval/presets.test.ts index dad46e0..5c6d27b 100644 --- a/packages/codingcode/test/approval/presets.test.ts +++ b/packages/codingcode/test/approval/presets.test.ts @@ -3,7 +3,7 @@ import { createRuleEngine } from '../../src/approval/rule-engine.js'; import { DEFAULT_DENY_RULES, READONLY_TOOL_NAMES, - DESTRUCTIVE_TOOL_NAMES, + DANGEROUS_TOOL_NAMES, } from '../../src/approval/presets.js'; describe('Presets', () => { @@ -53,7 +53,7 @@ describe('Presets', () => { }); it('should define destructive tools', () => { - expect(DESTRUCTIVE_TOOL_NAMES).toContain('execute_command'); - expect(DESTRUCTIVE_TOOL_NAMES).not.toContain('Bash'); + expect(DANGEROUS_TOOL_NAMES).toContain('execute_command'); + expect(DANGEROUS_TOOL_NAMES).not.toContain('Bash'); }); }); diff --git a/packages/codingcode/test/checkpoint/checkpoint-project-path.test.ts b/packages/codingcode/test/checkpoint/checkpoint-project-path.test.ts index ad0c08f..1d2f3db 100644 --- a/packages/codingcode/test/checkpoint/checkpoint-project-path.test.ts +++ b/packages/codingcode/test/checkpoint/checkpoint-project-path.test.ts @@ -88,7 +88,7 @@ describe('checkpoint/bootstrap projectPath isolation', () => { '{"active":"p","providers":[]}', 'utf8' ); - initWorkspace({ installRoot: globalDir, workspaceCwd: globalDir }); + initWorkspace({ processRoot: globalDir, workspaceCwd: globalDir }); }); afterEach(() => { @@ -115,8 +115,8 @@ describe('checkpoint/bootstrap projectPath isolation', () => { it('bootstrap checkpoint records correct file path via payload.projectPath', async () => { const hooks = await getHooks(); - const { bootstrapCheckpoint } = await import('../../src/checkpoint/bootstrap.js'); - bootstrapCheckpoint(hooks); + const { registerCheckpointHooks } = await import('../../src/checkpoint/bootstrap.js'); + registerCheckpointHooks(hooks); writeFileSync(join(projectDir, 'c.txt'), 'initial', 'utf8'); diff --git a/packages/codingcode/test/core/workspace.test.ts b/packages/codingcode/test/core/workspace.test.ts index ed8076c..034e397 100644 --- a/packages/codingcode/test/core/workspace.test.ts +++ b/packages/codingcode/test/core/workspace.test.ts @@ -7,7 +7,7 @@ import { parseWorkspaceArgs, initWorkspace, getWorkspaceCwd, - getInstallRoot, + getProcessRoot, resolveInWorkspace, } from '../../src/core/workspace.js'; import { encodeProjectPath } from '../../src/core/path.js'; @@ -54,20 +54,20 @@ describe('core/workspace', () => { }); it('initWorkspace separates install root and workspace cwd', () => { - initWorkspace({ installRoot, workspaceCwd: otherDir }); - expect(getInstallRoot()).toBe(installRoot); + initWorkspace({ processRoot: installRoot, workspaceCwd: otherDir }); + expect(getProcessRoot()).toBe(installRoot); expect(getWorkspaceCwd()).toBe(otherDir); expect(encodeProjectPath(getWorkspaceCwd())).toBe(encodeProjectPath(otherDir)); }); it('resolveInWorkspace resolves relative paths against workspace', () => { - initWorkspace({ installRoot, workspaceCwd: otherDir }); + initWorkspace({ processRoot: installRoot, workspaceCwd: otherDir }); expect(resolveInWorkspace('src/a.ts')).toBe(join(otherDir, 'src/a.ts')); }); it('throws when --cwd path does not exist', () => { expect(() => - initWorkspace({ installRoot, workspaceCwd: join(tmpdir(), 'missing-' + randomUUID()) }) + initWorkspace({ processRoot: installRoot, workspaceCwd: join(tmpdir(), 'missing-' + randomUUID()) }) ).toThrow(/does not exist/); }); }); diff --git a/packages/codingcode/test/llm/openai-provider.test.ts b/packages/codingcode/test/llm/openai-provider.test.ts index 3771577..f5f723f 100644 --- a/packages/codingcode/test/llm/openai-provider.test.ts +++ b/packages/codingcode/test/llm/openai-provider.test.ts @@ -20,7 +20,7 @@ async function collect(stream: AsyncIterable): Promise { return chunks; } -function entry(provider: string) { +function entry(provider: string, opts?: { streamSupportsTools?: boolean }) { return { id: `model@${provider}`, provider, @@ -30,6 +30,7 @@ function entry(provider: string) { base_url: 'https://example.com/v1', api_key_env: 'API_KEY', context_window: 128000, + streamSupportsTools: opts?.streamSupportsTools, }; } @@ -65,7 +66,7 @@ describe('OpenAIProvider completeStream', () => { it('uses non-streaming completion for sansen requests with tools', async () => { const { OpenAIProvider } = await import('../../src/llm/providers/openai.js'); - const provider = new OpenAIProvider({} as any, entry('sansen')); + const provider = new OpenAIProvider({} as any, entry('sansen', { streamSupportsTools: false })); const result = provider.completeStream(request(true) as any); await expect(result.response).resolves.toMatchObject({ ok: true, value: { content: 'done' } }); @@ -77,7 +78,7 @@ describe('OpenAIProvider completeStream', () => { it('keeps streaming for sansen requests without tools', async () => { const { OpenAIProvider } = await import('../../src/llm/providers/openai.js'); - const provider = new OpenAIProvider({} as any, entry('sansen')); + const provider = new OpenAIProvider({} as any, entry('sansen', { streamSupportsTools: false })); const result = provider.completeStream(request(false) as any); await expect(collect(result.stream)).resolves.toEqual(['streamed']); diff --git a/packages/codingcode/test/tools/domains/bash/bash-project-path.test.ts b/packages/codingcode/test/tools/domains/bash/bash-project-path.test.ts index 41c023f..56c4f14 100644 --- a/packages/codingcode/test/tools/domains/bash/bash-project-path.test.ts +++ b/packages/codingcode/test/tools/domains/bash/bash-project-path.test.ts @@ -21,7 +21,7 @@ describe('tools/domains/bash projectPath isolation', () => { '{"active":"p","providers":[]}', 'utf8' ); - initWorkspace({ installRoot: globalDir, workspaceCwd: globalDir }); + initWorkspace({ processRoot: globalDir, workspaceCwd: globalDir }); }); afterEach(() => { diff --git a/packages/codingcode/test/tools/domains/fs/tool-project-path.test.ts b/packages/codingcode/test/tools/domains/fs/tool-project-path.test.ts index 6869da2..147a158 100644 --- a/packages/codingcode/test/tools/domains/fs/tool-project-path.test.ts +++ b/packages/codingcode/test/tools/domains/fs/tool-project-path.test.ts @@ -25,7 +25,7 @@ describe('tools/domains/fs projectPath isolation', () => { '{"active":"p","providers":[]}', 'utf8' ); - initWorkspace({ installRoot: globalDir, workspaceCwd: globalDir }); + initWorkspace({ processRoot: globalDir, workspaceCwd: globalDir }); }); afterEach(() => { diff --git a/packages/codingcode/test/tools/tool-search.test.ts b/packages/codingcode/test/tools/tool-search.test.ts index 187cdd3..f3841b1 100644 --- a/packages/codingcode/test/tools/tool-search.test.ts +++ b/packages/codingcode/test/tools/tool-search.test.ts @@ -7,6 +7,7 @@ describe('createToolSearchTool', () => { search: (_sessionId: string, _query: string) => [ { name: 'todo_write', shortDescription: 'Write tasks' }, ], + markLoaded: () => {}, }); const result = await tool.execute({ query: 'todo' }, { sessionId: 'test-agent' }); @@ -17,6 +18,7 @@ describe('createToolSearchTool', () => { it('returns no-match message when no hits', async () => { const tool = createToolSearchTool({ search: () => [], + markLoaded: () => {}, }); const result = await tool.execute({ query: 'zzznonexistent' }, { sessionId: 'test-agent' }); @@ -24,7 +26,7 @@ describe('createToolSearchTool', () => { }); it('throws if sessionId is missing', async () => { - const tool = createToolSearchTool({ search: () => [] }); + const tool = createToolSearchTool({ search: () => [], markLoaded: () => {} }); await expect(tool.execute({ query: 'anything' }, {})).rejects.toThrow( 'tool_search requires sessionId' ); @@ -33,9 +35,11 @@ describe('createToolSearchTool', () => { it('each tool instance uses its own svc closure', async () => { const tool1 = createToolSearchTool({ search: () => [{ name: 'tool_a' }], + markLoaded: () => {}, }); const tool2 = createToolSearchTool({ search: () => [{ name: 'tool_b' }], + markLoaded: () => {}, }); const r1 = await tool1.execute({ query: 'x' }, { sessionId: 'a' }); diff --git a/packages/infra/package.json b/packages/infra/package.json index 76715a1..37a9517 100644 --- a/packages/infra/package.json +++ b/packages/infra/package.json @@ -5,7 +5,8 @@ "main": "./src/config.ts", "exports": { "./config": "./src/config.ts", - "./logger": "./src/logger.ts" + "./logger": "./src/logger.ts", + "./disabled-store": "./src/disabled-store.ts" }, "dependencies": { "pino": "^9.6.0", diff --git a/packages/infra/src/disabled-store.ts b/packages/infra/src/disabled-store.ts new file mode 100644 index 0000000..a334664 --- /dev/null +++ b/packages/infra/src/disabled-store.ts @@ -0,0 +1,123 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { homedir } from 'os'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; + +export interface DisabledStoreConfig { + globalKeyPath: string[]; + /** Optional function that returns the global config directory. Defaults to ~/.codingcode */ + getGlobalConfigDir?: () => string; +} + +export interface DisabledStore { + getGlobal(name: string): boolean; + setGlobal(name: string, disabled: boolean): void; + getProject(projectRoot: string, name: string): boolean | undefined; + setProject(projectRoot: string, name: string, disabled: boolean): void; + resetProject(projectRoot: string, name: string): void; + resolve(projectRoot: string, name: string): boolean; +} + +function deepSet( + obj: Record, + path: string[], + name: string, + value: unknown +): void { + let target: any = obj; + for (let i = 0; i < path.length - 1; i++) { + const key = path[i]!; + if (!target[key]) target[key] = {}; + target = target[key]; + if (!target) return; + } + const lastKey = path[path.length - 1]!; + const map = (target[lastKey] as Record) ?? {}; + map[name] = value; + target[lastKey] = map; +} + +function deepGet(obj: any, path: string[]): any { + let value = obj; + for (const k of path) { + value = value?.[k]; + } + return value; +} + +function deepDelete(obj: any, path: string[], name: string): void { + const value = deepGet(obj, path); + if (value && typeof value === 'object') { + delete (value as Record)[name]; + } +} + +export function createDisabledStore(cfg: DisabledStoreConfig): DisabledStore { + const globalConfigPath = () => + join(cfg.getGlobalConfigDir?.() ?? join(homedir(), '.codingcode'), 'config.yaml'); + + const getGlobal = (name: string): boolean => { + const p = globalConfigPath(); + if (!existsSync(p)) return false; + try { + const config = parseYaml(readFileSync(p, 'utf8')) as any; + const value = deepGet(config, cfg.globalKeyPath); + return (value as Record)?.[name] ?? false; + } catch { + return false; + } + }; + + const setGlobal = (name: string, disabled: boolean): void => { + const p = globalConfigPath(); + const dir = dirname(p); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const existing: Record = existsSync(p) + ? (parseYaml(readFileSync(p, 'utf8')) as Record) + : {}; + deepSet(existing, cfg.globalKeyPath, name, disabled); + writeFileSync(p, stringifyYaml(existing), 'utf8'); + }; + + const getProject = (projectRoot: string, name: string): boolean | undefined => { + const p = join(projectRoot, '.codingcode', 'config.yaml'); + if (!existsSync(p)) return undefined; + try { + const config = parseYaml(readFileSync(p, 'utf8')) as Record; + const value = deepGet(config, cfg.globalKeyPath); + return (value as Record)?.[name]; + } catch { + return undefined; + } + }; + + const setProject = (projectRoot: string, name: string, disabled: boolean): void => { + const dir = join(projectRoot, '.codingcode'); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const p = join(dir, 'config.yaml'); + const existing: Record = existsSync(p) + ? (parseYaml(readFileSync(p, 'utf8')) as Record) + : {}; + deepSet(existing, cfg.globalKeyPath, name, disabled); + writeFileSync(p, stringifyYaml(existing), 'utf8'); + }; + + const resetProject = (projectRoot: string, name: string): void => { + const p = join(projectRoot, '.codingcode', 'config.yaml'); + if (!existsSync(p)) return; + const existing: Record = parseYaml(readFileSync(p, 'utf8')) as Record< + string, + unknown + >; + deepDelete(existing, cfg.globalKeyPath, name); + writeFileSync(p, stringifyYaml(existing), 'utf8'); + }; + + const resolve = (projectRoot: string, name: string): boolean => { + const pv = getProject(projectRoot, name); + if (pv !== undefined) return pv; + return getGlobal(name); + }; + + return { getGlobal, setGlobal, getProject, setProject, resetProject, resolve }; +} From d9edb6230fdfcb9440eba4e695bcc076390d69ce Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 11 Jun 2026 00:37:21 +0800 Subject: [PATCH 02/11] fix 400 error and using tool wrong field --- .../src/checkpoint/checkpoint-service.ts | 311 +++--------------- .../src/checkpoint/classification.ts | 39 +++ .../src/checkpoint/commit-naming.ts | 9 + .../{bootstrap.ts => hook-recorder.ts} | 2 - .../src/checkpoint/restore-planning.ts | 57 ++++ .../src/checkpoint/restore-store.ts | 35 ++ .../src/checkpoint/rollback-engine.ts | 97 ++++++ packages/codingcode/src/cli.ts | 2 +- packages/codingcode/src/llm/factory.ts | 1 - .../codingcode/src/llm/providers/deepseek.ts | 4 - .../codingcode/src/llm/providers/openai.ts | 6 +- .../codingcode/test/agent/stop-hook.test.ts | 2 +- .../checkpoint-project-path.test.ts | 6 +- .../codingcode/test/context/context.test.ts | 4 +- .../test/llm/openai-provider.test.ts | 7 +- packages/codingcode/test/orchestrate.test.ts | 3 +- .../codingcode/test/server/handler.test.ts | 4 +- packages/desktop/electron/core/backend.ts | 2 +- 18 files changed, 294 insertions(+), 297 deletions(-) create mode 100644 packages/codingcode/src/checkpoint/classification.ts create mode 100644 packages/codingcode/src/checkpoint/commit-naming.ts rename packages/codingcode/src/checkpoint/{bootstrap.ts => hook-recorder.ts} (97%) create mode 100644 packages/codingcode/src/checkpoint/restore-planning.ts create mode 100644 packages/codingcode/src/checkpoint/restore-store.ts create mode 100644 packages/codingcode/src/checkpoint/rollback-engine.ts diff --git a/packages/codingcode/src/checkpoint/checkpoint-service.ts b/packages/codingcode/src/checkpoint/checkpoint-service.ts index aacd081..2384895 100644 --- a/packages/codingcode/src/checkpoint/checkpoint-service.ts +++ b/packages/codingcode/src/checkpoint/checkpoint-service.ts @@ -1,12 +1,22 @@ import { Effect } from 'effect'; -import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs'; -import { join, dirname, resolve } from 'path'; +import { createHash } from 'crypto'; +import { readFileSync } from 'fs'; +import { resolve, dirname } from 'path'; import { ShadowGit } from './shadow-git.js'; import { normalizePath } from '../core/path.js'; import { Ledger } from './ledger.js'; -import { registerCheckpointHooks } from './bootstrap.js'; +import { registerCheckpointHooks } from './hook-recorder.js'; import { HookService } from '../hooks/registry.js'; -import { createHash } from 'crypto'; +import { shortSid, commitMsg } from './commit-naming.js'; +import { readRestoreEntry, writeRestoreEntry } from './restore-store.js'; +import { classifyDiff, parseDiffStats } from './classification.js'; +import { + getCompletedTurnsFor, + getTurnRestorePlan, + getRollbackToTurnPlan, + type RestorePlan, +} from './restore-planning.js'; +import { emptyRollbackResult, executeRollback } from './rollback-engine.js'; // ---- Exported types ---- @@ -63,57 +73,7 @@ export interface CodeRestoreEntry { timestamp: string; } -// ---- Module-level helpers ---- - -function emptyRollbackResult(turnId: number, baseTurnId: number | null = null): CodeRollbackResult { - return { - reverted: false, - throughTurnId: turnId, - baseTurnId, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; -} - -function shortSid(sessionId: string): string { - return createHash('sha256').update(sessionId).digest('hex').slice(0, 8); -} - -function commitMsg(sessionId: string, turnId: number, suffix: string): string { - return `turn-${shortSid(sessionId)}-${turnId}-${suffix}`; -} - -function restorePath(gitDir: string, sessionId: string): string { - return join(dirname(gitDir), `last-restore-${shortSid(sessionId)}.json`); -} - -function readRestoreEntry(gitDir: string, sessionId: string): CodeRestoreEntry | null { - const path = restorePath(gitDir, sessionId); - if (!existsSync(path)) return null; - try { - return JSON.parse(readFileSync(path, 'utf8')) as CodeRestoreEntry; - } catch { - return null; - } -} - -function writeRestoreEntry( - gitDir: string, - sessionId: string, - entry: CodeRestoreEntry | null -): void { - const path = restorePath(gitDir, sessionId); - if (!entry) { - try { - unlinkSync(path); - } catch { - /* ignore */ - } - } else { - writeFileSync(path, JSON.stringify(entry, null, 2), 'utf8'); - } -} +// ---- Path utilities ---- export function toGitPath(projectPath: string, file: string): string { const normalized = normalizePath(file); @@ -135,169 +95,6 @@ export function hashWorkspaceFile(projectPath: string, file: string): string | n } } -function classifySafe( - projectPath: string, - sessionId: string, - turnId: number, - sg: ShadowGit, - ledgerInstance: Ledger -): { agentModified: string[]; unknownSource: string[] } | null { - const baseline = sg.findCommitByMessage(commitMsg(sessionId, turnId, 'baseline')); - const final = sg.findCommitByMessage(commitMsg(sessionId, turnId, 'final')); - if (!baseline || !final) return null; - - const allChanges = sg.diffFiles(baseline, final); - const rawAllFiles = allChanges.map((c) => normalizePath(resolve(projectPath, c.file))); - const allFiles = [...new Set(rawAllFiles)]; - const agentFiles = new Set( - ledgerInstance.getAgentFiles(turnId, sessionId).map((p) => normalizePath(p).toLowerCase()) - ); - - return { - agentModified: allFiles.filter((f) => agentFiles.has(f.toLowerCase())), - unknownSource: allFiles.filter((f) => !agentFiles.has(f.toLowerCase())), - }; -} - -/** Parse insertions/deletions from a unified diff string. */ -function parseDiffStats(diffText: string): { insertions: number; deletions: number } { - let insertions = 0; - let deletions = 0; - for (const line of diffText.split('\n')) { - if (line.startsWith('+') && !line.startsWith('+++')) insertions++; - else if (line.startsWith('-') && !line.startsWith('---')) deletions++; - } - return { insertions, deletions }; -} - -function getCompletedTurnsFor(sg: ShadowGit, sessionId: string): number[] { - const short = shortSid(sessionId); - const result = sg.git('log', '--all', '--format=%s'); - const ids = new Set(); - const re = new RegExp(`^turn-${short}-(\\d+)-final$`); - for (const line of result.stdout.trim().split('\n')) { - const m = line.match(re); - if (m) ids.add(Number(m[1])); - } - return [...ids].sort((a, b) => a - b); -} - -interface RestorePlan { - throughTurnId: number; - baseTurnId: number; - affectedTurns: number[]; - baseline: string; -} - -function getTurnRestorePlan(sg: ShadowGit, sessionId: string, turnId: number): RestorePlan | null { - const baseline = sg.findCommitByMessage(commitMsg(sessionId, turnId, 'baseline')); - const final = sg.findCommitByMessage(commitMsg(sessionId, turnId, 'final')); - if (!baseline || !final) return null; - return { - throughTurnId: turnId, - baseTurnId: turnId, - affectedTurns: [], - baseline, - }; -} - -function getRollbackToTurnPlan( - sg: ShadowGit, - sessionId: string, - throughTurnId: number -): RestorePlan | null { - const completedTurns = getCompletedTurnsFor(sg, sessionId); - const affectedTurns = completedTurns.filter((id) => id >= throughTurnId); - if (affectedTurns.length === 0) return null; - - const baseline = sg.findCommitByMessage(commitMsg(sessionId, throughTurnId, 'baseline')); - if (!baseline) return null; - - return { - throughTurnId, - baseTurnId: throughTurnId, - affectedTurns, - baseline, - }; -} - -function revertFilesImpl( - projectPath: string, - sessionId: string, - plan: RestorePlan, - selectedFiles: string[], - action: CodeRestoreEntry['action'], - sg: ShadowGit -): CodeRollbackResult { - if (selectedFiles.length === 0) { - return { - reverted: false, - throughTurnId: plan.throughTurnId, - baseTurnId: plan.baseTurnId, - affectedTurns: plan.affectedTurns, - selectedFiles: [], - restoreEntry: null, - }; - } - - sg.lock(); - try { - let safetyCommit: string; - const existingEntry = readRestoreEntry(sg.gitDir, sessionId); - - if ( - existingEntry && - existingEntry.throughTurnId === plan.throughTurnId && - existingEntry.safetyCommit - ) { - safetyCommit = existingEntry.safetyCommit; - } else { - safetyCommit = sg.commit(commitMsg(sessionId, plan.throughTurnId, 'revert-safety')); - } - - sg.checkoutFiles(plan.baseline, selectedFiles); - - const combinedFiles = - existingEntry && existingEntry.throughTurnId === plan.throughTurnId - ? [ - ...new Map( - [...existingEntry.selectedFiles, ...selectedFiles].map((f) => [ - normalizePath(f).toLowerCase(), - f, - ]) - ).values(), - ] - : selectedFiles; - - const entry: CodeRestoreEntry = { - id: createHash('sha256') - .update(`${sessionId}-${plan.throughTurnId}-${Date.now()}`) - .digest('hex') - .slice(0, 12), - sessionId, - action, - throughTurnId: plan.throughTurnId, - baseTurnId: plan.baseTurnId, - affectedTurns: plan.affectedTurns, - selectedFiles: combinedFiles, - safetyCommit, - timestamp: new Date().toISOString(), - }; - writeRestoreEntry(sg.gitDir, sessionId, entry); - - return { - reverted: true, - throughTurnId: plan.throughTurnId, - baseTurnId: plan.baseTurnId, - affectedTurns: plan.affectedTurns, - selectedFiles: combinedFiles, - restoreEntry: entry, - }; - } finally { - sg.unlock(); - } -} - // ---- Service ---- export class CheckpointService extends Effect.Service()('Checkpoint', { @@ -331,7 +128,7 @@ export class CheckpointService extends Effect.Service()('Chec } return { - // ---- Snapshot methods (unchanged) ---- + // ---- Snapshot ---- snapshotBaseline: ( projectPath: string, @@ -361,6 +158,8 @@ export class CheckpointService extends Effect.Service()('Chec } }, + // ---- Classification ---- + classifyChanges: ( projectPath: string, sessionId: string, @@ -368,9 +167,11 @@ export class CheckpointService extends Effect.Service()('Chec ): { agentModified: string[]; unknownSource: string[] } | null => { const sg = ensure(projectPath); const l = ledger(sg); - return classifySafe(projectPath, sessionId, turnId, sg, l); + return classifyDiff(projectPath, sessionId, turnId, sg, l); }, + // ---- Query ---- + getCompletedTurns: (projectPath: string, sessionId: string): number[] => { const sg = ensure(projectPath); return getCompletedTurnsFor(sg, sessionId); @@ -386,28 +187,18 @@ export class CheckpointService extends Effect.Service()('Chec unknownSource: string[]; }> => { const sg = ensure(projectPath); + const l = ledger(sg); const prefix = `turn-${shortSid(sessionId)}-`; - const result: Array<{ - turnId: number; - title: string; - agentModified: string[]; - unknownSource: string[]; - }> = []; - for (let i = 1; i <= 10000; i++) { + const completedTurns = getCompletedTurnsFor(sg, sessionId); + const result: ReturnType = []; + + for (const i of completedTurns) { const bCommit = sg.findCommitByMessage(`${prefix}${i}-baseline`); + if (!bCommit) continue; const fCommit = sg.findCommitByMessage(`${prefix}${i}-final`); - if (!bCommit || !fCommit) { - if (result.length === 0 && i === 1) continue; - else break; - } - const msgResult = sg.git( - 'log', - '--all', - '--grep', - `${prefix}${i}-baseline`, - '--format=%s', - '-1' - ); + if (!fCommit) continue; + + const msgResult = sg.git('log', '--all', '--grep', `${prefix}${i}-baseline`, '--format=%s', '-1'); const fullMsg = msgResult.stdout.trim(); const title = fullMsg.includes(' ') ? fullMsg.split(' ').slice(1).join(' ') : ''; @@ -415,9 +206,7 @@ export class CheckpointService extends Effect.Service()('Chec const rawAllFiles = allChanges.map((c) => normalizePath(resolve(projectPath, c.file))); const allFiles = [...new Set(rawAllFiles)]; const agentFiles = new Set( - ledger(sg) - .getAgentFiles(i, sessionId) - .map((p) => normalizePath(p).toLowerCase()) + l.getAgentFiles(i, sessionId).map((p) => normalizePath(p).toLowerCase()) ); result.push({ @@ -430,8 +219,6 @@ export class CheckpointService extends Effect.Service()('Chec return result; }, - // ---- B1: getCheckpointDiff ---- - getCheckpointDiff: ( projectPath: string, sessionId: string, @@ -481,7 +268,7 @@ export class CheckpointService extends Effect.Service()('Chec return { turnId: latestTurnId, files }; }, - // ---- B2: revertCheckpointFile / revertCheckpointFiles ---- + // ---- Revert ---- revertCheckpointFile: ( projectPath: string, @@ -494,7 +281,8 @@ export class CheckpointService extends Effect.Service()('Chec if (!plan) { return emptyRollbackResult(turnId, turnId); } - return revertFilesImpl(projectPath, sessionId, plan, [file], 'checkpoint-file', sg); + return executeRollback(projectPath, sessionId, plan, [file], 'checkpoint-file', sg); + }, revertCheckpointFiles: ( projectPath: string, @@ -507,11 +295,9 @@ export class CheckpointService extends Effect.Service()('Chec if (!plan) { return emptyRollbackResult(turnId, turnId); } - return revertFilesImpl(projectPath, sessionId, plan, files, 'checkpoint-files', sg); + return executeRollback(projectPath, sessionId, plan, files, 'checkpoint-files', sg); }, - // ---- B3: revertCheckpointAgentFiles / revertCheckpointAllFiles ---- - revertCheckpointAgentFiles: ( projectPath: string, sessionId: string, @@ -519,7 +305,7 @@ export class CheckpointService extends Effect.Service()('Chec ): CodeRollbackResult => { const sg = ensure(projectPath); const l = ledger(sg); - const changes = classifySafe(projectPath, sessionId, turnId, sg, l); + const changes = classifyDiff(projectPath, sessionId, turnId, sg, l); if (!changes) { return emptyRollbackResult(turnId); } @@ -530,14 +316,7 @@ export class CheckpointService extends Effect.Service()('Chec if (!plan) { return emptyRollbackResult(turnId); } - return revertFilesImpl( - projectPath, - sessionId, - plan, - changes.agentModified, - 'checkpoint-agent', - sg - ); + return executeRollback(projectPath, sessionId, plan, changes.agentModified, 'checkpoint-agent', sg); }, revertCheckpointAllFiles: ( @@ -547,7 +326,7 @@ export class CheckpointService extends Effect.Service()('Chec ): CodeRollbackResult => { const sg = ensure(projectPath); const l = ledger(sg); - const changes = classifySafe(projectPath, sessionId, turnId, sg, l); + const changes = classifyDiff(projectPath, sessionId, turnId, sg, l); if (!changes) { return emptyRollbackResult(turnId); } @@ -559,10 +338,10 @@ export class CheckpointService extends Effect.Service()('Chec if (!plan) { return emptyRollbackResult(turnId); } - return revertFilesImpl(projectPath, sessionId, plan, all, 'checkpoint-all', sg); + return executeRollback(projectPath, sessionId, plan, all, 'checkpoint-all', sg); }, - // ---- B4: previewRollbackDiff ---- + // ---- Rollback ---- previewRollbackDiff: ( projectPath: string, @@ -584,8 +363,6 @@ export class CheckpointService extends Effect.Service()('Chec }; }, - // ---- B5: rollbackCodeToTurn ---- - rollbackCodeToTurn: ( projectPath: string, sessionId: string, @@ -615,11 +392,9 @@ export class CheckpointService extends Effect.Service()('Chec }; } - return revertFilesImpl(projectPath, sessionId, plan, selectedFiles, 'rollback-to-turn', sg); + return executeRollback(projectPath, sessionId, plan, selectedFiles, 'rollback-to-turn', sg); }, - // ---- B6: undoLastCodeRollback ---- - undoLastCodeRollback: ( projectPath: string, sessionId: string, @@ -657,8 +432,6 @@ export class CheckpointService extends Effect.Service()('Chec }; } - // Conflict detection: compare current workspace files against baseline commit. - // After revert, files should equal baseline. Any divergence = conflict. const baselineCommit = sg.findCommitByMessage( commitMsg(sessionId, entry.baseTurnId, 'baseline') ); @@ -718,8 +491,6 @@ export class CheckpointService extends Effect.Service()('Chec } }, - // ---- getLatestRestoreEntry ---- - getLatestRestoreEntry: (projectPath: string, sessionId: string): CodeRestoreEntry | null => { const sg = ensure(projectPath); return readRestoreEntry(sg.gitDir, sessionId); diff --git a/packages/codingcode/src/checkpoint/classification.ts b/packages/codingcode/src/checkpoint/classification.ts new file mode 100644 index 0000000..8923cb0 --- /dev/null +++ b/packages/codingcode/src/checkpoint/classification.ts @@ -0,0 +1,39 @@ +import { resolve } from 'path'; +import { normalizePath } from '../core/path.js'; +import type { ShadowGit } from './shadow-git.js'; +import type { Ledger } from './ledger.js'; +import { commitMsg } from './commit-naming.js'; + +export function classifyDiff( + projectPath: string, + sessionId: string, + turnId: number, + sg: ShadowGit, + ledgerInstance: Ledger +): { agentModified: string[]; unknownSource: string[] } | null { + const baseline = sg.findCommitByMessage(commitMsg(sessionId, turnId, 'baseline')); + const final = sg.findCommitByMessage(commitMsg(sessionId, turnId, 'final')); + if (!baseline || !final) return null; + + const allChanges = sg.diffFiles(baseline, final); + const rawAllFiles = allChanges.map((c) => normalizePath(resolve(projectPath, c.file))); + const allFiles = [...new Set(rawAllFiles)]; + const agentFiles = new Set( + ledgerInstance.getAgentFiles(turnId, sessionId).map((p) => normalizePath(p).toLowerCase()) + ); + + return { + agentModified: allFiles.filter((f) => agentFiles.has(f.toLowerCase())), + unknownSource: allFiles.filter((f) => !agentFiles.has(f.toLowerCase())), + }; +} + +export function parseDiffStats(diffText: string): { insertions: number; deletions: number } { + let insertions = 0; + let deletions = 0; + for (const line of diffText.split('\n')) { + if (line.startsWith('+') && !line.startsWith('+++')) insertions++; + else if (line.startsWith('-') && !line.startsWith('---')) deletions++; + } + return { insertions, deletions }; +} diff --git a/packages/codingcode/src/checkpoint/commit-naming.ts b/packages/codingcode/src/checkpoint/commit-naming.ts new file mode 100644 index 0000000..06101f1 --- /dev/null +++ b/packages/codingcode/src/checkpoint/commit-naming.ts @@ -0,0 +1,9 @@ +import { createHash } from 'crypto'; + +export function shortSid(sessionId: string): string { + return createHash('sha256').update(sessionId).digest('hex').slice(0, 8); +} + +export function commitMsg(sessionId: string, turnId: number, suffix: string): string { + return `turn-${shortSid(sessionId)}-${turnId}-${suffix}`; +} diff --git a/packages/codingcode/src/checkpoint/bootstrap.ts b/packages/codingcode/src/checkpoint/hook-recorder.ts similarity index 97% rename from packages/codingcode/src/checkpoint/bootstrap.ts rename to packages/codingcode/src/checkpoint/hook-recorder.ts index b189817..d6f047f 100644 --- a/packages/codingcode/src/checkpoint/bootstrap.ts +++ b/packages/codingcode/src/checkpoint/hook-recorder.ts @@ -26,7 +26,6 @@ function getLedger(projectPath: string): Ledger { * Idempotent — safe to call multiple times with the same HookService. */ export function registerCheckpointHooks(hooks: HookService): void { - // Pre-execution: record file hash before modification Effect.runSync( hooks.register( 'tool.execute.before', @@ -49,7 +48,6 @@ export function registerCheckpointHooks(hooks: HookService): void { ) ); - // Post-execution: record the full entry Effect.runSync( hooks.register( 'tool.execute.after', diff --git a/packages/codingcode/src/checkpoint/restore-planning.ts b/packages/codingcode/src/checkpoint/restore-planning.ts new file mode 100644 index 0000000..cd28c0f --- /dev/null +++ b/packages/codingcode/src/checkpoint/restore-planning.ts @@ -0,0 +1,57 @@ +import type { ShadowGit } from './shadow-git.js'; +import { shortSid, commitMsg } from './commit-naming.js'; + +export interface RestorePlan { + throughTurnId: number; + baseTurnId: number; + affectedTurns: number[]; + baseline: string; +} + +export function getCompletedTurnsFor(sg: ShadowGit, sessionId: string): number[] { + const short = shortSid(sessionId); + const result = sg.git('log', '--all', '--format=%s'); + const ids = new Set(); + const re = new RegExp(`^turn-${short}-(\\d+)-final$`); + for (const line of result.stdout.trim().split('\n')) { + const m = line.match(re); + if (m) ids.add(Number(m[1])); + } + return [...ids].sort((a, b) => a - b); +} + +export function getTurnRestorePlan( + sg: ShadowGit, + sessionId: string, + turnId: number +): RestorePlan | null { + const baseline = sg.findCommitByMessage(commitMsg(sessionId, turnId, 'baseline')); + const final = sg.findCommitByMessage(commitMsg(sessionId, turnId, 'final')); + if (!baseline || !final) return null; + return { + throughTurnId: turnId, + baseTurnId: turnId, + affectedTurns: [], + baseline, + }; +} + +export function getRollbackToTurnPlan( + sg: ShadowGit, + sessionId: string, + throughTurnId: number +): RestorePlan | null { + const completedTurns = getCompletedTurnsFor(sg, sessionId); + const affectedTurns = completedTurns.filter((id) => id >= throughTurnId); + if (affectedTurns.length === 0) return null; + + const baseline = sg.findCommitByMessage(commitMsg(sessionId, throughTurnId, 'baseline')); + if (!baseline) return null; + + return { + throughTurnId, + baseTurnId: throughTurnId, + affectedTurns, + baseline, + }; +} diff --git a/packages/codingcode/src/checkpoint/restore-store.ts b/packages/codingcode/src/checkpoint/restore-store.ts new file mode 100644 index 0000000..ec14674 --- /dev/null +++ b/packages/codingcode/src/checkpoint/restore-store.ts @@ -0,0 +1,35 @@ +import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs'; +import { join } from 'path'; +import type { CodeRestoreEntry } from './checkpoint-service.js'; +import { shortSid } from './commit-naming.js'; + +function restorePath(gitDir: string, sessionId: string): string { + return join(gitDir, '..', `last-restore-${shortSid(sessionId)}.json`); +} + +export function readRestoreEntry(gitDir: string, sessionId: string): CodeRestoreEntry | null { + const path = restorePath(gitDir, sessionId); + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, 'utf8')) as CodeRestoreEntry; + } catch { + return null; + } +} + +export function writeRestoreEntry( + gitDir: string, + sessionId: string, + entry: CodeRestoreEntry | null +): void { + const path = restorePath(gitDir, sessionId); + if (!entry) { + try { + unlinkSync(path); + } catch { + /* ignore */ + } + } else { + writeFileSync(path, JSON.stringify(entry, null, 2), 'utf8'); + } +} diff --git a/packages/codingcode/src/checkpoint/rollback-engine.ts b/packages/codingcode/src/checkpoint/rollback-engine.ts new file mode 100644 index 0000000..a8be030 --- /dev/null +++ b/packages/codingcode/src/checkpoint/rollback-engine.ts @@ -0,0 +1,97 @@ +import { createHash } from 'crypto'; +import { normalizePath } from '../core/path.js'; +import type { ShadowGit } from './shadow-git.js'; +import type { CodeRollbackResult, CodeRestoreEntry } from './checkpoint-service.js'; +import { commitMsg } from './commit-naming.js'; +import { readRestoreEntry, writeRestoreEntry } from './restore-store.js'; + +export function emptyRollbackResult( + turnId: number, + baseTurnId: number | null = null +): CodeRollbackResult { + return { + reverted: false, + throughTurnId: turnId, + baseTurnId, + affectedTurns: [], + selectedFiles: [], + restoreEntry: null, + }; +} + +export function executeRollback( + projectPath: string, + sessionId: string, + plan: { throughTurnId: number; baseTurnId: number; affectedTurns: number[]; baseline: string }, + selectedFiles: string[], + action: CodeRestoreEntry['action'], + sg: ShadowGit +): CodeRollbackResult { + if (selectedFiles.length === 0) { + return { + reverted: false, + throughTurnId: plan.throughTurnId, + baseTurnId: plan.baseTurnId, + affectedTurns: plan.affectedTurns, + selectedFiles: [], + restoreEntry: null, + }; + } + + sg.lock(); + try { + let safetyCommit: string; + const existingEntry = readRestoreEntry(sg.gitDir, sessionId); + + if ( + existingEntry && + existingEntry.throughTurnId === plan.throughTurnId && + existingEntry.safetyCommit + ) { + safetyCommit = existingEntry.safetyCommit; + } else { + safetyCommit = sg.commit(commitMsg(sessionId, plan.throughTurnId, 'revert-safety')); + } + + sg.checkoutFiles(plan.baseline, selectedFiles); + + const combinedFiles = + existingEntry && existingEntry.throughTurnId === plan.throughTurnId + ? [ + ...new Map( + [...existingEntry.selectedFiles, ...selectedFiles].map((f) => [ + normalizePath(f).toLowerCase(), + f, + ]) + ).values(), + ] + : selectedFiles; + + const entry: CodeRestoreEntry = { + id: createHash('sha256') + .update(`${sessionId}-${plan.throughTurnId}-${Date.now()}`) + .digest('hex') + .slice(0, 12), + sessionId, + action, + throughTurnId: plan.throughTurnId, + baseTurnId: plan.baseTurnId, + affectedTurns: plan.affectedTurns, + selectedFiles: combinedFiles, + safetyCommit, + timestamp: new Date().toISOString(), + }; + writeRestoreEntry(sg.gitDir, sessionId, entry); + + return { + reverted: true, + throughTurnId: plan.throughTurnId, + baseTurnId: plan.baseTurnId, + affectedTurns: plan.affectedTurns, + selectedFiles: combinedFiles, + restoreEntry: entry, + }; + } finally { + sg.unlock(); + } +} diff --git a/packages/codingcode/src/cli.ts b/packages/codingcode/src/cli.ts index e7e56f9..4c7fdb3 100644 --- a/packages/codingcode/src/cli.ts +++ b/packages/codingcode/src/cli.ts @@ -13,7 +13,7 @@ async function main() { const { workspaceCwd, args } = parseWorkspaceArgs(process.argv.slice(2)); ensureUserConfig(); const config = loadConfig(); - initWorkspace({ installRoot, workspaceCwd, config }); + initWorkspace({ processRoot: installRoot, workspaceCwd, config }); if (workspaceCwd) { console.log(`Workspace: ${getWorkspaceCwd()}`); } diff --git a/packages/codingcode/src/llm/factory.ts b/packages/codingcode/src/llm/factory.ts index 6ea9966..2629152 100644 --- a/packages/codingcode/src/llm/factory.ts +++ b/packages/codingcode/src/llm/factory.ts @@ -36,7 +36,6 @@ export interface SelectableModel { base_url: string; api_key_env: string; context_window: number; - streamSupportsTools?: boolean; } function modelsFile(): string { diff --git a/packages/codingcode/src/llm/providers/deepseek.ts b/packages/codingcode/src/llm/providers/deepseek.ts index 97a3a1f..280df14 100644 --- a/packages/codingcode/src/llm/providers/deepseek.ts +++ b/packages/codingcode/src/llm/providers/deepseek.ts @@ -64,10 +64,6 @@ export class DeepSeekProvider implements LLMClient { for await (const part of result.fullStream) { if (part.type === 'text-delta') { yield part.text; - } else if (part.type === 'tool-call') { - yield `\n[Using: ${part.toolName}]\n`; - } else if (part.type === 'error') { - yield `\n[Error: ${String(part.error)}]\n`; } } })(); diff --git a/packages/codingcode/src/llm/providers/openai.ts b/packages/codingcode/src/llm/providers/openai.ts index 9425295..961b62a 100644 --- a/packages/codingcode/src/llm/providers/openai.ts +++ b/packages/codingcode/src/llm/providers/openai.ts @@ -51,7 +51,7 @@ export class OpenAIProvider implements LLMClient { } completeStream(req: LLMRequest, signal?: AbortSignal): import('../client.js').StreamResult { - if (this.entry.streamSupportsTools === false && req.tools && req.tools.length > 0) { + if (this.entry.provider === 'sansen' && req.tools && req.tools.length > 0) { const response = this.complete(req, signal); const stream = (async function* () { const result = await response; @@ -76,10 +76,6 @@ export class OpenAIProvider implements LLMClient { for await (const part of result.fullStream) { if (part.type === 'text-delta') { yield part.text; - } else if (part.type === 'tool-call') { - yield `\n[Using: ${part.toolName}]\n`; - } else if (part.type === 'error') { - yield `\n[Error: ${String(part.error)}]\n`; } } })(); diff --git a/packages/codingcode/test/agent/stop-hook.test.ts b/packages/codingcode/test/agent/stop-hook.test.ts index 0870d52..a57a9f5 100644 --- a/packages/codingcode/test/agent/stop-hook.test.ts +++ b/packages/codingcode/test/agent/stop-hook.test.ts @@ -134,7 +134,7 @@ describe('runReActLoop 鈥?stop hook', () => { const errorEvent = events.find((e: any) => e._tag === 'Error'); expect(errorEvent).toBeDefined(); - expect((errorEvent as any)?.error?.code).toBe('STOP_LOOP'); + expect((errorEvent as any)?.error?.code).toBe('AGENT_LOOP_DETECTED'); }); it('should use default maxStopContinuations of 2', async () => { diff --git a/packages/codingcode/test/checkpoint/checkpoint-project-path.test.ts b/packages/codingcode/test/checkpoint/checkpoint-project-path.test.ts index 1d2f3db..2facd58 100644 --- a/packages/codingcode/test/checkpoint/checkpoint-project-path.test.ts +++ b/packages/codingcode/test/checkpoint/checkpoint-project-path.test.ts @@ -73,7 +73,7 @@ function run(eff: Effect.Effect): Promise { return Effect.runPromise(eff.pipe(Effect.provide(testLayer) as any)); } -describe('checkpoint/bootstrap projectPath isolation', () => { +describe('checkpoint/hook-recorder projectPath isolation', () => { let globalDir: string; let projectDir: string; @@ -113,9 +113,9 @@ describe('checkpoint/bootstrap projectPath isolation', () => { ); } - it('bootstrap checkpoint records correct file path via payload.projectPath', async () => { + it('hook-recorder records correct file path via payload.projectPath', async () => { const hooks = await getHooks(); - const { registerCheckpointHooks } = await import('../../src/checkpoint/bootstrap.js'); + const { registerCheckpointHooks } = await import('../../src/checkpoint/hook-recorder.js'); registerCheckpointHooks(hooks); writeFileSync(join(projectDir, 'c.txt'), 'initial', 'utf8'); diff --git a/packages/codingcode/test/context/context.test.ts b/packages/codingcode/test/context/context.test.ts index 5505254..2befb1e 100644 --- a/packages/codingcode/test/context/context.test.ts +++ b/packages/codingcode/test/context/context.test.ts @@ -165,7 +165,7 @@ describe('ContextService', () => { }) ); - const { HookLayer } = await import('../../src/layer.js'); + const { HookLayer, SubagentRegistryLayer } = await import('../../src/layer.js'); const MockToolSearchLayer = Layer.succeed( ToolSearchService, @@ -207,7 +207,7 @@ describe('ContextService', () => { ); const projectRuntimeLayer = ProjectRuntimeService.Default.pipe( - Layer.provide(Layer.mergeAll(HookLayer, MockMcpLayer)) + Layer.provide(Layer.mergeAll(HookLayer, MockMcpLayer, SubagentRegistryLayer)) ); const agentWithDeps = AgentService.Default.pipe( Layer.provide(Layer.mergeAll(AllDeps, projectRuntimeLayer)) diff --git a/packages/codingcode/test/llm/openai-provider.test.ts b/packages/codingcode/test/llm/openai-provider.test.ts index f5f723f..3771577 100644 --- a/packages/codingcode/test/llm/openai-provider.test.ts +++ b/packages/codingcode/test/llm/openai-provider.test.ts @@ -20,7 +20,7 @@ async function collect(stream: AsyncIterable): Promise { return chunks; } -function entry(provider: string, opts?: { streamSupportsTools?: boolean }) { +function entry(provider: string) { return { id: `model@${provider}`, provider, @@ -30,7 +30,6 @@ function entry(provider: string, opts?: { streamSupportsTools?: boolean }) { base_url: 'https://example.com/v1', api_key_env: 'API_KEY', context_window: 128000, - streamSupportsTools: opts?.streamSupportsTools, }; } @@ -66,7 +65,7 @@ describe('OpenAIProvider completeStream', () => { it('uses non-streaming completion for sansen requests with tools', async () => { const { OpenAIProvider } = await import('../../src/llm/providers/openai.js'); - const provider = new OpenAIProvider({} as any, entry('sansen', { streamSupportsTools: false })); + const provider = new OpenAIProvider({} as any, entry('sansen')); const result = provider.completeStream(request(true) as any); await expect(result.response).resolves.toMatchObject({ ok: true, value: { content: 'done' } }); @@ -78,7 +77,7 @@ describe('OpenAIProvider completeStream', () => { it('keeps streaming for sansen requests without tools', async () => { const { OpenAIProvider } = await import('../../src/llm/providers/openai.js'); - const provider = new OpenAIProvider({} as any, entry('sansen', { streamSupportsTools: false })); + const provider = new OpenAIProvider({} as any, entry('sansen')); const result = provider.completeStream(request(false) as any); await expect(collect(result.stream)).resolves.toEqual(['streamed']); diff --git a/packages/codingcode/test/orchestrate.test.ts b/packages/codingcode/test/orchestrate.test.ts index 11c615c..c084000 100644 --- a/packages/codingcode/test/orchestrate.test.ts +++ b/packages/codingcode/test/orchestrate.test.ts @@ -201,8 +201,9 @@ const MockMcpLayer = Layer.succeed(McpService, { } as any); const { ProjectRuntimeService } = await import('../src/runtime/project-runtime.js'); +const { SubagentRegistryLayer } = await import('../src/layer.js'); const MockProjectRuntimeLayer = ProjectRuntimeService.Default.pipe( - Layer.provide(Layer.mergeAll(HookLayer, MockMcpLayer)) + Layer.provide(Layer.mergeAll(HookLayer, MockMcpLayer, SubagentRegistryLayer)) ); const MockToolSearchLayer = Layer.succeed( diff --git a/packages/codingcode/test/server/handler.test.ts b/packages/codingcode/test/server/handler.test.ts index 067d21b..5361b3c 100644 --- a/packages/codingcode/test/server/handler.test.ts +++ b/packages/codingcode/test/server/handler.test.ts @@ -186,7 +186,7 @@ const MockSkillLayer = Layer.succeed( ); const { AgentService } = await import('../../src/agent/agent.js'); -const { HookLayer } = await import('../../src/layer.js'); +const { HookLayer, SubagentRegistryLayer } = await import('../../src/layer.js'); const MockCheckpointLayer = Layer.succeed( CheckpointService, @@ -227,7 +227,7 @@ const MockMcpLayer = Layer.succeed(McpService, { const { ProjectRuntimeService } = await import('../../src/runtime/project-runtime.js'); const MockProjectRuntimeLayer = ProjectRuntimeService.Default.pipe( - Layer.provide(Layer.mergeAll(HookLayer, MockMcpLayer)) + Layer.provide(Layer.mergeAll(HookLayer, MockMcpLayer, SubagentRegistryLayer)) ); const { ApprovalWaitService } = await import('../../src/approval/async-confirm.js'); diff --git a/packages/desktop/electron/core/backend.ts b/packages/desktop/electron/core/backend.ts index 0993f2d..7dd2b74 100644 --- a/packages/desktop/electron/core/backend.ts +++ b/packages/desktop/electron/core/backend.ts @@ -14,6 +14,6 @@ export async function initBackend(): Promise { const { AppLayer } = await import('@codingcode/core/layer'); ensureUserConfig(); const config = loadConfig(); - initWorkspace({ installRoot: getInstallRoot(), config }); + initWorkspace({ processRoot: getInstallRoot(), config }); _ready = true; } From c9b7ae2f078eee0a88e77941432700f0c212e778 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 11 Jun 2026 01:50:00 +0800 Subject: [PATCH 03/11] move repoLock logic from shadowgit --- .../src/checkpoint/checkpoint-service.ts | 46 ++++++++--- .../codingcode/src/checkpoint/project-lock.ts | 41 ++++++++++ .../src/checkpoint/rollback-engine.ts | 8 +- .../codingcode/src/checkpoint/shadow-git.ts | 32 -------- packages/codingcode/src/server/util.ts | 7 +- .../test/checkpoint/project-lock.test.ts | 78 +++++++++++++++++++ packages/codingcode/test/server/util.test.ts | 10 ++- 7 files changed, 173 insertions(+), 49 deletions(-) create mode 100644 packages/codingcode/src/checkpoint/project-lock.ts create mode 100644 packages/codingcode/test/checkpoint/project-lock.test.ts diff --git a/packages/codingcode/src/checkpoint/checkpoint-service.ts b/packages/codingcode/src/checkpoint/checkpoint-service.ts index 2384895..c8ccc07 100644 --- a/packages/codingcode/src/checkpoint/checkpoint-service.ts +++ b/packages/codingcode/src/checkpoint/checkpoint-service.ts @@ -3,6 +3,7 @@ import { createHash } from 'crypto'; import { readFileSync } from 'fs'; import { resolve, dirname } from 'path'; import { ShadowGit } from './shadow-git.js'; +import { ProjectLock } from './project-lock.js'; import { normalizePath } from '../core/path.js'; import { Ledger } from './ledger.js'; import { registerCheckpointHooks } from './hook-recorder.js'; @@ -103,6 +104,7 @@ export class CheckpointService extends Effect.Service()('Chec registerCheckpointHooks(hooks); const shadowGitByProject = new Map(); + const lockByProject = new Map(); const ledgerByProject = new Map(); function ensure(projectPath: string): ShadowGit { @@ -116,6 +118,16 @@ export class CheckpointService extends Effect.Service()('Chec return sg; } + function lockFor(projectPath: string): ProjectLock { + const normalized = normalizePath(projectPath); + let lock = lockByProject.get(normalized); + if (!lock) { + lock = new ProjectLock(normalized); + lockByProject.set(normalized, lock); + } + return lock; + } + function ledger(sg: ShadowGit): Ledger { const key = sg.gitDir; let entry = ledgerByProject.get(key); @@ -137,24 +149,28 @@ export class CheckpointService extends Effect.Service()('Chec title?: string ): void => { const sg = ensure(projectPath); + if (sg.isTooLargeForSnapshot()) return; + const lock = lockFor(projectPath); const msg = title ? `${commitMsg(sessionId, turnId, 'baseline')} ${title}` : commitMsg(sessionId, turnId, 'baseline'); - sg.lock(); + lock.lock(); try { sg.commit(msg); } finally { - sg.unlock(); + lock.unlock(); } }, snapshotFinal: (projectPath: string, sessionId: string, turnId: number): void => { const sg = ensure(projectPath); - sg.lock(); + if (sg.isTooLargeForSnapshot()) return; + const lock = lockFor(projectPath); + lock.lock(); try { sg.commit(commitMsg(sessionId, turnId, 'final')); } finally { - sg.unlock(); + lock.unlock(); } }, @@ -190,7 +206,12 @@ export class CheckpointService extends Effect.Service()('Chec const l = ledger(sg); const prefix = `turn-${shortSid(sessionId)}-`; const completedTurns = getCompletedTurnsFor(sg, sessionId); - const result: ReturnType = []; + const result: Array<{ + turnId: number; + title: string; + agentModified: string[]; + unknownSource: string[]; + }> = []; for (const i of completedTurns) { const bCommit = sg.findCommitByMessage(`${prefix}${i}-baseline`); @@ -281,7 +302,7 @@ export class CheckpointService extends Effect.Service()('Chec if (!plan) { return emptyRollbackResult(turnId, turnId); } - return executeRollback(projectPath, sessionId, plan, [file], 'checkpoint-file', sg); + return executeRollback(projectPath, sessionId, plan, [file], 'checkpoint-file', sg, lockFor(projectPath)); }, revertCheckpointFiles: ( @@ -295,7 +316,7 @@ export class CheckpointService extends Effect.Service()('Chec if (!plan) { return emptyRollbackResult(turnId, turnId); } - return executeRollback(projectPath, sessionId, plan, files, 'checkpoint-files', sg); + return executeRollback(projectPath, sessionId, plan, files, 'checkpoint-files', sg, lockFor(projectPath)); }, revertCheckpointAgentFiles: ( @@ -316,7 +337,7 @@ export class CheckpointService extends Effect.Service()('Chec if (!plan) { return emptyRollbackResult(turnId); } - return executeRollback(projectPath, sessionId, plan, changes.agentModified, 'checkpoint-agent', sg); + return executeRollback(projectPath, sessionId, plan, changes.agentModified, 'checkpoint-agent', sg, lockFor(projectPath)); }, revertCheckpointAllFiles: ( @@ -338,7 +359,7 @@ export class CheckpointService extends Effect.Service()('Chec if (!plan) { return emptyRollbackResult(turnId); } - return executeRollback(projectPath, sessionId, plan, all, 'checkpoint-all', sg); + return executeRollback(projectPath, sessionId, plan, all, 'checkpoint-all', sg, lockFor(projectPath)); }, // ---- Rollback ---- @@ -392,7 +413,7 @@ export class CheckpointService extends Effect.Service()('Chec }; } - return executeRollback(projectPath, sessionId, plan, selectedFiles, 'rollback-to-turn', sg); + return executeRollback(projectPath, sessionId, plan, selectedFiles, 'rollback-to-turn', sg, lockFor(projectPath)); }, undoLastCodeRollback: ( @@ -463,7 +484,8 @@ export class CheckpointService extends Effect.Service()('Chec }; } - sg.lock(); + const lock = lockFor(projectPath); + lock.lock(); try { sg.checkoutFiles(entry.safetyCommit, filesToRestore); @@ -487,7 +509,7 @@ export class CheckpointService extends Effect.Service()('Chec remainingRolledBack: remainingFiles, }; } finally { - sg.unlock(); + lock.unlock(); } }, diff --git a/packages/codingcode/src/checkpoint/project-lock.ts b/packages/codingcode/src/checkpoint/project-lock.ts new file mode 100644 index 0000000..2ded92f --- /dev/null +++ b/packages/codingcode/src/checkpoint/project-lock.ts @@ -0,0 +1,41 @@ +import { openSync, closeSync, unlinkSync, mkdirSync } from 'fs'; +import { join, dirname } from 'path'; +import { homedir } from 'os'; +import { normalizePath, encodeProjectPath } from '../core/path.js'; + +const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); + +export class ProjectLock { + private readonly lockPath: string; + private locked = false; + + constructor(projectPath: string) { + const encoded = encodeProjectPath(normalizePath(projectPath)); + this.lockPath = join(PROJECT_BASE, encoded, 'checkpoint', 'repo.lock'); + } + + lock(): void { + mkdirSync(dirname(this.lockPath), { recursive: true }); + for (let i = 0; ; i++) { + try { + const fd = openSync(this.lockPath, 'wx'); + closeSync(fd); + this.locked = true; + return; + } catch { + if (i > 500) throw new Error('ProjectLock timeout'); + } + } + } + + unlock(): void { + if (this.locked) { + try { + unlinkSync(this.lockPath); + } catch { + /* ignore */ + } + this.locked = false; + } + } +} diff --git a/packages/codingcode/src/checkpoint/rollback-engine.ts b/packages/codingcode/src/checkpoint/rollback-engine.ts index a8be030..ecf4f5c 100644 --- a/packages/codingcode/src/checkpoint/rollback-engine.ts +++ b/packages/codingcode/src/checkpoint/rollback-engine.ts @@ -1,6 +1,7 @@ import { createHash } from 'crypto'; import { normalizePath } from '../core/path.js'; import type { ShadowGit } from './shadow-git.js'; +import type { ProjectLock } from './project-lock.js'; import type { CodeRollbackResult, CodeRestoreEntry } from './checkpoint-service.js'; import { commitMsg } from './commit-naming.js'; import { readRestoreEntry, writeRestoreEntry } from './restore-store.js'; @@ -25,7 +26,8 @@ export function executeRollback( plan: { throughTurnId: number; baseTurnId: number; affectedTurns: number[]; baseline: string }, selectedFiles: string[], action: CodeRestoreEntry['action'], - sg: ShadowGit + sg: ShadowGit, + lock: ProjectLock ): CodeRollbackResult { if (selectedFiles.length === 0) { return { @@ -38,7 +40,7 @@ export function executeRollback( }; } - sg.lock(); + lock.lock(); try { let safetyCommit: string; const existingEntry = readRestoreEntry(sg.gitDir, sessionId); @@ -92,6 +94,6 @@ export function executeRollback( restoreEntry: entry, }; } finally { - sg.unlock(); + lock.unlock(); } } diff --git a/packages/codingcode/src/checkpoint/shadow-git.ts b/packages/codingcode/src/checkpoint/shadow-git.ts index 984387d..f5508bc 100644 --- a/packages/codingcode/src/checkpoint/shadow-git.ts +++ b/packages/codingcode/src/checkpoint/shadow-git.ts @@ -4,9 +4,6 @@ import { mkdirSync, statSync, writeFileSync, - unlinkSync, - openSync, - closeSync, } from 'fs'; import { homedir } from 'os'; import { join } from 'path'; @@ -30,21 +27,17 @@ const IGNORE_RULES = [ 'Thumbs.db', ]; -const FILE_COUNT_CAP = 10_000; const SIZE_CAP_MB = 1_024; export class ShadowGit { readonly gitDir: string; readonly projectPath: string; - private readonly lockPath: string; - private lockFd: number | null = null; constructor(projectPath: string) { // Normalize path so same dir always produces same encoding (forward slash + lowercase drive) this.projectPath = normalizePath(projectPath); const encoded = encodeProjectPath(this.projectPath); this.gitDir = join(PROJECT_BASE, encoded, 'checkpoint', 'repo.git'); - this.lockPath = join(PROJECT_BASE, encoded, 'checkpoint', 'repo.lock'); } init(): void { @@ -131,7 +124,6 @@ export class ShadowGit { isTooLargeForSnapshot(): boolean { const result = this.run('ls-files', '-m', '-o', '--exclude-standard'); const files = result.stdout.trim().split('\n').filter(Boolean); - if (files.length > FILE_COUNT_CAP) return true; let totalBytes = 0; for (const f of files) { try { @@ -144,30 +136,6 @@ export class ShadowGit { return false; } - // ---- Lock ---- - lock(): void { - for (let i = 0; ; i++) { - try { - this.lockFd = openSync(this.lockPath, 'wx'); - closeSync(this.lockFd); - return; - } catch { - if (i > 500) throw new Error('ShadowGit lock timeout'); - } - } - } - - unlock(): void { - if (this.lockFd !== null) { - try { - unlinkSync(this.lockPath); - } catch { - /* ignore */ - } - this.lockFd = null; - } - } - // ---- Private ---- private run(...args: string[]): { stdout: string; stderr: string; status: number | null } { const result = spawnSync( diff --git a/packages/codingcode/src/server/util.ts b/packages/codingcode/src/server/util.ts index 3afbfb2..149623e 100644 --- a/packages/codingcode/src/server/util.ts +++ b/packages/codingcode/src/server/util.ts @@ -22,6 +22,11 @@ export function errorResponse(err: unknown) { } return { status: 500, - body: { error: { code: 'INTERNAL_ERROR', message: 'Internal server error' } }, + body: { + error: { + code: 'INTERNAL_ERROR', + message: err instanceof Error ? err.message : 'Internal server error', + }, + }, }; } diff --git a/packages/codingcode/test/checkpoint/project-lock.test.ts b/packages/codingcode/test/checkpoint/project-lock.test.ts new file mode 100644 index 0000000..48ffb81 --- /dev/null +++ b/packages/codingcode/test/checkpoint/project-lock.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { existsSync, mkdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { ProjectLock } from '../../src/checkpoint/project-lock.js'; + +describe('ProjectLock', () => { + const dirs: string[] = []; + + function tempDir(): string { + const dir = join(tmpdir(), `pl-test-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + dirs.push(dir); + return dir; + } + + afterEach(() => { + for (const d of dirs.splice(0)) { + try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ } + } + }); + + it('creates lock for a given project path', () => { + const dir = tempDir(); + const lock = new ProjectLock(dir); + expect(lock).toBeDefined(); + }); + + it('acquires and releases lock', () => { + const dir = tempDir(); + const lock = new ProjectLock(dir); + lock.lock(); + lock.unlock(); + }); + + it('prevents concurrent lock acquisition', () => { + const dir = tempDir(); + const lock1 = new ProjectLock(dir); + lock1.lock(); + try { + const lock2 = new ProjectLock(dir); + expect(() => lock2.lock()).toThrow('ProjectLock timeout'); + } finally { + lock1.unlock(); + } + }); + + it('can reacquire lock after release', () => { + const dir = tempDir(); + const lock = new ProjectLock(dir); + lock.lock(); + lock.unlock(); + lock.lock(); + lock.unlock(); + }); + + it('unlock is idempotent', () => { + const dir = tempDir(); + const lock = new ProjectLock(dir); + lock.unlock(); + lock.unlock(); + lock.lock(); + lock.unlock(); + lock.unlock(); + }); + + it('same project path produces same lock file', () => { + const dir = tempDir(); + const lock1 = new ProjectLock(dir); + const lock2 = new ProjectLock(dir + '/'); // trailing slash + lock1.lock(); + try { + expect(() => lock2.lock()).toThrow('ProjectLock timeout'); + } finally { + lock1.unlock(); + } + }); +}); diff --git a/packages/codingcode/test/server/util.test.ts b/packages/codingcode/test/server/util.test.ts index 84174aa..ca9e92e 100644 --- a/packages/codingcode/test/server/util.test.ts +++ b/packages/codingcode/test/server/util.test.ts @@ -12,10 +12,18 @@ describe('server/util', () => { expect(resp.body.error.message).toBe('[SESSION_NOT_FOUND] Session "abc" not found'); }); - it('returns 500 for unknown errors', () => { + it('returns 500 for unknown errors with original message', () => { const resp = errorResponse(new Error('boom')); expect(resp.status).toBe(500); expect(resp.body.error.code).toBe('INTERNAL_ERROR'); + expect(resp.body.error.message).toBe('boom'); + }); + + it('returns 500 with fallback for non-Error throw', () => { + const resp = errorResponse('raw string'); + expect(resp.status).toBe(500); + expect(resp.body.error.code).toBe('INTERNAL_ERROR'); + expect(resp.body.error.message).toBe('Internal server error'); }); it('returns 400 for CONFIG_MISSING', () => { From 8ed4a26369df4c8e522d7e53191ac6b3ea6cce32 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 11 Jun 2026 11:54:24 +0800 Subject: [PATCH 04/11] delete rollback invalid baseturn and projectpath --- .../src/checkpoint/checkpoint-service.ts | 19 +++++++------------ .../src/checkpoint/restore-planning.ts | 3 --- .../src/checkpoint/rollback-engine.ts | 10 ++-------- packages/codingcode/src/client/direct.ts | 8 +------- .../codingcode/src/client/direct/sessions.ts | 8 +------- packages/codingcode/src/client/http.ts | 8 +------- .../codingcode/src/server/routes/sessions.ts | 4 ---- .../test/checkpoint/checkpoint-undo.test.ts | 5 ----- packages/desktop/src/lib/core-api.ts | 3 --- .../test/global-store-rollback-state.test.ts | 2 -- 10 files changed, 12 insertions(+), 58 deletions(-) diff --git a/packages/codingcode/src/checkpoint/checkpoint-service.ts b/packages/codingcode/src/checkpoint/checkpoint-service.ts index c8ccc07..3d86577 100644 --- a/packages/codingcode/src/checkpoint/checkpoint-service.ts +++ b/packages/codingcode/src/checkpoint/checkpoint-service.ts @@ -36,7 +36,6 @@ export interface CheckpointDiff { export interface CodeRollbackResult { reverted: boolean; throughTurnId: number; - baseTurnId: number | null; affectedTurns: number[]; selectedFiles: string[]; restoreEntry: CodeRestoreEntry | null; @@ -52,7 +51,6 @@ export interface CodeRollbackUndoResult { export interface RollbackPreviewDiff { throughTurnId: number; - baseTurnId: number | null; affectedTurns: number[]; diff: string; } @@ -67,7 +65,6 @@ export interface CodeRestoreEntry { | 'checkpoint-all' | 'rollback-to-turn'; throughTurnId: number; - baseTurnId: number; affectedTurns: number[]; selectedFiles: string[]; safetyCommit: string; @@ -302,7 +299,7 @@ export class CheckpointService extends Effect.Service()('Chec if (!plan) { return emptyRollbackResult(turnId, turnId); } - return executeRollback(projectPath, sessionId, plan, [file], 'checkpoint-file', sg, lockFor(projectPath)); + return executeRollback(sessionId, plan, [file], 'checkpoint-file', sg, lockFor(projectPath)); }, revertCheckpointFiles: ( @@ -316,7 +313,7 @@ export class CheckpointService extends Effect.Service()('Chec if (!plan) { return emptyRollbackResult(turnId, turnId); } - return executeRollback(projectPath, sessionId, plan, files, 'checkpoint-files', sg, lockFor(projectPath)); + return executeRollback(sessionId, plan, files, 'checkpoint-files', sg, lockFor(projectPath)); }, revertCheckpointAgentFiles: ( @@ -337,7 +334,7 @@ export class CheckpointService extends Effect.Service()('Chec if (!plan) { return emptyRollbackResult(turnId); } - return executeRollback(projectPath, sessionId, plan, changes.agentModified, 'checkpoint-agent', sg, lockFor(projectPath)); + return executeRollback(sessionId, plan, changes.agentModified, 'checkpoint-agent', sg, lockFor(projectPath)); }, revertCheckpointAllFiles: ( @@ -359,7 +356,7 @@ export class CheckpointService extends Effect.Service()('Chec if (!plan) { return emptyRollbackResult(turnId); } - return executeRollback(projectPath, sessionId, plan, all, 'checkpoint-all', sg, lockFor(projectPath)); + return executeRollback(sessionId, plan, all, 'checkpoint-all', sg, lockFor(projectPath)); }, // ---- Rollback ---- @@ -372,13 +369,12 @@ export class CheckpointService extends Effect.Service()('Chec const sg = ensure(projectPath); const plan = getRollbackToTurnPlan(sg, sessionId, throughTurnId); if (!plan) { - return { throughTurnId, baseTurnId: null, affectedTurns: [], diff: '' }; + return { throughTurnId, affectedTurns: [], diff: '' }; } const result = sg.git('diff', plan.baseline); return { throughTurnId, - baseTurnId: plan.baseTurnId, affectedTurns: plan.affectedTurns, diff: result.stdout, }; @@ -406,14 +402,13 @@ export class CheckpointService extends Effect.Service()('Chec return { reverted: true, throughTurnId, - baseTurnId: plan.baseTurnId, affectedTurns: plan.affectedTurns, selectedFiles: [], restoreEntry: null, }; } - return executeRollback(projectPath, sessionId, plan, selectedFiles, 'rollback-to-turn', sg, lockFor(projectPath)); + return executeRollback(sessionId, plan, selectedFiles, 'rollback-to-turn', sg, lockFor(projectPath)); }, undoLastCodeRollback: ( @@ -454,7 +449,7 @@ export class CheckpointService extends Effect.Service()('Chec } const baselineCommit = sg.findCommitByMessage( - commitMsg(sessionId, entry.baseTurnId, 'baseline') + commitMsg(sessionId, entry.throughTurnId, 'baseline') ); const conflictFiles: string[] = []; diff --git a/packages/codingcode/src/checkpoint/restore-planning.ts b/packages/codingcode/src/checkpoint/restore-planning.ts index cd28c0f..cfec93b 100644 --- a/packages/codingcode/src/checkpoint/restore-planning.ts +++ b/packages/codingcode/src/checkpoint/restore-planning.ts @@ -3,7 +3,6 @@ import { shortSid, commitMsg } from './commit-naming.js'; export interface RestorePlan { throughTurnId: number; - baseTurnId: number; affectedTurns: number[]; baseline: string; } @@ -30,7 +29,6 @@ export function getTurnRestorePlan( if (!baseline || !final) return null; return { throughTurnId: turnId, - baseTurnId: turnId, affectedTurns: [], baseline, }; @@ -50,7 +48,6 @@ export function getRollbackToTurnPlan( return { throughTurnId, - baseTurnId: throughTurnId, affectedTurns, baseline, }; diff --git a/packages/codingcode/src/checkpoint/rollback-engine.ts b/packages/codingcode/src/checkpoint/rollback-engine.ts index ecf4f5c..783b444 100644 --- a/packages/codingcode/src/checkpoint/rollback-engine.ts +++ b/packages/codingcode/src/checkpoint/rollback-engine.ts @@ -7,13 +7,11 @@ import { commitMsg } from './commit-naming.js'; import { readRestoreEntry, writeRestoreEntry } from './restore-store.js'; export function emptyRollbackResult( - turnId: number, - baseTurnId: number | null = null + turnId: number ): CodeRollbackResult { return { reverted: false, throughTurnId: turnId, - baseTurnId, affectedTurns: [], selectedFiles: [], restoreEntry: null, @@ -21,9 +19,8 @@ export function emptyRollbackResult( } export function executeRollback( - projectPath: string, sessionId: string, - plan: { throughTurnId: number; baseTurnId: number; affectedTurns: number[]; baseline: string }, + plan: { throughTurnId: number; affectedTurns: number[]; baseline: string }, selectedFiles: string[], action: CodeRestoreEntry['action'], sg: ShadowGit, @@ -33,7 +30,6 @@ export function executeRollback( return { reverted: false, throughTurnId: plan.throughTurnId, - baseTurnId: plan.baseTurnId, affectedTurns: plan.affectedTurns, selectedFiles: [], restoreEntry: null, @@ -77,7 +73,6 @@ export function executeRollback( sessionId, action, throughTurnId: plan.throughTurnId, - baseTurnId: plan.baseTurnId, affectedTurns: plan.affectedTurns, selectedFiles: combinedFiles, safetyCommit, @@ -88,7 +83,6 @@ export function executeRollback( return { reverted: true, throughTurnId: plan.throughTurnId, - baseTurnId: plan.baseTurnId, affectedTurns: plan.affectedTurns, selectedFiles: combinedFiles, restoreEntry: entry, diff --git a/packages/codingcode/src/client/direct.ts b/packages/codingcode/src/client/direct.ts index c62fd9b..1c20352 100644 --- a/packages/codingcode/src/client/direct.ts +++ b/packages/codingcode/src/client/direct.ts @@ -209,7 +209,6 @@ export async function createDirectClient(llm: any): Promise { return { reverted: false, throughTurnId: turnId, - baseTurnId: null, affectedTurns: [], selectedFiles: [], restoreEntry: null, @@ -226,7 +225,6 @@ export async function createDirectClient(llm: any): Promise { return { reverted: false, throughTurnId: turnId, - baseTurnId: null, affectedTurns: [], selectedFiles: [], restoreEntry: null, @@ -243,7 +241,6 @@ export async function createDirectClient(llm: any): Promise { return { reverted: false, throughTurnId: turnId, - baseTurnId: null, affectedTurns: [], selectedFiles: [], restoreEntry: null, @@ -259,7 +256,6 @@ export async function createDirectClient(llm: any): Promise { return { reverted: false, throughTurnId: turnId, - baseTurnId: null, affectedTurns: [], selectedFiles: [], restoreEntry: null, @@ -269,7 +265,7 @@ export async function createDirectClient(llm: any): Promise { async previewRollbackDiff(throughTurnId: number) { if (!currentSessionId) - return { throughTurnId, baseTurnId: null, affectedTurns: [], diff: '' }; + return { throughTurnId, affectedTurns: [], diff: '' }; return clients.sessions.previewRollbackDiff({ sessionId: currentSessionId, cwd: cwd(), @@ -282,7 +278,6 @@ export async function createDirectClient(llm: any): Promise { return { reverted: false, throughTurnId, - baseTurnId: null, affectedTurns: [], selectedFiles: [], restoreEntry: null, @@ -310,7 +305,6 @@ export async function createDirectClient(llm: any): Promise { codeResult: { reverted: false, throughTurnId, - baseTurnId: null, affectedTurns: [], selectedFiles: [], restoreEntry: null, diff --git a/packages/codingcode/src/client/direct/sessions.ts b/packages/codingcode/src/client/direct/sessions.ts index b5645a6..d639386 100644 --- a/packages/codingcode/src/client/direct/sessions.ts +++ b/packages/codingcode/src/client/direct/sessions.ts @@ -125,7 +125,6 @@ export function createDirectSessionClient( return { reverted: false, throughTurnId: 0, - baseTurnId: null, affectedTurns: [], selectedFiles: [], restoreEntry: null, @@ -135,7 +134,6 @@ export function createDirectSessionClient( return { reverted: false, throughTurnId: 0, - baseTurnId: null, affectedTurns: [], selectedFiles: [], restoreEntry: null, @@ -145,7 +143,6 @@ export function createDirectSessionClient( return { reverted: false, throughTurnId: 0, - baseTurnId: null, affectedTurns: [], selectedFiles: [], restoreEntry: null, @@ -155,20 +152,18 @@ export function createDirectSessionClient( return { reverted: false, throughTurnId: 0, - baseTurnId: null, affectedTurns: [], selectedFiles: [], restoreEntry: null, }; }, async previewRollbackDiff() { - return { throughTurnId: 0, baseTurnId: null, affectedTurns: [], diff: '' }; + return { throughTurnId: 0, affectedTurns: [], diff: '' }; }, async rollbackCodeToTurn() { return { reverted: false, throughTurnId: 0, - baseTurnId: null, affectedTurns: [], selectedFiles: [], restoreEntry: null, @@ -183,7 +178,6 @@ export function createDirectSessionClient( codeResult: { reverted: false, throughTurnId: 0, - baseTurnId: null, affectedTurns: [], selectedFiles: [], restoreEntry: null, diff --git a/packages/codingcode/src/client/http.ts b/packages/codingcode/src/client/http.ts index 59ed3fd..08eab21 100644 --- a/packages/codingcode/src/client/http.ts +++ b/packages/codingcode/src/client/http.ts @@ -146,7 +146,6 @@ export async function createHttpClient(serverUrl: string): Promise return { reverted: false, throughTurnId: 0, - baseTurnId: null, affectedTurns: [], selectedFiles: [], restoreEntry: null, @@ -156,7 +155,6 @@ export async function createHttpClient(serverUrl: string): Promise return { reverted: false, throughTurnId: 0, - baseTurnId: null, affectedTurns: [], selectedFiles: [], restoreEntry: null, @@ -166,7 +164,6 @@ export async function createHttpClient(serverUrl: string): Promise return { reverted: false, throughTurnId: 0, - baseTurnId: null, affectedTurns: [], selectedFiles: [], restoreEntry: null, @@ -176,20 +173,18 @@ export async function createHttpClient(serverUrl: string): Promise return { reverted: false, throughTurnId: 0, - baseTurnId: null, affectedTurns: [], selectedFiles: [], restoreEntry: null, }; }, async previewRollbackDiff() { - return { throughTurnId: 0, baseTurnId: null, affectedTurns: [], diff: '' }; + return { throughTurnId: 0, affectedTurns: [], diff: '' }; }, async rollbackCodeToTurn() { return { reverted: false, throughTurnId: 0, - baseTurnId: null, affectedTurns: [], selectedFiles: [], restoreEntry: null, @@ -204,7 +199,6 @@ export async function createHttpClient(serverUrl: string): Promise codeResult: { reverted: false, throughTurnId: 0, - baseTurnId: null, affectedTurns: [], selectedFiles: [], restoreEntry: null, diff --git a/packages/codingcode/src/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index 00b0f0a..c309abb 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -224,7 +224,6 @@ sessionsRouter.post('/:id/checkpoints/latest/revert-file', async (c) => { return { reverted: false, throughTurnId: 0, - baseTurnId: null, affectedTurns: [], selectedFiles: [], restoreEntry: null, @@ -254,7 +253,6 @@ sessionsRouter.post('/:id/checkpoints/latest/revert-files', async (c) => { return { reverted: false, throughTurnId: 0, - baseTurnId: null, affectedTurns: [], selectedFiles: [], restoreEntry: null, @@ -284,7 +282,6 @@ sessionsRouter.post('/:id/checkpoints/latest/revert-agent', async (c) => { return { reverted: false, throughTurnId: 0, - baseTurnId: null, affectedTurns: [], selectedFiles: [], restoreEntry: null, @@ -314,7 +311,6 @@ sessionsRouter.post('/:id/checkpoints/latest/revert-all', async (c) => { return { reverted: false, throughTurnId: 0, - baseTurnId: null, affectedTurns: [], selectedFiles: [], restoreEntry: null, diff --git a/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts b/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts index f63cd83..6b21b59 100644 --- a/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts +++ b/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts @@ -165,7 +165,6 @@ describe('undoLastCodeRollback end-to-end via ShadowGit', () => { sessionId, action: 'checkpoint-file', throughTurnId: 1, - baseTurnId: 1, affectedTurns: [], selectedFiles: [join(projectPath, 'src/main.ts')], safetyCommit: safetyHash, @@ -226,7 +225,6 @@ describe('rollbackCodeToTurn uses inclusive target turn', () => { }).pipe(Effect.provide(checkpointLayer)) ); - expect(preview.baseTurnId).toBe(1); expect(preview.affectedTurns).toEqual([1]); expect(preview.diff).toContain('articles/one.md'); } finally { @@ -258,7 +256,6 @@ describe('rollbackCodeToTurn uses inclusive target turn', () => { ); expect(result.reverted).toBe(true); - expect(result.baseTurnId).toBe(1); expect(result.affectedTurns).toEqual([1]); expect( result.selectedFiles.some((f) => f.replace(/\\/g, '/').endsWith('articles/one.md')) @@ -302,7 +299,6 @@ describe('rollbackCodeToTurn uses inclusive target turn', () => { }).pipe(Effect.provide(checkpointLayer)) ); - expect(preview.baseTurnId).toBe(2); expect(preview.affectedTurns).toEqual([2, 3]); expect(preview.diff).toContain('two.txt'); expect(preview.diff).toContain('three.txt'); @@ -370,7 +366,6 @@ describe('undoLastCodeRollback case-insensitive path matching', () => { sessionId, action: 'checkpoint-file', throughTurnId: 1, - baseTurnId: 1, affectedTurns: [], selectedFiles: [join(projectPath, 'src/main.ts').toLowerCase()], safetyCommit: safetyHash, diff --git a/packages/desktop/src/lib/core-api.ts b/packages/desktop/src/lib/core-api.ts index f36454a..031f7eb 100644 --- a/packages/desktop/src/lib/core-api.ts +++ b/packages/desktop/src/lib/core-api.ts @@ -234,7 +234,6 @@ export interface CheckpointDiff { export interface CodeRollbackResult { reverted: boolean; throughTurnId: number; - baseTurnId: number | null; affectedTurns: number[]; selectedFiles: string[]; restoreEntry: CodeRestoreEntry | null; @@ -250,7 +249,6 @@ export interface CodeRollbackUndoResult { export interface RollbackPreviewDiff { throughTurnId: number; - baseTurnId: number | null; affectedTurns: number[]; diff: string; } @@ -260,7 +258,6 @@ export interface CodeRestoreEntry { sessionId: string; action: string; throughTurnId: number; - baseTurnId: number; affectedTurns: number[]; selectedFiles: string[]; safetyCommit: string; diff --git a/packages/desktop/test/global-store-rollback-state.test.ts b/packages/desktop/test/global-store-rollback-state.test.ts index 1daed60..e5a1ccf 100644 --- a/packages/desktop/test/global-store-rollback-state.test.ts +++ b/packages/desktop/test/global-store-rollback-state.test.ts @@ -59,7 +59,6 @@ describe('Rollback state in global store', () => { it('setRollbackPreview stores preview', () => { const preview = { throughTurnId: 2, - baseTurnId: 1, affectedTurns: [3, 4], diff: 'diff content', }; @@ -73,7 +72,6 @@ describe('Rollback state in global store', () => { it('clearRollbackPreview removes preview', () => { const preview = { throughTurnId: 2, - baseTurnId: 1, affectedTurns: [3, 4], diff: 'diff content', }; From 546e1a18bdac52e3c8ebb68bb28d576456b703e1 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 11 Jun 2026 15:15:27 +0800 Subject: [PATCH 05/11] Delete or modify source attribution --- packages/codingcode/package.json | 1 - packages/codingcode/src/agent/agent.ts | 2 + .../src/checkpoint/checkpoint-service.ts | 140 ++++------------ .../src/checkpoint/classification.ts | 39 ----- .../src/checkpoint/hook-recorder.ts | 102 ------------ packages/codingcode/src/checkpoint/ledger.ts | 48 ------ .../src/checkpoint/rollback-engine.ts | 4 +- packages/codingcode/src/client/direct.ts | 37 ----- .../codingcode/src/client/direct/sessions.ts | 20 --- packages/codingcode/src/client/http.ts | 27 --- .../codingcode/src/client/http/sessions.ts | 10 -- packages/codingcode/src/client/types.ts | 12 +- .../codingcode/src/server/routes/sessions.ts | 58 ------- .../checkpoint-project-path.test.ts | 156 ------------------ .../codingcode/test/context/context.test.ts | 10 +- packages/codingcode/test/orchestrate.test.ts | 10 +- .../codingcode/test/server/handler.test.ts | 10 +- packages/desktop/src/agent/MessageStream.tsx | 45 ++--- packages/desktop/src/hooks/useAgent.ts | 29 ---- packages/desktop/src/lib/core-api.ts | 15 -- packages/desktop/src/stores/global.store.ts | 27 --- .../test/global-store-rollback-state.test.ts | 35 +--- packages/tui/src/commands/registry.ts | 6 - packages/tui/src/components/App.tsx | 69 -------- packages/tui/src/types.ts | 9 - 25 files changed, 77 insertions(+), 844 deletions(-) delete mode 100644 packages/codingcode/src/checkpoint/classification.ts delete mode 100644 packages/codingcode/src/checkpoint/hook-recorder.ts delete mode 100644 packages/codingcode/src/checkpoint/ledger.ts delete mode 100644 packages/codingcode/test/checkpoint/checkpoint-project-path.test.ts diff --git a/packages/codingcode/package.json b/packages/codingcode/package.json index 96641bd..de53c18 100644 --- a/packages/codingcode/package.json +++ b/packages/codingcode/package.json @@ -36,7 +36,6 @@ "./client/direct-clients": "./src/client/direct/index.ts", "./checkpoint/checkpoint-service": "./src/checkpoint/checkpoint-service.ts", "./checkpoint/shadow-git": "./src/checkpoint/shadow-git.ts", - "./checkpoint/ledger": "./src/checkpoint/ledger.ts", "./checkpoint/bootstrap": "./src/checkpoint/bootstrap.ts", "./subagent/registry": "./src/subagent/registry.ts", "./subagent/loader": "./src/subagent/loader.ts", diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index bb9fd80..a62c3fb 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -306,6 +306,7 @@ export async function* runReActLoop( // Check abort signal if (opts.abortSignal?.aborted) { + checkpoint.snapshotFinal(projectPath, state.sessionId, state.currentTurnId); yield { _tag: 'Error', error: new AgentError('AGENT_ABORTED', 'cancelled') }; await Effect.runPromise( hooks.emit('agent.turn.end', { @@ -557,6 +558,7 @@ export async function* runReActLoop( // If abort fired during tool execution, terminate immediately if (opts.abortSignal?.aborted) { + checkpoint.snapshotFinal(projectPath, state.sessionId, state.currentTurnId); yield { _tag: 'Error', error: new AgentError('AGENT_ABORTED', 'cancelled') }; await Effect.runPromise( hooks.emit('agent.turn.end', { diff --git a/packages/codingcode/src/checkpoint/checkpoint-service.ts b/packages/codingcode/src/checkpoint/checkpoint-service.ts index 3d86577..29354a4 100644 --- a/packages/codingcode/src/checkpoint/checkpoint-service.ts +++ b/packages/codingcode/src/checkpoint/checkpoint-service.ts @@ -1,21 +1,16 @@ import { Effect } from 'effect'; import { createHash } from 'crypto'; import { readFileSync } from 'fs'; -import { resolve, dirname } from 'path'; +import { resolve } from 'path'; import { ShadowGit } from './shadow-git.js'; import { ProjectLock } from './project-lock.js'; import { normalizePath } from '../core/path.js'; -import { Ledger } from './ledger.js'; -import { registerCheckpointHooks } from './hook-recorder.js'; -import { HookService } from '../hooks/registry.js'; import { shortSid, commitMsg } from './commit-naming.js'; import { readRestoreEntry, writeRestoreEntry } from './restore-store.js'; -import { classifyDiff, parseDiffStats } from './classification.js'; import { getCompletedTurnsFor, getTurnRestorePlan, getRollbackToTurnPlan, - type RestorePlan, } from './restore-planning.js'; import { emptyRollbackResult, executeRollback } from './rollback-engine.js'; @@ -25,7 +20,6 @@ export interface CheckpointDiff { turnId: number; files: Array<{ path: string; - source: 'agent' | 'unknown'; status: string; diff: string; insertions: number; @@ -58,12 +52,7 @@ export interface RollbackPreviewDiff { export interface CodeRestoreEntry { id: string; sessionId: string; - action: - | 'checkpoint-file' - | 'checkpoint-files' - | 'checkpoint-agent' - | 'checkpoint-all' - | 'rollback-to-turn'; + action: 'checkpoint-file' | 'checkpoint-files' | 'rollback-to-turn'; throughTurnId: number; affectedTurns: number[]; selectedFiles: string[]; @@ -97,12 +86,8 @@ export function hashWorkspaceFile(projectPath: string, file: string): string | n export class CheckpointService extends Effect.Service()('Checkpoint', { effect: Effect.gen(function* () { - const hooks = yield* HookService; - registerCheckpointHooks(hooks); - const shadowGitByProject = new Map(); const lockByProject = new Map(); - const ledgerByProject = new Map(); function ensure(projectPath: string): ShadowGit { const normalized = normalizePath(projectPath); @@ -125,15 +110,20 @@ export class CheckpointService extends Effect.Service()('Chec return lock; } - function ledger(sg: ShadowGit): Ledger { - const key = sg.gitDir; - let entry = ledgerByProject.get(key); - if (!entry || entry.gitDir !== key) { - const l = new Ledger(dirname(sg.gitDir)); - entry = { ledger: l, gitDir: key }; - ledgerByProject.set(key, entry); + function repairIncompleteTurn(sg: ShadowGit, sessionId: string): void { + const completed = getCompletedTurnsFor(sg, sessionId); + const candidate = completed.length > 0 ? completed[completed.length - 1]! + 1 : 1; + const baseline = sg.findCommitByMessage(commitMsg(sessionId, candidate, 'baseline')); + if (!baseline) return; + const final = sg.findCommitByMessage(commitMsg(sessionId, candidate, 'final')); + if (final) return; + const lock = lockFor(sg.projectPath); + lock.lock(); + try { + sg.commit(commitMsg(sessionId, candidate, 'final')); + } finally { + lock.unlock(); } - return entry.ledger; } return { @@ -146,6 +136,7 @@ export class CheckpointService extends Effect.Service()('Chec title?: string ): void => { const sg = ensure(projectPath); + repairIncompleteTurn(sg, sessionId); if (sg.isTooLargeForSnapshot()) return; const lock = lockFor(projectPath); const msg = title @@ -171,22 +162,11 @@ export class CheckpointService extends Effect.Service()('Chec } }, - // ---- Classification ---- - - classifyChanges: ( - projectPath: string, - sessionId: string, - turnId: number - ): { agentModified: string[]; unknownSource: string[] } | null => { - const sg = ensure(projectPath); - const l = ledger(sg); - return classifyDiff(projectPath, sessionId, turnId, sg, l); - }, - // ---- Query ---- getCompletedTurns: (projectPath: string, sessionId: string): number[] => { const sg = ensure(projectPath); + repairIncompleteTurn(sg, sessionId); return getCompletedTurnsFor(sg, sessionId); }, @@ -196,18 +176,16 @@ export class CheckpointService extends Effect.Service()('Chec ): Array<{ turnId: number; title: string; - agentModified: string[]; - unknownSource: string[]; + files: string[]; }> => { const sg = ensure(projectPath); - const l = ledger(sg); + repairIncompleteTurn(sg, sessionId); const prefix = `turn-${shortSid(sessionId)}-`; const completedTurns = getCompletedTurnsFor(sg, sessionId); const result: Array<{ turnId: number; title: string; - agentModified: string[]; - unknownSource: string[]; + files: string[]; }> = []; for (const i of completedTurns) { @@ -221,18 +199,9 @@ export class CheckpointService extends Effect.Service()('Chec const title = fullMsg.includes(' ') ? fullMsg.split(' ').slice(1).join(' ') : ''; const allChanges = sg.diffFiles(bCommit, fCommit); - const rawAllFiles = allChanges.map((c) => normalizePath(resolve(projectPath, c.file))); - const allFiles = [...new Set(rawAllFiles)]; - const agentFiles = new Set( - l.getAgentFiles(i, sessionId).map((p) => normalizePath(p).toLowerCase()) - ); + const files = [...new Set(allChanges.map((c) => normalizePath(resolve(projectPath, c.file))))]; - result.push({ - turnId: i, - title, - agentModified: allFiles.filter((f) => agentFiles.has(f.toLowerCase())), - unknownSource: allFiles.filter((f) => !agentFiles.has(f.toLowerCase())), - }); + result.push({ turnId: i, title, files }); } return result; }, @@ -243,6 +212,7 @@ export class CheckpointService extends Effect.Service()('Chec turnId?: number ): CheckpointDiff => { const sg = ensure(projectPath); + repairIncompleteTurn(sg, sessionId); const completedTurns = getCompletedTurnsFor(sg, sessionId); const latestTurnId = turnId ?? (completedTurns.length > 0 ? completedTurns[completedTurns.length - 1]! : 0); @@ -257,20 +227,19 @@ export class CheckpointService extends Effect.Service()('Chec const allChanges = sg.diffFiles(baseline, final); const rawAllFiles = allChanges.map((c) => normalizePath(resolve(projectPath, c.file))); const allFiles = [...new Set(rawAllFiles)]; - const agentFiles = new Set( - ledger(sg) - .getAgentFiles(latestTurnId, sessionId) - .map((p) => normalizePath(p).toLowerCase()) - ); const files = allFiles.map((f) => { const relPath = toGitPath(projectPath, f); const diffResult = sg.git('diff', baseline, final, '--', relPath); const rawPath = normalizePath(resolve(projectPath, relPath)); - const stats = parseDiffStats(diffResult.stdout); + let insertions = 0; + let deletions = 0; + for (const line of diffResult.stdout.split('\n')) { + if (line.startsWith('+') && !line.startsWith('+++')) insertions++; + else if (line.startsWith('-') && !line.startsWith('---')) deletions++; + } return { path: f, - source: (agentFiles.has(f.toLowerCase()) ? 'agent' : 'unknown') as 'agent' | 'unknown', status: allChanges.find( (c) => @@ -278,8 +247,8 @@ export class CheckpointService extends Effect.Service()('Chec rawPath.toLowerCase() )?.status ?? 'M', diff: diffResult.stdout, - insertions: stats.insertions, - deletions: stats.deletions, + insertions, + deletions, }; }); @@ -297,7 +266,7 @@ export class CheckpointService extends Effect.Service()('Chec const sg = ensure(projectPath); const plan = getTurnRestorePlan(sg, sessionId, turnId); if (!plan) { - return emptyRollbackResult(turnId, turnId); + return emptyRollbackResult(turnId); } return executeRollback(sessionId, plan, [file], 'checkpoint-file', sg, lockFor(projectPath)); }, @@ -310,53 +279,10 @@ export class CheckpointService extends Effect.Service()('Chec ): CodeRollbackResult => { const sg = ensure(projectPath); const plan = getTurnRestorePlan(sg, sessionId, turnId); - if (!plan) { - return emptyRollbackResult(turnId, turnId); - } - return executeRollback(sessionId, plan, files, 'checkpoint-files', sg, lockFor(projectPath)); - }, - - revertCheckpointAgentFiles: ( - projectPath: string, - sessionId: string, - turnId: number - ): CodeRollbackResult => { - const sg = ensure(projectPath); - const l = ledger(sg); - const changes = classifyDiff(projectPath, sessionId, turnId, sg, l); - if (!changes) { - return emptyRollbackResult(turnId); - } - if (changes.agentModified.length === 0) { - return emptyRollbackResult(turnId); - } - const plan = getTurnRestorePlan(sg, sessionId, turnId); - if (!plan) { - return emptyRollbackResult(turnId); - } - return executeRollback(sessionId, plan, changes.agentModified, 'checkpoint-agent', sg, lockFor(projectPath)); - }, - - revertCheckpointAllFiles: ( - projectPath: string, - sessionId: string, - turnId: number - ): CodeRollbackResult => { - const sg = ensure(projectPath); - const l = ledger(sg); - const changes = classifyDiff(projectPath, sessionId, turnId, sg, l); - if (!changes) { - return emptyRollbackResult(turnId); - } - const all = [...changes.agentModified, ...changes.unknownSource]; - if (all.length === 0) { - return emptyRollbackResult(turnId); - } - const plan = getTurnRestorePlan(sg, sessionId, turnId); if (!plan) { return emptyRollbackResult(turnId); } - return executeRollback(sessionId, plan, all, 'checkpoint-all', sg, lockFor(projectPath)); + return executeRollback(sessionId, plan, files, 'checkpoint-files', sg, lockFor(projectPath)); }, // ---- Rollback ---- diff --git a/packages/codingcode/src/checkpoint/classification.ts b/packages/codingcode/src/checkpoint/classification.ts deleted file mode 100644 index 8923cb0..0000000 --- a/packages/codingcode/src/checkpoint/classification.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { resolve } from 'path'; -import { normalizePath } from '../core/path.js'; -import type { ShadowGit } from './shadow-git.js'; -import type { Ledger } from './ledger.js'; -import { commitMsg } from './commit-naming.js'; - -export function classifyDiff( - projectPath: string, - sessionId: string, - turnId: number, - sg: ShadowGit, - ledgerInstance: Ledger -): { agentModified: string[]; unknownSource: string[] } | null { - const baseline = sg.findCommitByMessage(commitMsg(sessionId, turnId, 'baseline')); - const final = sg.findCommitByMessage(commitMsg(sessionId, turnId, 'final')); - if (!baseline || !final) return null; - - const allChanges = sg.diffFiles(baseline, final); - const rawAllFiles = allChanges.map((c) => normalizePath(resolve(projectPath, c.file))); - const allFiles = [...new Set(rawAllFiles)]; - const agentFiles = new Set( - ledgerInstance.getAgentFiles(turnId, sessionId).map((p) => normalizePath(p).toLowerCase()) - ); - - return { - agentModified: allFiles.filter((f) => agentFiles.has(f.toLowerCase())), - unknownSource: allFiles.filter((f) => !agentFiles.has(f.toLowerCase())), - }; -} - -export function parseDiffStats(diffText: string): { insertions: number; deletions: number } { - let insertions = 0; - let deletions = 0; - for (const line of diffText.split('\n')) { - if (line.startsWith('+') && !line.startsWith('+++')) insertions++; - else if (line.startsWith('-') && !line.startsWith('---')) deletions++; - } - return { insertions, deletions }; -} diff --git a/packages/codingcode/src/checkpoint/hook-recorder.ts b/packages/codingcode/src/checkpoint/hook-recorder.ts deleted file mode 100644 index d6f047f..0000000 --- a/packages/codingcode/src/checkpoint/hook-recorder.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Effect } from 'effect'; -import { createHash } from 'crypto'; -import { existsSync, readFileSync } from 'fs'; -import { join, resolve } from 'path'; -import { homedir } from 'os'; -import type { HookService } from '../hooks/registry.js'; -import { encodeProjectPath } from '../core/path.js'; - -import { Ledger } from './ledger.js'; - -/** - * Carry file hash from tool.execute.before to tool.execute.after. - * Keyed by execId (unique per tool execution) to avoid parallel race conditions. - */ -const hashBeforeEdit = new Map(); - -function getLedger(projectPath: string): Ledger { - const encoded = encodeProjectPath(projectPath); - const checkpointDir = join(homedir(), '.codingcode', 'project', encoded, 'checkpoint'); - return new Ledger(checkpointDir); -} - -/** - * Register hook observers that record file-modifying tool calls to the Ledger. - * Uses source: 'system' so these hooks survive reloadUserHooks() calls. - * Idempotent — safe to call multiple times with the same HookService. - */ -export function registerCheckpointHooks(hooks: HookService): void { - Effect.runSync( - hooks.register( - 'tool.execute.before', - async (payload) => { - const toolName = payload.toolName as string; - if (toolName !== 'edit_file' && toolName !== 'write_file') return; - - const args = payload.args as Record | undefined; - const rawPath = args?.path as string | undefined; - if (!rawPath) return; - - const base = (payload.projectPath as string | undefined) || process.cwd(); - const resolvedPath = resolve(base, rawPath); - const callId = payload.callId as string; - if (callId) { - hashBeforeEdit.set(callId, sha256Truncated(resolvedPath)); - } - }, - { source: 'system' } - ) - ); - - Effect.runSync( - hooks.register( - 'tool.execute.after', - async (payload) => { - const sessionId = payload.sessionId as string | undefined; - if (!sessionId) return; - const turnId = payload.turnId as number | undefined; - if (turnId === undefined) return; - const projectPath = payload.projectPath as string | undefined; - if (!projectPath) return; - - const toolName = payload.toolName as string; - if (toolName !== 'edit_file' && toolName !== 'write_file') return; - - const args = payload.args as Record | undefined; - const rawPath = args?.path as string | undefined; - if (!rawPath) return; - const base = (payload.projectPath as string | undefined) || process.cwd(); - const resolvedPath = resolve(base, rawPath); - - const callId = payload.callId as string; - const hashBefore = callId ? (hashBeforeEdit.get(callId) ?? '') : ''; - if (callId) { - hashBeforeEdit.delete(callId); - } - - const hashAfter = sha256Truncated(resolvedPath); - - getLedger(projectPath).record({ - turnId, - sessionId, - type: toolName, - path: resolvedPath, - hashBefore, - hashAfter, - timestamp: new Date().toISOString(), - }); - }, - { source: 'system' } - ) - ); -} - -function sha256Truncated(filePath: string): string { - try { - if (!existsSync(filePath)) return ''; - const content = readFileSync(filePath); - return createHash('sha256').update(content).digest('hex').slice(0, 16); - } catch { - return ''; - } -} diff --git a/packages/codingcode/src/checkpoint/ledger.ts b/packages/codingcode/src/checkpoint/ledger.ts deleted file mode 100644 index 6e5243b..0000000 --- a/packages/codingcode/src/checkpoint/ledger.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { existsSync, appendFileSync, readFileSync } from 'fs'; -import { join } from 'path'; - -export interface LedgerEntry { - turnId: number; - sessionId: string; - type: string; - path: string; - hashBefore: string; - hashAfter: string; - timestamp: string; -} - -/** - * File change ledger — JSONL log of every file-modifying tool call. - * Stored inside the project checkpoint folder: project//checkpoint/repo-ledger.jsonl - */ -export class Ledger { - private readonly path: string; - - constructor(checkpointDir: string) { - this.path = join(checkpointDir, 'repo-ledger.jsonl'); - } - - record(entry: LedgerEntry): void { - appendFileSync(this.path, JSON.stringify(entry) + '\n', 'utf8'); - } - - /** All ledger entries for a given turn */ - getForTurn(turnId: number, sessionId: string): LedgerEntry[] { - return this.readAll().filter((e) => e.turnId === turnId && e.sessionId === sessionId); - } - - /** File paths that were explicitly modified by edit_file / write_file in a turn */ - getAgentFiles(turnId: number, sessionId: string): string[] { - return this.getForTurn(turnId, sessionId) - .filter((e) => e.type === 'edit_file' || e.type === 'write_file') - .map((e) => e.path); - } - - private readAll(): LedgerEntry[] { - if (!existsSync(this.path)) return []; - return readFileSync(this.path, 'utf8') - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line) as LedgerEntry); - } -} diff --git a/packages/codingcode/src/checkpoint/rollback-engine.ts b/packages/codingcode/src/checkpoint/rollback-engine.ts index 783b444..4f82837 100644 --- a/packages/codingcode/src/checkpoint/rollback-engine.ts +++ b/packages/codingcode/src/checkpoint/rollback-engine.ts @@ -51,8 +51,6 @@ export function executeRollback( safetyCommit = sg.commit(commitMsg(sessionId, plan.throughTurnId, 'revert-safety')); } - sg.checkoutFiles(plan.baseline, selectedFiles); - const combinedFiles = existingEntry && existingEntry.throughTurnId === plan.throughTurnId ? [ @@ -80,6 +78,8 @@ export function executeRollback( }; writeRestoreEntry(sg.gitDir, sessionId, entry); + sg.checkoutFiles(plan.baseline, selectedFiles); + return { reverted: true, throughTurnId: plan.throughTurnId, diff --git a/packages/codingcode/src/client/direct.ts b/packages/codingcode/src/client/direct.ts index 1c20352..872627b 100644 --- a/packages/codingcode/src/client/direct.ts +++ b/packages/codingcode/src/client/direct.ts @@ -175,16 +175,6 @@ export async function createDirectClient(llm: any): Promise { activeLlm = clientResult.value; }, - async classifyLastCompletedChanges() { - return null; - }, - async revertLastCompleted(_mode: 'agent' | 'all') {}, - async revertCheckpoint(_turnId: number, _mode: 'agent' | 'all') {}, - async forwardLastRevert() {}, - async hasForwardStack() { - return false; - }, - async getCheckpoints() { if (!currentSessionId) return []; return runWithLayer( @@ -236,33 +226,6 @@ export async function createDirectClient(llm: any): Promise { }); }, - async revertCheckpointAgentFiles(turnId: number) { - if (!currentSessionId) - return { - reverted: false, - throughTurnId: turnId, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - return clients.sessions.revertCheckpointAgentFiles({ - sessionId: currentSessionId, - cwd: cwd(), - }); - }, - - async revertCheckpointAllFiles(turnId: number) { - if (!currentSessionId) - return { - reverted: false, - throughTurnId: turnId, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - return clients.sessions.revertCheckpointAllFiles({ sessionId: currentSessionId, cwd: cwd() }); - }, - async previewRollbackDiff(throughTurnId: number) { if (!currentSessionId) return { throughTurnId, affectedTurns: [], diff: '' }; diff --git a/packages/codingcode/src/client/direct/sessions.ts b/packages/codingcode/src/client/direct/sessions.ts index d639386..7ea4acc 100644 --- a/packages/codingcode/src/client/direct/sessions.ts +++ b/packages/codingcode/src/client/direct/sessions.ts @@ -19,8 +19,6 @@ export interface SessionClient { getCheckpointDiff(input: { sessionId: string; cwd: string; turnId?: number }): Promise; revertCheckpointFile(input: { sessionId: string; cwd: string; file: string }): Promise; revertCheckpointFiles(input: { sessionId: string; cwd: string; files: string[] }): Promise; - revertCheckpointAgentFiles(input: { sessionId: string; cwd: string }): Promise; - revertCheckpointAllFiles(input: { sessionId: string; cwd: string }): Promise; previewRollbackDiff(input: { sessionId: string; cwd: string; @@ -139,24 +137,6 @@ export function createDirectSessionClient( restoreEntry: null, }; }, - async revertCheckpointAgentFiles() { - return { - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - }, - async revertCheckpointAllFiles() { - return { - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - }, async previewRollbackDiff() { return { throughTurnId: 0, affectedTurns: [], diff: '' }; }, diff --git a/packages/codingcode/src/client/http.ts b/packages/codingcode/src/client/http.ts index 08eab21..13d3815 100644 --- a/packages/codingcode/src/client/http.ts +++ b/packages/codingcode/src/client/http.ts @@ -127,15 +127,6 @@ export async function createHttpClient(serverUrl: string): Promise return currentSessionId ?? 'unknown'; }, - async classifyLastCompletedChanges() { - return null; - }, - async revertLastCompleted(_mode: 'agent' | 'all') {}, - async revertCheckpoint(_turnId: number, _mode: 'agent' | 'all') {}, - async forwardLastRevert() {}, - async hasForwardStack() { - return false; - }, async getCheckpoints() { return []; }, @@ -160,24 +151,6 @@ export async function createHttpClient(serverUrl: string): Promise restoreEntry: null, }; }, - async revertCheckpointAgentFiles() { - return { - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - }, - async revertCheckpointAllFiles() { - return { - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - }, async previewRollbackDiff() { return { throughTurnId: 0, affectedTurns: [], diff: '' }; }, diff --git a/packages/codingcode/src/client/http/sessions.ts b/packages/codingcode/src/client/http/sessions.ts index 68c63a4..c83adee 100644 --- a/packages/codingcode/src/client/http/sessions.ts +++ b/packages/codingcode/src/client/http/sessions.ts @@ -20,8 +20,6 @@ export interface SessionClient { }): Promise<{ turnId: number; files: any[] }>; revertCheckpointFile(input: { sessionId: string; cwd: string; file: string }): Promise; revertCheckpointFiles(input: { sessionId: string; cwd: string; files: string[] }): Promise; - revertCheckpointAgentFiles(input: { sessionId: string; cwd: string }): Promise; - revertCheckpointAllFiles(input: { sessionId: string; cwd: string }): Promise; previewRollbackDiff(input: { sessionId: string; cwd: string; @@ -105,14 +103,6 @@ export function createHttpSessionClient( return apiPost(`/api/sessions/${sessionId}/checkpoints/latest/revert-files`, { cwd, files }); }, - async revertCheckpointAgentFiles({ sessionId, cwd }) { - return apiPost(`/api/sessions/${sessionId}/checkpoints/latest/revert-agent`, { cwd }); - }, - - async revertCheckpointAllFiles({ sessionId, cwd }) { - return apiPost(`/api/sessions/${sessionId}/checkpoints/latest/revert-all`, { cwd }); - }, - async previewRollbackDiff({ sessionId, cwd, throughTurnId }) { return apiGet( `/api/sessions/${sessionId}/rollback-preview?cwd=${encodeURIComponent(cwd)}&throughTurnId=${throughTurnId}` diff --git a/packages/codingcode/src/client/types.ts b/packages/codingcode/src/client/types.ts index efe0cbc..d89cc58 100644 --- a/packages/codingcode/src/client/types.ts +++ b/packages/codingcode/src/client/types.ts @@ -32,22 +32,12 @@ export interface AgentClient { listModels(): Promise; switchModel(id: string): Promise; getSessionId(): string; - classifyLastCompletedChanges(): Promise<{ - agentModified: string[]; - unknownSource: string[]; - } | null>; - revertLastCompleted(mode: 'agent' | 'all'): Promise; - revertCheckpoint(turnId: number, mode: 'agent' | 'all'): Promise; - forwardLastRevert(): Promise; - hasForwardStack(): Promise; getCheckpoints(): Promise< - Array<{ turnId: number; title: string; agentModified: string[]; unknownSource: string[] }> + Array<{ turnId: number; title: string; files: string[] }> >; getCheckpointDiff(turnId?: number): Promise; revertCheckpointFile(turnId: number, file: string): Promise; revertCheckpointFiles(turnId: number, files: string[]): Promise; - revertCheckpointAgentFiles(turnId: number): Promise; - revertCheckpointAllFiles(turnId: number): Promise; previewRollbackDiff(throughTurnId: number): Promise; rollbackCodeToTurn(throughTurnId: number): Promise; rollbackContext(throughTurnId: number): Promise<{ turns: any[]; rollbackState: any }>; diff --git a/packages/codingcode/src/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index c309abb..381f8b8 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -268,64 +268,6 @@ sessionsRouter.post('/:id/checkpoints/latest/revert-files', async (c) => { return c.json({ ok: true, result: result.value }); }); -// ---- C6: revert agent files ---- - -sessionsRouter.post('/:id/checkpoints/latest/revert-agent', async (c) => { - const sessionId = c.req.param('id'); - const body = (await c.req.json()) as { cwd: string }; - const cwd = resolveWorkspaceCwd(body.cwd); - const result = await runWithLayer( - Effect.gen(function* () { - const checkpoint = yield* CheckpointService; - const completedTurns = checkpoint.getCompletedTurns(cwd, sessionId); - if (completedTurns.length === 0) - return { - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - const latestTurnId = completedTurns[completedTurns.length - 1]!; - return checkpoint.revertCheckpointAgentFiles(cwd, sessionId, latestTurnId); - }) - ); - if (!result.ok) { - const { status, body } = errorResponse(result.error); - return c.json(body, status as any); - } - return c.json({ ok: true, result: result.value }); -}); - -// ---- C7: revert all files ---- - -sessionsRouter.post('/:id/checkpoints/latest/revert-all', async (c) => { - const sessionId = c.req.param('id'); - const body = (await c.req.json()) as { cwd: string }; - const cwd = resolveWorkspaceCwd(body.cwd); - const result = await runWithLayer( - Effect.gen(function* () { - const checkpoint = yield* CheckpointService; - const completedTurns = checkpoint.getCompletedTurns(cwd, sessionId); - if (completedTurns.length === 0) - return { - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - const latestTurnId = completedTurns[completedTurns.length - 1]!; - return checkpoint.revertCheckpointAllFiles(cwd, sessionId, latestTurnId); - }) - ); - if (!result.ok) { - const { status, body } = errorResponse(result.error); - return c.json(body, status as any); - } - return c.json({ ok: true, result: result.value }); -}); - // ---- C8: rollback preview diff ---- sessionsRouter.get('/:id/rollback-preview', async (c) => { diff --git a/packages/codingcode/test/checkpoint/checkpoint-project-path.test.ts b/packages/codingcode/test/checkpoint/checkpoint-project-path.test.ts deleted file mode 100644 index 2facd58..0000000 --- a/packages/codingcode/test/checkpoint/checkpoint-project-path.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdirSync, writeFileSync, rmSync, readFileSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; -import { randomUUID } from 'crypto'; -import { Effect, Layer } from 'effect'; -import { initWorkspace } from '../../src/core/workspace.js'; -import { HookService } from '../../src/hooks/registry.js'; - -const hooksLayer = Layer.succeed(HookService, { - register: (point: any, handler: any, opts?: any) => - Effect.sync(() => { - const entries = (hooksLayer as any)._entries ?? new Map(); - (hooksLayer as any)._entries = entries; - const set = entries.get(point) ?? []; - set.push({ - id: `obs-${Math.random()}`, - handler, - priority: 0, - source: opts?.source ?? 'user', - type: 'observer', - }); - entries.set(point, set); - return () => { - const s = entries.get(point); - if (s) { - const idx = s.findIndex((e: any) => e.handler === handler); - if (idx >= 0) s.splice(idx, 1); - } - }; - }), - emit: (point: any, payload: any) => - Effect.promise(async () => { - const entries = ((hooksLayer as any)._entries ?? new Map()).get(point) ?? []; - for (const entry of entries.slice().sort((a: any, b: any) => a.priority - b.priority)) { - if (entry.type === 'observer') { - try { - await entry.handler(payload); - } catch (e) { - /* ignore */ - } - } - } - }), - emitDecision: (_: any, _2: any) => Effect.succeed(null), - reloadUserHooks: (_: string) => Effect.void, - registerDecision: (point: any, handler: any, opts?: any) => - Effect.sync(() => { - const entries = (hooksLayer as any)._entries ?? new Map(); - (hooksLayer as any)._entries = entries; - const set = entries.get(point) ?? []; - set.push({ - id: `dec-${Math.random()}`, - handler, - priority: opts?.priority ?? 0, - source: opts?.source ?? 'user', - type: 'decision', - }); - entries.set(point, set); - return () => { - const s = entries.get(point); - if (s) { - const idx = s.findIndex((e: any) => e.handler === handler); - if (idx >= 0) s.splice(idx, 1); - } - }; - }), -} as any); - -const testLayer = hooksLayer; - -function run(eff: Effect.Effect): Promise { - return Effect.runPromise(eff.pipe(Effect.provide(testLayer) as any)); -} - -describe('checkpoint/hook-recorder projectPath isolation', () => { - let globalDir: string; - let projectDir: string; - - beforeEach(() => { - globalDir = join(tmpdir(), `global-${randomUUID().slice(0, 8)}`); - projectDir = join(tmpdir(), `project-${randomUUID().slice(0, 8)}`); - mkdirSync(globalDir, { recursive: true }); - mkdirSync(projectDir, { recursive: true }); - mkdirSync(join(globalDir, 'config'), { recursive: true }); - writeFileSync( - join(globalDir, 'config', 'models.json'), - '{"active":"p","providers":[]}', - 'utf8' - ); - initWorkspace({ processRoot: globalDir, workspaceCwd: globalDir }); - }); - - afterEach(() => { - try { - rmSync(globalDir, { recursive: true, force: true }); - } catch { - /* ignore */ - } - try { - rmSync(projectDir, { recursive: true, force: true }); - } catch { - /* ignore */ - } - }); - - async function getHooks() { - return await run( - Effect.gen(function* () { - const svc = yield* HookService; - return svc; - }) - ); - } - - it('hook-recorder records correct file path via payload.projectPath', async () => { - const hooks = await getHooks(); - const { registerCheckpointHooks } = await import('../../src/checkpoint/hook-recorder.js'); - registerCheckpointHooks(hooks); - - writeFileSync(join(projectDir, 'c.txt'), 'initial', 'utf8'); - - const execId = 'exec-test-789'; - const beforePayload = { - toolName: 'edit_file', - args: { path: 'c.txt', old_string: 'initial', new_string: 'modified' }, - sessionId: 'sess-1', - turnId: 1, - projectPath: projectDir, - execId, - }; - - await run(hooks.emit('tool.execute.before', beforePayload)); - - writeFileSync(join(projectDir, 'c.txt'), 'modified', 'utf8'); - - const afterPayload = { - toolName: 'edit_file', - args: { path: 'c.txt', old_string: 'initial', new_string: 'modified' }, - sessionId: 'sess-1', - turnId: 1, - projectPath: projectDir, - execId, - }; - - await run(hooks.emit('tool.execute.after', afterPayload)); - - // Ledger should have recorded the change under the correct project path - const { encodeProjectPath } = await import('../../src/core/path.js'); - const encoded = encodeProjectPath(projectDir); - const ledgerDir = join(tmpdir(), '..', '.codingcode', 'project', encoded, 'checkpoint'); - // We can't easily read ledger internals, but the fact that it didn't throw - // means the file path was resolved correctly - expect(true).toBe(true); - }); -}); diff --git a/packages/codingcode/test/context/context.test.ts b/packages/codingcode/test/context/context.test.ts index 2befb1e..214777a 100644 --- a/packages/codingcode/test/context/context.test.ts +++ b/packages/codingcode/test/context/context.test.ts @@ -84,11 +84,15 @@ const MockCheckpointLayer = Layer.succeed( _tag: 'Checkpoint' as const, snapshotBaseline: () => {}, snapshotFinal: () => {}, - classifyChanges: () => null, getCompletedTurns: () => [], - forward: () => null, - hasForwardStack: () => false, getCheckpoints: () => [], + getCheckpointDiff: () => ({ turnId: 0, files: [] }), + revertCheckpointFile: () => ({ reverted: false, throughTurnId: 0, affectedTurns: [], selectedFiles: [], restoreEntry: null }), + revertCheckpointFiles: () => ({ reverted: false, throughTurnId: 0, affectedTurns: [], selectedFiles: [], restoreEntry: null }), + previewRollbackDiff: () => ({ throughTurnId: 0, affectedTurns: [], diff: '' }), + rollbackCodeToTurn: () => ({ reverted: false, throughTurnId: 0, affectedTurns: [], selectedFiles: [], restoreEntry: null }), + undoLastCodeRollback: () => ({ restored: false, conflict: false, conflictFiles: [], restoredFiles: [], remainingRolledBack: [] }), + getLatestRestoreEntry: () => null, } as any) ); diff --git a/packages/codingcode/test/orchestrate.test.ts b/packages/codingcode/test/orchestrate.test.ts index c084000..2aae804 100644 --- a/packages/codingcode/test/orchestrate.test.ts +++ b/packages/codingcode/test/orchestrate.test.ts @@ -183,11 +183,15 @@ const MockCheckpointLayer = Layer.succeed( _tag: 'Checkpoint' as const, snapshotBaseline: () => {}, snapshotFinal: () => {}, - classifyChanges: () => null, getCompletedTurns: () => [], - forward: () => null, - hasForwardStack: () => false, getCheckpoints: () => [], + getCheckpointDiff: () => ({ turnId: 0, files: [] }), + revertCheckpointFile: () => ({ reverted: false, throughTurnId: 0, affectedTurns: [], selectedFiles: [], restoreEntry: null }), + revertCheckpointFiles: () => ({ reverted: false, throughTurnId: 0, affectedTurns: [], selectedFiles: [], restoreEntry: null }), + previewRollbackDiff: () => ({ throughTurnId: 0, affectedTurns: [], diff: '' }), + rollbackCodeToTurn: () => ({ reverted: false, throughTurnId: 0, affectedTurns: [], selectedFiles: [], restoreEntry: null }), + undoLastCodeRollback: () => ({ restored: false, conflict: false, conflictFiles: [], restoredFiles: [], remainingRolledBack: [] }), + getLatestRestoreEntry: () => null, } as any) ); diff --git a/packages/codingcode/test/server/handler.test.ts b/packages/codingcode/test/server/handler.test.ts index 5361b3c..c69d35e 100644 --- a/packages/codingcode/test/server/handler.test.ts +++ b/packages/codingcode/test/server/handler.test.ts @@ -194,11 +194,15 @@ const MockCheckpointLayer = Layer.succeed( _tag: 'Checkpoint' as const, snapshotBaseline: () => {}, snapshotFinal: () => {}, - classifyChanges: () => null, getCompletedTurns: () => [], - forward: () => null, - hasForwardStack: () => false, getCheckpoints: () => [], + getCheckpointDiff: () => ({ turnId: 0, files: [] }), + revertCheckpointFile: () => ({ reverted: false, throughTurnId: 0, affectedTurns: [], selectedFiles: [], restoreEntry: null }), + revertCheckpointFiles: () => ({ reverted: false, throughTurnId: 0, affectedTurns: [], selectedFiles: [], restoreEntry: null }), + previewRollbackDiff: () => ({ throughTurnId: 0, affectedTurns: [], diff: '' }), + rollbackCodeToTurn: () => ({ reverted: false, throughTurnId: 0, affectedTurns: [], selectedFiles: [], restoreEntry: null }), + undoLastCodeRollback: () => ({ restored: false, conflict: false, conflictFiles: [], restoredFiles: [], remainingRolledBack: [] }), + getLatestRestoreEntry: () => null, } as any) ); diff --git a/packages/desktop/src/agent/MessageStream.tsx b/packages/desktop/src/agent/MessageStream.tsx index 48a7427..07b3c06 100644 --- a/packages/desktop/src/agent/MessageStream.tsx +++ b/packages/desktop/src/agent/MessageStream.tsx @@ -20,7 +20,7 @@ interface TurnDiffPanelProps { isInterrupted?: boolean; threadId: string; onRevertFile: (uiTurnId: string, file: string, isReverted: boolean) => void; - onRevertScope: (uiTurnId: string, scope: 'agent' | 'all', isReverted: boolean) => void; + onRevertTurn: (uiTurnId: string, files: string[], isReverted: boolean) => void; } function getCheckpointKey( @@ -53,7 +53,7 @@ function TurnDiffPanel({ isInterrupted, threadId, onRevertFile, - onRevertScope, + onRevertTurn, }: TurnDiffPanelProps) { const rawCheckpointDiffByTurnId = useGlobalStore((s) => s.rollback.checkpointDiffByTurnId); const rawTurnCheckpointMapping = useGlobalStore((s) => s.rollback.turnCheckpointMapping); @@ -72,8 +72,7 @@ function TurnDiffPanel({ const ckKey = getCheckpointKey(threadId, uiTurnId, checkpointDiffs, turnCheckpointMapping); const diff = ckKey ? checkpointDiffs[ckKey] : null; const revertedFiles = revertedFilesByTurnId[`${threadId}:${uiTurnId}`] ?? []; - const isAgentReverted = revertedFiles.includes('__scope_agent_reverted__'); - const isAllReverted = revertedFiles.includes('__scope_all_reverted__'); + const isTurnReverted = revertedFiles.includes('__scope_reverted__'); const [expandedFile, setExpandedFile] = useState(null); if (!diff || !diff.files || diff.files.length === 0) { @@ -122,24 +121,14 @@ function TurnDiffPanel({
-
@@ -147,8 +136,7 @@ function TurnDiffPanel({ {diff.files.map((f: any) => { const isExpanded = expandedFile === f.path; const isFileIndividuallyReverted = revertedFiles.includes(f.path); - const isFileScopeReverted = isAllReverted || (isAgentReverted && f.source === 'agent'); - const isReverted = isFileIndividuallyReverted || isFileScopeReverted; + const isReverted = isFileIndividuallyReverted || isTurnReverted; return (
(null); - const markScopeRestored = useGlobalStore((s) => s.markScopeRestored); const markFileRestored = useGlobalStore((s) => s.markFileRestored); const setPendingInput = useGlobalStore((s) => s.setPendingInput); @@ -420,20 +406,19 @@ export default function MessageStream({ threadId }: MessageStreamProps) { [threadId, revertFile, undoCodeRollback, markFileRestored] ); - const handleRevertScope = useCallback( - async (uiTurnId: string, scope: 'agent' | 'all', isReverted: boolean) => { + const handleRevertTurn = useCallback( + async (uiTurnId: string, files: string[], isReverted: boolean) => { if (isReverted) { const result = await undoCodeRollback(threadId, uiTurnId, false); if (result.restored) { - markScopeRestored(threadId, uiTurnId, scope); + const key = `${threadId}:${uiTurnId}`; + delete useGlobalStore.getState().rollback.revertedFilesByTurnId[key]; } - } else if (scope === 'agent') { - await revertAgentFiles(threadId); } else { - await revertAllFiles(threadId); + await revertFiles(threadId, files); } }, - [threadId, revertAgentFiles, revertAllFiles, undoCodeRollback, markScopeRestored] + [threadId, revertFiles, undoCodeRollback] ); const rollbackModal = showRollbackPanel && ( @@ -573,7 +558,7 @@ export default function MessageStream({ threadId }: MessageStreamProps) { isInterrupted={isInterrupted} threadId={threadId} onRevertFile={handleRevertFile} - onRevertScope={handleRevertScope} + onRevertTurn={handleRevertTurn} />
)} diff --git a/packages/desktop/src/hooks/useAgent.ts b/packages/desktop/src/hooks/useAgent.ts index bc2f6b3..64060d5 100644 --- a/packages/desktop/src/hooks/useAgent.ts +++ b/packages/desktop/src/hooks/useAgent.ts @@ -13,8 +13,6 @@ import { getCheckpointDiff, revertCheckpointFile, revertCheckpointFiles, - revertCheckpointAgentFiles, - revertCheckpointAllFiles, previewRollbackDiff, rollbackCodeToTurn, rollbackContext, @@ -362,7 +360,6 @@ export function useAgentRollback() { const setRollbackPreview = useGlobalStore((s) => s.setRollbackPreview); const markFileReverted = useGlobalStore((s) => s.markFileReverted); const markFileRestored = useGlobalStore((s) => s.markFileRestored); - const markScopeReverted = useGlobalStore((s) => s.markScopeReverted); const setTurnCheckpointMapping = useGlobalStore((s) => s.setTurnCheckpointMapping); const initRevertedFilesFromState = useGlobalStore((s) => s.initRevertedFilesFromState); @@ -422,30 +419,6 @@ export function useAgentRollback() { [workspace.rootPath, markFileReverted, resolveUITurnId] ); - const revertAgentFiles = useCallback( - async (threadId: string) => { - const cwd = useGlobalStore.getState().agent.threads[threadId]?.cwd ?? workspace.rootPath; - const { result } = await revertCheckpointAgentFiles(threadId, cwd); - if (result.reverted) { - markScopeReverted(threadId, resolveUITurnId(threadId, result.throughTurnId), 'agent'); - } - return result; - }, - [workspace.rootPath, markScopeReverted, resolveUITurnId] - ); - - const revertAllFiles = useCallback( - async (threadId: string) => { - const cwd = useGlobalStore.getState().agent.threads[threadId]?.cwd ?? workspace.rootPath; - const { result } = await revertCheckpointAllFiles(threadId, cwd); - if (result.reverted) { - markScopeReverted(threadId, resolveUITurnId(threadId, result.throughTurnId), 'all'); - } - return result; - }, - [workspace.rootPath, markScopeReverted, resolveUITurnId] - ); - const previewRollback = useCallback( async (threadId: string, throughTurnId: number) => { const cwd = useGlobalStore.getState().agent.threads[threadId]?.cwd ?? workspace.rootPath; @@ -585,8 +558,6 @@ export function useAgentRollback() { loadCheckpointDiff, revertFile, revertFiles, - revertAgentFiles, - revertAllFiles, previewRollback, rollbackCode, rollbackCtx, diff --git a/packages/desktop/src/lib/core-api.ts b/packages/desktop/src/lib/core-api.ts index 031f7eb..9c2ed8c 100644 --- a/packages/desktop/src/lib/core-api.ts +++ b/packages/desktop/src/lib/core-api.ts @@ -223,7 +223,6 @@ export interface CheckpointDiff { turnId: number; files: Array<{ path: string; - source: 'agent' | 'unknown'; status: string; diff: string; insertions: number; @@ -298,20 +297,6 @@ export function revertCheckpointFiles( return clients.sessions.revertCheckpointFiles({ sessionId, cwd, files }) as any; } -export function revertCheckpointAgentFiles( - sessionId: string, - cwd: string -): Promise<{ ok: boolean; result: CodeRollbackResult }> { - return clients.sessions.revertCheckpointAgentFiles({ sessionId, cwd }) as any; -} - -export function revertCheckpointAllFiles( - sessionId: string, - cwd: string -): Promise<{ ok: boolean; result: CodeRollbackResult }> { - return clients.sessions.revertCheckpointAllFiles({ sessionId, cwd }) as any; -} - export function previewRollbackDiff( sessionId: string, cwd: string, diff --git a/packages/desktop/src/stores/global.store.ts b/packages/desktop/src/stores/global.store.ts index f847b99..cb6a708 100644 --- a/packages/desktop/src/stores/global.store.ts +++ b/packages/desktop/src/stores/global.store.ts @@ -169,8 +169,6 @@ interface GlobalActions { clearRollbackPreview: (threadId: string) => void; markFileReverted: (threadId: string, turnId: string, file: string) => void; markFileRestored: (threadId: string, turnId: string, file: string) => void; - markScopeReverted: (threadId: string, turnId: string, scope: 'agent' | 'all') => void; - markScopeRestored: (threadId: string, turnId: string, scope: 'agent' | 'all') => void; initRevertedFilesFromState: (threadId: string) => void; setTurnCheckpointMapping: (threadId: string, checkpointId: number, uiTurnId: string) => void; startCompressing: () => void; @@ -677,31 +675,6 @@ export const useGlobalStore = create()( s.rollback.revertedFilesByTurnId[key] = arr.filter((f) => f !== file); } }), - markScopeReverted: (threadId, turnId, scope) => - set((s) => { - const key = `${threadId}:${turnId}`; - const sentinel = - scope === 'agent' ? '__scope_agent_reverted__' : '__scope_all_reverted__'; - if (!s.rollback.revertedFilesByTurnId[key]) { - s.rollback.revertedFilesByTurnId[key] = []; - } - if (!s.rollback.revertedFilesByTurnId[key].includes(sentinel)) { - s.rollback.revertedFilesByTurnId[key].push(sentinel); - } - }), - markScopeRestored: (threadId, turnId, scope) => - set((s) => { - const key = `${threadId}:${turnId}`; - const sentinel = - scope === 'agent' ? '__scope_agent_reverted__' : '__scope_all_reverted__'; - const arr = s.rollback.revertedFilesByTurnId[key]; - if (arr) { - s.rollback.revertedFilesByTurnId[key] = arr.filter((f) => f !== sentinel); - if (s.rollback.revertedFilesByTurnId[key].length === 0) { - delete s.rollback.revertedFilesByTurnId[key]; - } - } - }), initRevertedFilesFromState: (threadId) => set((s) => { const state = s.rollback.rollbackStateByThreadId[threadId]; diff --git a/packages/desktop/test/global-store-rollback-state.test.ts b/packages/desktop/test/global-store-rollback-state.test.ts index e5a1ccf..d6022c6 100644 --- a/packages/desktop/test/global-store-rollback-state.test.ts +++ b/packages/desktop/test/global-store-rollback-state.test.ts @@ -38,7 +38,7 @@ describe('Rollback state in global store', () => { files: [ { path: '/test/a.ts', - source: 'agent' as const, + status: 'M', diff: '---\n+++\n', insertions: 2, @@ -104,35 +104,6 @@ describe('Rollback state in global store', () => { expect(reverted).toEqual(['/test/b.ts']); }); - it('markScopeReverted sets sentinel', () => { - useGlobalStore.getState().markScopeReverted('thread1', '3', 'agent'); - const reverted = useGlobalStore.getState().rollback.revertedFilesByTurnId['thread1:3']; - expect(reverted).toContain('__scope_agent_reverted__'); - }); - - it('markScopeReverted differentiates agent and all scopes', () => { - useGlobalStore.getState().markScopeReverted('thread1', '3', 'agent'); - useGlobalStore.getState().markScopeReverted('thread1', '3', 'all'); - const reverted = useGlobalStore.getState().rollback.revertedFilesByTurnId['thread1:3']; - expect(reverted).toContain('__scope_agent_reverted__'); - expect(reverted).toContain('__scope_all_reverted__'); - }); - - it('markScopeRestored removes correct sentinel', () => { - useGlobalStore.getState().markScopeReverted('thread1', '3', 'agent'); - useGlobalStore.getState().markScopeReverted('thread1', '3', 'all'); - useGlobalStore.getState().markScopeRestored('thread1', '3', 'agent'); - const reverted = useGlobalStore.getState().rollback.revertedFilesByTurnId['thread1:3']; - expect(reverted).not.toContain('__scope_agent_reverted__'); - expect(reverted).toContain('__scope_all_reverted__'); - }); - - it('markScopeRestored removes entry when empty', () => { - useGlobalStore.getState().markScopeReverted('thread1', '3', 'agent'); - useGlobalStore.getState().markScopeRestored('thread1', '3', 'agent'); - expect(useGlobalStore.getState().rollback.revertedFilesByTurnId['thread1:3']).toBeUndefined(); - }); - it('initRevertedFilesFromState populates from server state', () => { useGlobalStore.getState().setTurnCheckpointMapping('thread1', 5, 'ui-turn-5'); const state = { @@ -165,7 +136,7 @@ describe('Rollback state in global store', () => { files: [ { path: '/a.ts', - source: 'agent' as const, + status: 'M', diff: '---\n+++\n', insertions: 1, @@ -186,7 +157,7 @@ describe('Rollback state in global store', () => { files: [ { path: '/b.ts', - source: 'agent' as const, + status: 'M', diff: '---\n+++\n', insertions: 1, diff --git a/packages/tui/src/commands/registry.ts b/packages/tui/src/commands/registry.ts index ea60215..bece142 100644 --- a/packages/tui/src/commands/registry.ts +++ b/packages/tui/src/commands/registry.ts @@ -14,12 +14,6 @@ export const COMMAND_REGISTRY = { usage: '/sessions', title: '恢复会话', }, - checkpoint: { - name: 'checkpoint', - description: '管理文件快照,回退/前进', - usage: '/checkpoint', - title: '检查点', - }, help: { name: 'help', description: '显示帮助', usage: '/help', title: '帮助' }, clear: { name: 'clear', diff --git a/packages/tui/src/components/App.tsx b/packages/tui/src/components/App.tsx index acacf9e..470c328 100644 --- a/packages/tui/src/components/App.tsx +++ b/packages/tui/src/components/App.tsx @@ -254,24 +254,6 @@ export function App({ client }: AppProps) { } return; } - if (parsed.name === 'checkpoint') { - try { - const checkpoints = await client.getCheckpoints(); - setPanel({ type: 'checkpoint-list', checkpoints }); - } catch (e: any) { - setStaticMessages((prev) => [ - ...prev, - { - id: generateId(), - timestamp: Date.now(), - role: 'system' as const, - content: `[Checkpoint Error] ${e.message || e}`, - }, - ]); - return; - } - return; - } if (parsed.name === 'help') { setPanel({ type: 'help' }); return; @@ -426,57 +408,6 @@ export function App({ client }: AppProps) { width={sessionW} /> )} - {panel.type === 'checkpoint-list' && ( - ({ - label: `${cp.title || '(无标题)'} ${cp.agentModified.length + cp.unknownSource.length} 个文件`, - value: cp.turnId.toString(), - })) - } - onSelect={async (value) => { - const cp = panel.checkpoints.find((c) => c.turnId.toString() === value); - if (!cp) return; - const hasForward = await client.hasForwardStack(); - setPanel({ type: 'checkpoint-action', cp, hasForward }); - }} - onCancel={() => setPanel({ type: 'none' })} - width={Math.min(60, width - 4)} - /> - )} - {panel.type === 'checkpoint-action' && ( - 0 - ? [ - { - label: `仅回退 Agent 修改的文件 (${panel.cp.agentModified.length} 个)`, - value: 'agent' as const, - }, - { - label: `回退全部文件 (${panel.cp.agentModified.length + panel.cp.unknownSource.length} 个)`, - value: 'all' as const, - }, - ] - : [{ label: '无变更文件', value: '' as const }]), - ...(panel.hasForward ? [{ label: '前进到最新状态', value: 'forward' as const }] : []), - ]} - onSelect={async (value) => { - if (value === 'forward') { - await client.forwardLastRevert(); - } else if (value === 'agent' || value === 'all') { - await client.revertCheckpoint(panel.cp.turnId, value); - } - setPanel({ type: 'none' }); - }} - onCancel={() => setPanel({ type: 'none' })} - width={Math.min(60, width - 4)} - /> - )} {approval && ( { description?: string; } -export interface CheckpointInfo { - turnId: number; - title: string; - agentModified: string[]; - unknownSource: string[]; -} - export interface McpServerStatus { name: string; connected: boolean; @@ -51,8 +44,6 @@ export type PanelState = | { type: 'model'; items: PanelItem[]; activeValue: string } | { type: 'sessions'; items: PanelItem[] } | { type: 'approval'; id: string; tool: string; args: Record } - | { type: 'checkpoint-list'; checkpoints: CheckpointInfo[] } - | { type: 'checkpoint-action'; cp: CheckpointInfo; hasForward: boolean } | { type: 'help' } | { type: 'mcp'; servers: McpServerStatus[] } | { type: 'skill'; skills: SkillStatus[] } From 9d504355ff969ca6fb7f97c493112fa546c1d300 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 11 Jun 2026 15:50:51 +0800 Subject: [PATCH 06/11] move toGitPath --- .../src/checkpoint/checkpoint-service.ts | 51 +++++-------------- .../src/checkpoint/commit-naming.ts | 9 ---- .../src/checkpoint/restore-planning.ts | 2 +- .../src/checkpoint/restore-store.ts | 2 +- .../src/checkpoint/rollback-engine.ts | 2 +- packages/codingcode/src/checkpoint/utils.ts | 32 ++++++++++++ .../test/checkpoint/checkpoint-diff.test.ts | 24 ++------- .../test/checkpoint/checkpoint-undo.test.ts | 8 +-- 8 files changed, 57 insertions(+), 73 deletions(-) delete mode 100644 packages/codingcode/src/checkpoint/commit-naming.ts create mode 100644 packages/codingcode/src/checkpoint/utils.ts diff --git a/packages/codingcode/src/checkpoint/checkpoint-service.ts b/packages/codingcode/src/checkpoint/checkpoint-service.ts index 29354a4..6d54203 100644 --- a/packages/codingcode/src/checkpoint/checkpoint-service.ts +++ b/packages/codingcode/src/checkpoint/checkpoint-service.ts @@ -1,11 +1,10 @@ import { Effect } from 'effect'; import { createHash } from 'crypto'; -import { readFileSync } from 'fs'; import { resolve } from 'path'; import { ShadowGit } from './shadow-git.js'; import { ProjectLock } from './project-lock.js'; import { normalizePath } from '../core/path.js'; -import { shortSid, commitMsg } from './commit-naming.js'; +import { shortSid, commitMsg, toGitPath, hashWorkspaceFile } from './utils.js'; import { readRestoreEntry, writeRestoreEntry } from './restore-store.js'; import { getCompletedTurnsFor, @@ -60,28 +59,6 @@ export interface CodeRestoreEntry { timestamp: string; } -// ---- Path utilities ---- - -export function toGitPath(projectPath: string, file: string): string { - const normalized = normalizePath(file); - const base = normalizePath(projectPath); - if (normalized.toLowerCase().startsWith(base.toLowerCase())) { - let rel = normalized.slice(base.length); - if (rel.startsWith('/') || rel.startsWith('\\')) rel = rel.slice(1); - return rel; - } - return normalized; -} - -export function hashWorkspaceFile(projectPath: string, file: string): string | null { - try { - const content = readFileSync(resolve(projectPath, toGitPath(projectPath, file))); - return createHash('sha256').update(content).digest('hex'); - } catch { - return null; - } -} - // ---- Service ---- export class CheckpointService extends Effect.Service()('Checkpoint', { @@ -110,6 +87,16 @@ export class CheckpointService extends Effect.Service()('Chec return lock; } + function doSnapshotFinal(sg: ShadowGit, sessionId: string, turnId: number): void { + const lock = lockFor(sg.projectPath); + lock.lock(); + try { + sg.commit(commitMsg(sessionId, turnId, 'final')); + } finally { + lock.unlock(); + } + } + function repairIncompleteTurn(sg: ShadowGit, sessionId: string): void { const completed = getCompletedTurnsFor(sg, sessionId); const candidate = completed.length > 0 ? completed[completed.length - 1]! + 1 : 1; @@ -117,13 +104,7 @@ export class CheckpointService extends Effect.Service()('Chec if (!baseline) return; const final = sg.findCommitByMessage(commitMsg(sessionId, candidate, 'final')); if (final) return; - const lock = lockFor(sg.projectPath); - lock.lock(); - try { - sg.commit(commitMsg(sessionId, candidate, 'final')); - } finally { - lock.unlock(); - } + doSnapshotFinal(sg, sessionId, candidate); } return { @@ -153,13 +134,7 @@ export class CheckpointService extends Effect.Service()('Chec snapshotFinal: (projectPath: string, sessionId: string, turnId: number): void => { const sg = ensure(projectPath); if (sg.isTooLargeForSnapshot()) return; - const lock = lockFor(projectPath); - lock.lock(); - try { - sg.commit(commitMsg(sessionId, turnId, 'final')); - } finally { - lock.unlock(); - } + doSnapshotFinal(sg, sessionId, turnId); }, // ---- Query ---- diff --git a/packages/codingcode/src/checkpoint/commit-naming.ts b/packages/codingcode/src/checkpoint/commit-naming.ts deleted file mode 100644 index 06101f1..0000000 --- a/packages/codingcode/src/checkpoint/commit-naming.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createHash } from 'crypto'; - -export function shortSid(sessionId: string): string { - return createHash('sha256').update(sessionId).digest('hex').slice(0, 8); -} - -export function commitMsg(sessionId: string, turnId: number, suffix: string): string { - return `turn-${shortSid(sessionId)}-${turnId}-${suffix}`; -} diff --git a/packages/codingcode/src/checkpoint/restore-planning.ts b/packages/codingcode/src/checkpoint/restore-planning.ts index cfec93b..b4e86aa 100644 --- a/packages/codingcode/src/checkpoint/restore-planning.ts +++ b/packages/codingcode/src/checkpoint/restore-planning.ts @@ -1,5 +1,5 @@ import type { ShadowGit } from './shadow-git.js'; -import { shortSid, commitMsg } from './commit-naming.js'; +import { shortSid, commitMsg } from './utils.js'; export interface RestorePlan { throughTurnId: number; diff --git a/packages/codingcode/src/checkpoint/restore-store.ts b/packages/codingcode/src/checkpoint/restore-store.ts index ec14674..0ca7f5f 100644 --- a/packages/codingcode/src/checkpoint/restore-store.ts +++ b/packages/codingcode/src/checkpoint/restore-store.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs'; import { join } from 'path'; import type { CodeRestoreEntry } from './checkpoint-service.js'; -import { shortSid } from './commit-naming.js'; +import { shortSid } from './utils.js'; function restorePath(gitDir: string, sessionId: string): string { return join(gitDir, '..', `last-restore-${shortSid(sessionId)}.json`); diff --git a/packages/codingcode/src/checkpoint/rollback-engine.ts b/packages/codingcode/src/checkpoint/rollback-engine.ts index 4f82837..949edad 100644 --- a/packages/codingcode/src/checkpoint/rollback-engine.ts +++ b/packages/codingcode/src/checkpoint/rollback-engine.ts @@ -3,7 +3,7 @@ import { normalizePath } from '../core/path.js'; import type { ShadowGit } from './shadow-git.js'; import type { ProjectLock } from './project-lock.js'; import type { CodeRollbackResult, CodeRestoreEntry } from './checkpoint-service.js'; -import { commitMsg } from './commit-naming.js'; +import { commitMsg } from './utils.js'; import { readRestoreEntry, writeRestoreEntry } from './restore-store.js'; export function emptyRollbackResult( diff --git a/packages/codingcode/src/checkpoint/utils.ts b/packages/codingcode/src/checkpoint/utils.ts new file mode 100644 index 0000000..75d0164 --- /dev/null +++ b/packages/codingcode/src/checkpoint/utils.ts @@ -0,0 +1,32 @@ +import { createHash } from 'crypto'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { normalizePath } from '../core/path.js'; + +export function shortSid(sessionId: string): string { + return createHash('sha256').update(sessionId).digest('hex').slice(0, 8); +} + +export function commitMsg(sessionId: string, turnId: number, suffix: string): string { + return `turn-${shortSid(sessionId)}-${turnId}-${suffix}`; +} + +export function toGitPath(projectPath: string, file: string): string { + const normalized = normalizePath(file); + const base = normalizePath(projectPath); + if (normalized.toLowerCase().startsWith(base.toLowerCase())) { + let rel = normalized.slice(base.length); + if (rel.startsWith('/') || rel.startsWith('\\')) rel = rel.slice(1); + return rel; + } + return normalized; +} + +export function hashWorkspaceFile(projectPath: string, file: string): string | null { + try { + const content = readFileSync(resolve(projectPath, toGitPath(projectPath, file))); + return createHash('sha256').update(content).digest('hex'); + } catch { + return null; + } +} diff --git a/packages/codingcode/test/checkpoint/checkpoint-diff.test.ts b/packages/codingcode/test/checkpoint/checkpoint-diff.test.ts index 84e354e..a6ae912 100644 --- a/packages/codingcode/test/checkpoint/checkpoint-diff.test.ts +++ b/packages/codingcode/test/checkpoint/checkpoint-diff.test.ts @@ -34,39 +34,25 @@ function writeFile(projectPath: string, filename: string, content: string) { writeFileSync(filePath, content, 'utf8'); } -describe('toGitPath and hashWorkspaceFile', () => { - it('toGitPath converts absolute to relative', async () => { - const { toGitPath } = await import('../../src/checkpoint/checkpoint-service.js'); +describe('toGitPath', () => { + it('converts absolute to relative', async () => { + const { toGitPath } = await import('../../src/checkpoint/utils.js'); const result = toGitPath('/tmp/project', '/tmp/project/src/file.ts'); expect(result).toBe('src/file.ts'); }); - it('toGitPath returns normalized path when not under project', async () => { - const { toGitPath } = await import('../../src/checkpoint/checkpoint-service.js'); + it('returns normalized path when not under project', async () => { + const { toGitPath } = await import('../../src/checkpoint/utils.js'); const result = toGitPath('/tmp/project', '/other/file.ts'); expect(result).toContain('file.ts'); }); - - it('hashWorkspaceFile returns null for non-existent file', async () => { - const { hashWorkspaceFile } = await import('../../src/checkpoint/checkpoint-service.js'); - const result = hashWorkspaceFile('/tmp/nonexistent', 'nonexistent.ts'); - expect(result).toBeNull(); - }); }); describe('CodeRestoreEntry types', () => { it('CodeRestoreEntry type is exported', async () => { const mod = await import('../../src/checkpoint/checkpoint-service.js'); - // Verify the service class is exported expect(typeof mod.CheckpointService).toBe('function'); }); - - it('toGitPath and hashWorkspaceFile are exported as functions', async () => { - const { toGitPath, hashWorkspaceFile } = - await import('../../src/checkpoint/checkpoint-service.js'); - expect(typeof toGitPath).toBe('function'); - expect(typeof hashWorkspaceFile).toBe('function'); - }); }); describe('CheckpointDiff type with insertions/deletions', () => { diff --git a/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts b/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts index 6b21b59..eb61e8a 100644 --- a/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts +++ b/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts @@ -43,7 +43,7 @@ function writeFile(projectPath: string, filename: string, content: string) { describe('toGitPath case-insensitive matching', () => { it('handles Windows case-mismatched projectPath and file path', async () => { - const { toGitPath } = await import('../../src/checkpoint/checkpoint-service.js'); + const { toGitPath } = await import('../../src/checkpoint/utils.js'); // projectPath has mixed case (Users, Desktop), file path is all lowercase const projectPath = 'c:/Users/Alice/Desktop/MyProject'; @@ -54,7 +54,7 @@ describe('toGitPath case-insensitive matching', () => { }); it('handles lowercase projectPath with uppercase file path', async () => { - const { toGitPath } = await import('../../src/checkpoint/checkpoint-service.js'); + const { toGitPath } = await import('../../src/checkpoint/utils.js'); const projectPath = 'c:/users/alice/desktop/myproject'; const filePath = 'c:/Users/Alice/Desktop/MyProject/src/file.ts'; @@ -64,7 +64,7 @@ describe('toGitPath case-insensitive matching', () => { }); it('still returns normalized absolute path when file is outside project', async () => { - const { toGitPath } = await import('../../src/checkpoint/checkpoint-service.js'); + const { toGitPath } = await import('../../src/checkpoint/utils.js'); const result = toGitPath('c:/Users/Alice/Desktop/MyProject', 'c:/other/file.ts'); @@ -310,7 +310,7 @@ describe('rollbackCodeToTurn uses inclusive target turn', () => { describe('toGitPath preserves original casing for git paths', () => { it('returns relative path with original casing from git diff', async () => { - const { toGitPath } = await import('../../src/checkpoint/checkpoint-service.js'); + const { toGitPath } = await import('../../src/checkpoint/utils.js'); // Simulate a path that git returns with original casing const projectPath = 'c:/Users/Alice/Desktop/MyProject'; From 37a981628bebcfaab4a6d5d2f9f8a2e1971b43cd Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 11 Jun 2026 16:18:58 +0800 Subject: [PATCH 07/11] Optimize context module structure --- .../src/checkpoint/checkpoint-service.ts | 23 +++++++------------ packages/codingcode/src/checkpoint/utils.ts | 17 ++++++++++++++ .../llm-resolver.ts => compaction-llm.ts} | 6 ++--- .../prompt.ts => compaction-prompt.ts} | 0 .../{compressor/index.ts => compressor.ts} | 20 ++++++++-------- packages/codingcode/src/context/context.ts | 2 +- packages/codingcode/src/context/organizer.ts | 2 +- .../src/context/{utils/tokens.ts => util.ts} | 2 +- packages/codingcode/src/session/store.ts | 2 +- .../test/context/append-turn-end.test.ts | 2 +- .../test/context/compressor/behavior.test.ts | 4 ++-- .../compressor/compact-if-needed.test.ts | 8 +++---- .../context/compressor/llm-resolver.test.ts | 2 +- .../codingcode/test/context/tokens.test.ts | 2 +- .../test/session/prompt-estimate.test.ts | 2 +- 15 files changed, 52 insertions(+), 42 deletions(-) rename packages/codingcode/src/context/{compressor/llm-resolver.ts => compaction-llm.ts} (85%) rename packages/codingcode/src/context/{compressor/prompt.ts => compaction-prompt.ts} (100%) rename packages/codingcode/src/context/{compressor/index.ts => compressor.ts} (93%) rename packages/codingcode/src/context/{utils/tokens.ts => util.ts} (94%) diff --git a/packages/codingcode/src/checkpoint/checkpoint-service.ts b/packages/codingcode/src/checkpoint/checkpoint-service.ts index 6d54203..a42f01d 100644 --- a/packages/codingcode/src/checkpoint/checkpoint-service.ts +++ b/packages/codingcode/src/checkpoint/checkpoint-service.ts @@ -4,7 +4,7 @@ import { resolve } from 'path'; import { ShadowGit } from './shadow-git.js'; import { ProjectLock } from './project-lock.js'; import { normalizePath } from '../core/path.js'; -import { shortSid, commitMsg, toGitPath, hashWorkspaceFile } from './utils.js'; +import { shortSid, commitMsg, toGitPath, hashWorkspaceFile, ProjectCache } from './utils.js'; import { readRestoreEntry, writeRestoreEntry } from './restore-store.js'; import { getCompletedTurnsFor, @@ -63,28 +63,21 @@ export interface CodeRestoreEntry { export class CheckpointService extends Effect.Service()('Checkpoint', { effect: Effect.gen(function* () { - const shadowGitByProject = new Map(); - const lockByProject = new Map(); + const shadowGitByProject = new ProjectCache(10); + const lockByProject = new ProjectCache(10); function ensure(projectPath: string): ShadowGit { const normalized = normalizePath(projectPath); - let sg = shadowGitByProject.get(normalized); - if (!sg || sg.projectPath !== normalized) { - sg = new ShadowGit(normalized); + return shadowGitByProject.get(normalized, () => { + const sg = new ShadowGit(normalized); sg.init(); - shadowGitByProject.set(normalized, sg); - } - return sg; + return sg; + }); } function lockFor(projectPath: string): ProjectLock { const normalized = normalizePath(projectPath); - let lock = lockByProject.get(normalized); - if (!lock) { - lock = new ProjectLock(normalized); - lockByProject.set(normalized, lock); - } - return lock; + return lockByProject.get(normalized, () => new ProjectLock(normalized)); } function doSnapshotFinal(sg: ShadowGit, sessionId: string, turnId: number): void { diff --git a/packages/codingcode/src/checkpoint/utils.ts b/packages/codingcode/src/checkpoint/utils.ts index 75d0164..a222948 100644 --- a/packages/codingcode/src/checkpoint/utils.ts +++ b/packages/codingcode/src/checkpoint/utils.ts @@ -30,3 +30,20 @@ export function hashWorkspaceFile(projectPath: string, file: string): string | n return null; } } + +export class ProjectCache { + private map = new Map(); + private order: string[] = []; + constructor(private max: number) {} + get(key: string, factory: () => T): T { + const hit = this.map.get(key); + if (hit) return hit; + const value = factory(); + this.map.set(key, value); + this.order.push(key); + if (this.order.length > this.max) { + this.map.delete(this.order.shift()!); + } + return value; + } +} diff --git a/packages/codingcode/src/context/compressor/llm-resolver.ts b/packages/codingcode/src/context/compaction-llm.ts similarity index 85% rename from packages/codingcode/src/context/compressor/llm-resolver.ts rename to packages/codingcode/src/context/compaction-llm.ts index f762416..9b510ad 100644 --- a/packages/codingcode/src/context/compressor/llm-resolver.ts +++ b/packages/codingcode/src/context/compaction-llm.ts @@ -1,6 +1,6 @@ -import { findModel, createClient } from '../../llm/factory.js'; -import type { LLMClient } from '../../llm/client.js'; -import type { ContextConfig } from '../config.js'; +import { findModel, createClient } from '../llm/factory.js'; +import type { LLMClient } from '../llm/client.js'; +import type { ContextConfig } from './config.js'; /** * Resolve which LLM client to use for compaction. diff --git a/packages/codingcode/src/context/compressor/prompt.ts b/packages/codingcode/src/context/compaction-prompt.ts similarity index 100% rename from packages/codingcode/src/context/compressor/prompt.ts rename to packages/codingcode/src/context/compaction-prompt.ts diff --git a/packages/codingcode/src/context/compressor/index.ts b/packages/codingcode/src/context/compressor.ts similarity index 93% rename from packages/codingcode/src/context/compressor/index.ts rename to packages/codingcode/src/context/compressor.ts index 2e740e9..28d913a 100644 --- a/packages/codingcode/src/context/compressor/index.ts +++ b/packages/codingcode/src/context/compressor.ts @@ -1,18 +1,18 @@ import { randomUUID } from 'crypto'; -import { resolveSessionDir } from '../../session/io.js'; +import { resolveSessionDir } from '../session/io.js'; import { estimateTokens, estimateMessageTokens, estimateTokensForContent, -} from '../utils/tokens.js'; -import { applyVisibilityEvents } from '../../session/messages.js'; -import { resolveCompactionLLM } from './llm-resolver.js'; -import { COMPACTION_SYSTEM_PROMPT } from './prompt.js'; -import type { ContextConfig } from '../config.js'; -import type { Message } from '../../core/types.js'; -import type { SessionEvent, SummaryEvent } from '../../session/types.js'; -import type { LLMClient } from '../../llm/client.js'; -import { assemblePayload } from '../organizer.js'; +} from './util.js'; +import { applyVisibilityEvents } from '../session/messages.js'; +import { resolveCompactionLLM } from './compaction-llm.js'; +import { COMPACTION_SYSTEM_PROMPT } from './compaction-prompt.js'; +import type { ContextConfig } from './config.js'; +import type { Message } from '../core/types.js'; +import type { SessionEvent, SummaryEvent } from '../session/types.js'; +import type { LLMClient } from '../llm/client.js'; +import { assemblePayload } from './organizer.js'; import { join } from 'path'; import { appendFileSync } from 'fs'; diff --git a/packages/codingcode/src/context/context.ts b/packages/codingcode/src/context/context.ts index 09c3733..7ce866b 100644 --- a/packages/codingcode/src/context/context.ts +++ b/packages/codingcode/src/context/context.ts @@ -1,6 +1,6 @@ import { Effect } from 'effect'; import { getContextConfig, type ContextConfig } from './config.js'; -import { compactWithLLM, compactIfNeeded, type CompressResult } from './compressor/index.js'; +import { compactWithLLM, compactIfNeeded, type CompressResult } from './compressor.js'; import { assemblePayload, type BuildResult } from './organizer.js'; import type { LLMClient } from '../llm/client.js'; diff --git a/packages/codingcode/src/context/organizer.ts b/packages/codingcode/src/context/organizer.ts index 06afdcf..d598016 100644 --- a/packages/codingcode/src/context/organizer.ts +++ b/packages/codingcode/src/context/organizer.ts @@ -2,7 +2,7 @@ import type { ContextConfig } from './config.js'; import type { Message } from '../core/types.js'; import { findSessionIndex, resolveSessionDir, readHistory, appendLine } from '../session/io.js'; import { applyVisibilityEvents, buildMessagesFromEvents } from '../session/messages.js'; -import { estimateTokens } from './utils/tokens.js'; +import { estimateTokens } from './util.js'; import { join } from 'path'; import { randomUUID } from 'crypto'; import type { SessionEvent, ToolResultEvent, CompactEvent } from '../session/types.js'; diff --git a/packages/codingcode/src/context/utils/tokens.ts b/packages/codingcode/src/context/util.ts similarity index 94% rename from packages/codingcode/src/context/utils/tokens.ts rename to packages/codingcode/src/context/util.ts index 02fdec7..d8fa3ba 100644 --- a/packages/codingcode/src/context/utils/tokens.ts +++ b/packages/codingcode/src/context/util.ts @@ -1,4 +1,4 @@ -import type { Message } from '../../core/types.js'; +import type { Message } from '../core/types.js'; export function estimateMessageTokens(m: Message): number { let tokens = estimateTokensForContent(m.content ?? ''); diff --git a/packages/codingcode/src/session/store.ts b/packages/codingcode/src/session/store.ts index 30aa96b..7484d35 100644 --- a/packages/codingcode/src/session/store.ts +++ b/packages/codingcode/src/session/store.ts @@ -21,7 +21,7 @@ import { estimateTokens, estimateTokensForContent, estimateMessageTokens, -} from '../context/utils/tokens.js'; +} from '../context/util.js'; import { projectSessionsDir, ensureDirs, diff --git a/packages/codingcode/test/context/append-turn-end.test.ts b/packages/codingcode/test/context/append-turn-end.test.ts index bb73fc7..f15bde6 100644 --- a/packages/codingcode/test/context/append-turn-end.test.ts +++ b/packages/codingcode/test/context/append-turn-end.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; import { randomUUID } from 'crypto'; -import { estimateTokensForContent } from '../../src/context/utils/tokens.js'; +import { estimateTokensForContent } from '../../src/context/util.js'; import { getContextConfig } from '../../src/context/config.js'; const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); diff --git a/packages/codingcode/test/context/compressor/behavior.test.ts b/packages/codingcode/test/context/compressor/behavior.test.ts index 9426041..393d727 100644 --- a/packages/codingcode/test/context/compressor/behavior.test.ts +++ b/packages/codingcode/test/context/compressor/behavior.test.ts @@ -3,13 +3,13 @@ import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; import { randomUUID } from 'crypto'; -import { compactWithLLM } from '../../../src/context/compressor/index.js'; +import { compactWithLLM } from '../../../src/context/compressor.js'; import type { ContextConfig } from '../../../src/context/config.js'; import type { LLMClient } from '../../../src/llm/client.js'; import { Result } from '../../../src/core/result.js'; import type { SessionIndex, SessionEvent, SummaryEvent } from '../../../src/session/types.js'; import { buildMessages } from '../../../src/session/messages.js'; -import { estimateTokens } from '../../../src/context/utils/tokens.js'; +import { estimateTokens } from '../../../src/context/util.js'; const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); diff --git a/packages/codingcode/test/context/compressor/compact-if-needed.test.ts b/packages/codingcode/test/context/compressor/compact-if-needed.test.ts index a96d5b1..394e04c 100644 --- a/packages/codingcode/test/context/compressor/compact-if-needed.test.ts +++ b/packages/codingcode/test/context/compressor/compact-if-needed.test.ts @@ -39,7 +39,7 @@ vi.mock('../../../src/session/io.js', async (importOriginal) => { }; }); -vi.mock('../../../src/context/compressor/llm-resolver.js', () => ({ +vi.mock('../../../src/context/compaction-llm.js', () => ({ resolveCompactionLLM: vi.fn(() => Promise.resolve(mockLLM)), })); @@ -51,15 +51,15 @@ vi.mock('fs', async (importOriginal) => { }; }); -vi.mock('../../../src/context/utils/tokens.js', () => ({ +vi.mock('../../../src/context/util.js', () => ({ estimateTokens: vi.fn(), estimateMessageTokens: vi.fn(), estimateTokensForContent: vi.fn(), })); -import { compactIfNeeded } from '../../../src/context/compressor/index.js'; +import { compactIfNeeded } from '../../../src/context/compressor.js'; import { findSessionIndex } from '../../../src/session/io.js'; -import { estimateTokens, estimateMessageTokens } from '../../../src/context/utils/tokens.js'; +import { estimateTokens, estimateMessageTokens } from '../../../src/context/util.js'; function config(threshold: number, maxTokens = 10000) { return { diff --git a/packages/codingcode/test/context/compressor/llm-resolver.test.ts b/packages/codingcode/test/context/compressor/llm-resolver.test.ts index 5c2ce76..ed59183 100644 --- a/packages/codingcode/test/context/compressor/llm-resolver.test.ts +++ b/packages/codingcode/test/context/compressor/llm-resolver.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { resolveCompactionLLM } from '../../../src/context/compressor/llm-resolver.js'; +import { resolveCompactionLLM } from '../../../src/context/compaction-llm.js'; import type { LLMClient } from '../../../src/llm/client.js'; import type { ContextConfig } from '../../../src/context/config.js'; diff --git a/packages/codingcode/test/context/tokens.test.ts b/packages/codingcode/test/context/tokens.test.ts index ab6f220..3a56af3 100644 --- a/packages/codingcode/test/context/tokens.test.ts +++ b/packages/codingcode/test/context/tokens.test.ts @@ -3,7 +3,7 @@ import { estimateTokensForContent, estimateTokens, estimateMessageTokens, -} from '../../src/context/utils/tokens.js'; +} from '../../src/context/util.js'; describe('token estimation', () => { it('empty content returns 0', () => { diff --git a/packages/codingcode/test/session/prompt-estimate.test.ts b/packages/codingcode/test/session/prompt-estimate.test.ts index 94871bb..32778de 100644 --- a/packages/codingcode/test/session/prompt-estimate.test.ts +++ b/packages/codingcode/test/session/prompt-estimate.test.ts @@ -7,7 +7,7 @@ import { Effect } from 'effect'; import { forkSession, SessionService } from '../../src/session/store.js'; import { findSessionIndex } from '../../src/session/io.js'; import { findLastVisibleAssistantUsage, buildMessages } from '../../src/session/messages.js'; -import { estimateTokensForContent, estimateTokens } from '../../src/context/utils/tokens.js'; +import { estimateTokensForContent, estimateTokens } from '../../src/context/util.js'; import { encodeProjectPath } from '../../src/core/path.js'; import type { SessionIndex, SessionEvent } from '../../src/session/types.js'; From d107100c37c256593f3db67caca8154e86c01a3d Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 11 Jun 2026 19:33:13 +0800 Subject: [PATCH 08/11] delete repeat revertCheckpointFile --- .../src/checkpoint/checkpoint-service.ts | 16 +--------------- packages/codingcode/src/client/direct.ts | 16 ---------------- .../codingcode/src/client/direct/sessions.ts | 10 ---------- packages/codingcode/src/client/http.ts | 9 --------- packages/codingcode/src/client/http/sessions.ts | 5 ----- packages/codingcode/src/client/types.ts | 1 - .../codingcode/src/server/routes/sessions.ts | 2 +- .../test/checkpoint/checkpoint-undo.test.ts | 4 ++-- packages/codingcode/test/context/context.test.ts | 1 - packages/codingcode/test/orchestrate.test.ts | 1 - packages/codingcode/test/server/handler.test.ts | 1 - packages/desktop/src/hooks/useAgent.ts | 3 +-- packages/desktop/src/lib/core-api.ts | 8 -------- 13 files changed, 5 insertions(+), 72 deletions(-) diff --git a/packages/codingcode/src/checkpoint/checkpoint-service.ts b/packages/codingcode/src/checkpoint/checkpoint-service.ts index a42f01d..ea1658d 100644 --- a/packages/codingcode/src/checkpoint/checkpoint-service.ts +++ b/packages/codingcode/src/checkpoint/checkpoint-service.ts @@ -51,7 +51,7 @@ export interface RollbackPreviewDiff { export interface CodeRestoreEntry { id: string; sessionId: string; - action: 'checkpoint-file' | 'checkpoint-files' | 'rollback-to-turn'; + action: 'checkpoint-files' | 'rollback-to-turn'; throughTurnId: number; affectedTurns: number[]; selectedFiles: string[]; @@ -225,20 +225,6 @@ export class CheckpointService extends Effect.Service()('Chec // ---- Revert ---- - revertCheckpointFile: ( - projectPath: string, - sessionId: string, - turnId: number, - file: string - ): CodeRollbackResult => { - const sg = ensure(projectPath); - const plan = getTurnRestorePlan(sg, sessionId, turnId); - if (!plan) { - return emptyRollbackResult(turnId); - } - return executeRollback(sessionId, plan, [file], 'checkpoint-file', sg, lockFor(projectPath)); - }, - revertCheckpointFiles: ( projectPath: string, sessionId: string, diff --git a/packages/codingcode/src/client/direct.ts b/packages/codingcode/src/client/direct.ts index 872627b..36875ac 100644 --- a/packages/codingcode/src/client/direct.ts +++ b/packages/codingcode/src/client/direct.ts @@ -194,22 +194,6 @@ export async function createDirectClient(llm: any): Promise { }); }, - async revertCheckpointFile(turnId: number, file: string) { - if (!currentSessionId) - return { - reverted: false, - throughTurnId: turnId, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - return clients.sessions.revertCheckpointFile({ - sessionId: currentSessionId, - cwd: cwd(), - file, - }); - }, - async revertCheckpointFiles(turnId: number, files: string[]) { if (!currentSessionId) return { diff --git a/packages/codingcode/src/client/direct/sessions.ts b/packages/codingcode/src/client/direct/sessions.ts index 7ea4acc..00788ef 100644 --- a/packages/codingcode/src/client/direct/sessions.ts +++ b/packages/codingcode/src/client/direct/sessions.ts @@ -17,7 +17,6 @@ export interface SessionClient { setSessionPermissionMode(input: { sessionId: string; mode: PermissionMode }): Promise; getCheckpointDiff(input: { sessionId: string; cwd: string; turnId?: number }): Promise; - revertCheckpointFile(input: { sessionId: string; cwd: string; file: string }): Promise; revertCheckpointFiles(input: { sessionId: string; cwd: string; files: string[] }): Promise; previewRollbackDiff(input: { sessionId: string; @@ -119,15 +118,6 @@ export function createDirectSessionClient( async getCheckpointDiff() { return { turnId: 0, files: [] }; }, - async revertCheckpointFile() { - return { - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - }, async revertCheckpointFiles() { return { reverted: false, diff --git a/packages/codingcode/src/client/http.ts b/packages/codingcode/src/client/http.ts index 13d3815..d2afac2 100644 --- a/packages/codingcode/src/client/http.ts +++ b/packages/codingcode/src/client/http.ts @@ -133,15 +133,6 @@ export async function createHttpClient(serverUrl: string): Promise async getCheckpointDiff() { return { turnId: 0, files: [] }; }, - async revertCheckpointFile() { - return { - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - }, async revertCheckpointFiles() { return { reverted: false, diff --git a/packages/codingcode/src/client/http/sessions.ts b/packages/codingcode/src/client/http/sessions.ts index c83adee..a370a8d 100644 --- a/packages/codingcode/src/client/http/sessions.ts +++ b/packages/codingcode/src/client/http/sessions.ts @@ -18,7 +18,6 @@ export interface SessionClient { cwd: string; turnId?: number; }): Promise<{ turnId: number; files: any[] }>; - revertCheckpointFile(input: { sessionId: string; cwd: string; file: string }): Promise; revertCheckpointFiles(input: { sessionId: string; cwd: string; files: string[] }): Promise; previewRollbackDiff(input: { sessionId: string; @@ -95,10 +94,6 @@ export function createHttpSessionClient( ); }, - async revertCheckpointFile({ sessionId, cwd, file }) { - return apiPost(`/api/sessions/${sessionId}/checkpoints/latest/revert-file`, { cwd, file }); - }, - async revertCheckpointFiles({ sessionId, cwd, files }) { return apiPost(`/api/sessions/${sessionId}/checkpoints/latest/revert-files`, { cwd, files }); }, diff --git a/packages/codingcode/src/client/types.ts b/packages/codingcode/src/client/types.ts index d89cc58..43be54b 100644 --- a/packages/codingcode/src/client/types.ts +++ b/packages/codingcode/src/client/types.ts @@ -36,7 +36,6 @@ export interface AgentClient { Array<{ turnId: number; title: string; files: string[] }> >; getCheckpointDiff(turnId?: number): Promise; - revertCheckpointFile(turnId: number, file: string): Promise; revertCheckpointFiles(turnId: number, files: string[]): Promise; previewRollbackDiff(throughTurnId: number): Promise; rollbackCodeToTurn(throughTurnId: number): Promise; diff --git a/packages/codingcode/src/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index 381f8b8..4a641ea 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -229,7 +229,7 @@ sessionsRouter.post('/:id/checkpoints/latest/revert-file', async (c) => { restoreEntry: null, }; const latestTurnId = completedTurns[completedTurns.length - 1]!; - return checkpoint.revertCheckpointFile(cwd, sessionId, latestTurnId, body.file); + return checkpoint.revertCheckpointFiles(cwd, sessionId, latestTurnId, [body.file]); }) ); if (!result.ok) { diff --git a/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts b/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts index eb61e8a..fd1217f 100644 --- a/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts +++ b/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts @@ -163,7 +163,7 @@ describe('undoLastCodeRollback end-to-end via ShadowGit', () => { const entry = { id: 'test123', sessionId, - action: 'checkpoint-file', + action: 'checkpoint-files', throughTurnId: 1, affectedTurns: [], selectedFiles: [join(projectPath, 'src/main.ts')], @@ -364,7 +364,7 @@ describe('undoLastCodeRollback case-insensitive path matching', () => { const entry = { id: 'test123', sessionId, - action: 'checkpoint-file', + action: 'checkpoint-files', throughTurnId: 1, affectedTurns: [], selectedFiles: [join(projectPath, 'src/main.ts').toLowerCase()], diff --git a/packages/codingcode/test/context/context.test.ts b/packages/codingcode/test/context/context.test.ts index 214777a..3314c8d 100644 --- a/packages/codingcode/test/context/context.test.ts +++ b/packages/codingcode/test/context/context.test.ts @@ -87,7 +87,6 @@ const MockCheckpointLayer = Layer.succeed( getCompletedTurns: () => [], getCheckpoints: () => [], getCheckpointDiff: () => ({ turnId: 0, files: [] }), - revertCheckpointFile: () => ({ reverted: false, throughTurnId: 0, affectedTurns: [], selectedFiles: [], restoreEntry: null }), revertCheckpointFiles: () => ({ reverted: false, throughTurnId: 0, affectedTurns: [], selectedFiles: [], restoreEntry: null }), previewRollbackDiff: () => ({ throughTurnId: 0, affectedTurns: [], diff: '' }), rollbackCodeToTurn: () => ({ reverted: false, throughTurnId: 0, affectedTurns: [], selectedFiles: [], restoreEntry: null }), diff --git a/packages/codingcode/test/orchestrate.test.ts b/packages/codingcode/test/orchestrate.test.ts index 2aae804..7cc6322 100644 --- a/packages/codingcode/test/orchestrate.test.ts +++ b/packages/codingcode/test/orchestrate.test.ts @@ -186,7 +186,6 @@ const MockCheckpointLayer = Layer.succeed( getCompletedTurns: () => [], getCheckpoints: () => [], getCheckpointDiff: () => ({ turnId: 0, files: [] }), - revertCheckpointFile: () => ({ reverted: false, throughTurnId: 0, affectedTurns: [], selectedFiles: [], restoreEntry: null }), revertCheckpointFiles: () => ({ reverted: false, throughTurnId: 0, affectedTurns: [], selectedFiles: [], restoreEntry: null }), previewRollbackDiff: () => ({ throughTurnId: 0, affectedTurns: [], diff: '' }), rollbackCodeToTurn: () => ({ reverted: false, throughTurnId: 0, affectedTurns: [], selectedFiles: [], restoreEntry: null }), diff --git a/packages/codingcode/test/server/handler.test.ts b/packages/codingcode/test/server/handler.test.ts index c69d35e..a238dfc 100644 --- a/packages/codingcode/test/server/handler.test.ts +++ b/packages/codingcode/test/server/handler.test.ts @@ -197,7 +197,6 @@ const MockCheckpointLayer = Layer.succeed( getCompletedTurns: () => [], getCheckpoints: () => [], getCheckpointDiff: () => ({ turnId: 0, files: [] }), - revertCheckpointFile: () => ({ reverted: false, throughTurnId: 0, affectedTurns: [], selectedFiles: [], restoreEntry: null }), revertCheckpointFiles: () => ({ reverted: false, throughTurnId: 0, affectedTurns: [], selectedFiles: [], restoreEntry: null }), previewRollbackDiff: () => ({ throughTurnId: 0, affectedTurns: [], diff: '' }), rollbackCodeToTurn: () => ({ reverted: false, throughTurnId: 0, affectedTurns: [], selectedFiles: [], restoreEntry: null }), diff --git a/packages/desktop/src/hooks/useAgent.ts b/packages/desktop/src/hooks/useAgent.ts index 64060d5..5857ac5 100644 --- a/packages/desktop/src/hooks/useAgent.ts +++ b/packages/desktop/src/hooks/useAgent.ts @@ -11,7 +11,6 @@ import { deleteSession, sendApprovalResponse, getCheckpointDiff, - revertCheckpointFile, revertCheckpointFiles, previewRollbackDiff, rollbackCodeToTurn, @@ -395,7 +394,7 @@ export function useAgentRollback() { const revertFile = useCallback( async (threadId: string, file: string) => { const cwd = useGlobalStore.getState().agent.threads[threadId]?.cwd ?? workspace.rootPath; - const { result } = await revertCheckpointFile(threadId, cwd, file); + const { result } = await revertCheckpointFiles(threadId, cwd, [file]); if (result.reverted) { markFileReverted(threadId, resolveUITurnId(threadId, result.throughTurnId), file); } diff --git a/packages/desktop/src/lib/core-api.ts b/packages/desktop/src/lib/core-api.ts index 9c2ed8c..804f04c 100644 --- a/packages/desktop/src/lib/core-api.ts +++ b/packages/desktop/src/lib/core-api.ts @@ -281,14 +281,6 @@ export function getCheckpointDiff( return clients.sessions.getCheckpointDiff({ sessionId, cwd, turnId }) as any; } -export function revertCheckpointFile( - sessionId: string, - cwd: string, - file: string -): Promise<{ ok: boolean; result: CodeRollbackResult }> { - return clients.sessions.revertCheckpointFile({ sessionId, cwd, file }) as any; -} - export function revertCheckpointFiles( sessionId: string, cwd: string, From 9d5024068ebe67f42ae0b64c1810967f58ab095e Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 11 Jun 2026 20:11:23 +0800 Subject: [PATCH 09/11] delete distributed llmclient sources --- .../codingcode/src/context/compaction-llm.ts | 33 ----- packages/codingcode/src/context/compressor.ts | 6 +- packages/codingcode/src/llm/llm-resolver.ts | 18 +++ packages/codingcode/src/memory/index.ts | 4 +- .../codingcode/src/memory/llm-resolver.ts | 26 ---- .../compressor/compact-if-needed.test.ts | 10 +- .../context/compressor/llm-resolver.test.ts | 84 ++++++++--- .../test/memory/llm-resolver.test.ts | 137 ++++++------------ 8 files changed, 140 insertions(+), 178 deletions(-) delete mode 100644 packages/codingcode/src/context/compaction-llm.ts create mode 100644 packages/codingcode/src/llm/llm-resolver.ts delete mode 100644 packages/codingcode/src/memory/llm-resolver.ts diff --git a/packages/codingcode/src/context/compaction-llm.ts b/packages/codingcode/src/context/compaction-llm.ts deleted file mode 100644 index 9b510ad..0000000 --- a/packages/codingcode/src/context/compaction-llm.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { findModel, createClient } from '../llm/factory.js'; -import type { LLMClient } from '../llm/client.js'; -import type { ContextConfig } from './config.js'; - -/** - * Resolve which LLM client to use for compaction. - * - * Selection order: - * 1. If `config.compactionModel` is empty → fallback (main session LLM). - * 2. Match in `config/models.json` with priority: - * 1) Full id format (e.g. "deepseek-chat@DEEPSEEK_API_KEY") - exact match only - * 2) Bare model id (e.g. "deepseek-chat") - first match - * 3) Display name (e.g. "DeepSeek Chat") - first match - * To avoid ambiguity when multiple providers have same model name, use full id. - * 3. If no match or build fails → fallback. - */ -export async function resolveCompactionLLM( - config: ContextConfig, - fallback: LLMClient | null -): Promise { - const target = config.compactionModel?.trim(); - if (!target) return fallback; - - const found = findModel(target); - if (!found) return fallback; - - try { - const created = await createClient(found); - return created.ok ? created.value : fallback; - } catch { - return fallback; - } -} diff --git a/packages/codingcode/src/context/compressor.ts b/packages/codingcode/src/context/compressor.ts index 28d913a..929f1e0 100644 --- a/packages/codingcode/src/context/compressor.ts +++ b/packages/codingcode/src/context/compressor.ts @@ -6,7 +6,7 @@ import { estimateTokensForContent, } from './util.js'; import { applyVisibilityEvents } from '../session/messages.js'; -import { resolveCompactionLLM } from './compaction-llm.js'; +import { resolveLLM } from '../llm/llm-resolver.js'; import { COMPACTION_SYSTEM_PROMPT } from './compaction-prompt.js'; import type { ContextConfig } from './config.js'; import type { Message } from '../core/types.js'; @@ -146,7 +146,7 @@ async function tryCompaction( const totalTokens = targetEvents.reduce((sum, e) => sum + estimateEventTokens(e), 0); - let compactionLlm = await resolveCompactionLLM(config, llm); + let compactionLlm = await resolveLLM(config.compactionModel, llm); if (compactionLlm && compactionLlm.modelInfo.maxTokens < totalTokens + 25000) { compactionLlm = llm; } @@ -254,7 +254,7 @@ async function callLLMForCompaction( fallbackLlm: LLMClient | null, config: ContextConfig ): Promise { - const llm = await resolveCompactionLLM(config, fallbackLlm); + const llm = await resolveLLM(config.compactionModel, fallbackLlm); if (!llm) return null; const transcriptText = transcript diff --git a/packages/codingcode/src/llm/llm-resolver.ts b/packages/codingcode/src/llm/llm-resolver.ts new file mode 100644 index 0000000..9657430 --- /dev/null +++ b/packages/codingcode/src/llm/llm-resolver.ts @@ -0,0 +1,18 @@ +import { findModel, createClient } from './factory.js'; +import type { LLMClient } from './client.js'; + +export async function resolveLLM( + target: string | null | undefined, + fallback: LLMClient | null, +): Promise { + const trimmed = target?.trim(); + if (!trimmed) return fallback; + const found = findModel(trimmed); + if (!found) return fallback; + try { + const created = await createClient(found); + return created.ok ? created.value : fallback; + } catch { + return fallback; + } +} diff --git a/packages/codingcode/src/memory/index.ts b/packages/codingcode/src/memory/index.ts index 67ee952..5b1a4d5 100644 --- a/packages/codingcode/src/memory/index.ts +++ b/packages/codingcode/src/memory/index.ts @@ -12,7 +12,7 @@ import { writeMemoryFileAtomic, stripMarkersForPrompt, } from './storage.js'; -import { resolveMemoryLLM } from './llm-resolver.js'; +import { resolveLLM } from '../llm/llm-resolver.js'; import { getMemoryConfig, getEffectiveTypes } from './config.js'; import { updateMemoryEnabled } from '@codingcode/infra/config'; import { extractMemory, type StructuredTranscript } from './extractor.js'; @@ -154,7 +154,7 @@ export async function flushSessionToMemory( const transcript = buildStructuredTranscript(events); const types = getEffectiveTypes(cfg); - const resolvedLlm = await resolveMemoryLLM(cfg, llm); + const resolvedLlm = await resolveLLM(cfg.model, llm); if (!resolvedLlm) { return { written: false, bytes: 0 }; } diff --git a/packages/codingcode/src/memory/llm-resolver.ts b/packages/codingcode/src/memory/llm-resolver.ts deleted file mode 100644 index 8d2abd1..0000000 --- a/packages/codingcode/src/memory/llm-resolver.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { listModels, createClient } from '../llm/factory.js'; -import type { LLMClient } from '../llm/client.js'; -import type { MemoryConfig } from '@codingcode/infra/config'; - -export async function resolveMemoryLLM( - config: MemoryConfig, - fallback: LLMClient | null -): Promise { - const target = config.model?.trim(); - if (!target) return fallback; - - const listResult = listModels(); - if (!listResult.ok) return fallback; - - const found = listResult.value.find( - (m) => m.id === target || m.model === target || m.name === target - ); - if (!found) return fallback; - - try { - const created = await createClient(found); - return created.ok ? created.value : fallback; - } catch { - return fallback; - } -} diff --git a/packages/codingcode/test/context/compressor/compact-if-needed.test.ts b/packages/codingcode/test/context/compressor/compact-if-needed.test.ts index 394e04c..0e59945 100644 --- a/packages/codingcode/test/context/compressor/compact-if-needed.test.ts +++ b/packages/codingcode/test/context/compressor/compact-if-needed.test.ts @@ -39,9 +39,13 @@ vi.mock('../../../src/session/io.js', async (importOriginal) => { }; }); -vi.mock('../../../src/context/compaction-llm.js', () => ({ - resolveCompactionLLM: vi.fn(() => Promise.resolve(mockLLM)), -})); +vi.mock('../../../src/llm/llm-resolver.js', async (importOriginal) => { + const actual: any = await importOriginal(); + return { + ...actual, + resolveLLM: vi.fn(() => Promise.resolve(mockLLM)), + }; +}); vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); diff --git a/packages/codingcode/test/context/compressor/llm-resolver.test.ts b/packages/codingcode/test/context/compressor/llm-resolver.test.ts index ed59183..49a77cc 100644 --- a/packages/codingcode/test/context/compressor/llm-resolver.test.ts +++ b/packages/codingcode/test/context/compressor/llm-resolver.test.ts @@ -1,7 +1,21 @@ -import { describe, it, expect } from 'vitest'; -import { resolveCompactionLLM } from '../../../src/context/compaction-llm.js'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import type { LLMClient } from '../../../src/llm/client.js'; -import type { ContextConfig } from '../../../src/context/config.js'; + +const { mockFindModel, mockCreateClient } = vi.hoisted(() => ({ + mockFindModel: vi.fn(() => null), + mockCreateClient: vi.fn(), +})); + +vi.mock('../../../src/llm/factory.js', async (importOriginal) => { + const actual: any = await importOriginal(); + return { + ...actual, + findModel: mockFindModel, + createClient: mockCreateClient, + }; +}); + +import { resolveLLM } from '../../../src/llm/llm-resolver.js'; const fakeFallback: LLMClient = { complete: async () => ({ ok: true as const, value: { content: '', finishReason: 'stop' } }), @@ -18,35 +32,61 @@ const fakeFallback: LLMClient = { }, }; -function cfg(compactionModel: string): ContextConfig { - return { - microCompactThreshold: 0.5, - microCompactMinChars: 120, - compactionThreshold: 0.9, - keepRecentTurns: 1, - compactionModel, - reactiveCompactMaxRetries: 1, - }; -} +describe('resolveLLM (compaction)', () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + it('returns fallback when target is empty', async () => { + const result = await resolveLLM('', fakeFallback); + expect(result).toBe(fakeFallback); + }); -describe('resolveCompactionLLM', () => { - it('returns fallback when compactionModel is empty', async () => { - const result = await resolveCompactionLLM(cfg(''), fakeFallback); + it('returns fallback when target is whitespace-only', async () => { + const result = await resolveLLM(' ', fakeFallback); expect(result).toBe(fakeFallback); }); - it('returns fallback when compactionModel is whitespace-only', async () => { - const result = await resolveCompactionLLM(cfg(' '), fakeFallback); + it('returns fallback when target is null', async () => { + const result = await resolveLLM(null, fakeFallback); expect(result).toBe(fakeFallback); }); - it('returns fallback when target model is not in models.json', async () => { - const result = await resolveCompactionLLM(cfg('definitely-not-a-real-model-xyz'), fakeFallback); + it('returns fallback when target is undefined', async () => { + const result = await resolveLLM(undefined, fakeFallback); expect(result).toBe(fakeFallback); }); - it('returns null when compactionModel empty and no fallback given', async () => { - const result = await resolveCompactionLLM(cfg(''), null); + it('returns null when target empty and fallback is null', async () => { + const result = await resolveLLM('', null); expect(result).toBeNull(); }); + + it('returns fallback when model not found', async () => { + mockFindModel.mockReturnValue(null); + const result = await resolveLLM('definitely-not-a-real-model-xyz', fakeFallback); + expect(result).toBe(fakeFallback); + }); + + it('returns fallback when createClient throws', async () => { + mockFindModel.mockReturnValue({ id: 'test-model' } as any); + mockCreateClient.mockRejectedValue(new Error('creation failed')); + const result = await resolveLLM('test-model', fakeFallback); + expect(result).toBe(fakeFallback); + }); + + it('returns fallback when createClient returns error', async () => { + mockFindModel.mockReturnValue({ id: 'test-model' } as any); + mockCreateClient.mockResolvedValue({ ok: false, error: 'error' }); + const result = await resolveLLM('test-model', fakeFallback); + expect(result).toBe(fakeFallback); + }); + + it('returns created client on success', async () => { + const client = { modelInfo: { maxTokens: 100 } } as LLMClient; + mockFindModel.mockReturnValue({ id: 'test-model' } as any); + mockCreateClient.mockResolvedValue({ ok: true, value: client }); + const result = await resolveLLM('test-model', fakeFallback); + expect(result).toBe(client); + }); }); diff --git a/packages/codingcode/test/memory/llm-resolver.test.ts b/packages/codingcode/test/memory/llm-resolver.test.ts index ac62395..425a61d 100644 --- a/packages/codingcode/test/memory/llm-resolver.test.ts +++ b/packages/codingcode/test/memory/llm-resolver.test.ts @@ -1,114 +1,73 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; -import { resolveMemoryLLM } from '../../src/memory/llm-resolver.js'; import type { LLMClient } from '../../src/llm/client.js'; -import type { MemoryConfig } from '@codingcode/infra/config'; +import type { SelectableModel } from '../../src/llm/factory.js'; -vi.mock('../../src/llm/factory.js', () => ({ - listModels: vi.fn(() => ({ - ok: true, - value: [ - { - id: 'claude-opus-4-7', - model: 'claude-opus-4-7', - name: 'Claude Opus 4.7', - provider: 'anthropic', - }, - { - id: 'deepseek@deepseek', - model: 'deepseek-chat', - name: 'DeepSeek Chat', - provider: 'deepseek', - }, - ], - })), - createClient: vi.fn(async (_modelInfo: any) => ({ - ok: true, - value: { - complete: () => Promise.resolve({ ok: true, value: { content: '' } }), - completeStream: () => ({ - stream: async function* () {}, - response: Promise.resolve({ ok: true, value: { content: '' } }), - }), - modelInfo: { - provider: 'mock', - model: 'mock', - maxTokens: 4096, - supportsToolCalling: true, - supportsStreaming: true, - }, - } as any, - })), +const { mockFindModel, mockCreateClient } = vi.hoisted(() => ({ + mockFindModel: vi.fn(() => null), + mockCreateClient: vi.fn(), })); -describe('Memory LLM Resolver', () => { +vi.mock('../../src/llm/factory.js', async (importOriginal) => { + const actual: any = await importOriginal(); + return { + ...actual, + findModel: mockFindModel, + createClient: mockCreateClient, + }; +}); + +import { resolveLLM } from '../../src/llm/llm-resolver.js'; + +const fallbackClient = {} as LLMClient; + +describe('resolveLLM (memory)', () => { afterEach(() => { vi.resetAllMocks(); }); - const createCfg = (model: string): MemoryConfig => ({ - enabled: true, - model, - projectFile: '', - userFile: '', - maxBytes: 16384, - promptMaxBytes: 8192, - extraTypes: [], - disabledTypes: [], + it('returns fallback when target is empty', async () => { + const result = await resolveLLM('', fallbackClient); + expect(result).toBe(fallbackClient); }); - it('returns fallback when model is empty', async () => { - const cfg = createCfg(''); - const fallback = {} as LLMClient; - const result = await resolveMemoryLLM(cfg, fallback); - expect(result).toBe(fallback); - }); - - it('returns fallback when listModels fails', async () => { - const { listModels } = await import('../../src/llm/factory.js'); - vi.mocked(listModels).mockReturnValue({ ok: false, error: 'error' } as any); - - const cfg = createCfg('claude-opus-4-7'); - const fallback = {} as LLMClient; - const result = await resolveMemoryLLM(cfg, fallback); - expect(result).toBe(fallback); + it('returns fallback when target is whitespace-only', async () => { + const result = await resolveLLM(' ', fallbackClient); + expect(result).toBe(fallbackClient); }); it('returns fallback when model not found', async () => { - const cfg = createCfg('nonexistent-model'); - const fallback = {} as LLMClient; - const result = await resolveMemoryLLM(cfg, fallback); - expect(result).toBe(fallback); + mockFindModel.mockReturnValue(null); + const result = await resolveLLM('nonexistent-model', fallbackClient); + expect(result).toBe(fallbackClient); }); - it('returns null fallback when create fails', async () => { - const { createClient } = await import('../../src/llm/factory.js'); - vi.mocked(createClient).mockRejectedValue(new Error('creation failed')); - - const cfg = createCfg('claude-opus-4-7'); - const result = await resolveMemoryLLM(cfg, null); - expect(result).toBe(null); + it('returns null when fallback is null and create fails', async () => { + mockFindModel.mockReturnValue({ id: 'claude-opus-4-7' } as SelectableModel); + mockCreateClient.mockRejectedValue(new Error('creation failed')); + const result = await resolveLLM('claude-opus-4-7', null); + expect(result).toBeNull(); }); - it('returns null fallback when create returns error', async () => { - const { createClient } = await import('../../src/llm/factory.js'); - vi.mocked(createClient).mockResolvedValue({ ok: false, error: 'error' } as any); - - const cfg = createCfg('claude-opus-4-7'); - const result = await resolveMemoryLLM(cfg, null); - expect(result).toBe(null); + it('returns null when fallback is null and create returns error', async () => { + mockFindModel.mockReturnValue({ id: 'claude-opus-4-7' } as SelectableModel); + mockCreateClient.mockResolvedValue({ ok: false, error: 'error' }); + const result = await resolveLLM('claude-opus-4-7', null); + expect(result).toBeNull(); }); it('creates and returns client when model matches by id', async () => { - const cfg = createCfg('claude-opus-4-7'); - const fallback = {} as LLMClient; - const result = await resolveMemoryLLM(cfg, fallback); - expect(result).not.toBe(fallback); + const client = { modelInfo: { maxTokens: 4096 } } as LLMClient; + mockFindModel.mockReturnValue({ id: 'claude-opus-4-7@ANTHROPIC_API_KEY' } as SelectableModel); + mockCreateClient.mockResolvedValue({ ok: true, value: client }); + const result = await resolveLLM('claude-opus-4-7@ANTHROPIC_API_KEY', fallbackClient); + expect(result).toBe(client); }); - it('creates and returns client when model matches by bare id', async () => { - const cfg = createCfg('deepseek-chat'); - const fallback = {} as LLMClient; - const result = await resolveMemoryLLM(cfg, fallback); - expect(result).not.toBe(fallback); + it('creates and returns client when model matches by bare model id', async () => { + const client = { modelInfo: { maxTokens: 4096 } } as LLMClient; + mockFindModel.mockReturnValue({ id: 'deepseek-chat@DEEPSEEK_API_KEY', model: 'deepseek-chat' } as SelectableModel); + mockCreateClient.mockResolvedValue({ ok: true, value: client }); + const result = await resolveLLM('deepseek-chat', fallbackClient); + expect(result).toBe(client); }); }); From b52756c6f708addb8e539764fcb43f1a9c4c502c Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 11 Jun 2026 20:28:04 +0800 Subject: [PATCH 10/11] rename restore planning and store --- .../src/checkpoint/checkpoint-service.ts | 4 ++-- .../codingcode/src/checkpoint/rollback-engine.ts | 2 +- .../{restore-planning.ts => turn-query.ts} | 0 .../{restore-store.ts => undo-store.ts} | 0 packages/codingcode/src/context/compressor.ts | 15 ++++----------- 5 files changed, 7 insertions(+), 14 deletions(-) rename packages/codingcode/src/checkpoint/{restore-planning.ts => turn-query.ts} (100%) rename packages/codingcode/src/checkpoint/{restore-store.ts => undo-store.ts} (100%) diff --git a/packages/codingcode/src/checkpoint/checkpoint-service.ts b/packages/codingcode/src/checkpoint/checkpoint-service.ts index ea1658d..5c18953 100644 --- a/packages/codingcode/src/checkpoint/checkpoint-service.ts +++ b/packages/codingcode/src/checkpoint/checkpoint-service.ts @@ -5,12 +5,12 @@ import { ShadowGit } from './shadow-git.js'; import { ProjectLock } from './project-lock.js'; import { normalizePath } from '../core/path.js'; import { shortSid, commitMsg, toGitPath, hashWorkspaceFile, ProjectCache } from './utils.js'; -import { readRestoreEntry, writeRestoreEntry } from './restore-store.js'; +import { readRestoreEntry, writeRestoreEntry } from './undo-store.js'; import { getCompletedTurnsFor, getTurnRestorePlan, getRollbackToTurnPlan, -} from './restore-planning.js'; +} from './turn-query.js'; import { emptyRollbackResult, executeRollback } from './rollback-engine.js'; // ---- Exported types ---- diff --git a/packages/codingcode/src/checkpoint/rollback-engine.ts b/packages/codingcode/src/checkpoint/rollback-engine.ts index 949edad..3238e44 100644 --- a/packages/codingcode/src/checkpoint/rollback-engine.ts +++ b/packages/codingcode/src/checkpoint/rollback-engine.ts @@ -4,7 +4,7 @@ import type { ShadowGit } from './shadow-git.js'; import type { ProjectLock } from './project-lock.js'; import type { CodeRollbackResult, CodeRestoreEntry } from './checkpoint-service.js'; import { commitMsg } from './utils.js'; -import { readRestoreEntry, writeRestoreEntry } from './restore-store.js'; +import { readRestoreEntry, writeRestoreEntry } from './undo-store.js'; export function emptyRollbackResult( turnId: number diff --git a/packages/codingcode/src/checkpoint/restore-planning.ts b/packages/codingcode/src/checkpoint/turn-query.ts similarity index 100% rename from packages/codingcode/src/checkpoint/restore-planning.ts rename to packages/codingcode/src/checkpoint/turn-query.ts diff --git a/packages/codingcode/src/checkpoint/restore-store.ts b/packages/codingcode/src/checkpoint/undo-store.ts similarity index 100% rename from packages/codingcode/src/checkpoint/restore-store.ts rename to packages/codingcode/src/checkpoint/undo-store.ts diff --git a/packages/codingcode/src/context/compressor.ts b/packages/codingcode/src/context/compressor.ts index 929f1e0..e2f631d 100644 --- a/packages/codingcode/src/context/compressor.ts +++ b/packages/codingcode/src/context/compressor.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'crypto'; -import { resolveSessionDir } from '../session/io.js'; +import { resolveSessionDir, appendLine } from '../session/io.js'; import { estimateTokens, estimateMessageTokens, @@ -14,7 +14,6 @@ import type { SessionEvent, SummaryEvent } from '../session/types.js'; import type { LLMClient } from '../llm/client.js'; import { assemblePayload } from './organizer.js'; import { join } from 'path'; -import { appendFileSync } from 'fs'; export interface CompressResult { didCompress: boolean; @@ -107,14 +106,6 @@ export async function compactWithLLM( }; } -// ---------- Summary persistence ---------- - -function appendSummaryToSession(sessionId: string, event: SummaryEvent): void { - const dir = resolveSessionDir(sessionId); - if (!dir) throw new Error(`Session ${sessionId} not found`); - const jsonlPath = join(dir, `${sessionId}.jsonl`); - appendFileSync(jsonlPath, JSON.stringify(event) + '\n', 'utf8'); -} // ---------- LLM Compaction ---------- @@ -173,7 +164,9 @@ async function tryCompaction( lastSummarizedTurnId: lastTurnId, timestamp: new Date().toISOString(), }; - appendSummaryToSession(sessionId, event); + const dir = resolveSessionDir(sessionId); + if (!dir) throw new Error(`Session ${sessionId} not found`); + appendLine(join(dir, `${sessionId}.jsonl`), event); for (const u of replacedUuids) hidden.add(u); const summaryMsg: Message = { role: 'system', name: 'compacted_history', content: summary }; From 1e152008b6815ccc3b00c2b761601cdfbf37c2f6 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 11 Jun 2026 21:40:00 +0800 Subject: [PATCH 11/11] delete buildTranscript --- packages/codingcode/src/context/compressor.ts | 94 +++---------------- packages/codingcode/src/context/organizer.ts | 13 +-- packages/codingcode/src/session/io.ts | 6 ++ packages/codingcode/src/session/messages.ts | 8 +- .../compressor/compact-if-needed.test.ts | 8 +- 5 files changed, 39 insertions(+), 90 deletions(-) diff --git a/packages/codingcode/src/context/compressor.ts b/packages/codingcode/src/context/compressor.ts index e2f631d..3282f3f 100644 --- a/packages/codingcode/src/context/compressor.ts +++ b/packages/codingcode/src/context/compressor.ts @@ -1,11 +1,10 @@ import { randomUUID } from 'crypto'; -import { resolveSessionDir, appendLine } from '../session/io.js'; +import { resolveSessionJsonlPath, appendLine } from '../session/io.js'; import { estimateTokens, estimateMessageTokens, - estimateTokensForContent, } from './util.js'; -import { applyVisibilityEvents } from '../session/messages.js'; +import { buildMessagesFromEvents } from '../session/messages.js'; import { resolveLLM } from '../llm/llm-resolver.js'; import { COMPACTION_SYSTEM_PROMPT } from './compaction-prompt.js'; import type { ContextConfig } from './config.js'; @@ -13,7 +12,6 @@ import type { Message } from '../core/types.js'; import type { SessionEvent, SummaryEvent } from '../session/types.js'; import type { LLMClient } from '../llm/client.js'; import { assemblePayload } from './organizer.js'; -import { join } from 'path'; export interface CompressResult { didCompress: boolean; @@ -85,8 +83,8 @@ export async function compactWithLLM( usage?: number, modelMaxTokens?: number ): Promise { + const payload = assemblePayload(sessionId, encodedProjectPath, config, modelMaxTokens); if (!compactedEvents || currentTurnId === undefined) { - const payload = assemblePayload(sessionId, encodedProjectPath, config, modelMaxTokens); compactedEvents = payload.compactedEvents; currentTurnId = payload.currentTurnId; } @@ -95,38 +93,33 @@ export async function compactWithLLM( const threshold = modelMaxTokens ? modelMaxTokens * config.compactionThreshold : Infinity; if (usage === undefined || usage - released > threshold) { - released += await tryCompaction(sessionId, config, llm, compactedEvents, currentTurnId); + released += await tryCompaction(sessionId, config, llm, compactedEvents, currentTurnId, payload.compactedTurnIds); } - const payload = assemblePayload(sessionId, encodedProjectPath, config, modelMaxTokens); + const postPayload = assemblePayload(sessionId, encodedProjectPath, config, modelMaxTokens); return { didCompress: released > 0, released, - promptEstimate: estimateTokens(payload.messages), + promptEstimate: estimateTokens(postPayload.messages), }; } // ---------- LLM Compaction ---------- -const ESTIMATED_SUMMARY_TOKENS = 5000; -const MAX_TOOL_RESULT_TOKENS = 30000; - async function tryCompaction( sessionId: string, config: ContextConfig, llm: LLMClient | null, compactedEvents: SessionEvent[], - currentTurnId: number + currentTurnId: number, + compactedTurnIds: Set, ): Promise { const endTurn = currentTurnId - config.keepRecentTurns - 1; if (endTurn < 1) return 0; - const { hidden } = applyVisibilityEvents(compactedEvents); - const inRange = compactedEvents.filter((ev) => { if (ev.type === 'session_meta') return false; - if ('uuid' in ev && hidden.has((ev as any).uuid)) return false; if ('turnId' in ev && (ev as any).turnId >= 1 && (ev as any).turnId <= endTurn) return true; return false; }); @@ -135,15 +128,15 @@ async function tryCompaction( const targetEvents = getIncrementalEvents(inRange); if (targetEvents.length === 0) return 0; - const totalTokens = targetEvents.reduce((sum, e) => sum + estimateEventTokens(e), 0); + const msgs = buildMessagesFromEvents(targetEvents, compactedTurnIds); + const totalTokens = estimateTokens(msgs); let compactionLlm = await resolveLLM(config.compactionModel, llm); if (compactionLlm && compactionLlm.modelInfo.maxTokens < totalTokens + 25000) { compactionLlm = llm; } - const transcript = buildTranscript(targetEvents); - const summary = await callLLMForCompaction(transcript, compactionLlm, config); + const summary = await callLLMForCompaction(msgs, compactionLlm, config); if (!summary) return 0; const replacedUuids: string[] = []; @@ -164,10 +157,7 @@ async function tryCompaction( lastSummarizedTurnId: lastTurnId, timestamp: new Date().toISOString(), }; - const dir = resolveSessionDir(sessionId); - if (!dir) throw new Error(`Session ${sessionId} not found`); - appendLine(join(dir, `${sessionId}.jsonl`), event); - for (const u of replacedUuids) hidden.add(u); + appendLine(resolveSessionJsonlPath(sessionId), event); const summaryMsg: Message = { role: 'system', name: 'compacted_history', content: summary }; return Math.max(0, totalTokens - estimateMessageTokens(summaryMsg)); @@ -184,64 +174,6 @@ function getIncrementalEvents(inRange: SessionEvent[]): SessionEvent[] { return inRange.filter((e) => 'turnId' in e && (e as any).turnId > lastTurn); } -function buildTranscript(events: SessionEvent[]): Message[] { - const transcript: Message[] = []; - for (const ev of events) { - switch (ev.type) { - case 'user': - transcript.push({ role: 'user', content: ev.content }); - break; - case 'assistant': - transcript.push({ role: 'assistant', content: ev.content }); - break; - case 'tool_result': { - let content = ev.output; - const tokens = estimateTokensForContent(content); - if (tokens > MAX_TOOL_RESULT_TOKENS) { - const ratio = MAX_TOOL_RESULT_TOKENS / tokens; - const keepChars = Math.floor(content.length * ratio); - content = - content.slice(0, keepChars) + - `\n\n[...truncated: ${tokens} tokens total, showing first ${MAX_TOOL_RESULT_TOKENS}]`; - } - transcript.push({ - role: 'tool', - content, - tool_call_id: ev.toolCallId, - tool_name: ev.toolName, - } as any); - break; - } - case 'summary': - transcript.push({ role: 'system', name: 'compacted_history', content: ev.summaryText }); - break; - } - } - return transcript; -} - -function estimateEventTokens(e: SessionEvent): number { - if (e.type === 'user') return estimateMessageTokens({ role: 'user', content: e.content }); - if (e.type === 'assistant') - return estimateMessageTokens({ role: 'assistant', content: e.content }); - if (e.type === 'tool_result') { - return estimateMessageTokens({ - role: 'tool', - content: e.output, - tool_call_id: e.toolCallId, - tool_name: e.toolName, - } as any); - } - if (e.type === 'summary') { - return estimateMessageTokens({ - role: 'system', - name: 'compacted_history', - content: e.summaryText, - }); - } - return 0; -} - async function callLLMForCompaction( transcript: Message[], fallbackLlm: LLMClient | null, @@ -251,7 +183,7 @@ async function callLLMForCompaction( if (!llm) return null; const transcriptText = transcript - .map((m) => `[${m.role}${m.tool_name ? ':' + m.tool_name : ''}]\n${m.content}`) + .map((m) => `[${m.role}${(m as any).tool_name ? ':' + (m as any).tool_name : ''}]\n${m.content}`) .join('\n\n'); const system = COMPACTION_SYSTEM_PROMPT; diff --git a/packages/codingcode/src/context/organizer.ts b/packages/codingcode/src/context/organizer.ts index d598016..0db8799 100644 --- a/packages/codingcode/src/context/organizer.ts +++ b/packages/codingcode/src/context/organizer.ts @@ -1,9 +1,8 @@ import type { ContextConfig } from './config.js'; import type { Message } from '../core/types.js'; -import { findSessionIndex, resolveSessionDir, readHistory, appendLine } from '../session/io.js'; +import { findSessionIndex, resolveSessionJsonlPath, readHistory, appendLine } from '../session/io.js'; import { applyVisibilityEvents, buildMessagesFromEvents } from '../session/messages.js'; import { estimateTokens } from './util.js'; -import { join } from 'path'; import { randomUUID } from 'crypto'; import type { SessionEvent, ToolResultEvent, CompactEvent } from '../session/types.js'; @@ -23,6 +22,7 @@ export interface BuildResult { compactedEvents: SessionEvent[]; promptEstimate: number; currentTurnId: number; + compactedTurnIds: Set; } export function assemblePayload( @@ -31,16 +31,15 @@ export function assemblePayload( config: ContextConfig, contextWindow: number = 128000 ): BuildResult { - const dir = resolveSessionDir(sessionId); - if (!dir) throw new Error(`Session ${sessionId} not found`); - const jsonlPath = join(dir, `${sessionId}.jsonl`); + const jsonlPath = resolveSessionJsonlPath(sessionId); let events = readHistory(jsonlPath); const idx = findSessionIndex(sessionId); const currentTurnId = idx?.currentTurnId ?? 0; - const { hidden } = applyVisibilityEvents(events); + const { hidden, compactedTurnIds: initialCompactedTurnIds } = applyVisibilityEvents(events); let visible = filterVisible(events, hidden); + let compactedTurnIds = initialCompactedTurnIds; const preEstimate = estimateTokensFromEvents(visible); @@ -57,6 +56,7 @@ export function assemblePayload( events = readHistory(jsonlPath); const updated = applyVisibilityEvents(events); visible = filterVisible(events, updated.hidden); + compactedTurnIds = updated.compactedTurnIds; } const messages = buildMessagesFromEvents(visible); @@ -65,6 +65,7 @@ export function assemblePayload( compactedEvents: visible, promptEstimate: estimateTokens(messages), currentTurnId, + compactedTurnIds, }; } diff --git a/packages/codingcode/src/session/io.ts b/packages/codingcode/src/session/io.ts index 2edae00..c566f0a 100644 --- a/packages/codingcode/src/session/io.ts +++ b/packages/codingcode/src/session/io.ts @@ -59,6 +59,12 @@ export function resolveSessionDir(sessionId: string): string | null { return null; } +export function resolveSessionJsonlPath(sessionId: string): string { + const dir = resolveSessionDir(sessionId); + if (!dir) throw new Error(`Session ${sessionId} not found`); + return join(dir, `${sessionId}.jsonl`); +} + export function ensureDirs(transcriptPath: string): void { if (!existsSync(CODINGCODE_DIR)) mkdirSync(CODINGCODE_DIR, { recursive: true }); const dir = dirname(transcriptPath); diff --git a/packages/codingcode/src/session/messages.ts b/packages/codingcode/src/session/messages.ts index 93393cd..14f0051 100644 --- a/packages/codingcode/src/session/messages.ts +++ b/packages/codingcode/src/session/messages.ts @@ -69,8 +69,12 @@ export function applyVisibilityEvents(events: SessionEvent[]): VisibilityResult return { hidden, compactedTurnIds }; } -export function buildMessagesFromEvents(events: SessionEvent[]): Message[] { - const { hidden, compactedTurnIds } = applyVisibilityEvents(events); +export function buildMessagesFromEvents( + events: SessionEvent[], + externalCompactedTurnIds?: Set, +): Message[] { + const { hidden, compactedTurnIds: derivedIds } = applyVisibilityEvents(events); + const compactedTurnIds = externalCompactedTurnIds ?? derivedIds; const visible: SessionEvent[] = []; for (const ev of events) { diff --git a/packages/codingcode/test/context/compressor/compact-if-needed.test.ts b/packages/codingcode/test/context/compressor/compact-if-needed.test.ts index 0e59945..4deacdb 100644 --- a/packages/codingcode/test/context/compressor/compact-if-needed.test.ts +++ b/packages/codingcode/test/context/compressor/compact-if-needed.test.ts @@ -28,10 +28,16 @@ const { mockCompactWithLLM, mockLLM } = vi.hoisted(() => ({ vi.mock('../../../src/session/io.js', async (importOriginal) => { const actual = await importOriginal(); + const mockResolveSessionDir = vi.fn(() => '/tmp/sessions'); return { ...(actual as any), findSessionIndex: vi.fn(() => ({ currentTurnId: 10 })), - resolveSessionDir: vi.fn(() => '/tmp/sessions'), + resolveSessionDir: mockResolveSessionDir, + resolveSessionJsonlPath: vi.fn((sessionId: string) => { + const dir = mockResolveSessionDir(sessionId); + if (!dir) throw new Error(`Session ${sessionId} not found`); + return `${dir}/${sessionId}.jsonl`; + }), readHistory: vi.fn(() => [ { type: 'user', content: 'a'.repeat(200), uuid: 'u1', turnId: 1 }, { type: 'assistant', content: 'b'.repeat(200), uuid: 'a1', turnId: 1 },