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/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 e6241aa..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', { @@ -465,7 +466,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 +478,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)'; @@ -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/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 deleted file mode 100644 index 5ffea9b..0000000 --- a/packages/codingcode/src/checkpoint/bootstrap.ts +++ /dev/null @@ -1,112 +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'; - -/** 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(); - -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; -} - -/** - * 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 bootstrapCheckpoint(hooks: HookService): void { - // Pre-execution: record file hash before modification - 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) { - pendingHash.set(callId, fileHash(resolvedPath)); - } - }, - { source: 'system' } - ) - ); - - // Post-execution: record the full entry - 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 ? (pendingHash.get(callId) ?? '') : ''; - if (callId) { - pendingHash.delete(callId); - } - - const hashAfter = fileHash(resolvedPath); - - getLedger(projectPath).record({ - turnId, - sessionId, - type: toolName, - path: resolvedPath, - hashBefore, - hashAfter, - timestamp: new Date().toISOString(), - }); - }, - { source: 'system' } - ) - ); -} - -function fileHash(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/checkpoint-service.ts b/packages/codingcode/src/checkpoint/checkpoint-service.ts index 2214e67..5c18953 100644 --- a/packages/codingcode/src/checkpoint/checkpoint-service.ts +++ b/packages/codingcode/src/checkpoint/checkpoint-service.ts @@ -1,12 +1,17 @@ import { Effect } from 'effect'; -import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs'; -import { join, dirname, resolve } from 'path'; +import { createHash } from 'crypto'; +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 { bootstrapCheckpoint } from './bootstrap.js'; -import { HookService } from '../hooks/registry.js'; -import { createHash } from 'crypto'; +import { shortSid, commitMsg, toGitPath, hashWorkspaceFile, ProjectCache } from './utils.js'; +import { readRestoreEntry, writeRestoreEntry } from './undo-store.js'; +import { + getCompletedTurnsFor, + getTurnRestorePlan, + getRollbackToTurnPlan, +} from './turn-query.js'; +import { emptyRollbackResult, executeRollback } from './rollback-engine.js'; // ---- Exported types ---- @@ -14,7 +19,6 @@ export interface CheckpointDiff { turnId: number; files: Array<{ path: string; - source: 'agent' | 'unknown'; status: string; diff: string; insertions: number; @@ -25,7 +29,6 @@ export interface CheckpointDiff { export interface CodeRollbackResult { reverted: boolean; throughTurnId: number; - baseTurnId: number | null; affectedTurns: number[]; selectedFiles: string[]; restoreEntry: CodeRestoreEntry | null; @@ -41,7 +44,6 @@ export interface CodeRollbackUndoResult { export interface RollbackPreviewDiff { throughTurnId: number; - baseTurnId: number | null; affectedTurns: number[]; diff: string; } @@ -49,278 +51,57 @@ export interface RollbackPreviewDiff { export interface CodeRestoreEntry { id: string; sessionId: string; - action: - | 'checkpoint-file' - | 'checkpoint-files' - | 'checkpoint-agent' - | 'checkpoint-all' - | 'rollback-to-turn'; + action: 'checkpoint-files' | 'rollback-to-turn'; throughTurnId: number; - baseTurnId: number; affectedTurns: number[]; selectedFiles: string[]; safetyCommit: string; timestamp: string; } -// ---- Module-level helpers ---- - -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'); - } -} - -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; - } -} - -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 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; - } - return ids; -} - -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', { effect: Effect.gen(function* () { - const hooks = yield* HookService; - bootstrapCheckpoint(hooks); - - const shadowGitByProject = new Map(); - const ledgerByProject = 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); + return lockByProject.get(normalized, () => new ProjectLock(normalized)); } - 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 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(); } - return entry.ledger; + } + + 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; + doSnapshotFinal(sg, sessionId, candidate); } return { - // ---- Snapshot methods (unchanged) ---- + // ---- Snapshot ---- snapshotBaseline: ( projectPath: string, @@ -329,39 +110,31 @@ 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 ? `${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(); - try { - sg.commit(commitMsg(sessionId, turnId, 'final')); - } finally { - sg.unlock(); - } + if (sg.isTooLargeForSnapshot()) return; + doSnapshotFinal(sg, sessionId, turnId); }, - classifyChanges: ( - projectPath: string, - sessionId: string, - turnId: number - ): { agentModified: string[]; unknownSource: string[] } | null => { - const sg = ensure(projectPath); - const l = ledger(sg); - return classifySafe(projectPath, sessionId, turnId, sg, l); - }, + // ---- Query ---- getCompletedTurns: (projectPath: string, sessionId: string): number[] => { const sg = ensure(projectPath); + repairIncompleteTurn(sg, sessionId); return getCompletedTurnsFor(sg, sessionId); }, @@ -371,62 +144,43 @@ export class CheckpointService extends Effect.Service()('Chec ): Array<{ turnId: number; title: string; - agentModified: string[]; - unknownSource: string[]; + files: string[]; }> => { const sg = ensure(projectPath); + 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 (let i = 1; i <= 10000; i++) { + + 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(' ') : ''; 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( - ledger(sg) - .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; }, - // ---- B1: getCheckpointDiff ---- - getCheckpointDiff: ( projectPath: string, sessionId: string, 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); @@ -441,20 +195,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) => @@ -462,35 +215,15 @@ export class CheckpointService extends Effect.Service()('Chec rawPath.toLowerCase() )?.status ?? 'M', diff: diffResult.stdout, - insertions: stats.insertions, - deletions: stats.deletions, + insertions, + deletions, }; }); return { turnId: latestTurnId, files }; }, - // ---- B2: revertCheckpointFile / revertCheckpointFiles ---- - - revertCheckpointFile: ( - projectPath: string, - sessionId: string, - turnId: number, - file: string - ): CodeRollbackResult => { - const sg = ensure(projectPath); - const plan = getTurnRestorePlan(sg, sessionId, turnId); - if (!plan) - return { - reverted: false, - throughTurnId: turnId, - baseTurnId: turnId, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - return revertFilesImpl(projectPath, sessionId, plan, [file], 'checkpoint-file', sg); - }, + // ---- Revert ---- revertCheckpointFiles: ( projectPath: string, @@ -500,107 +233,13 @@ 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, - }; - return revertFilesImpl(projectPath, sessionId, plan, files, 'checkpoint-files', sg); - }, - - // ---- B3: revertCheckpointAgentFiles / revertCheckpointAllFiles ---- - - revertCheckpointAgentFiles: ( - projectPath: string, - sessionId: string, - turnId: number - ): CodeRollbackResult => { - 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, - }; - const plan = getTurnRestorePlan(sg, sessionId, turnId); - if (!plan) - return { - reverted: false, - throughTurnId: turnId, - baseTurnId: null, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - return revertFilesImpl( - projectPath, - sessionId, - plan, - changes.agentModified, - 'checkpoint-agent', - sg - ); - }, - - revertCheckpointAllFiles: ( - projectPath: string, - sessionId: string, - turnId: number - ): CodeRollbackResult => { - 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, - }; - const all = [...changes.agentModified, ...changes.unknownSource]; - if (all.length === 0) - return { - reverted: false, - throughTurnId: turnId, - baseTurnId: null, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - const plan = getTurnRestorePlan(sg, sessionId, turnId); - if (!plan) - return { - reverted: false, - throughTurnId: turnId, - baseTurnId: null, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - return revertFilesImpl(projectPath, sessionId, plan, all, 'checkpoint-all', sg); + if (!plan) { + return emptyRollbackResult(turnId); + } + return executeRollback(sessionId, plan, files, 'checkpoint-files', sg, lockFor(projectPath)); }, - // ---- B4: previewRollbackDiff ---- + // ---- Rollback ---- previewRollbackDiff: ( projectPath: string, @@ -610,20 +249,17 @@ 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, }; }, - // ---- B5: rollbackCodeToTurn ---- - rollbackCodeToTurn: ( projectPath: string, sessionId: string, @@ -632,14 +268,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); @@ -653,18 +282,15 @@ export class CheckpointService extends Effect.Service()('Chec return { reverted: true, throughTurnId, - baseTurnId: plan.baseTurnId, affectedTurns: plan.affectedTurns, selectedFiles: [], restoreEntry: null, }; } - return revertFilesImpl(projectPath, sessionId, plan, selectedFiles, 'rollback-to-turn', sg); + return executeRollback(sessionId, plan, selectedFiles, 'rollback-to-turn', sg, lockFor(projectPath)); }, - // ---- B6: undoLastCodeRollback ---- - undoLastCodeRollback: ( projectPath: string, sessionId: string, @@ -702,10 +328,8 @@ 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') + commitMsg(sessionId, entry.throughTurnId, 'baseline') ); const conflictFiles: string[] = []; @@ -735,7 +359,8 @@ export class CheckpointService extends Effect.Service()('Chec }; } - sg.lock(); + const lock = lockFor(projectPath); + lock.lock(); try { sg.checkoutFiles(entry.safetyCommit, filesToRestore); @@ -759,12 +384,10 @@ export class CheckpointService extends Effect.Service()('Chec remainingRolledBack: remainingFiles, }; } finally { - sg.unlock(); + lock.unlock(); } }, - // ---- getLatestRestoreEntry ---- - getLatestRestoreEntry: (projectPath: string, sessionId: string): CodeRestoreEntry | null => { const sg = ensure(projectPath); return readRestoreEntry(sg.gitDir, sessionId); 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/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 new file mode 100644 index 0000000..3238e44 --- /dev/null +++ b/packages/codingcode/src/checkpoint/rollback-engine.ts @@ -0,0 +1,93 @@ +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 './utils.js'; +import { readRestoreEntry, writeRestoreEntry } from './undo-store.js'; + +export function emptyRollbackResult( + turnId: number +): CodeRollbackResult { + return { + reverted: false, + throughTurnId: turnId, + affectedTurns: [], + selectedFiles: [], + restoreEntry: null, + }; +} + +export function executeRollback( + sessionId: string, + plan: { throughTurnId: number; affectedTurns: number[]; baseline: string }, + selectedFiles: string[], + action: CodeRestoreEntry['action'], + sg: ShadowGit, + lock: ProjectLock +): CodeRollbackResult { + if (selectedFiles.length === 0) { + return { + reverted: false, + throughTurnId: plan.throughTurnId, + affectedTurns: plan.affectedTurns, + selectedFiles: [], + restoreEntry: null, + }; + } + + lock.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')); + } + + 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, + affectedTurns: plan.affectedTurns, + selectedFiles: combinedFiles, + safetyCommit, + timestamp: new Date().toISOString(), + }; + writeRestoreEntry(sg.gitDir, sessionId, entry); + + sg.checkoutFiles(plan.baseline, selectedFiles); + + return { + reverted: true, + throughTurnId: plan.throughTurnId, + affectedTurns: plan.affectedTurns, + selectedFiles: combinedFiles, + restoreEntry: entry, + }; + } finally { + lock.unlock(); + } +} diff --git a/packages/codingcode/src/checkpoint/shadow-git.ts b/packages/codingcode/src/checkpoint/shadow-git.ts index 3fca9fa..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 { @@ -128,10 +121,9 @@ 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; 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/checkpoint/turn-query.ts b/packages/codingcode/src/checkpoint/turn-query.ts new file mode 100644 index 0000000..b4e86aa --- /dev/null +++ b/packages/codingcode/src/checkpoint/turn-query.ts @@ -0,0 +1,54 @@ +import type { ShadowGit } from './shadow-git.js'; +import { shortSid, commitMsg } from './utils.js'; + +export interface RestorePlan { + throughTurnId: 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, + 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, + affectedTurns, + baseline, + }; +} diff --git a/packages/codingcode/src/checkpoint/undo-store.ts b/packages/codingcode/src/checkpoint/undo-store.ts new file mode 100644 index 0000000..0ca7f5f --- /dev/null +++ b/packages/codingcode/src/checkpoint/undo-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 './utils.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/utils.ts b/packages/codingcode/src/checkpoint/utils.ts new file mode 100644 index 0000000..a222948 --- /dev/null +++ b/packages/codingcode/src/checkpoint/utils.ts @@ -0,0 +1,49 @@ +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; + } +} + +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/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/client/direct.ts b/packages/codingcode/src/client/direct.ts index c62fd9b..36875ac 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( @@ -204,29 +194,11 @@ export async function createDirectClient(llm: any): Promise { }); }, - async revertCheckpointFile(turnId: number, file: string) { - if (!currentSessionId) - return { - reverted: false, - throughTurnId: turnId, - baseTurnId: null, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - return clients.sessions.revertCheckpointFile({ - sessionId: currentSessionId, - cwd: cwd(), - file, - }); - }, - async revertCheckpointFiles(turnId: number, files: string[]) { if (!currentSessionId) return { reverted: false, throughTurnId: turnId, - baseTurnId: null, affectedTurns: [], selectedFiles: [], restoreEntry: null, @@ -238,38 +210,9 @@ export async function createDirectClient(llm: any): Promise { }); }, - async revertCheckpointAgentFiles(turnId: number) { - if (!currentSessionId) - return { - reverted: false, - throughTurnId: turnId, - baseTurnId: null, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - return clients.sessions.revertCheckpointAgentFiles({ - sessionId: currentSessionId, - cwd: cwd(), - }); - }, - - async revertCheckpointAllFiles(turnId: number) { - if (!currentSessionId) - return { - reverted: false, - throughTurnId: turnId, - baseTurnId: null, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - return clients.sessions.revertCheckpointAllFiles({ sessionId: currentSessionId, cwd: cwd() }); - }, - 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 +225,6 @@ export async function createDirectClient(llm: any): Promise { return { reverted: false, throughTurnId, - baseTurnId: null, affectedTurns: [], selectedFiles: [], restoreEntry: null, @@ -310,7 +252,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..00788ef 100644 --- a/packages/codingcode/src/client/direct/sessions.ts +++ b/packages/codingcode/src/client/direct/sessions.ts @@ -17,10 +17,7 @@ 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; - revertCheckpointAgentFiles(input: { sessionId: string; cwd: string }): Promise; - revertCheckpointAllFiles(input: { sessionId: string; cwd: string }): Promise; previewRollbackDiff(input: { sessionId: string; cwd: string; @@ -121,54 +118,22 @@ export function createDirectSessionClient( async getCheckpointDiff() { return { turnId: 0, files: [] }; }, - async revertCheckpointFile() { - return { - reverted: false, - throughTurnId: 0, - baseTurnId: null, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - }, async revertCheckpointFiles() { return { reverted: false, throughTurnId: 0, - baseTurnId: null, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - }, - async revertCheckpointAgentFiles() { - return { - reverted: false, - throughTurnId: 0, - baseTurnId: null, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - }, - async revertCheckpointAllFiles() { - 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 +148,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..d2afac2 100644 --- a/packages/codingcode/src/client/http.ts +++ b/packages/codingcode/src/client/http.ts @@ -127,69 +127,28 @@ 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 []; }, async getCheckpointDiff() { return { turnId: 0, files: [] }; }, - async revertCheckpointFile() { - return { - reverted: false, - throughTurnId: 0, - baseTurnId: null, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - }, async revertCheckpointFiles() { return { reverted: false, throughTurnId: 0, - baseTurnId: null, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - }, - async revertCheckpointAgentFiles() { - return { - reverted: false, - throughTurnId: 0, - baseTurnId: null, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - }, - async revertCheckpointAllFiles() { - 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 +163,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/client/http/sessions.ts b/packages/codingcode/src/client/http/sessions.ts index 68c63a4..a370a8d 100644 --- a/packages/codingcode/src/client/http/sessions.ts +++ b/packages/codingcode/src/client/http/sessions.ts @@ -18,10 +18,7 @@ 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; - revertCheckpointAgentFiles(input: { sessionId: string; cwd: string }): Promise; - revertCheckpointAllFiles(input: { sessionId: string; cwd: string }): Promise; previewRollbackDiff(input: { sessionId: string; cwd: string; @@ -97,22 +94,10 @@ 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 }); }, - 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..43be54b 100644 --- a/packages/codingcode/src/client/types.ts +++ b/packages/codingcode/src/client/types.ts @@ -32,22 +32,11 @@ 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/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 57% rename from packages/codingcode/src/context/compressor/index.ts rename to packages/codingcode/src/context/compressor.ts index 2e740e9..3282f3f 100644 --- a/packages/codingcode/src/context/compressor/index.ts +++ b/packages/codingcode/src/context/compressor.ts @@ -1,20 +1,17 @@ import { randomUUID } from 'crypto'; -import { resolveSessionDir } from '../../session/io.js'; +import { resolveSessionJsonlPath, appendLine } 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'; -import { join } from 'path'; -import { appendFileSync } from 'fs'; +} from './util.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'; +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'; export interface CompressResult { didCompress: boolean; @@ -86,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; } @@ -96,46 +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), }; } -// ---------- 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 ---------- -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; }); @@ -144,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 resolveCompactionLLM(config, llm); + 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[] = []; @@ -173,8 +157,7 @@ async function tryCompaction( lastSummarizedTurnId: lastTurnId, timestamp: new Date().toISOString(), }; - appendSummaryToSession(sessionId, 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)); @@ -191,74 +174,16 @@ 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, config: ContextConfig ): Promise { - const llm = await resolveCompactionLLM(config, fallbackLlm); + const llm = await resolveLLM(config.compactionModel, fallbackLlm); 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/compressor/llm-resolver.ts b/packages/codingcode/src/context/compressor/llm-resolver.ts deleted file mode 100644 index f762416..0000000 --- a/packages/codingcode/src/context/compressor/llm-resolver.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/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 0b231d7..0db8799 100644 --- a/packages/codingcode/src/context/organizer.ts +++ b/packages/codingcode/src/context/organizer.ts @@ -1,13 +1,12 @@ 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 './utils/tokens.js'; -import { join } from 'path'; +import { estimateTokens } from './util.js'; 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', @@ -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, }; } @@ -101,7 +102,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/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/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..2629152 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'; @@ -39,7 +39,7 @@ export interface SelectableModel { } 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/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/llm/providers/deepseek.ts b/packages/codingcode/src/llm/providers/deepseek.ts index 6b5ab2c..280df14 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( @@ -141,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 cfc0c41..961b62a 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( @@ -153,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/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/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/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/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index 00b0f0a..4a641ea 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -224,13 +224,12 @@ sessionsRouter.post('/:id/checkpoints/latest/revert-file', async (c) => { return { reverted: false, throughTurnId: 0, - baseTurnId: null, affectedTurns: [], selectedFiles: [], 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) { @@ -254,7 +253,6 @@ sessionsRouter.post('/:id/checkpoints/latest/revert-files', async (c) => { return { reverted: false, throughTurnId: 0, - baseTurnId: null, affectedTurns: [], selectedFiles: [], restoreEntry: null, @@ -270,66 +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, - baseTurnId: null, - 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, - baseTurnId: null, - 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/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/src/session/io.ts b/packages/codingcode/src/session/io.ts index 1e1eb8d..c566f0a 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 { @@ -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); @@ -86,7 +92,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 +112,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..14f0051 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', @@ -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) { @@ -104,7 +108,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..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, @@ -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/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/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-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-project-path.test.ts b/packages/codingcode/test/checkpoint/checkpoint-project-path.test.ts deleted file mode 100644 index ad0c08f..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/bootstrap 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({ installRoot: 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('bootstrap checkpoint records correct file path via payload.projectPath', async () => { - const hooks = await getHooks(); - const { bootstrapCheckpoint } = await import('../../src/checkpoint/bootstrap.js'); - bootstrapCheckpoint(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/checkpoint/checkpoint-undo.test.ts b/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts index f63cd83..fd1217f 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'); @@ -163,9 +163,8 @@ describe('undoLastCodeRollback end-to-end via ShadowGit', () => { const entry = { id: 'test123', sessionId, - action: 'checkpoint-file', + action: 'checkpoint-files', 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'); @@ -314,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'; @@ -368,9 +364,8 @@ describe('undoLastCodeRollback case-insensitive path matching', () => { const entry = { id: 'test123', sessionId, - action: 'checkpoint-file', + action: 'checkpoint-files', throughTurnId: 1, - baseTurnId: 1, affectedTurns: [], selectedFiles: [join(projectPath, 'src/main.ts').toLowerCase()], safetyCommit: safetyHash, 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/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..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 }, @@ -39,9 +45,13 @@ vi.mock('../../../src/session/io.js', async (importOriginal) => { }; }); -vi.mock('../../../src/context/compressor/llm-resolver.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(); @@ -51,15 +61,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..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/compressor/llm-resolver.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/context/context.test.ts b/packages/codingcode/test/context/context.test.ts index 5505254..3314c8d 100644 --- a/packages/codingcode/test/context/context.test.ts +++ b/packages/codingcode/test/context/context.test.ts @@ -84,11 +84,14 @@ const MockCheckpointLayer = Layer.succeed( _tag: 'Checkpoint' as const, snapshotBaseline: () => {}, snapshotFinal: () => {}, - classifyChanges: () => null, getCompletedTurns: () => [], - forward: () => null, - hasForwardStack: () => false, getCheckpoints: () => [], + getCheckpointDiff: () => ({ turnId: 0, files: [] }), + 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) ); @@ -165,7 +168,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 +210,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/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/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/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); }); }); diff --git a/packages/codingcode/test/orchestrate.test.ts b/packages/codingcode/test/orchestrate.test.ts index 11c615c..7cc6322 100644 --- a/packages/codingcode/test/orchestrate.test.ts +++ b/packages/codingcode/test/orchestrate.test.ts @@ -183,11 +183,14 @@ const MockCheckpointLayer = Layer.succeed( _tag: 'Checkpoint' as const, snapshotBaseline: () => {}, snapshotFinal: () => {}, - classifyChanges: () => null, getCompletedTurns: () => [], - forward: () => null, - hasForwardStack: () => false, getCheckpoints: () => [], + getCheckpointDiff: () => ({ turnId: 0, files: [] }), + 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) ); @@ -201,8 +204,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..a238dfc 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, @@ -194,11 +194,14 @@ const MockCheckpointLayer = Layer.succeed( _tag: 'Checkpoint' as const, snapshotBaseline: () => {}, snapshotFinal: () => {}, - classifyChanges: () => null, getCompletedTurns: () => [], - forward: () => null, - hasForwardStack: () => false, getCheckpoints: () => [], + getCheckpointDiff: () => ({ turnId: 0, files: [] }), + 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) ); @@ -227,7 +230,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/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', () => { 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'; 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/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; } 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..5857ac5 100644 --- a/packages/desktop/src/hooks/useAgent.ts +++ b/packages/desktop/src/hooks/useAgent.ts @@ -11,10 +11,7 @@ import { deleteSession, sendApprovalResponse, getCheckpointDiff, - revertCheckpointFile, revertCheckpointFiles, - revertCheckpointAgentFiles, - revertCheckpointAllFiles, previewRollbackDiff, rollbackCodeToTurn, rollbackContext, @@ -362,7 +359,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); @@ -398,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); } @@ -422,30 +418,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 +557,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 f36454a..804f04c 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; @@ -234,7 +233,6 @@ export interface CheckpointDiff { export interface CodeRollbackResult { reverted: boolean; throughTurnId: number; - baseTurnId: number | null; affectedTurns: number[]; selectedFiles: string[]; restoreEntry: CodeRestoreEntry | null; @@ -250,7 +248,6 @@ export interface CodeRollbackUndoResult { export interface RollbackPreviewDiff { throughTurnId: number; - baseTurnId: number | null; affectedTurns: number[]; diff: string; } @@ -260,7 +257,6 @@ export interface CodeRestoreEntry { sessionId: string; action: string; throughTurnId: number; - baseTurnId: number; affectedTurns: number[]; selectedFiles: string[]; safetyCommit: string; @@ -285,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, @@ -301,20 +289,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 1daed60..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, @@ -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', }; @@ -106,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 = { @@ -167,7 +136,7 @@ describe('Rollback state in global store', () => { files: [ { path: '/a.ts', - source: 'agent' as const, + status: 'M', diff: '---\n+++\n', insertions: 1, @@ -188,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/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 }; +} 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[] }