diff --git a/packages/codingcode/package.json b/packages/codingcode/package.json index 42e0a18..d3ff8ff 100644 --- a/packages/codingcode/package.json +++ b/packages/codingcode/package.json @@ -33,10 +33,13 @@ "./server/adapter": "./src/server/adapter.ts", "./server/port-discovery": "./src/server/port-discovery.ts", "./client/types": "./src/client/types.ts", - "./client/direct": "./src/client/direct.ts", "./client/http": "./src/client/http.ts", "./client/http-clients": "./src/client/http/index.ts", - "./client/direct-clients": "./src/client/direct/index.ts", + "./direct/agent-runtime": "./src/direct/agent-runtime.ts", + "./direct/sessions": "./src/direct/sessions.ts", + "./direct/settings": "./src/direct/settings.ts", + "./direct/models": "./src/direct/models.ts", + "./agent/stream-adapter": "./src/agent/stream-adapter.ts", "./checkpoint/checkpoint-service": "./src/checkpoint/checkpoint-service.ts", "./checkpoint/shadow-git": "./src/checkpoint/shadow-git.ts", "./checkpoint/bootstrap": "./src/checkpoint/bootstrap.ts", diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index 643144c..20ef1e0 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -122,10 +122,10 @@ export const sendMessage = ( llm: LLMClient, options: { signal?: AbortSignal; - approvalOverride?: any; - mode: SessionMode; - permissionMode: PermissionMode; - model: string; + approvalOverride?: import('../approval/index.js').ApprovalService; + mode?: SessionMode; + permissionMode?: PermissionMode; + model?: string; } ) => Effect.gen(function* () { @@ -148,18 +148,19 @@ export const sendMessage = ( yield* skills.evictProject(normalizedCwd); if (!sessionId) { - const created = yield* session.create(normalizedCwd, { + if (!options.mode || !options.permissionMode || !options.model) { + return yield* Effect.fail( + new AgentError( + 'CONFIG_MISSING', + 'new session requires mode, permissionMode, and model' + ) + ); + } + const created = yield* session.createSessionWithProfile(normalizedCwd, { model: options.model, mode: options.mode, permissionMode: options.permissionMode, }); - const profile = modeToProfile(options.mode); - yield* runtime.setSessionProfile( - normalizedCwd, - created.sessionId, - profile, - options.permissionMode - ); sessionId = created.sessionId; } const state = yield* session.load(normalizedCwd, sessionId); @@ -335,12 +336,7 @@ export function agentLoop( const compressResult = yield* Effect.tryPromise({ try: () => - context.compactIfNeeded( - state.transcriptPath, - messages, - llm.modelInfo.maxTokens, - llm - ), + context.compactIfNeeded(state.transcriptPath, messages, llm.modelInfo.maxTokens, llm), catch: (e) => new AgentError('LLM_FAILED', String(e)), }); if (compressResult.didCompress && compressResult.messages) { diff --git a/packages/codingcode/src/agent/stream-adapter.ts b/packages/codingcode/src/agent/stream-adapter.ts new file mode 100644 index 0000000..c9fd103 --- /dev/null +++ b/packages/codingcode/src/agent/stream-adapter.ts @@ -0,0 +1,67 @@ +import type { AgentEvent } from './types.js'; +import type { StreamChunk } from '../client/types.js'; + +export async function* agentEventToStreamChunk( + source: AsyncGenerator +): AsyncGenerator { + let currentStep = 0; + for await (const event of source) { + switch (event._tag) { + case 'Step': + currentStep = event.step; + break; + case 'TurnId': + yield { type: 'turn_id', turnId: event.turnId }; + break; + case 'LlmChunk': + yield { type: 'text', text: event.text, messageId: currentStep }; + break; + case 'Assistant': + yield { type: 'message', id: currentStep, content: event.content, partial: false }; + break; + case 'ToolStart': + yield { type: 'tool_start', id: event.id, name: event.name, args: event.args }; + break; + case 'ToolResult': + yield { + type: 'tool_result', + id: event.id, + name: event.name, + output: event.output, + ok: event.ok, + }; + break; + case 'ToolDenied': + yield { type: 'tool_denied', id: event.id, name: event.name, reason: event.reason }; + break; + case 'Error': + yield { + type: 'error', + message: event.error.message ?? String(event.error), + code: event.error.code, + }; + break; + case 'Done': + yield { type: 'done' }; + break; + case 'TodoUpdate': + yield { type: 'todo_update', items: event.items as any }; + break; + case 'Usage': + yield { + type: 'usage', + prompt: event.prompt, + completion: event.completion, + total: event.total, + }; + break; + case 'ReactiveCompact': + yield { + type: 'reactive_compact', + released: event.released, + promptEstimate: event.promptEstimate, + }; + break; + } + } +} diff --git a/packages/codingcode/src/agent/types.ts b/packages/codingcode/src/agent/types.ts index 2cd7f07..d9d9073 100644 --- a/packages/codingcode/src/agent/types.ts +++ b/packages/codingcode/src/agent/types.ts @@ -98,6 +98,6 @@ export interface RunStreamOptions { agentName?: string; maxStepsOverride?: number; maxStopContinuations?: number; - approvalOverride?: any; + approvalOverride?: import('../approval/index.js').ApprovalService; rulesText?: string; } diff --git a/packages/codingcode/src/approval/index.ts b/packages/codingcode/src/approval/index.ts index 6181498..251c759 100644 --- a/packages/codingcode/src/approval/index.ts +++ b/packages/codingcode/src/approval/index.ts @@ -13,7 +13,6 @@ export class ApprovalService extends Effect.Service()('Approval const ruleEngine: RuleEngine = createRuleEngine(DEFAULT_DENY_RULES); const destructiveTools = new Set(DANGEROUS_TOOL_NAMES); const readonlyTools = new Set(READONLY_TOOL_NAMES); - let _globalPermissionMode: PermissionMode = 'default'; function makeForkedService( engine: RuleEngine, @@ -64,6 +63,7 @@ export class ApprovalService extends Effect.Service()('Approval fork: (opts?: { extraDenyRules?: PermissionRule[]; readonly?: boolean; + permissionMode?: PermissionMode; }): Effect.Effect => Effect.sync(() => { const nextEngine = createRuleEngine(engine.getAllRules()); @@ -84,7 +84,7 @@ export class ApprovalService extends Effect.Service()('Approval } return makeForkedService( nextEngine, - currentPermMode, + opts?.permissionMode ?? currentPermMode, new Set(roTools), new Set(destTools) ); @@ -112,7 +112,7 @@ export class ApprovalService extends Effect.Service()('Approval ruleEngine, readonlyTools, destructiveTools, - permissionMode: _globalPermissionMode, + permissionMode: 'default', onAlways: (rule) => ruleEngine.addRule(rule), onNever: (rule) => ruleEngine.addRule(rule), sessionId: request.sessionId, @@ -129,16 +129,17 @@ export class ApprovalService extends Effect.Service()('Approval removeRule: (id: string): Effect.Effect => Effect.sync(() => ruleEngine.removeRule(id)), - setPermissionMode: (mode: PermissionMode): Effect.Effect => + setPermissionMode: (_mode: PermissionMode): Effect.Effect => Effect.sync(() => { - _globalPermissionMode = mode; + /* no-op at root; only fork children maintain their own currentPermMode */ }), - getPermissionMode: (): PermissionMode => _globalPermissionMode, + getPermissionMode: (): PermissionMode => 'default', fork: (opts?: { extraDenyRules?: PermissionRule[]; readonly?: boolean; + permissionMode?: PermissionMode; }): Effect.Effect => Effect.sync(() => { const parentRules = ruleEngine.getAllRules(); @@ -161,7 +162,7 @@ export class ApprovalService extends Effect.Service()('Approval } return makeForkedService( childEngine, - _globalPermissionMode, + opts?.permissionMode ?? 'default', new Set(readonlyTools), new Set(destructiveTools) ); diff --git a/packages/codingcode/src/approval/types.ts b/packages/codingcode/src/approval/types.ts index 5e98a5d..f86b45b 100644 --- a/packages/codingcode/src/approval/types.ts +++ b/packages/codingcode/src/approval/types.ts @@ -1,6 +1,10 @@ export type PermissionMode = 'default' | 'acceptEdits' | 'bypass'; -export const PERMISSION_MODES: readonly PermissionMode[] = ['default', 'acceptEdits', 'bypass'] as const; +export const PERMISSION_MODES: readonly PermissionMode[] = [ + 'default', + 'acceptEdits', + 'bypass', +] as const; export function isPermissionMode(value: unknown): value is PermissionMode { return typeof value === 'string' && (PERMISSION_MODES as readonly string[]).includes(value); diff --git a/packages/codingcode/src/cli.ts b/packages/codingcode/src/cli.ts index b3ba273..58ba53e 100644 --- a/packages/codingcode/src/cli.ts +++ b/packages/codingcode/src/cli.ts @@ -38,9 +38,12 @@ async function main() { if (tuiOnly) { const tuiPath = '../../tui/src/index.js'; - const { runTui } = yield* Effect.tryPromise(() => import(tuiPath)); + const { runTui, createTuiClientFromFacades } = yield* Effect.tryPromise(() => + import(tuiPath) + ); const llm = yield* llmFactory.getLLMClient(); - runTui({ llm, rt }); + const client = createTuiClientFromFacades(llm, rt); + runTui({ client }); return; } @@ -50,9 +53,12 @@ async function main() { if (!serveOnly) { const tuiPath = '../../tui/src/index.js'; - const { runTui } = yield* Effect.tryPromise(() => import(tuiPath)); + const { runTui, createTuiClientFromFacades } = yield* Effect.tryPromise(() => + import(tuiPath) + ); const llm = yield* llmFactory.getLLMClient(); - runTui({ llm, rt }); + const client = createTuiClientFromFacades(llm, rt); + runTui({ client }); } }); diff --git a/packages/codingcode/src/client/direct.ts b/packages/codingcode/src/client/direct.ts deleted file mode 100644 index d54d452..0000000 --- a/packages/codingcode/src/client/direct.ts +++ /dev/null @@ -1,508 +0,0 @@ -import { Effect } from 'effect'; -import type { AgentEvent } from '../agent/types.js'; -import { sendMessage } from '../agent/agent.js'; -import { CheckpointService } from '../checkpoint/checkpoint-service.js'; -import { LLMFactoryService } from '../llm/factory.js'; -import { WorkspaceService } from '../core/workspace.js'; -import { ApprovalService } from '../approval/index.js'; -import { ApprovalWaitService } from '../approval/async-confirm.js'; -import type { PermissionMode } from '../approval/types.js'; -import type { McpServerConfig } from '../mcp/types.js'; -import type { AgentProfile } from '../subagent/types.js'; -import type { UserHookConfig } from '../hooks/types.js'; -import type { StreamChunk, AgentClient } from './types.js'; -import { createDirectClients } from './direct/index.js'; -import type { AppRuntime } from '../layer.js'; -import type { LLMClient } from '../llm/client.js'; - -export async function* agentEventToStreamChunk( - source: AsyncGenerator -): AsyncGenerator { - let currentStep = 0; - for await (const event of source) { - switch (event._tag) { - case 'Step': - currentStep = event.step; - break; - case 'TurnId': - yield { type: 'turn_id', turnId: event.turnId }; - break; - case 'LlmChunk': - yield { type: 'text', text: event.text, messageId: currentStep }; - break; - case 'Assistant': - yield { type: 'message', id: currentStep, content: event.content, partial: false }; - break; - case 'ToolStart': - yield { type: 'tool_start', id: event.id, name: event.name, args: event.args }; - break; - case 'ToolResult': - yield { - type: 'tool_result', - id: event.id, - name: event.name, - output: event.output, - ok: event.ok, - }; - break; - case 'ToolDenied': - yield { type: 'tool_denied', id: event.id, name: event.name, reason: event.reason }; - break; - case 'Error': - yield { - type: 'error', - message: event.error.message ?? String(event.error), - code: event.error.code, - }; - break; - case 'Done': - yield { type: 'done' }; - break; - case 'TodoUpdate': - yield { type: 'todo_update', items: event.items as any }; - break; - case 'Usage': - yield { - type: 'usage', - prompt: event.prompt, - completion: event.completion, - total: event.total, - }; - break; - case 'ReactiveCompact': - yield { - type: 'reactive_compact', - released: event.released, - promptEstimate: event.promptEstimate, - }; - break; - } - } -} - -export async function createDirectClient(llm: LLMClient, rt: AppRuntime): Promise { - let currentSessionId = ''; - let activeLlm = llm; - - const runWithLayer = (eff: any): Promise => rt.runPromise(eff); - - const clients = createDirectClients(activeLlm, rt); - const cwdValue = await rt.runPromise( - Effect.gen(function* () { - const ws = yield* WorkspaceService; - return ws.getWorkspaceCwd(); - }) - ); - const cwd = () => cwdValue; - - return { - getSessionId() { - return currentSessionId; - }, - - async *sendMessage(input: string): AsyncGenerator { - const waitService = await rt.runPromise( - Effect.gen(function* () { - return yield* ApprovalWaitService; - }) - ); - const program = sendMessage(currentSessionId || undefined, input, cwd(), activeLlm, { - mode: 'build', - permissionMode: 'default', - model: activeLlm.modelInfo.model, - }); - const { stream: agentGen, sessionId } = (await runWithLayer(program)) as any; - currentSessionId = sessionId; - - let notify: - | ((req: { - type: 'approval_request'; - id: string; - tool: string; - args: Record; - }) => void) - | null = null; - Effect.runSync( - waitService.registerEmitter( - sessionId, - (id: string, tool: string, args: Record) => { - notify?.({ type: 'approval_request', id, tool, args }); - } - ) - ); - - try { - const gen = agentEventToStreamChunk(agentGen); - let pending = gen.next(); - - while (true) { - const approvalPromise = new Promise<{ - type: 'approval_request'; - id: string; - tool: string; - args: Record; - }>((resolve) => { - notify = resolve; - }); - - const winner = await Promise.race([ - pending.then((c): { tag: 'chunk'; value: IteratorResult } => ({ - tag: 'chunk', - value: c, - })), - approvalPromise.then((req): { tag: 'approval'; value: typeof req } => ({ - tag: 'approval', - value: req, - })), - ]); - - if (winner.tag === 'chunk') { - notify = null; - if (winner.value.done) break; - yield winner.value.value; - pending = gen.next(); - } else { - yield winner.value; - } - } - } finally { - Effect.runSync(waitService.unregisterEmitter(sessionId)); - } - }, - - async sendApprovalResponse(id: string, response: string) { - if (!currentSessionId) return; - await clients.agent.sendApprovalResponse({ - sessionId: currentSessionId, - approvalId: id, - response, - }); - }, - - async resumeSession(sid: string) { - currentSessionId = sid; - return clients.sessions.resumeSession({ sessionId: sid, cwd: cwd() }); - }, - - async listSessions() { - return clients.sessions.listSessions({ cwd: cwd() }); - }, - - async listModels() { - return clients.models.listModels(); - }, - - async switchModel(id: string) { - await clients.models.switchModel({ id }); - activeLlm = await rt.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - return yield* factory.getLLMClient(); - }) - ); - }, - - async getCheckpoints() { - if (!currentSessionId) return []; - return runWithLayer( - Effect.gen(function* () { - const checkpoint = yield* CheckpointService; - return yield* checkpoint.getCheckpoints(cwd(), currentSessionId); - }) - ); - }, - - async getCheckpointDiff(turnId?: number) { - if (!currentSessionId) return { turnId: 0, files: [] }; - return clients.sessions.getCheckpointDiff({ - sessionId: currentSessionId, - cwd: cwd(), - turnId, - }); - }, - - async revertCheckpointFiles(turnId: number, files: string[]) { - if (!currentSessionId) - return { - reverted: false, - throughTurnId: turnId, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - return clients.sessions.revertCheckpointFiles({ - sessionId: currentSessionId, - cwd: cwd(), - files, - }); - }, - - async previewRollbackDiff(throughTurnId: number) { - if (!currentSessionId) return { throughTurnId, affectedTurns: [], diff: '' }; - return clients.sessions.previewRollbackDiff({ - sessionId: currentSessionId, - cwd: cwd(), - throughTurnId, - }); - }, - - async rollbackCodeToTurn(throughTurnId: number) { - if (!currentSessionId) - return { - reverted: false, - throughTurnId, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - return clients.sessions.rollbackCodeToTurn({ - sessionId: currentSessionId, - cwd: cwd(), - throughTurnId, - }); - }, - - async rollbackContext(throughTurnId: number) { - if (!currentSessionId) - return { - turns: [] as import('../session/types.js').SessionEvent[], - rollbackState: { - context: { active: false, currentThroughTurnId: null }, - code: { - canUndoLast: false, - lastEntry: null, - revertedFiles: [] as string[], - lastEntryId: null, - }, - } as import('../checkpoint/types.js').RollbackState, - }; - return clients.sessions.rollbackContext({ - sessionId: currentSessionId, - cwd: cwd(), - throughTurnId, - }); - }, - - async rollbackBothToTurn(throughTurnId: number) { - if (!currentSessionId) - return { - turns: [] as import('../session/types.js').SessionEvent[], - codeResult: { - reverted: false, - throughTurnId, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }, - rollbackState: { - context: { active: false, currentThroughTurnId: null }, - code: { - canUndoLast: false, - lastEntry: null, - revertedFiles: [] as string[], - lastEntryId: null, - }, - } as import('../checkpoint/types.js').RollbackState, - }; - return clients.sessions.rollbackBothToTurn({ - sessionId: currentSessionId, - cwd: cwd(), - throughTurnId, - }); - }, - - async undoLastCodeRollback(force?: boolean, files?: string[]) { - if (!currentSessionId) - return { - restored: false, - conflict: false, - conflictFiles: [], - restoredFiles: [], - remainingRolledBack: [], - }; - return clients.sessions.undoLastCodeRollback({ - sessionId: currentSessionId, - cwd: cwd(), - force, - files, - }); - }, - - async getRollbackState() { - if (!currentSessionId) - return { - context: { active: false, currentThroughTurnId: null }, - code: { canUndoLast: false, lastEntry: null, revertedFiles: [], lastEntryId: null }, - }; - return clients.sessions.getRollbackState({ sessionId: currentSessionId, cwd: cwd() }); - }, - - async forkSession(atTurnId?: number) { - if (!currentSessionId) return ''; - const result = await clients.sessions.forkSession({ - sessionId: currentSessionId, - cwd: cwd(), - atTurnId, - }); - return result.sessionId; - }, - - async compact() { - if (!currentSessionId) return; - await clients.agent.compact({ sessionId: currentSessionId, cwd: cwd() }); - }, - - async getMemoryEnabled() { - return clients.settings.getMemoryEnabled(); - }, - - async setMemoryEnabled(enabled: boolean) { - await clients.settings.setMemoryEnabled(enabled); - }, - - async getMemoryConfig() { - return clients.settings.getMemoryConfig(); - }, - - async setTypeDisabled(name: string, disabled: boolean) { - await clients.settings.setMemoryTypeDisabled(name, disabled); - }, - - async addExtraType(type: { name: string; description: string }) { - await clients.settings.addMemoryExtraType(type); - }, - - async updateExtraType(name: string, type: { name: string; description: string }) { - await clients.settings.updateMemoryExtraType(name, type); - }, - - async deleteExtraType(name: string) { - await clients.settings.deleteMemoryExtraType(name); - }, - - async getSubagentEnabled({ cwd: targetCwd }: { cwd: string }) { - return clients.settings.getSubagentEnabled({ cwd: targetCwd }); - }, - - async setSubagentEnabled(body: { enabled: boolean; cwd: string }) { - await clients.settings.setSubagentEnabled(body); - }, - - async resetSubagentEnabled(body: { cwd: string }) { - await clients.settings.resetSubagentEnabled(body); - }, - - async getMcpStatus({ cwd: targetCwd }: { cwd: string }) { - return clients.settings.getMcpStatus({ cwd: targetCwd }); - }, - - async setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }) { - await clients.settings.setMcpDisabled(body); - }, - - async resetMcpDisabled(body: { name: string; cwd: string }) { - await clients.settings.resetMcpDisabled(body); - }, - - async createMcpServer( - server: McpServerConfig, - { cwd: targetCwd }: { cwd: string } - ): Promise { - await clients.settings.createMcpServer({ cwd: targetCwd, server }); - }, - - async updateMcpServer( - name: string, - server: McpServerConfig, - { cwd: targetCwd }: { cwd: string } - ): Promise { - await clients.settings.updateMcpServer({ cwd: targetCwd, name, server }); - }, - - async deleteMcpServer(name: string, { cwd: targetCwd }: { cwd: string }): Promise { - await clients.settings.deleteMcpServer({ cwd: targetCwd, name }); - }, - - async listSkills() { - return clients.settings.listSkills(); - }, - - async toggleSkill(body: { name: string; enabled: boolean; cwd: string }) { - await clients.settings.toggleSkill(body); - }, - - async listAgents({ cwd: targetCwd }: { cwd: string }) { - return clients.settings.listAgents({ cwd: targetCwd }); - }, - - async createAgent(profile: AgentProfile, { cwd: targetCwd }: { cwd: string }): Promise { - await clients.settings.createAgent({ cwd: targetCwd, profile }); - }, - - async updateAgent( - name: string, - profile: AgentProfile, - { cwd: targetCwd }: { cwd: string } - ): Promise { - await clients.settings.updateAgent({ cwd: targetCwd, name, profile }); - }, - - async deleteAgent(name: string, { cwd: targetCwd }: { cwd: string }): Promise { - await clients.settings.deleteAgent({ cwd: targetCwd, name }); - }, - - async setAgentDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise { - await clients.settings.setAgentDisabled(body); - }, - - async resetAgentDisabled(body: { name: string; cwd: string }): Promise { - await clients.settings.resetAgentDisabled(body); - }, - - async listHooks({ cwd: targetCwd }: { cwd: string }) { - return clients.settings.listHooks({ cwd: targetCwd }); - }, - - async setHookDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise { - await clients.settings.setHookDisabled(body); - }, - - async resetHookDisabled(body: { name: string; cwd: string }): Promise { - await clients.settings.resetHookDisabled(body); - }, - - async createHook(hook: UserHookConfig, { cwd: targetCwd }: { cwd: string }): Promise { - await clients.settings.createHook({ cwd: targetCwd, hook }); - }, - - async updateHook( - name: string, - hook: UserHookConfig, - { cwd: targetCwd }: { cwd: string } - ): Promise { - await clients.settings.updateHook({ cwd: targetCwd, name, hook }); - }, - - async deleteHook(name: string, { cwd: targetCwd }: { cwd: string }): Promise { - await clients.settings.deleteHook({ cwd: targetCwd, name }); - }, - - async getPermissionMode(): Promise { - const approval = await rt.runPromise( - Effect.gen(function* () { - return yield* ApprovalService; - }) - ); - return approval.getPermissionMode(); - }, - - async setPermissionMode(mode: PermissionMode): Promise { - const approval = await rt.runPromise( - Effect.gen(function* () { - return yield* ApprovalService; - }) - ); - await rt.runPromise(approval.setPermissionMode(mode)); - }, - }; -} diff --git a/packages/codingcode/src/client/direct/agent-runtime.ts b/packages/codingcode/src/client/direct/agent-runtime.ts deleted file mode 100644 index 16e9204..0000000 --- a/packages/codingcode/src/client/direct/agent-runtime.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { Effect } from 'effect'; -import { sendMessage } from '../../agent/agent.js'; -import { ApprovalWaitService } from '../../approval/async-confirm.js'; -import { parseApprovalResponse } from '../../approval/response.js'; -import { ContextService } from '../../context/service.js'; -import { HookService } from '../../hooks/registry.js'; -import { SessionService } from '../../session/store.js'; -import type { StreamChunk } from '../types.js'; -import { agentEventToStreamChunk } from '../direct.js'; -import type { AppRuntime } from '../../layer.js'; -import type { LLMClient } from '../../llm/client.js'; - -export interface AgentRuntimeClient { - sendMessage( - input: string, - options: { sessionId?: string; cwd: string } - ): AsyncGenerator; - - sendApprovalResponse(input: { - sessionId: string; - approvalId: string; - response: string; - }): Promise; - - compact(input: { sessionId: string; cwd: string }): Promise; -} - -export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRuntimeClient { - return { - async *sendMessage(input, { sessionId, cwd }) { - const program = sendMessage(sessionId || undefined, input, cwd, llm, { - mode: 'build', - permissionMode: 'default', - model: llm.modelInfo.model, - }); - const { stream: agentGen, sessionId: resolvedSessionId } = (await rt.runPromise( - program - )) as any; - - yield { type: 'session_id', sessionId: resolvedSessionId }; - - let notifyApproval: ((req: StreamChunk) => void) | null = null; - let notifyPlan: ((req: StreamChunk) => void) | null = null; - const waitService = await rt.runPromise( - Effect.gen(function* () { - return yield* ApprovalWaitService; - }) - ); - const hookService = await rt.runPromise( - Effect.gen(function* () { - return yield* HookService; - }) - ); - Effect.runSync( - waitService.registerEmitter( - resolvedSessionId, - (id: string, tool: string, args: Record) => { - notifyApproval?.({ type: 'approval_request', id, tool, args }); - } - ) - ); - const unregisterPlanReady = Effect.runSync( - hookService.register('plan.ready', (payload) => { - const p = payload as { - sessionId?: string; - title?: string; - }; - if (p.sessionId !== resolvedSessionId) return; - notifyPlan?.({ - type: 'plan_ready', - sessionId: p.sessionId ?? '', - title: p.title ?? '', - }); - }) - ); - - try { - const gen = agentEventToStreamChunk(agentGen); - let pending = gen.next(); - let currentApprovalPromise = new Promise((resolve) => { - notifyApproval = resolve; - }); - let currentPlanPromise = new Promise((resolve) => { - notifyPlan = resolve; - }); - - while (true) { - const approvalPromise = currentApprovalPromise; - const planPromise = currentPlanPromise; - const winner = await Promise.race([ - pending.then((c): { tag: 'chunk'; value: IteratorResult } => ({ - tag: 'chunk', - value: c, - })), - approvalPromise.then((req): { tag: 'approval'; value: StreamChunk } => ({ - tag: 'approval', - value: req, - })), - planPromise.then((req): { tag: 'plan'; value: StreamChunk } => ({ - tag: 'plan', - value: req, - })), - ]); - - if (winner.tag === 'chunk') { - if (winner.value.done) break; - yield winner.value.value; - currentApprovalPromise = new Promise((resolve) => { - notifyApproval = resolve; - }); - currentPlanPromise = new Promise((resolve) => { - notifyPlan = resolve; - }); - pending = gen.next(); - } else if (winner.tag === 'approval') { - yield winner.value; - currentApprovalPromise = new Promise((resolve) => { - notifyApproval = resolve; - }); - } else { - yield winner.value; - currentPlanPromise = new Promise((resolve) => { - notifyPlan = resolve; - }); - } - } - } finally { - unregisterPlanReady(); - Effect.runSync(waitService.unregisterEmitter(resolvedSessionId)); - } - }, - - async sendApprovalResponse({ sessionId, approvalId, response }) { - const result = parseApprovalResponse(response); - await rt.runPromise( - Effect.gen(function* () { - const svc = yield* ApprovalWaitService; - return yield* svc.resolveConfirm(approvalId, sessionId, result); - }) - ); - }, - - async compact({ sessionId, cwd }) { - await rt.runPromise( - Effect.gen(function* () { - const session = yield* SessionService; - const context = yield* ContextService; - const state = yield* session.load(cwd, sessionId); - return yield* Effect.promise(() => - context.compactWithLLM(state.transcriptPath, llm.modelInfo.maxTokens, null) - ); - }) - ); - }, - }; -} diff --git a/packages/codingcode/src/client/direct/index.ts b/packages/codingcode/src/client/direct/index.ts deleted file mode 100644 index e5b163d..0000000 --- a/packages/codingcode/src/client/direct/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createDirectAgentClient, type AgentRuntimeClient } from './agent-runtime.js'; -import { createDirectSessionClient, type SessionClient } from './sessions.js'; -import { createDirectModelClient, type ModelClient } from './models.js'; -import { createDirectSettingsClient, type SettingsClient } from './settings.js'; -import type { AppRuntime } from '../../layer.js'; -import type { LLMClient } from '../../llm/client.js'; - -export interface DirectClients { - agent: AgentRuntimeClient; - sessions: SessionClient; - models: ModelClient; - settings: SettingsClient; -} - -export function createDirectClients(llm: LLMClient, rt: AppRuntime): DirectClients { - return { - agent: createDirectAgentClient(llm, rt), - sessions: createDirectSessionClient(rt), - models: createDirectModelClient(rt), - settings: createDirectSettingsClient(rt), - }; -} diff --git a/packages/codingcode/src/client/http.ts b/packages/codingcode/src/client/http.ts index ed05e0f..c8ae146 100644 --- a/packages/codingcode/src/client/http.ts +++ b/packages/codingcode/src/client/http.ts @@ -134,84 +134,52 @@ export async function createHttpClient(serverUrl: string): Promise }, async getCheckpoints() { - return []; + return clients.agent.getCheckpoints(); }, - async getCheckpointDiff() { - return { turnId: 0, files: [] }; + async getCheckpointDiff(turnId?: number) { + return clients.agent.getCheckpointDiff(turnId); }, - async revertCheckpointFiles() { - return { - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; + async revertCheckpointFiles(turnId: number, files: string[]) { + return clients.agent.revertCheckpointFiles(turnId, files); }, - async previewRollbackDiff() { - return { throughTurnId: 0, affectedTurns: [], diff: '' }; + async previewRollbackDiff(throughTurnId: number) { + return clients.agent.previewRollbackDiff(throughTurnId); }, - async rollbackCodeToTurn() { - return { - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; + async rollbackCodeToTurn(throughTurnId: number) { + return clients.agent.rollbackCodeToTurn(throughTurnId); }, - async rollbackContext() { + async rollbackContext(throughTurnId: number) { + const res = await clients.agent.rollbackContext(throughTurnId); return { - turns: [] as SessionEvent[], - rollbackState: { - context: { active: false, currentThroughTurnId: null }, - code: { - canUndoLast: false, - lastEntry: null, - revertedFiles: [] as string[], - lastEntryId: null, - }, - } as RollbackState, + turns: (res as any).turns ?? [], + rollbackState: + (res as any).rollbackState ?? { active: false, currentThroughTurnId: null }, }; }, - async rollbackBothToTurn() { + async rollbackBothToTurn(throughTurnId: number) { + const res = await clients.agent.rollbackBothToTurn(throughTurnId); return { - turns: [] as SessionEvent[], - codeResult: { - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }, - rollbackState: { - context: { active: false, currentThroughTurnId: null }, - code: { - canUndoLast: false, - lastEntry: null, - revertedFiles: [] as string[], - lastEntryId: null, + turns: (res as any).turns ?? [], + codeResult: + (res as any).codeResult ?? { + reverted: false, + throughTurnId, + affectedTurns: [], + selectedFiles: [], + restoreEntry: null, }, - } as RollbackState, + rollbackState: + (res as any).rollbackState ?? { active: false, currentThroughTurnId: null }, }; }, - async undoLastCodeRollback() { - return { - restored: false, - conflict: false, - conflictFiles: [], - restoredFiles: [], - remainingRolledBack: [], - }; + async undoLastCodeRollback(force?: boolean, files?: string[]) { + return clients.agent.undoLastCodeRollback(force, files); }, async getRollbackState() { - return { - context: { active: false, currentThroughTurnId: null }, - code: { canUndoLast: false, lastEntry: null, revertedFiles: [], lastEntryId: null }, - }; + return clients.agent.getRollbackState(); }, - async forkSession(_atTurnId?: number) { - return ''; + async forkSession(atTurnId?: number) { + return clients.agent.forkSession(atTurnId); }, async compact() { @@ -340,12 +308,12 @@ export async function createHttpClient(serverUrl: string): Promise await clients.settings.deleteHook({ cwd, name }); }, - async getPermissionMode() { - return clients.settings.getGlobalPermissionMode(); + async getPermissionMode(input: { sessionId: string; cwd: string }) { + return clients.settings.getGlobalPermissionMode(input); }, - async setPermissionMode(mode: PermissionMode) { - await clients.settings.setGlobalPermissionMode(mode); + async setPermissionMode(input: { sessionId: string; cwd: string; mode: PermissionMode }) { + await clients.settings.setGlobalPermissionMode(input); }, }; } diff --git a/packages/codingcode/src/client/http/agent-runtime.ts b/packages/codingcode/src/client/http/agent-runtime.ts index 6353d93..ced9e45 100644 --- a/packages/codingcode/src/client/http/agent-runtime.ts +++ b/packages/codingcode/src/client/http/agent-runtime.ts @@ -14,13 +14,48 @@ export interface AgentRuntimeClient { response: string; }): Promise; compact(input: { sessionId: string; cwd: string }): Promise; + + getCheckpoints(): Promise>; + getCheckpointDiff(turnId?: number): Promise; + revertCheckpointFiles( + turnId: number, + files: string[] + ): Promise; + previewRollbackDiff( + throughTurnId: number + ): Promise; + rollbackCodeToTurn( + throughTurnId: number + ): Promise; + rollbackContext( + throughTurnId: number + ): Promise<{ + turns: Array<{ id: string; items: object[]; status: string }>; + rollbackState: import('../../checkpoint/types.js').RollbackState; + }>; + rollbackBothToTurn(throughTurnId: number): Promise<{ + turns: Array<{ id: string; items: object[]; status: string }>; + codeResult: import('../../checkpoint/types.js').CodeRollbackResult; + rollbackState: import('../../checkpoint/types.js').RollbackState; + }>; + undoLastCodeRollback( + force?: boolean, + files?: string[] + ): Promise; + getRollbackState(): Promise; + forkSession( + atTurnId?: number + ): Promise<{ + sessionId: string; + turns: Array<{ id: string; items: object[]; status: string }>; + }>; } export function createHttpAgentClient( baseUrl: string, request: ReturnType ): AgentRuntimeClient { - const { apiPost } = request; + const { apiPost, apiGet } = request; return { async *sendMessage(input, { sessionId, cwd, signal }) { @@ -131,5 +166,49 @@ export function createHttpAgentClient( async compact({ sessionId, cwd }) { await apiPost(`/api/sessions/${sessionId}/compact`, { cwd }); }, + + async getCheckpoints() { + return apiGet('/api/checkpoints'); + }, + + async getCheckpointDiff(turnId?: number) { + const segment = turnId != null ? String(turnId) : 'latest'; + return apiGet(`/api/sessions/_/checkpoints/${segment}/diff?cwd=_`); + }, + + async revertCheckpointFiles(turnId: number, files: string[]) { + return apiPost(`/api/sessions/_/checkpoints/latest/revert-files?cwd=_`, { + turnId, + files, + }); + }, + + async previewRollbackDiff(throughTurnId: number) { + return apiGet(`/api/sessions/_/rollback-preview?cwd=_&throughTurnId=${throughTurnId}`); + }, + + async rollbackCodeToTurn(throughTurnId: number) { + return apiPost(`/api/sessions/_/rollback-code-to-turn?cwd=_`, { throughTurnId }); + }, + + async rollbackContext(throughTurnId: number) { + return apiPost(`/api/sessions/_/rollback-context?cwd=_`, { throughTurnId }); + }, + + async rollbackBothToTurn(throughTurnId: number) { + return apiPost(`/api/sessions/_/rollback-both-to-turn?cwd=_`, { throughTurnId }); + }, + + async undoLastCodeRollback(force?: boolean, files?: string[]) { + return apiPost(`/api/sessions/_/undo-code-rollback?cwd=_`, { force, files }); + }, + + async getRollbackState() { + return apiGet('/api/sessions/_/rollback-state?cwd=_'); + }, + + async forkSession(atTurnId?: number) { + return apiPost('/api/sessions/_/fork?cwd=_', { atTurnId }); + }, }; } diff --git a/packages/codingcode/src/client/http/request.ts b/packages/codingcode/src/client/http/request.ts index 5b753d4..1b2b8b7 100644 --- a/packages/codingcode/src/client/http/request.ts +++ b/packages/codingcode/src/client/http/request.ts @@ -1,7 +1,20 @@ +import { ApiError } from '../../core/error.js'; + +async function parseErrorBody( + res: Response +): Promise<{ code: string; message: string } | undefined> { + try { + const json = (await res.json()) as { error?: { code: string; message: string } }; + return json?.error; + } catch { + return undefined; + } +} + export function createRequestHelpers(baseUrl: string) { async function apiGet(path: string): Promise { const res = await fetch(`${baseUrl}${path}`); - if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`); + if (!res.ok) throw new ApiError(res.status, path, await parseErrorBody(res)); return res.json() as Promise; } @@ -11,7 +24,7 @@ export function createRequestHelpers(baseUrl: string) { headers: { 'Content-Type': 'application/json' }, body: body !== undefined ? JSON.stringify(body) : undefined, }); - if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`); + if (!res.ok) throw new ApiError(res.status, path, await parseErrorBody(res)); return res.json() as Promise; } @@ -21,13 +34,13 @@ export function createRequestHelpers(baseUrl: string) { headers: { 'Content-Type': 'application/json' }, body: body !== undefined ? JSON.stringify(body) : undefined, }); - if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`); + if (!res.ok) throw new ApiError(res.status, path, await parseErrorBody(res)); return res.json() as Promise; } async function apiDelete(path: string): Promise { const res = await fetch(`${baseUrl}${path}`, { method: 'DELETE' }); - if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`); + if (!res.ok) throw new ApiError(res.status, path, await parseErrorBody(res)); } return { apiGet, apiPost, apiPut, apiDelete }; diff --git a/packages/codingcode/src/client/http/sessions.ts b/packages/codingcode/src/client/http/sessions.ts index 1f3518d..bda4fac 100644 --- a/packages/codingcode/src/client/http/sessions.ts +++ b/packages/codingcode/src/client/http/sessions.ts @@ -20,12 +20,27 @@ export interface SessionClient { listSessions(input: { cwd: string }): Promise; getSessionHistory(input: { sessionId: string; cwd: string }): Promise; deleteSession(input: { sessionId: string; cwd: string }): Promise; + getSessionMode(input: { sessionId: string; cwd: string }): Promise<{ + mode: SessionMode; + permissionMode: PermissionMode; + cwd: string; + available: Array<{ name: string; description: string }>; + }>; + setSessionMode(input: { + sessionId: string; + cwd: string; + mode: SessionMode; + }): Promise<{ mode: SessionMode; permissionMode: PermissionMode }>; getSessionPermissionMode(input: { sessionId: string; cwd: string }): Promise; setSessionPermissionMode(input: { sessionId: string; cwd: string; mode: PermissionMode; }): Promise; + getSessionPlan(input: { + sessionId: string; + cwd: string; + }): Promise<{ content: string; path: string; directory: string; exists: boolean }>; getCheckpointDiff(input: { sessionId: string; @@ -100,6 +115,14 @@ export function createHttpSessionClient( await apiDelete(`/api/sessions/${sessionId}?cwd=${encodeURIComponent(cwd)}`); }, + async getSessionMode({ sessionId, cwd }) { + return apiGet(`/api/sessions/${sessionId}/mode?cwd=${encodeURIComponent(cwd)}`); + }, + + async setSessionMode({ sessionId, cwd, mode }) { + return apiPost(`/api/sessions/${sessionId}/mode`, { cwd, mode }); + }, + async getSessionPermissionMode({ sessionId, cwd }) { const data = await apiGet<{ mode: PermissionMode }>( `/api/sessions/${sessionId}/permission-mode?cwd=${encodeURIComponent(cwd)}` @@ -111,6 +134,12 @@ export function createHttpSessionClient( await apiPut(`/api/sessions/${sessionId}/permission-mode`, { cwd, mode }); }, + async getSessionPlan({ sessionId, cwd }) { + return apiGet( + `/api/sessions/${sessionId}/plan?cwd=${encodeURIComponent(cwd)}` + ); + }, + async getCheckpointDiff({ sessionId, cwd, turnId }) { const segment = turnId != null ? String(turnId) : 'latest'; return apiGet( diff --git a/packages/codingcode/src/client/http/settings.ts b/packages/codingcode/src/client/http/settings.ts index b0761a1..59cbad9 100644 --- a/packages/codingcode/src/client/http/settings.ts +++ b/packages/codingcode/src/client/http/settings.ts @@ -9,12 +9,16 @@ export interface SettingsClient { getMemoryConfig(): Promise<{ enabled: boolean; types: Array<{ name: string; description: string; isBuiltIn: boolean; disabled: boolean }>; + model: string; }>; setMemoryEnabled(enabled: boolean): Promise; setMemoryTypeDisabled(name: string, disabled: boolean): Promise; addMemoryExtraType(type: { name: string; description: string }): Promise; updateMemoryExtraType(name: string, type: { name: string; description: string }): Promise; deleteMemoryExtraType(name: string): Promise; + setMemoryModel(model: string): Promise<{ model: string }>; + getAgentConfig(): Promise<{ maxSteps: number; maxStopContinuations: number }>; + setCompactionModel(compactionModel: string): Promise<{ compactionModel: string }>; getSubagentEnabled(query: { cwd: string }): Promise<{ enabled: boolean; source: string }>; setSubagentEnabled(body: { enabled: boolean; cwd: string }): Promise; resetSubagentEnabled(body: { cwd: string }): Promise; @@ -38,8 +42,12 @@ export interface SettingsClient { deleteHook(input: { cwd: string; name: string }): Promise; setHookDisabled(input: { cwd: string; name: string; disabled: boolean }): Promise; resetHookDisabled(body: { name: string; cwd: string }): Promise; - getGlobalPermissionMode(): Promise; - setGlobalPermissionMode(mode: PermissionMode): Promise; + getGlobalPermissionMode(input: { sessionId: string; cwd: string }): Promise; + setGlobalPermissionMode(input: { + sessionId: string; + cwd: string; + mode: PermissionMode; + }): Promise; } export function createHttpSettingsClient( @@ -61,6 +69,18 @@ export function createHttpSettingsClient( return apiGet('/api/settings/memory/config'); }, + async setMemoryModel(model) { + return apiPost('/api/settings/memory/model', { model }); + }, + + async getAgentConfig() { + return apiGet('/api/settings/agent/config'); + }, + + async setCompactionModel(compactionModel) { + return apiPost('/api/settings/context/compaction-model', { compactionModel }); + }, + async setMemoryEnabled(enabled) { await apiPost('/api/settings/memory/enabled', { enabled }); }, @@ -190,13 +210,22 @@ export function createHttpSettingsClient( ); }, - async getGlobalPermissionMode() { - const data = await apiGet<{ mode: PermissionMode }>('/api/agent/permission-mode'); + async getGlobalPermissionMode(input: { + sessionId: string; + cwd: string; + }): Promise { + const data = await apiGet<{ mode: PermissionMode }>( + `/api/sessions/${input.sessionId}/permission-mode?cwd=${encodeURIComponent(input.cwd)}` + ); return data.mode; }, - async setGlobalPermissionMode(mode) { - await apiPost('/api/agent/permission-mode', { mode }); + async setGlobalPermissionMode(input: { + sessionId: string; + cwd: string; + mode: PermissionMode; + }): Promise { + await apiPut(`/api/sessions/${input.sessionId}/permission-mode`, input); }, }; } diff --git a/packages/codingcode/src/client/types.ts b/packages/codingcode/src/client/types.ts index 12c0cf1..ba0773a 100644 --- a/packages/codingcode/src/client/types.ts +++ b/packages/codingcode/src/client/types.ts @@ -56,13 +56,19 @@ export interface AgentClient { }>; undoLastCodeRollback(force?: boolean, files?: string[]): Promise; getRollbackState(): Promise; - forkSession(atTurnId?: number): Promise; + forkSession( + atTurnId?: number + ): Promise<{ + sessionId: string; + turns: Array<{ id: string; items: object[]; status: string }>; + }>; compact(): Promise; getMemoryEnabled(): Promise; setMemoryEnabled(enabled: boolean): Promise; getMemoryConfig(): Promise<{ enabled: boolean; types: Array<{ name: string; description: string; isBuiltIn: boolean; disabled: boolean }>; + model: string; }>; setTypeDisabled(name: string, disabled: boolean): Promise; addExtraType(type: { name: string; description: string }): Promise; @@ -102,6 +108,6 @@ export interface AgentClient { createHook(hook: UserHookConfig, query: { cwd: string }): Promise; updateHook(name: string, hook: UserHookConfig, query: { cwd: string }): Promise; deleteHook(name: string, query: { cwd: string }): Promise; - getPermissionMode(): Promise; - setPermissionMode(mode: PermissionMode): Promise; + getPermissionMode(input: { sessionId: string; cwd: string }): Promise; + setPermissionMode(input: { sessionId: string; cwd: string; mode: PermissionMode }): Promise; } diff --git a/packages/codingcode/src/core/error.ts b/packages/codingcode/src/core/error.ts index 0863abd..2f124ed 100644 --- a/packages/codingcode/src/core/error.ts +++ b/packages/codingcode/src/core/error.ts @@ -18,17 +18,36 @@ export type ErrorCode = | 'SESSION_IO_ERROR'; export class AlreadyExistsError extends Error { + readonly code = 'ALREADY_EXISTS'; constructor(message: string) { super(message); this.name = 'AlreadyExistsError'; } + httpStatus(): 409 { + return 409; + } +} + +export class ApiError extends Error { + constructor( + public readonly status: number, + public readonly path: string, + public readonly body?: { code: string; message: string } + ) { + super(body?.message ?? `HTTP ${status}: ${path}`); + this.name = 'ApiError'; + } } export class NotFoundError extends Error { + readonly code = 'NOT_FOUND'; constructor(message: string) { super(message); this.name = 'NotFoundError'; } + httpStatus(): 404 { + return 404; + } } export class AgentError extends Error { diff --git a/packages/codingcode/src/core/path.ts b/packages/codingcode/src/core/path.ts index 75b3db5..cbcb775 100644 --- a/packages/codingcode/src/core/path.ts +++ b/packages/codingcode/src/core/path.ts @@ -16,7 +16,6 @@ export function encodeProjectPath(p: string): string { .toLowerCase(); } - let _projectBaseOverride: string | undefined; let _projectPlansBaseOverride: string | undefined; @@ -35,3 +34,34 @@ export function getProjectBaseDir(): string { export function getProjectPlansBaseDir(): string { return _projectPlansBaseOverride ?? join(homedir(), '.codingcode', 'projects'); } + +export interface SessionPaths { + sessionId: string; + cwd: string; + projectPath: string; + transcriptPath: string; + indexPath: string; +} + +export function projectSessionsDir(encodedProjectPath: string): string { + return join(getProjectBaseDir(), encodedProjectPath, 'sessions'); +} + +export function sessionJsonlPathFromCwd(cwd: string, sessionId: string): string { + return computePaths(cwd, sessionId).transcriptPath; +} + +export function computePaths( + cwd: string, + sessionId: string, + parentSessionId?: string +): SessionPaths { + const normalizedCwd = normalizePath(cwd); + const projectPath = encodeProjectPath(normalizedCwd); + const sessionsDir = projectSessionsDir(projectPath); + const transcriptPath = parentSessionId + ? join(sessionsDir, parentSessionId, 'subagents', `${sessionId}.jsonl`) + : join(sessionsDir, `${sessionId}.jsonl`); + const indexPath = transcriptPath.replace('.jsonl', '.index.json'); + return { sessionId, cwd: normalizedCwd, projectPath, transcriptPath, indexPath }; +} diff --git a/packages/codingcode/src/direct/agent-runtime.ts b/packages/codingcode/src/direct/agent-runtime.ts new file mode 100644 index 0000000..b9509be --- /dev/null +++ b/packages/codingcode/src/direct/agent-runtime.ts @@ -0,0 +1,358 @@ +import { Effect } from 'effect'; +import { sendMessage } from '../agent/agent.js'; +import { ApprovalWaitService } from '../approval/async-confirm.js'; +import { parseApprovalResponse } from '../approval/response.js'; +import { ContextService } from '../context/service.js'; +import { HookService } from '../hooks/registry.js'; +import { SessionService } from '../session/store.js'; +import { CheckpointService } from '../checkpoint/checkpoint-service.js'; +import { readUIHistory } from '../session/ui-history.js'; +import { findUserMessageForTurn } from '../session/ui-history.js'; +import type { StreamChunk } from '../client/types.js'; +import { agentEventToStreamChunk } from '../agent/stream-adapter.js'; +import type { AppRuntime } from '../layer.js'; +import type { LLMClient } from '../llm/client.js'; + +export interface AgentRuntimeClient { + sendMessage( + input: string, + options: { sessionId?: string; cwd: string } + ): AsyncGenerator; + + sendApprovalResponse(input: { + sessionId: string; + approvalId: string; + response: string; + }): Promise; + compact(input: { sessionId: string; cwd: string }): Promise; + + getCheckpoints(cwd: string): Promise>; + getCheckpointDiff( + cwd: string, + turnId?: number + ): Promise; + revertCheckpointFiles( + cwd: string, + turnId: number, + files: string[] + ): Promise; + previewRollbackDiff( + cwd: string, + throughTurnId: number + ): Promise; + rollbackCodeToTurn( + cwd: string, + throughTurnId: number + ): Promise; + rollbackContext( + cwd: string, + throughTurnId: number + ): Promise<{ + turns: Array<{ id: string; items: object[]; status: string }>; + rollbackState: import('../checkpoint/types.js').RollbackState; + }>; + rollbackBothToTurn( + cwd: string, + throughTurnId: number + ): Promise<{ + turns: Array<{ id: string; items: object[]; status: string }>; + codeResult: import('../checkpoint/types.js').CodeRollbackResult; + rollbackState: import('../checkpoint/types.js').RollbackState; + }>; + undoLastCodeRollback( + cwd: string, + force?: boolean, + files?: string[] + ): Promise; + getRollbackState(cwd: string): Promise; + forkSession( + cwd: string, + atTurnId?: number + ): Promise<{ + sessionId: string; + turns: Array<{ id: string; items: object[]; status: string }>; + }>; +} + +export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRuntimeClient { + let currentSessionId = ''; + + return { + async *sendMessage(input, { sessionId, cwd }) { + const opts: Parameters[4] = {}; + if (!sessionId) { + opts.mode = 'build'; + opts.permissionMode = 'default'; + opts.model = llm.modelInfo.model; + } + const program = sendMessage(sessionId || undefined, input, cwd, llm, opts); + const { stream: agentGen, sessionId: resolvedSessionId } = (await rt.runPromise( + program + )) as any; + currentSessionId = resolvedSessionId; + + yield { type: 'session_id', sessionId: resolvedSessionId }; + + let notifyApproval: ((req: StreamChunk) => void) | null = null; + let notifyPlan: ((req: StreamChunk) => void) | null = null; + const waitService = await rt.runPromise( + Effect.gen(function* () { + return yield* ApprovalWaitService; + }) + ); + const hookService = await rt.runPromise( + Effect.gen(function* () { + return yield* HookService; + }) + ); + Effect.runSync( + waitService.registerEmitter( + resolvedSessionId, + (id: string, tool: string, args: Record) => { + notifyApproval?.({ type: 'approval_request', id, tool, args }); + } + ) + ); + const unregisterPlanReady = Effect.runSync( + hookService.register('plan.ready', (payload) => { + const p = payload as { + sessionId?: string; + title?: string; + }; + if (p.sessionId !== resolvedSessionId) return; + notifyPlan?.({ + type: 'plan_ready', + sessionId: p.sessionId ?? '', + title: p.title ?? '', + }); + }) + ); + + try { + const gen = agentEventToStreamChunk(agentGen); + let pending = gen.next(); + let currentApprovalPromise = new Promise((resolve) => { + notifyApproval = resolve; + }); + let currentPlanPromise = new Promise((resolve) => { + notifyPlan = resolve; + }); + + while (true) { + const approvalPromise = currentApprovalPromise; + const planPromise = currentPlanPromise; + const winner = await Promise.race([ + pending.then((c): { tag: 'chunk'; value: IteratorResult } => ({ + tag: 'chunk', + value: c, + })), + approvalPromise.then((req): { tag: 'approval'; value: StreamChunk } => ({ + tag: 'approval', + value: req, + })), + planPromise.then((req): { tag: 'plan'; value: StreamChunk } => ({ + tag: 'plan', + value: req, + })), + ]); + + if (winner.tag === 'chunk') { + if (winner.value.done) break; + yield winner.value.value; + currentApprovalPromise = new Promise((resolve) => { + notifyApproval = resolve; + }); + currentPlanPromise = new Promise((resolve) => { + notifyPlan = resolve; + }); + pending = gen.next(); + } else if (winner.tag === 'approval') { + yield winner.value; + currentApprovalPromise = new Promise((resolve) => { + notifyApproval = resolve; + }); + } else { + yield winner.value; + currentPlanPromise = new Promise((resolve) => { + notifyPlan = resolve; + }); + } + } + } finally { + unregisterPlanReady(); + Effect.runSync(waitService.unregisterEmitter(resolvedSessionId)); + } + }, + + async sendApprovalResponse({ sessionId, approvalId, response }) { + const result = parseApprovalResponse(response); + await rt.runPromise( + Effect.gen(function* () { + const svc = yield* ApprovalWaitService; + return yield* svc.resolveConfirm(approvalId, sessionId, result); + }) + ); + }, + + async compact({ sessionId, cwd }) { + await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + const context = yield* ContextService; + const state = yield* session.load(cwd, sessionId); + return yield* Effect.promise(() => + context.compactWithLLM(state.transcriptPath, llm.modelInfo.maxTokens, null) + ); + }) + ); + }, + + async getCheckpoints(cwd: string) { + return rt.runPromise( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + return yield* checkpoint.getCheckpoints(cwd, currentSessionId); + }) + ); + }, + + async getCheckpointDiff(cwd: string, turnId?: number) { + return rt.runPromise( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + return yield* checkpoint.getCheckpointDiff(cwd, currentSessionId, turnId); + }) + ); + }, + + async revertCheckpointFiles(cwd: string, turnId: number, files: string[]) { + return rt.runPromise( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + return yield* checkpoint.revertCheckpointFiles( + cwd, + currentSessionId, + turnId, + files + ); + }) + ); + }, + + async previewRollbackDiff(cwd: string, throughTurnId: number) { + return rt.runPromise( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + return yield* checkpoint.previewRollbackDiff( + cwd, + currentSessionId, + throughTurnId + ); + }) + ); + }, + + async rollbackCodeToTurn(cwd: string, throughTurnId: number) { + return rt.runPromise( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + return yield* checkpoint.rollbackCodeToTurn( + cwd, + currentSessionId, + throughTurnId + ); + }) + ); + }, + + async rollbackContext(cwd: string, throughTurnId: number) { + return rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + const state = yield* session.load(cwd, currentSessionId); + yield* session.rollbackToTurn(state, throughTurnId, 'user rollback'); + const turns = readUIHistory(currentSessionId, cwd); + const rollbackState: import('../checkpoint/types.js').RollbackState = { + context: { active: true, currentThroughTurnId: throughTurnId }, + code: { + canUndoLast: false, + lastEntry: null, + revertedFiles: [], + lastEntryId: null, + }, + }; + return { turns, rollbackState }; + }) + ); + }, + + async rollbackBothToTurn(cwd: string, throughTurnId: number) { + return rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + const checkpoint = yield* CheckpointService; + const state = yield* session.load(cwd, currentSessionId); + const codeResult = yield* checkpoint.rollbackCodeToTurn( + cwd, + currentSessionId, + throughTurnId + ); + yield* session.rollbackToTurn(state, throughTurnId, 'user rollback'); + const turns = readUIHistory(currentSessionId, cwd); + const rollbackState: import('../checkpoint/types.js').RollbackState = { + context: { active: true, currentThroughTurnId: throughTurnId }, + code: { + canUndoLast: false, + lastEntry: null, + revertedFiles: [], + lastEntryId: null, + }, + }; + return { turns, codeResult, rollbackState }; + }) + ); + }, + + async undoLastCodeRollback(cwd: string, force?: boolean, files?: string[]) { + return rt.runPromise( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + return yield* checkpoint.undoLastCodeRollback(cwd, currentSessionId, { + force, + files, + }); + }) + ); + }, + + async getRollbackState(cwd: string) { + return rt.runPromise( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + const entry = yield* checkpoint.getLatestRestoreEntry(cwd, currentSessionId); + return { + context: { active: false, currentThroughTurnId: null }, + code: { + canUndoLast: entry !== null, + lastEntry: entry, + revertedFiles: entry?.selectedFiles ?? [], + lastEntryId: entry?.id ?? null, + }, + }; + }) + ); + }, + + async forkSession(cwd: string, atTurnId?: number) { + return rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + const state = yield* session.load(cwd, currentSessionId); + const newSessionId = yield* session.forkSession(state, atTurnId ?? 0); + const turns = readUIHistory(newSessionId, cwd); + return { sessionId: newSessionId, turns }; + }) + ); + }, + }; +} diff --git a/packages/codingcode/src/client/direct/models.ts b/packages/codingcode/src/direct/models.ts similarity index 87% rename from packages/codingcode/src/client/direct/models.ts rename to packages/codingcode/src/direct/models.ts index 9c94c13..57942dc 100644 --- a/packages/codingcode/src/client/direct/models.ts +++ b/packages/codingcode/src/direct/models.ts @@ -1,7 +1,7 @@ import { Effect } from 'effect'; -import { LLMFactoryService } from '../../llm/factory.js'; -import type { SelectableModel } from '../../llm/factory.js'; -import type { AppRuntime } from '../../layer.js'; +import { LLMFactoryService } from '../llm/factory.js'; +import type { SelectableModel } from '../llm/factory.js'; +import type { AppRuntime } from '../layer.js'; export interface ModelClient { listModels(): Promise<{ models: SelectableModel[]; activeId: string | null }>; diff --git a/packages/codingcode/src/client/direct/sessions.ts b/packages/codingcode/src/direct/sessions.ts similarity index 68% rename from packages/codingcode/src/client/direct/sessions.ts rename to packages/codingcode/src/direct/sessions.ts index 02b8aba..5d30c62 100644 --- a/packages/codingcode/src/client/direct/sessions.ts +++ b/packages/codingcode/src/direct/sessions.ts @@ -1,17 +1,20 @@ import { Effect } from 'effect'; -import { SessionService } from '../../session/store.js'; -import { ProjectRuntimeService, modeToProfile } from '../../runtime/project-runtime.js'; -import { deleteSession } from '../../session/file-ops.js'; -import type { PermissionMode } from '../../approval/types.js'; +import { readFileSync, readdirSync, statSync, existsSync } from 'fs'; +import { join } from 'path'; +import { SessionService } from '../session/store.js'; +import { ProjectRuntimeService, modeToProfile } from '../runtime/project-runtime.js'; +import { deleteSession } from '../session/file-ops.js'; +import { encodeProjectPath, getProjectBaseDir } from '../core/path.js'; +import type { PermissionMode } from '../approval/types.js'; import type { CheckpointDiff, CodeRollbackResult, CodeRollbackUndoResult, RollbackPreviewDiff, RollbackState, -} from '../../checkpoint/types.js'; -import type { SessionEvent, SessionIndex, SessionMode } from '../../session/types.js'; -import type { AppRuntime } from '../../layer.js'; +} from '../checkpoint/types.js'; +import type { SessionEvent, SessionIndex, SessionMode } from '../session/types.js'; +import type { AppRuntime } from '../layer.js'; export interface SessionClient { createSession(input: { @@ -25,12 +28,27 @@ export interface SessionClient { getSessionHistory(input: { sessionId: string; cwd: string }): Promise; deleteSession(input: { sessionId: string; cwd: string }): Promise; + getSessionMode(input: { sessionId: string; cwd: string }): Promise<{ + mode: SessionMode; + permissionMode: PermissionMode; + cwd: string; + available: Array<{ name: string; description: string }>; + }>; + setSessionMode(input: { + sessionId: string; + cwd: string; + mode: SessionMode; + }): Promise<{ mode: SessionMode; permissionMode: PermissionMode }>; getSessionPermissionMode(input: { sessionId: string; cwd: string }): Promise; setSessionPermissionMode(input: { sessionId: string; cwd: string; mode: PermissionMode; }): Promise; + getSessionPlan(input: { + sessionId: string; + cwd: string; + }): Promise<{ content: string; path: string; directory: string; exists: boolean }>; getCheckpointDiff(input: { sessionId: string; @@ -82,10 +100,11 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { return rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; - const runtime = yield* ProjectRuntimeService; - const state = yield* session.create(cwd, { model, mode, permissionMode }); - const profile = modeToProfile(mode); - yield* runtime.setSessionProfile(cwd, state.sessionId, profile, permissionMode); + const state = yield* session.createSessionWithProfile(cwd, { + model, + mode, + permissionMode, + }); return { sessionId: state.sessionId }; }) ); @@ -124,6 +143,37 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { deleteSession(sessionId, cwd); }, + async getSessionMode({ sessionId, cwd }) { + return rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + const state = yield* session.load(cwd, sessionId); + return { + mode: state.mode, + permissionMode: state.permissionMode, + cwd, + available: [ + { name: 'plan', description: 'Planning agent' }, + { name: 'build', description: 'Default build agent' }, + ], + }; + }) + ); + }, + + async setSessionMode({ sessionId, cwd, mode }) { + return rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + yield* session.setModeOnDisk(cwd, sessionId, mode); + const profile = modeToProfile(mode); + yield* session.setActiveProfile(cwd, sessionId, profile.name); + const state = yield* session.load(cwd, sessionId); + return { mode: state.mode, permissionMode: state.permissionMode }; + }) + ); + }, + async getSessionPermissionMode({ sessionId, cwd }): Promise { const mode = await rt.runPromise( Effect.gen(function* () { @@ -145,6 +195,27 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { ); }, + async getSessionPlan({ cwd }) { + const planDir = join(getProjectBaseDir(), encodeProjectPath(cwd)); + if (!existsSync(planDir)) { + return { content: '', path: '', directory: planDir, exists: false }; + } + let latest: { path: string; mtime: number } | null = null; + for (const name of readdirSync(planDir)) { + if (!name.endsWith('.md')) continue; + const full = join(planDir, name); + const mtime = statSync(full).mtimeMs; + if (latest === null || mtime > latest.mtime) { + latest = { path: full, mtime }; + } + } + if (latest === null) { + return { content: '', path: '', directory: planDir, exists: false }; + } + const content = readFileSync(latest.path, 'utf8'); + return { content, path: latest.path, directory: planDir, exists: true }; + }, + async getCheckpointDiff() { return { turnId: 0, files: [] }; }, diff --git a/packages/codingcode/src/client/direct/settings.ts b/packages/codingcode/src/direct/settings.ts similarity index 88% rename from packages/codingcode/src/client/direct/settings.ts rename to packages/codingcode/src/direct/settings.ts index 38a7de9..c256cb8 100644 --- a/packages/codingcode/src/client/direct/settings.ts +++ b/packages/codingcode/src/direct/settings.ts @@ -1,12 +1,11 @@ import { Effect } from 'effect'; -import { McpService } from '../../mcp/index.js'; -import type { McpServerConfig, McpStatus } from '../../mcp/types.js'; -import { SkillService } from '../../skills/service.js'; -import { ApprovalService } from '../../approval/index.js'; -import type { PermissionMode } from '../../approval/types.js'; -import type { AgentProfile } from '../../subagent/types.js'; -import type { UserHookConfig } from '../../hooks/types.js'; -import { isGlobalCwd } from '../../core/workspace.js'; +import { McpService } from '../mcp/index.js'; +import type { McpServerConfig, McpStatus } from '../mcp/types.js'; +import { SkillService } from '../skills/service.js'; +import type { PermissionMode } from '../approval/types.js'; +import type { AgentProfile } from '../subagent/types.js'; +import type { UserHookConfig } from '../hooks/types.js'; +import { isGlobalCwd } from '../core/workspace.js'; import { loadMcpConfig, writeMcpConfig, @@ -17,7 +16,7 @@ import { setGlobalMcpDisabledState, setProjectMcpDisabledState, resetProjectMcpDisabledState, -} from '../../mcp/config.js'; +} from '../mcp/config.js'; import { loadAgentProfiles, writeAgentProfile, @@ -27,7 +26,7 @@ import { writeGlobalAgentProfile, updateGlobalAgentProfile, deleteGlobalAgentProfile, -} from '../../subagent/loader.js'; +} from '../subagent/loader.js'; import { EXPLORE_PROFILE, PLAN_PROFILE, @@ -42,7 +41,7 @@ import { resetProjectAgentDisabledState, resolveAgentDisabled, getProjectAgentDisabledState, -} from '../../subagent/registry.js'; +} from '../subagent/registry.js'; import { loadHookConfigs, writeHookConfigs, @@ -53,8 +52,8 @@ import { setGlobalHookDisabledState, setProjectHookDisabledState, resetProjectHookDisabledState, -} from '../../hooks/config.js'; -import { setHookRuntimeEnabled } from '../../hooks/executor.js'; +} from '../hooks/config.js'; +import { setHookRuntimeEnabled } from '../hooks/executor.js'; import { getMemoryConfig, getAllTypesWithStatus, @@ -62,22 +61,32 @@ import { addMemoryExtraType as _addMemoryExtraType, updateMemoryExtraType as _updateMemoryExtraType, deleteMemoryExtraType as _deleteMemoryExtraType, -} from '../../memory/config.js'; -import { MemoryService } from '../../memory/index.js'; -import { AlreadyExistsError, NotFoundError } from '../../core/error.js'; -import type { AppRuntime } from '../../layer.js'; +} from '../memory/config.js'; +import { MemoryService } from '../memory/index.js'; +import { AlreadyExistsError, NotFoundError } from '../core/error.js'; +import { + loadConfig, + updateMemoryModel, + updateContextCompactionModel, +} from '@codingcode/infra/config'; +import type { AppRuntime } from '../layer.js'; +import { SessionService } from '../session/store.js'; export interface SettingsClient { getMemoryEnabled(): Promise; getMemoryConfig(): Promise<{ enabled: boolean; types: Array<{ name: string; description: string; isBuiltIn: boolean; disabled: boolean }>; + model: string; }>; setMemoryEnabled(enabled: boolean): Promise; setMemoryTypeDisabled(name: string, disabled: boolean): Promise; addMemoryExtraType(type: { name: string; description: string }): Promise; updateMemoryExtraType(name: string, type: { name: string; description: string }): Promise; deleteMemoryExtraType(name: string): Promise; + setMemoryModel(model: string): Promise<{ model: string }>; + getAgentConfig(): Promise<{ maxSteps: number; maxStopContinuations: number }>; + setCompactionModel(compactionModel: string): Promise<{ compactionModel: string }>; getSubagentEnabled(query: { cwd: string }): Promise<{ enabled: boolean; source: string }>; setSubagentEnabled(body: { enabled: boolean; cwd: string }): Promise; resetSubagentEnabled(body: { cwd: string }): Promise; @@ -101,8 +110,12 @@ export interface SettingsClient { deleteHook(input: { cwd: string; name: string }): Promise; setHookDisabled(input: { cwd: string; name: string; disabled: boolean }): Promise; resetHookDisabled(body: { name: string; cwd: string }): Promise; - getGlobalPermissionMode(): Promise; - setGlobalPermissionMode(mode: PermissionMode): Promise; + getGlobalPermissionMode(input: { sessionId: string; cwd: string }): Promise; + setGlobalPermissionMode(input: { + sessionId: string; + cwd: string; + mode: PermissionMode; + }): Promise; } // ---- Helpers with validation ---- @@ -414,7 +427,7 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { async getMemoryConfig() { const cfg = getMemoryConfig(); - return { enabled: cfg.enabled, types: getAllTypesWithStatus(cfg) }; + return { enabled: cfg.enabled, types: getAllTypesWithStatus(cfg), model: cfg.model }; }, async setMemoryEnabled(enabled) { @@ -426,6 +439,21 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { ); }, + async setMemoryModel(model) { + updateMemoryModel(model); + return { model }; + }, + + async getAgentConfig() { + const cfg = loadConfig(); + return { maxSteps: cfg.maxSteps, maxStopContinuations: cfg.maxStopContinuations }; + }, + + async setCompactionModel(compactionModel) { + updateContextCompactionModel(compactionModel); + return { compactionModel }; + }, + async setMemoryTypeDisabled(name, disabled) { setMemoryTypeDisabled(name, disabled); }, @@ -641,22 +669,31 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { resetProjectHookDisabledState(cwd, name); }, - async getGlobalPermissionMode() { - const approval = await rt.runPromise( + async getGlobalPermissionMode(input: { + sessionId: string; + cwd: string; + }): Promise { + return rt.runPromise( Effect.gen(function* () { - return yield* ApprovalService; + const session = yield* SessionService; + const state = yield* session.load(input.cwd, input.sessionId); + return yield* session.getPermissionMode(state); }) ); - return approval.getPermissionMode(); }, - async setGlobalPermissionMode(mode) { - const approval = await rt.runPromise( + async setGlobalPermissionMode(input: { + sessionId: string; + cwd: string; + mode: PermissionMode; + }): Promise { + await rt.runPromise( Effect.gen(function* () { - return yield* ApprovalService; + const session = yield* SessionService; + const state = yield* session.load(input.cwd, input.sessionId); + yield* session.setPermissionMode(state, input.mode); }) ); - await rt.runPromise(approval.setPermissionMode(mode)); }, }; } diff --git a/packages/codingcode/src/memory/index.ts b/packages/codingcode/src/memory/index.ts index 5e9e9ef..7a4cbee 100644 --- a/packages/codingcode/src/memory/index.ts +++ b/packages/codingcode/src/memory/index.ts @@ -1,6 +1,6 @@ import { Effect } from 'effect'; import type { LLMClient } from '../llm/client.js'; -import { sessionJsonlPathFromCwd } from '../session/file-ops.js'; +import { sessionJsonlPathFromCwd } from '../core/path.js'; import type { SessionEvent } from '../session/types.js'; import { readMemoryFile, diff --git a/packages/codingcode/src/plan/index.ts b/packages/codingcode/src/plan/index.ts index e640190..8981917 100644 --- a/packages/codingcode/src/plan/index.ts +++ b/packages/codingcode/src/plan/index.ts @@ -1,4 +1,6 @@ +import { readFileSync } from 'fs'; import type { DecisionHandler } from '../hooks/types.js'; +import { computePaths } from '../core/path.js'; // ---- Profile name constants + structural helper ---- @@ -14,21 +16,18 @@ export const PLAN_MODE_ALLOWED_TOOLS: ReadonlySet = new Set([ 'dispatch_agent', ]); -// ---- Plan-mode side channel ---- +// ---- Plan-mode state: read from .index.json (disk is single source of truth) ---- -const planModeSessions = new Set(); - -export function markSessionPlanMode(sessionId: string, isPlanMode: boolean): void { - if (isPlanMode) planModeSessions.add(sessionId); - else planModeSessions.delete(sessionId); -} - -export function isSessionInPlanMode(sessionId: string): boolean { - return planModeSessions.has(sessionId); -} - -export function clearPlanModeSession(sessionId: string): void { - planModeSessions.delete(sessionId); +export function isSessionInPlanMode(sessionId: string, cwd: string): boolean { + try { + const paths = computePaths(cwd, sessionId); + const idx = JSON.parse(readFileSync(paths.indexPath, 'utf8')) as { + mode?: string; + }; + return idx?.mode === 'plan'; + } catch { + return false; + } } // ---- Plan-mode subagent whitelist (called inline by dispatch_agent) ---- @@ -50,8 +49,9 @@ export function checkSubagentAllowedInPlanMode( export const planModeGateHook: DecisionHandler = (payload) => { const sessionId = payload.sessionId as string | undefined; - if (!sessionId) return null; - if (!isSessionInPlanMode(sessionId)) return null; + const projectPath = payload.projectPath as string | undefined; + if (!sessionId || !projectPath) return null; + if (!isSessionInPlanMode(sessionId, projectPath)) return null; const toolName = payload.toolName as string | undefined; if (!toolName) return null; @@ -62,4 +62,3 @@ export const planModeGateHook: DecisionHandler = (payload) => { reason: 'Write operations denied in plan mode. Use submit_plan to submit a plan.', }; }; - diff --git a/packages/codingcode/src/runtime/project-runtime.ts b/packages/codingcode/src/runtime/project-runtime.ts index 7b619ad..32874ac 100644 --- a/packages/codingcode/src/runtime/project-runtime.ts +++ b/packages/codingcode/src/runtime/project-runtime.ts @@ -13,18 +13,11 @@ import { McpService } from '../mcp/index.js'; import { RulesService } from '../rules/index.js'; import { SessionService } from '../session/store.js'; import { normalizePath } from '../core/path.js'; -import { ApprovalService } from '../approval/index.js'; import type { PermissionMode } from '../approval/types.js'; import type { SessionMode } from '../session/types.js'; -import { computePaths, readCurrentIndex, setPermissionMode } from '../session/file-ops.js'; -import { writeFileSync } from 'fs'; -import { - isPlanProfile, - markSessionPlanMode, - clearPlanModeSession, -} from '../plan/index.js'; +import { readCurrentIndex } from '../session/file-ops.js'; +import { computePaths } from '../core/path.js'; -/** 构建全局 profile:内置 + ~/.codingcode/agents/ */ function buildGlobalProfiles(): AgentProfile[] { const profiles: AgentProfile[] = [BUILD_PROFILE, EXPLORE_PROFILE, PLAN_PROFILE]; for (const p of agentLoader.loadGlobalAgentProfiles()) { @@ -35,7 +28,6 @@ function buildGlobalProfiles(): AgentProfile[] { return profiles; } -/** 构建项目级 profile:/.codingcode/agents/ */ function buildProjectProfiles(projectPath: string): AgentProfile[] { return agentLoader.loadAgentProfiles(projectPath); } @@ -53,11 +45,8 @@ export class ProjectRuntimeService extends Effect.Service const subagent = yield* SubagentService; const rules = yield* RulesService; const session = yield* SessionService; - const sessionAgentProfiles = new Map(); - const sessionPermissionModes = new Map(); const prepared = new Set(); - // 启动时注册全局 profile(内置 + ~/.codingcode/agents/),只做一次 subagent.registerGlobal(buildGlobalProfiles()); return { @@ -76,9 +65,10 @@ export class ProjectRuntimeService extends Effect.Service projectPath: string, sessionId: string ): AgentProfile | undefined => { - const sessionOverride = sessionAgentProfiles.get(sessionId); - if (sessionOverride) return sessionOverride; - return agentLoader.loadMainAgentProfile(projectPath); + const idx = readCurrentIndex(computePaths(projectPath, sessionId).indexPath); + const name = idx?.activeProfile; + if (!name) return agentLoader.loadMainAgentProfile(projectPath); + return subagent.get(projectPath, name) ?? agentLoader.loadMainAgentProfile(projectPath); }, resolveSubagentProfile: (projectPath: string, name: string): AgentProfile | undefined => { @@ -110,76 +100,52 @@ export class ProjectRuntimeService extends Effect.Service projectPath: string, sessionId: string, profile: AgentProfile, - permissionModeOverride?: PermissionMode, - parentSessionId?: string + permissionModeOverride?: PermissionMode ): Effect.Effect => Effect.gen(function* () { - sessionAgentProfiles.set(sessionId, profile); - markSessionPlanMode(sessionId, isPlanProfile(profile)); - - if (isPlanProfile(profile)) { - // Plan 模式:内存 map 强制 'default',SessionIndex.permissionMode 不写盘(保留 build 偏好) - sessionPermissionModes.set(sessionId, 'default'); - return; - } - - const effectivePermissionMode: PermissionMode = + const mode: SessionMode = profile.name === 'plan' ? 'plan' : 'build'; + const effectivePerm: PermissionMode = permissionModeOverride ?? profile.permissionMode ?? 'default'; - sessionPermissionModes.set(sessionId, effectivePermissionMode); - const paths = computePaths(projectPath, sessionId, parentSessionId); - setPermissionMode(sessionId, paths.indexPath, effectivePermissionMode); - // Update activeProfile in the same index file. - const current = readCurrentIndex(paths.indexPath); - if (current) { - const index = { - ...current, - activeProfile: profile.name, - updatedAt: new Date().toISOString(), - }; - writeFileSync(paths.indexPath, JSON.stringify(index, null, 2), 'utf8'); - } + yield* session.setModeOnDisk(projectPath, sessionId, mode); + yield* session.setPermissionModeOnDisk(projectPath, sessionId, effectivePerm); + yield* session.setActiveProfile(projectPath, sessionId, profile.name); }), - getSessionProfile: (sessionId: string): AgentProfile | undefined => - sessionAgentProfiles.get(sessionId), + getSessionProfile: ( + sessionId: string, + projectPath: string + ): Effect.Effect => + Effect.gen(function* () { + const name = yield* session.getActiveProfile(projectPath, sessionId); + if (!name) return undefined; + return subagent.get(projectPath, name); + }), - getSessionPermissionMode: (sessionId: string): PermissionMode => - sessionPermissionModes.get(sessionId) ?? 'default', + getSessionPermissionMode: ( + sessionId: string, + projectPath: string + ): Effect.Effect => + session.getPermissionModeFromDisk(projectPath, sessionId), restoreSessionProfile: ( projectPath: string, sessionId: string, profileName: string | undefined, - permissionModeOverride?: PermissionMode, - parentSessionId?: string + permissionModeOverride?: PermissionMode ): Effect.Effect => Effect.gen(function* () { if (!profileName) return; - const norm = normalizePath(projectPath); - const profile = subagent.get(norm, profileName); + const profile = subagent.get(projectPath, profileName); if (!profile) return; - sessionAgentProfiles.set(sessionId, profile); - markSessionPlanMode(sessionId, isPlanProfile(profile)); - - if (isPlanProfile(profile)) { - sessionPermissionModes.set(sessionId, 'default'); - return; - } - - const effectivePermissionMode: PermissionMode = + const mode: SessionMode = profile.name === 'plan' ? 'plan' : 'build'; + const effectivePerm: PermissionMode = permissionModeOverride ?? profile.permissionMode ?? 'default'; - sessionPermissionModes.set(sessionId, effectivePermissionMode); - // Direct write — see setSessionProfile above. - const paths = computePaths(projectPath, sessionId, parentSessionId); - setPermissionMode(sessionId, paths.indexPath, effectivePermissionMode); + yield* session.setModeOnDisk(projectPath, sessionId, mode); + yield* session.setPermissionModeOnDisk(projectPath, sessionId, effectivePerm); + yield* session.setActiveProfile(projectPath, sessionId, profile.name); }), - disposeSession: (sessionId: string): Effect.Effect => - Effect.sync(() => { - sessionAgentProfiles.delete(sessionId); - sessionPermissionModes.delete(sessionId); - clearPlanModeSession(sessionId); - }), + disposeSession: (_sessionId: string): Effect.Effect => Effect.void, disposeProject: (projectPath: string): Effect.Effect => Effect.sync(() => { diff --git a/packages/codingcode/src/scheduler/service.ts b/packages/codingcode/src/scheduler/service.ts index 8659ef1..811cdb7 100644 --- a/packages/codingcode/src/scheduler/service.ts +++ b/packages/codingcode/src/scheduler/service.ts @@ -7,6 +7,7 @@ import { readAutomations, writeAutomations } from './store.js'; import { sendMessage } from '../agent/agent.js'; import type { AgentEvent } from '../agent/types.js'; import { LLMFactoryService } from '../llm/factory.js'; +import { ApprovalService } from '../approval/index.js'; import { AgentError } from '../core/error.js'; const logger = createLogger(); @@ -48,11 +49,18 @@ export class SchedulerService extends Effect.Service()('Schedu const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); + const approval = await _rt.runPromise( + Effect.gen(function* () { + const svc = yield* ApprovalService; + return yield* svc.fork({ permissionMode: 'bypass' }); + }) + ); + try { const { stream, sessionId } = await _rt.runPromise( sendMessage(undefined, auto.description, auto.projectCwd, llm, { signal: controller.signal, - approvalOverride: { permissionMode: 'bypass' }, + approvalOverride: approval, mode: 'build', permissionMode: 'bypass', model: llm.modelInfo.model, @@ -180,18 +188,25 @@ export class SchedulerService extends Effect.Service()('Schedu const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); - try { - const { stream, sessionId } = await _rt.runPromise( - sendMessage(undefined, auto.description, auto.projectCwd, llm, { - signal: controller.signal, - approvalOverride: { permissionMode: 'bypass' }, - mode: 'build', - permissionMode: 'bypass', - model: llm.modelInfo.model, - }) - ); - - for await (const event of stream) { + const approval = await _rt.runPromise( + Effect.gen(function* () { + const svc = yield* ApprovalService; + return yield* svc.fork({ permissionMode: 'bypass' }); + }) + ); + + try { + const { stream, sessionId } = await _rt.runPromise( + sendMessage(undefined, auto.description, auto.projectCwd, llm, { + signal: controller.signal, + approvalOverride: approval, + mode: 'build', + permissionMode: 'bypass', + model: llm.modelInfo.model, + }) + ); + + for await (const event of stream) { if (event._tag === 'Error') { logger.error(`Manual run for ${id} agent error:`, event.error); } diff --git a/packages/codingcode/src/server/index.ts b/packages/codingcode/src/server/index.ts index 0499aaa..256370e 100644 --- a/packages/codingcode/src/server/index.ts +++ b/packages/codingcode/src/server/index.ts @@ -5,10 +5,9 @@ import { createSessionsRouter } from './routes/sessions.js'; import { createMessagesRouter } from './routes/messages.js'; import { createModelsRouter } from './routes/models.js'; import { createApprovalRouter } from './routes/approval.js'; -import { createAgentRouter } from './routes/agent.js'; import { createSettingsRouter } from './routes/settings.js'; import { createAutomationsRouter } from './routes/automations.js'; -import { AgentError, AlreadyExistsError, NotFoundError } from '../core/error.js'; +import { AgentError } from '../core/error.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; @@ -19,11 +18,13 @@ export async function createServer(rt: ManagedRt): Promise { if (err instanceof AgentError) { return c.json({ error: { code: err.code, message: err.message } }, err.httpStatus() as any); } - if (err instanceof NotFoundError) { - return c.json({ error: { code: 'NOT_FOUND', message: err.message } }, 404); - } - if (err instanceof AlreadyExistsError) { - return c.json({ error: { code: 'ALREADY_EXISTS', message: err.message } }, 409); + if ( + err && + typeof (err as { code?: unknown }).code === 'string' && + typeof (err as { httpStatus?: unknown }).httpStatus === 'function' + ) { + const e = err as unknown as { code: string; message: string; httpStatus: () => number }; + return c.json({ error: { code: e.code, message: e.message } }, e.httpStatus() as any); } console.error('[500 INTERNAL_ERROR]', err); return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Internal server error' } }, 500); @@ -44,7 +45,6 @@ export async function createServer(rt: ManagedRt): Promise { app.route('/api', createMessagesRouter(rt)); app.route('/api/models', createModelsRouter(rt)); app.route('/api', createApprovalRouter(rt)); - app.route('/api/agent', createAgentRouter(rt)); app.route('/api/settings', await createSettingsRouter(rt)); app.route('/api/automations', createAutomationsRouter(rt)); diff --git a/packages/codingcode/src/server/routes/agent.ts b/packages/codingcode/src/server/routes/agent.ts deleted file mode 100644 index 97f2e72..0000000 --- a/packages/codingcode/src/server/routes/agent.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Hono } from 'hono'; -import { Effect, ManagedRuntime } from 'effect'; -import { ApprovalService } from '../../approval/index.js'; -import { ProjectRuntimeService } from '../../runtime/project-runtime.js'; -import { isPlanProfile } from '../../plan/index.js'; -import type { PermissionMode } from '../../approval/types.js'; - -type ManagedRt = ManagedRuntime.ManagedRuntime; - -const VALID_PERMISSION_MODES = new Set([ - 'default', - 'acceptEdits', - 'bypass', -]); - -export function createAgentRouter(rt: ManagedRt): Hono { - const router = new Hono(); - - router.get('/permission-mode', async (c) => { - const approval: any = await rt.runPromise( - Effect.gen(function* () { - return yield* ApprovalService; - }) - ); - return c.json({ mode: approval.getPermissionMode() }); - }); - - router.post('/permission-mode', async (c) => { - const body = (await c.req.json()) as { mode: string; cwd?: string; sessionId?: string }; - if (!VALID_PERMISSION_MODES.has(body.mode as PermissionMode)) { - return c.json({ error: `Invalid mode: ${body.mode}` }, 400); - } - if (body.cwd && body.sessionId) { - const result = await rt.runPromise( - Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; - const profile = runtime.getSessionProfile(body.sessionId!); - return profile; - }) - ); - if (isPlanProfile(result)) { - return c.json( - { - error: - 'Permission mode is fixed in plan mode. Use /mode to switch to build mode first.', - }, - 409 - ); - } - } - const approval: any = await rt.runPromise( - Effect.gen(function* () { - return yield* ApprovalService; - }) - ); - await rt.runPromise(approval.setPermissionMode(body.mode as PermissionMode)); - return c.json({ mode: approval.getPermissionMode() }); - }); - - return router; -} diff --git a/packages/codingcode/src/server/routes/messages.ts b/packages/codingcode/src/server/routes/messages.ts index c649f31..63d215d 100644 --- a/packages/codingcode/src/server/routes/messages.ts +++ b/packages/codingcode/src/server/routes/messages.ts @@ -4,13 +4,13 @@ import { sendMessage } from '../../agent/agent.js'; import { WorkspaceService } from '../../core/workspace.js'; import { toSseEvents } from '../adapter.js'; import { ApprovalService } from '../../approval/index.js'; -import { sessionJsonlPathFromCwd, getPermissionMode } from '../../session/file-ops.js'; +import { getPermissionMode } from '../../session/file-ops.js'; +import { computePaths } from '../../core/path.js'; import { existsSync } from 'fs'; import type { PermissionMode } from '../../approval/types.js'; import { LLMFactoryService } from '../../llm/factory.js'; import { errorResponse } from '../util.js'; import { createSseHandler } from '../handler.js'; -import { activeApprovalForks } from './sessions.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; @@ -43,39 +43,30 @@ export function createMessagesRouter(rt: ManagedRt): Hono { // Read session permissionMode if session exists let approvalOverride: any = undefined; if (sessionId !== '_') { - const idxPath = sessionJsonlPathFromCwd(normalizedCwd, sessionId).replace( - '.jsonl', - '.index.json' - ); + const idxPath = computePaths(normalizedCwd, sessionId).indexPath; if (existsSync(idxPath)) { const mode = getPermissionMode(idxPath) as PermissionMode; const forked: any = await rt.runPromise( Effect.gen(function* () { const approval = yield* ApprovalService; - return yield* approval.fork({}); + return yield* approval.fork({ permissionMode: mode }); }) ); - await rt.runPromise(forked.setPermissionMode(mode)); approvalOverride = forked; - activeApprovalForks.set(sessionId, { - setPermissionMode: (m) => rt.runPromise(forked.setPermissionMode(m)), - }); } } - const program = sendMessage( - sessionId === '_' || !sessionId ? undefined : sessionId, - input, - normalizedCwd, - llm, - { - signal: c.req.raw.signal, - approvalOverride, - mode: 'build', - permissionMode: 'default', - model: llm.modelInfo.model, - } - ); + const isNew = sessionId === '_' || !sessionId; + const sendOptions: Parameters[4] = { + signal: c.req.raw.signal, + approvalOverride, + }; + if (isNew) { + sendOptions.mode = 'build'; + sendOptions.permissionMode = 'default'; + sendOptions.model = llm.modelInfo.model; + } + const program = sendMessage(isNew ? undefined : sessionId, input, normalizedCwd, llm, sendOptions); const result = await rt.runPromise( program.pipe( @@ -96,20 +87,6 @@ export function createMessagesRouter(rt: ManagedRt): Hono { const { stream, sessionId: actualSid } = result.value as any; sessionId = actualSid; - // If newly created session, fork approval with default mode - if (!approvalOverride && sessionId !== '_') { - const forked: any = await rt.runPromise( - Effect.gen(function* () { - const approval = yield* ApprovalService; - return yield* approval.fork({}); - }) - ); - approvalOverride = forked; - activeApprovalForks.set(sessionId, { - setPermissionMode: (m) => rt.runPromise(forked.setPermissionMode(m)), - }); - } - return sseHandler( async function* () { yield* toSseEvents(stream); @@ -117,9 +94,6 @@ export function createMessagesRouter(rt: ManagedRt): Hono { { initialEvents: [{ type: 'session_id', sessionId }], sessionId, - onDone: () => { - activeApprovalForks.delete(sessionId); - }, } )(c); }); diff --git a/packages/codingcode/src/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index 1b42e5a..a81ffc3 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -5,11 +5,10 @@ import { join } from 'path'; import type { SessionStoreState, SessionMode } from '../../session/types.js'; import { SessionService } from '../../session/store.js'; import { - sessionJsonlPathFromCwd, getPermissionMode, - setPermissionMode, deleteSession, } from '../../session/file-ops.js'; +import { computePaths } from '../../core/path.js'; import { readUIHistory, findUserMessageForTurn } from '../../session/ui-history.js'; import { ContextService, estimatePromptTokens } from '../../context/service.js'; import { CheckpointService } from '../../checkpoint/checkpoint-service.js'; @@ -18,18 +17,12 @@ import { LLMFactoryService } from '../../llm/factory.js'; import type { LLMClient } from '../../llm/client.js'; import { errorResponse } from '../util.js'; import { encodeProjectPath, getProjectBaseDir } from '../../core/path.js'; -import { ProjectRuntimeService, modeToProfile } from '../../runtime/project-runtime.js'; +import { modeToProfile } from '../../runtime/project-runtime.js'; import { BUILD_PROFILE, PLAN_PROFILE } from '../../subagent/registry.js'; import { isPermissionMode, type PermissionMode } from '../../approval/types.js'; -import { isPlanProfile } from '../../plan/index.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; -export const activeApprovalForks = new Map< - string, - { setPermissionMode: (mode: any) => Promise | void } ->(); - export function createSessionsRouter(rt: ManagedRt): Hono { const router = new Hono(); const runWithLayer = async (eff: Effect.Effect) => { @@ -79,9 +72,6 @@ export function createSessionsRouter(rt: ManagedRt): Hono { if (!isPermissionMode(body.permissionMode)) { return c.json({ error: `Invalid permissionMode: ${body.permissionMode}` }, 400); } - if (body.mode === 'plan' && body.permissionMode !== 'default') { - return c.json({ error: 'Plan mode requires permissionMode "default"' }, 400); - } if (!body.model) { return c.json({ error: 'model required' }, 400); } @@ -94,19 +84,11 @@ export function createSessionsRouter(rt: ManagedRt): Hono { const result = await runWithLayer( Effect.gen(function* () { const session = yield* SessionService; - const runtime = yield* ProjectRuntimeService; - const state = yield* session.create(normalizedCwd, { + const state = yield* session.createSessionWithProfile(normalizedCwd, { model: body.model, mode: body.mode, permissionMode: body.permissionMode, }); - const profile = modeToProfile(body.mode); - yield* runtime.setSessionProfile( - normalizedCwd, - state.sessionId, - profile, - body.permissionMode - ); return state; }) as any ); @@ -255,11 +237,10 @@ export function createSessionsRouter(rt: ManagedRt): Hono { const result = await runWithLayer( Effect.gen(function* () { const session = yield* SessionService; - const runtime = yield* ProjectRuntimeService; const state = yield* session.load(cwd, sessionId); return { mode: state.mode, - permissionMode: runtime.getSessionPermissionMode(sessionId), + permissionMode: state.permissionMode, }; }) ); @@ -292,21 +273,14 @@ export function createSessionsRouter(rt: ManagedRt): Hono { } const result = await runWithLayer( Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; const session = yield* SessionService; - const profile = - runtime.resolveSubagentProfile(cwd, mode) ?? modeToProfile(mode); + yield* session.setModeOnDisk(cwd, sessionId, mode); + const profile = modeToProfile(mode); + yield* session.setActiveProfile(cwd, sessionId, profile.name); const state = yield* session.load(cwd, sessionId); - yield* runtime.setSessionProfile(cwd, sessionId, profile, state.permissionMode); - if (!isPlanProfile(profile)) { - yield* session.updateActiveProfile(state, profile.name); - state.mode = 'build'; - } else { - state.mode = 'plan'; - } return { mode: state.mode, - permissionMode: runtime.getSessionPermissionMode(sessionId), + permissionMode: state.permissionMode, }; }) ); @@ -314,11 +288,6 @@ export function createSessionsRouter(rt: ManagedRt): Hono { const { status, body: errBody } = errorResponse(result.error); return c.json(errBody, status as any); } - const handle = activeApprovalForks.get(sessionId); - if (handle) { - const permMode: PermissionMode = result.value.permissionMode; - Promise.resolve(handle.setPermissionMode(permMode)).catch(() => undefined); - } return c.json(result.value); }); @@ -326,7 +295,7 @@ export function createSessionsRouter(rt: ManagedRt): Hono { const sessionId = c.req.param('id'); const cwd = c.req.query('cwd'); if (!cwd) return c.json({ mode: 'default' }); - const idxPath = sessionJsonlPathFromCwd(cwd, sessionId).replace('.jsonl', '.index.json'); + const idxPath = computePaths(cwd, sessionId).indexPath; if (!existsSync(idxPath)) return c.json({ mode: 'default' }); const mode = getPermissionMode(idxPath); return c.json({ mode }); @@ -341,20 +310,8 @@ export function createSessionsRouter(rt: ManagedRt): Hono { } const setResult = await runWithLayer( Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; const session = yield* SessionService; - const state = yield* session.load(cwd, sessionId); - const profileName = runtime.getSessionProfile(sessionId)?.name; - if (profileName === PLAN_PROFILE.name && mode !== 'default') { - return yield* Effect.fail( - new Error('Plan mode requires permissionMode "default"') - ); - } - const profile = - profileName === PLAN_PROFILE.name - ? PLAN_PROFILE - : runtime.getSessionProfile(sessionId) ?? BUILD_PROFILE; - yield* runtime.setSessionProfile(cwd, sessionId, profile, mode); + yield* session.setPermissionModeOnDisk(cwd, sessionId, mode); return { ok: true }; }) as any ); @@ -362,8 +319,6 @@ export function createSessionsRouter(rt: ManagedRt): Hono { const { status, body: errBody } = errorResponse(setResult.error); return c.json(errBody, status as any); } - const handle = activeApprovalForks.get(sessionId); - if (handle) handle.setPermissionMode(mode); return c.json({ ok: true }); }); @@ -658,7 +613,7 @@ export function createSessionsRouter(rt: ManagedRt): Hono { const state = yield* session.load(cwd, sessionId); const newSessionId = yield* session.forkSession(state, atTurnId); const turns = readUIHistory(newSessionId, cwd); - const newJsonlPath = sessionJsonlPathFromCwd(cwd, newSessionId); + const newJsonlPath = computePaths(cwd, newSessionId).transcriptPath; const promptEstimate = estimatePromptTokens(newJsonlPath); return { sessionId: newSessionId, turns, promptEstimate }; }) as any diff --git a/packages/codingcode/src/session/file-ops.ts b/packages/codingcode/src/session/file-ops.ts index baf4db1..f05d690 100644 --- a/packages/codingcode/src/session/file-ops.ts +++ b/packages/codingcode/src/session/file-ops.ts @@ -13,37 +13,11 @@ import { } from 'fs'; import { homedir } from 'os'; import { join, dirname } from 'path'; -import { - normalizePath, - encodeProjectPath, - getProjectBaseDir, -} from '../core/path.js'; +import { getProjectBaseDir } from '../core/path.js'; +import { computePaths, projectSessionsDir, sessionJsonlPathFromCwd } from '../core/path.js'; import type { SessionEvent, SessionMetaEvent, SessionIndex, SessionStoreState } from './types.js'; -export function projectSessionsDir(encodedProjectPath: string): string { - return join(getProjectBaseDir(), encodedProjectPath, 'sessions'); -} - -export function sessionJsonlPathFromCwd(cwd: string, sessionId: string): string { - const projectPath = encodeProjectPath(normalizePath(cwd)); - const sessionsDir = projectSessionsDir(projectPath); - return join(sessionsDir, `${sessionId}.jsonl`); -} - -export function computePaths( - cwd: string, - sessionId: string, - parentSessionId?: string -): Pick { - const normalizedCwd = normalizePath(cwd); - const projectPath = encodeProjectPath(normalizedCwd); - const sessionsDir = projectSessionsDir(projectPath); - const transcriptPath = parentSessionId - ? join(sessionsDir, parentSessionId, 'subagents', `${sessionId}.jsonl`) - : join(sessionsDir, `${sessionId}.jsonl`); - const indexPath = transcriptPath.replace('.jsonl', '.index.json'); - return { sessionId, cwd: normalizedCwd, projectPath, transcriptPath, indexPath }; -} +export { computePaths, projectSessionsDir, sessionJsonlPathFromCwd }; export function ensureDirs(transcriptPath: string): void { const codingcodeDir = join(homedir(), '.codingcode'); @@ -158,7 +132,24 @@ export function readCurrentIndex(indexPath: string): Partial | nul } } -export function setPermissionMode(sessionId: string, indexPath: string, mode: import('../approval/types.js').PermissionMode): void { +export function writeIndexAtomic(indexPath: string, patch: Partial): void { + let current: Partial = {}; + if (existsSync(indexPath)) { + try { + current = JSON.parse(readFileSync(indexPath, 'utf8')); + } catch { + /* corrupt */ + } + } + const merged = { ...current, ...patch, updatedAt: new Date().toISOString() }; + writeFileSync(indexPath, JSON.stringify(merged, null, 2), 'utf8'); +} + +export function setPermissionMode( + sessionId: string, + indexPath: string, + mode: import('../approval/types.js').PermissionMode +): void { let index: SessionIndex | null = null; if (existsSync(indexPath)) { try { diff --git a/packages/codingcode/src/session/store.ts b/packages/codingcode/src/session/store.ts index 0a288bf..11e5181 100644 --- a/packages/codingcode/src/session/store.ts +++ b/packages/codingcode/src/session/store.ts @@ -26,12 +26,13 @@ import { setPermissionMode, getPermissionMode, readCurrentIndex, + writeIndexAtomic, countNonMetaEvents, truncateTitle, findFirstUserContent, - sessionJsonlPathFromCwd, - computePaths, } from './file-ops.js'; +import { computePaths, sessionJsonlPathFromCwd } from '../core/path.js'; +import { modeToProfile } from '../runtime/project-runtime.js'; function assertResumeWorkspace(cwd: string, sessionId: string): void { const expectedPath = sessionJsonlPathFromCwd(cwd, sessionId); @@ -58,6 +59,7 @@ export class SessionService extends Effect.Service()('Session', permissionMode: state.permissionMode, memorySnapshot: state.memorySnapshot, activeProfile: current?.activeProfile, + parentSessionId: state.parentSessionId, }; writeFileSync(state.indexPath, JSON.stringify(index, null, 2), 'utf8'); } @@ -69,7 +71,7 @@ export class SessionService extends Effect.Service()('Session', mode: SessionMode; permissionMode: PermissionMode; }, - opts?: { parentSessionId?: string; agentName?: string } + opts?: { parentSessionId?: string; agentName?: string; activeProfile?: string } ): Effect.Effect => Effect.try({ try: () => { @@ -87,6 +89,8 @@ export class SessionService extends Effect.Service()('Session', currentTurnId: 0, usage: undefined, memorySnapshot: '', + activeProfile: opts?.activeProfile, + parentSessionId: opts?.parentSessionId, }; const meta: SessionMetaEvent = { @@ -104,6 +108,9 @@ export class SessionService extends Effect.Service()('Session', appendLine(state.transcriptPath, meta); state.messageCount++; updateIndex(state); + if (state.activeProfile) { + writeIndexAtomic(state.indexPath, { activeProfile: state.activeProfile }); + } return state; }, catch: (e) => @@ -143,8 +150,6 @@ export class SessionService extends Effect.Service()('Session', if (meta) { state.sessionMeta = meta; state.messageCount = history.filter((e) => e.type !== 'session_meta').length; - if (meta.mode) state.mode = meta.mode; - if (meta.permissionMode) state.permissionMode = meta.permissionMode; } const firstUser = findFirstUserContent(history); if (firstUser) state.title = truncateTitle(firstUser); @@ -373,8 +378,83 @@ export class SessionService extends Effect.Service()('Session', return state.currentTurnId; }; + const setModeOnDisk = ( + cwd: string, + sessionId: string, + mode: SessionMode + ): Effect.Effect => + Effect.sync(() => { + const paths = computePaths(cwd, sessionId); + writeIndexAtomic(paths.indexPath, { mode }); + }); + + const setPermissionModeOnDisk = ( + cwd: string, + sessionId: string, + mode: import('../approval/types.js').PermissionMode + ): Effect.Effect => + Effect.sync(() => { + const paths = computePaths(cwd, sessionId); + setPermissionMode(sessionId, paths.indexPath, mode); + }); + + const setActiveProfile = ( + cwd: string, + sessionId: string, + profile: string + ): Effect.Effect => + Effect.sync(() => { + const paths = computePaths(cwd, sessionId); + writeIndexAtomic(paths.indexPath, { activeProfile: profile }); + }); + + const getModeFromDisk = ( + cwd: string, + sessionId: string + ): Effect.Effect => + Effect.sync(() => { + const paths = computePaths(cwd, sessionId); + const idx = readCurrentIndex(paths.indexPath); + return (idx?.mode as SessionMode) ?? 'build'; + }); + + const getPermissionModeFromDisk = ( + cwd: string, + sessionId: string + ): Effect.Effect => + Effect.sync(() => { + const paths = computePaths(cwd, sessionId); + const raw = getPermissionMode(paths.indexPath); + if (raw === 'default' || raw === 'acceptEdits' || raw === 'bypass') return raw; + return 'default'; + }); + + const getActiveProfile = ( + cwd: string, + sessionId: string + ): Effect.Effect => + Effect.sync(() => { + const paths = computePaths(cwd, sessionId); + const idx = readCurrentIndex(paths.indexPath); + return idx?.activeProfile; + }); + + const createSessionWithProfile = ( + cwd: string, + options: { + model: string; + mode: SessionMode; + permissionMode: PermissionMode; + }, + opts?: { parentSessionId?: string; agentName?: string; activeProfile?: string } + ): Effect.Effect => { + const activeProfile = opts?.activeProfile ?? modeToProfile(options.mode).name; + return create(cwd, options, { ...opts, activeProfile }); + }; + return { create, + createSessionWithProfile, load, recordUser, recordAssistant, @@ -393,6 +473,12 @@ export class SessionService extends Effect.Service()('Session', incrementTurn, readHistoryFile: (path: string): SessionEvent[] => readHistory(path), appendLineProxy: (path: string, event: object): void => appendLine(path, event), + setModeOnDisk, + setPermissionModeOnDisk, + setActiveProfile, + getModeFromDisk, + getPermissionModeFromDisk, + getActiveProfile, }; }), }) {} diff --git a/packages/codingcode/src/session/types.ts b/packages/codingcode/src/session/types.ts index 54725ea..e9d0252 100644 --- a/packages/codingcode/src/session/types.ts +++ b/packages/codingcode/src/session/types.ts @@ -85,6 +85,7 @@ export interface SessionIndex { permissionMode: import('../approval/types.js').PermissionMode; memorySnapshot?: string; activeProfile?: string; + parentSessionId?: string; } export interface SessionStoreState { @@ -103,4 +104,5 @@ export interface SessionStoreState { usage: TokenUsage | undefined; memorySnapshot: string; activeProfile?: string; + parentSessionId?: string; } diff --git a/packages/codingcode/src/session/ui-history.ts b/packages/codingcode/src/session/ui-history.ts index d5825a8..4e91c0c 100644 --- a/packages/codingcode/src/session/ui-history.ts +++ b/packages/codingcode/src/session/ui-history.ts @@ -1,5 +1,6 @@ import { existsSync } from 'fs'; -import { sessionJsonlPathFromCwd, readHistory } from './file-ops.js'; +import { readHistory } from './file-ops.js'; +import { sessionJsonlPathFromCwd } from '../core/path.js'; import type { SessionEvent, SummaryEvent, CompactEvent } from './types.js'; export function filterForUI(events: SessionEvent[]): SessionEvent[] { diff --git a/packages/codingcode/src/tools/domains/subagent/dispatch.ts b/packages/codingcode/src/tools/domains/subagent/dispatch.ts index 23528e9..24cda3b 100644 --- a/packages/codingcode/src/tools/domains/subagent/dispatch.ts +++ b/packages/codingcode/src/tools/domains/subagent/dispatch.ts @@ -7,11 +7,17 @@ import { ApprovalService } from '../../../approval/index.js'; import { HookService } from '../../../hooks/registry.js'; import { McpService } from '../../../mcp/index.js'; import { LLMFactoryService } from '../../../llm/factory.js'; -import { resolveSubagentEnabled, resolveAgentDisabled, BUILD_PROFILE } from '../../../subagent/registry.js'; +import { + resolveSubagentEnabled, + resolveAgentDisabled, + BUILD_PROFILE, +} from '../../../subagent/registry.js'; import { RulesService } from '../../../rules/index.js'; import { ProjectRuntimeService } from '../../../runtime/project-runtime.js'; import { SubagentRunnerService } from '../../../subagent/runner-service.js'; import { checkSubagentAllowedInPlanMode } from '../../../plan/index.js'; +import { readCurrentIndex } from '../../../session/file-ops.js'; +import { computePaths } from '../../../core/path.js'; import type { SessionMode } from '../../../session/types.js'; import type { PermissionMode } from '../../../approval/types.js'; @@ -93,9 +99,11 @@ export function createDispatchAgentTool(): Effect.Effect< // Emit spawn.before hook (decision hook, can deny) const parentSessionId = ctx?.sessionId; - const parentMainProfile = parentSessionId - ? runtime.getSessionProfile(parentSessionId)?.name - : undefined; + const parentMainProfile = + parentSessionId && projectPath + ? readCurrentIndex(computePaths(projectPath, parentSessionId).indexPath) + ?.activeProfile + : undefined; const whitelist = checkSubagentAllowedInPlanMode( parentSessionId, @@ -123,11 +131,21 @@ export function createDispatchAgentTool(): Effect.Effect< // Create subagent transcript nested under parent session const subagentProfile = runtime.resolveSubagentProfile(projectPath, agentName); const childMode: SessionMode = 'build'; + + // Read parent session's permissionMode for inheritance (priority: profile > parent > 'default') + let parentPermissionMode: PermissionMode | undefined; + if (ctx?.sessionId) { + const loaded = session.load(projectPath, ctx.sessionId); + const parentState = yield* loaded; + parentPermissionMode = parentState.permissionMode; + } const childPermissionMode: PermissionMode = - (subagentProfile?.permissionMode as PermissionMode | undefined) ?? 'default'; + (subagentProfile?.permissionMode as PermissionMode | undefined) ?? + parentPermissionMode ?? + 'default'; const childModel: string = subagentProfile?.model ?? llm.modelInfo.model; - const childState = yield* session.create( + const childState = yield* session.createSessionWithProfile( projectPath, { model: childModel, @@ -137,24 +155,18 @@ export function createDispatchAgentTool(): Effect.Effect< { parentSessionId: ctx?.sessionId, agentName: agentName, + activeProfile: (subagentProfile ?? BUILD_PROFILE).name, } ); - yield* runtime.setSessionProfile( - projectPath, - childState.sessionId, - subagentProfile ?? BUILD_PROFILE, - childPermissionMode, - ctx?.sessionId - ); const childUuid = childState.sessionId; session.incrementTurn(childState); yield* session.recordUser(childState, prompt); - // Approval: bypass for readonly, fork without delegateEmitter for non-readonly - let childApproval; - if (!profile.readonly) { - childApproval = yield* approval.fork({ readonly: false }); - } + // Approval: always fork with permissionMode closure (no longer omitted for readonly) + const childApproval = yield* approval.fork({ + readonly: profile.readonly ?? false, + permissionMode: childPermissionMode, + }); // Attach subagent hooks if (profile.hooks && profile.hooks.length > 0) { diff --git a/packages/codingcode/src/tools/executor.ts b/packages/codingcode/src/tools/executor.ts index c15674d..5109bf9 100644 --- a/packages/codingcode/src/tools/executor.ts +++ b/packages/codingcode/src/tools/executor.ts @@ -25,7 +25,7 @@ export class ToolExecutorService extends Effect.Service()(' sessionId?: string; turnId?: number; projectPath?: string; - approval?: any; + approval?: import('../approval/index.js').ApprovalService; callId?: string; toolLookup?: ToolLookup; } @@ -132,7 +132,7 @@ export class ToolExecutorService extends Effect.Service()(' turnId?: number; projectPath?: string; signal?: AbortSignal; - approval?: any; + approval?: import('../approval/index.js').ApprovalService; toolLookup?: ToolLookup; } ): Effect.Effect { @@ -182,7 +182,7 @@ export class ToolExecutorService extends Effect.Service()(' turnId?: number; projectPath?: string; signal?: AbortSignal; - approval?: any; + approval?: import('../approval/index.js').ApprovalService; toolLookup?: ToolLookup; } ): Effect.Effect { diff --git a/packages/codingcode/test/agent/build-system-prompt.test.ts b/packages/codingcode/test/agent/build-system-prompt.test.ts index c383060..fdd4cec 100644 --- a/packages/codingcode/test/agent/build-system-prompt.test.ts +++ b/packages/codingcode/test/agent/build-system-prompt.test.ts @@ -86,6 +86,6 @@ describe('buildSystemPrompt', () => { }); expect(prompt).toContain('submit_plan'); expect(prompt).toContain("dispatch the 'explore' subagent"); - expect(prompt).toContain("write_file / edit_file / execute_command are denied"); + expect(prompt).toContain('write_file / edit_file / execute_command are denied'); }); }); diff --git a/packages/codingcode/test/agent/send-message-optional-mode.test.ts b/packages/codingcode/test/agent/send-message-optional-mode.test.ts new file mode 100644 index 0000000..e1a9f73 --- /dev/null +++ b/packages/codingcode/test/agent/send-message-optional-mode.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; + +describe('sendMessage options are optional with guard', () => { + it('agent.ts sendMessage options make mode/permissionMode/model optional', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/agent/agent.ts', + 'utf8' + ); + expect(src).toMatch(/mode\?:\s*SessionMode/); + expect(src).toMatch(/permissionMode\?:\s*PermissionMode/); + expect(src).toMatch(/model\?:\s*string/); + }); + + it('agent.ts guards new-session branch against missing mode/permissionMode/model', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/agent/agent.ts', + 'utf8' + ); + expect(src).toMatch(/SESSION_CONFIG_REQUIRED|new session requires mode/); + }); + + it('messages.ts conditionally builds options (no hardcoded mode on existing-session path)', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/server/routes/messages.ts', + 'utf8' + ); + expect(src).toMatch(/isNew\s*=/); + expect(src).toMatch(/if\s*\(isNew\)/); + }); + + it('direct agent-runtime.ts sends options only on new session', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/direct/agent-runtime.ts', + 'utf8' + ); + expect(src).toMatch(/if\s*\(!sessionId\)/); + }); + + it('http agent-runtime.ts sendMessage (sub-client used by desktop) sends options only on new session', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/client/http/agent-runtime.ts', + 'utf8' + ); + expect(src).toMatch(/sendMessage\(input,/); + }); +}); diff --git a/packages/codingcode/test/agent/submit-plan-turn-end.test.ts b/packages/codingcode/test/agent/submit-plan-turn-end.test.ts index 8047b82..92656a2 100644 --- a/packages/codingcode/test/agent/submit-plan-turn-end.test.ts +++ b/packages/codingcode/test/agent/submit-plan-turn-end.test.ts @@ -178,7 +178,9 @@ describe('agentLoop plan.ready emission on turn-end', () => { callCount++; return { stream: (async function* () {})(), - response: Promise.resolve(Result.ok({ content: 'Just a regular response', toolCalls: [] })), + response: Promise.resolve( + Result.ok({ content: 'Just a regular response', toolCalls: [] }) + ), }; }), }; diff --git a/packages/codingcode/test/approval/permission-mode.test.ts b/packages/codingcode/test/approval/fork-permission-mode.test.ts similarity index 55% rename from packages/codingcode/test/approval/permission-mode.test.ts rename to packages/codingcode/test/approval/fork-permission-mode.test.ts index 63c539f..7be6645 100644 --- a/packages/codingcode/test/approval/permission-mode.test.ts +++ b/packages/codingcode/test/approval/fork-permission-mode.test.ts @@ -33,9 +33,7 @@ const TestLayer = ApprovalService.Default.pipe( Layer.provide(Layer.succeed(ApprovalWaitService, mockApprovalWaitService as any)) ); -// Build the service once so state is shared across all run() calls let _service: ApprovalService | null = null; - async function getService(): Promise { if (!_service) { _service = await Effect.runPromise( @@ -47,36 +45,46 @@ async function getService(): Promise { return _service!; } -function run(eff: (svc: ApprovalService) => Effect.Effect): Promise { - return getService().then((svc) => Effect.runPromise(eff(svc) as any)); +function run(eff: (svc: ApprovalService) => Promise): Promise { + return getService().then(eff); } -describe('Global permission mode state', () => { +describe('approval.fork({ permissionMode }) closure', () => { beforeEach(async () => { - // Reset to default between tests - await run((svc) => svc.setPermissionMode('default')); + _service = null; }); - it('starts as default', async () => { - const mode = await run((svc) => Effect.succeed(svc.getPermissionMode())); - expect(mode).toBe('default'); + it('fork with permissionMode: bypass creates a child whose getPermissionMode returns bypass', async () => { + const mode = await run(async (svc) => { + const child = await Effect.runPromise(svc.fork({ permissionMode: 'bypass' })); + return child.getPermissionMode(); + }); + expect(mode).toBe('bypass'); }); - it('can be set to all valid modes', async () => { - const modes = ['default', 'acceptEdits', 'bypass'] as const; - for (const mode of modes) { - await run((svc) => svc.setPermissionMode(mode)); - const current = await run((svc) => Effect.succeed(svc.getPermissionMode())); - expect(current).toBe(mode); - } + it('fork with permissionMode: acceptEdits creates a child with acceptEdits', async () => { + const mode = await run(async (svc) => { + const child = await Effect.runPromise(svc.fork({ permissionMode: 'acceptEdits' })); + return child.getPermissionMode(); + }); + expect(mode).toBe('acceptEdits'); + }); + + it('fork without permissionMode defaults to "default"', async () => { + const mode = await run(async (svc) => { + const child = await Effect.runPromise(svc.fork({})); + return child.getPermissionMode(); + }); + expect(mode).toBe('default'); }); - it('is shared across multiple reads (module-level singleton)', async () => { - await run((svc) => svc.setPermissionMode('bypass')); - const mode1 = await run((svc) => Effect.succeed(svc.getPermissionMode())); - const mode2 = await run((svc) => Effect.succeed(svc.getPermissionMode())); - // Both reads return the same value — no per-call isolation - expect(mode1).toBe('bypass'); - expect(mode2).toBe('bypass'); + it('two forks with different permissionMode are isolated', async () => { + const result = await run(async (svc) => { + const a = await Effect.runPromise(svc.fork({ permissionMode: 'bypass' })); + const b = await Effect.runPromise(svc.fork({ permissionMode: 'default' })); + return { a: a.getPermissionMode(), b: b.getPermissionMode() }; + }); + expect(result.a).toBe('bypass'); + expect(result.b).toBe('default'); }); }); diff --git a/packages/codingcode/test/approval/response.test.ts b/packages/codingcode/test/approval/response.test.ts index ed86a23..3b6dff3 100644 --- a/packages/codingcode/test/approval/response.test.ts +++ b/packages/codingcode/test/approval/response.test.ts @@ -35,5 +35,4 @@ describe('parseApprovalResponse', () => { vi.useRealTimers(); }); - }); diff --git a/packages/codingcode/test/client/agent-client-cwd.test.ts b/packages/codingcode/test/client/agent-client-cwd.test.ts deleted file mode 100644 index a77d49d..0000000 --- a/packages/codingcode/test/client/agent-client-cwd.test.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { Effect, Layer, ManagedRuntime } from 'effect'; - -import { WorkspaceService } from '../../src/core/workspace.js'; -import { LLMFactoryService } from '../../src/llm/factory.js'; -import { AgentError } from '../../src/core/error.js'; -import type { LLMClient } from '../../src/llm/client.js'; - -const MockWorkspaceLayer = Layer.succeed(WorkspaceService, { - getWorkspaceCwd: () => '/workspace', -} as any); - -const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { - getLLMClient: () => Effect.succeed(null), - listModels: () => Effect.succeed([]), - switchModel: () => Effect.fail(new AgentError('CONFIG_INVALID', 'not found')), - findModel: () => Effect.succeed(null), - getActiveEntry: () => Effect.fail(new AgentError('CONFIG_INVALID', 'No active model')), - createClient: () => Effect.succeed(null), -} as any); - -const TestLayer = Layer.mergeAll(MockWorkspaceLayer, MockLLMFactoryLayer); - -const noopLlm: LLMClient = { - completeStream: () => ({ - stream: (async function* () {})(), - response: Promise.resolve({ ok: true, value: { content: '', finishReason: 'stop' as const } }), - }), - complete: () => Effect.succeed({ content: '' } as any), - modelInfo: { id: 'test', provider: 'test', name: 'Test', contextWindow: 128000 } as any, -}; - -const calls: Record = { - getSubagentEnabled: [], - getMcpStatus: [], - createMcpServer: [], - updateMcpServer: [], - deleteMcpServer: [], - listAgents: [], - createAgent: [], - updateAgent: [], - deleteAgent: [], - listHooks: [], - createHook: [], - updateHook: [], - deleteHook: [], - toggleSkill: [], - setAgentDisabled: [], - setHookDisabled: [], -}; - -function makeMockSettings() { - return { - getMemoryEnabled: vi.fn().mockResolvedValue(true), - setMemoryEnabled: vi.fn().mockResolvedValue(undefined), - getMemoryConfig: vi.fn().mockResolvedValue({ enabled: true, types: [] }), - setMemoryTypeDisabled: vi.fn().mockResolvedValue(undefined), - addMemoryExtraType: vi.fn().mockResolvedValue(undefined), - updateMemoryExtraType: vi.fn().mockResolvedValue(undefined), - deleteMemoryExtraType: vi.fn().mockResolvedValue(undefined), - getSubagentEnabled: vi.fn().mockImplementation((...args: unknown[]) => { - calls.getSubagentEnabled.push(args); - return Promise.resolve({ enabled: true, source: 'global' }); - }), - setSubagentEnabled: vi.fn().mockResolvedValue(undefined), - resetSubagentEnabled: vi.fn().mockResolvedValue(undefined), - getMcpStatus: vi.fn().mockImplementation((...args: unknown[]) => { - calls.getMcpStatus.push(args); - return Promise.resolve([]); - }), - setMcpDisabled: vi.fn().mockResolvedValue(undefined), - resetMcpDisabled: vi.fn().mockResolvedValue(undefined), - createMcpServer: vi.fn().mockImplementation((...args: unknown[]) => { - calls.createMcpServer.push(args); - return Promise.resolve(undefined); - }), - updateMcpServer: vi.fn().mockImplementation((...args: unknown[]) => { - calls.updateMcpServer.push(args); - return Promise.resolve(undefined); - }), - deleteMcpServer: vi.fn().mockImplementation((...args: unknown[]) => { - calls.deleteMcpServer.push(args); - return Promise.resolve(undefined); - }), - listSkills: vi.fn().mockResolvedValue([]), - toggleSkill: vi.fn().mockImplementation((...args: unknown[]) => { - calls.toggleSkill.push(args); - return Promise.resolve(undefined); - }), - listAgents: vi.fn().mockImplementation((...args: unknown[]) => { - calls.listAgents.push(args); - return Promise.resolve([]); - }), - createAgent: vi.fn().mockImplementation((...args: unknown[]) => { - calls.createAgent.push(args); - return Promise.resolve(undefined); - }), - updateAgent: vi.fn().mockImplementation((...args: unknown[]) => { - calls.updateAgent.push(args); - return Promise.resolve(undefined); - }), - deleteAgent: vi.fn().mockImplementation((...args: unknown[]) => { - calls.deleteAgent.push(args); - return Promise.resolve(undefined); - }), - setAgentDisabled: vi.fn().mockImplementation((...args: unknown[]) => { - calls.setAgentDisabled.push(args); - return Promise.resolve(undefined); - }), - resetAgentDisabled: vi.fn().mockResolvedValue(undefined), - listHooks: vi.fn().mockImplementation((...args: unknown[]) => { - calls.listHooks.push(args); - return Promise.resolve([]); - }), - setHookDisabled: vi.fn().mockImplementation((...args: unknown[]) => { - calls.setHookDisabled.push(args); - return Promise.resolve(undefined); - }), - resetHookDisabled: vi.fn().mockResolvedValue(undefined), - createHook: vi.fn().mockImplementation((...args: unknown[]) => { - calls.createHook.push(args); - return Promise.resolve(undefined); - }), - updateHook: vi.fn().mockImplementation((...args: unknown[]) => { - calls.updateHook.push(args); - return Promise.resolve(undefined); - }), - deleteHook: vi.fn().mockImplementation((...args: unknown[]) => { - calls.deleteHook.push(args); - return Promise.resolve(undefined); - }), - getGlobalPermissionMode: vi.fn().mockResolvedValue('default'), - setGlobalPermissionMode: vi.fn().mockResolvedValue(undefined), - }; -} - -vi.mock('../../src/client/direct/settings.js', () => ({ - createDirectSettingsClient: () => makeMockSettings(), -})); - -const { createDirectClient } = await import('../../src/client/direct.js'); - -describe('AgentClient SDK - unified cwd forwarding', () => { - let client: Awaited>; - - beforeEach(async () => { - for (const key of Object.keys(calls)) calls[key] = []; - const rt = ManagedRuntime.make(TestLayer); - client = await createDirectClient(noopLlm, rt); - }); - - describe('getSubagentEnabled - explicit cwd', () => { - it('forwards project cwd from query arg', async () => { - await client.getSubagentEnabled({ cwd: '/my-project' }); - expect(calls.getSubagentEnabled).toEqual([[{ cwd: '/my-project' }]]); - }); - - it('forwards empty cwd (= global) from query arg', async () => { - await client.getSubagentEnabled({ cwd: '' }); - expect(calls.getSubagentEnabled).toEqual([[{ cwd: '' }]]); - }); - }); - - describe('getMcpStatus - explicit cwd', () => { - it('forwards project cwd from query arg', async () => { - await client.getMcpStatus({ cwd: '/my-project' }); - expect(calls.getMcpStatus).toEqual([[{ cwd: '/my-project' }]]); - }); - - it('forwards empty cwd (= global) from query arg', async () => { - await client.getMcpStatus({ cwd: '' }); - expect(calls.getMcpStatus).toEqual([[{ cwd: '' }]]); - }); - }); - - describe('createMcpServer - explicit cwd', () => { - it('forwards cwd as second arg, not via closure', async () => { - await client.createMcpServer({ name: 'srv', command: 'npx' } as any, { - cwd: '/my-project', - }); - expect(calls.createMcpServer).toEqual([ - [{ cwd: '/my-project', server: { name: 'srv', command: 'npx' } }], - ]); - }); - }); - - describe('updateMcpServer - explicit cwd', () => { - it('forwards cwd as third arg', async () => { - await client.updateMcpServer('srv', { name: 'srv', command: 'npx' } as any, { - cwd: '/my-project', - }); - expect(calls.updateMcpServer).toEqual([ - [{ cwd: '/my-project', name: 'srv', server: { name: 'srv', command: 'npx' } }], - ]); - }); - }); - - describe('deleteMcpServer - explicit cwd', () => { - it('forwards cwd as second arg', async () => { - await client.deleteMcpServer('srv', { cwd: '/my-project' }); - expect(calls.deleteMcpServer).toEqual([[{ cwd: '/my-project', name: 'srv' }]]); - }); - }); - - describe('listAgents - explicit cwd', () => { - it('forwards cwd from query', async () => { - await client.listAgents({ cwd: '/my-project' }); - expect(calls.listAgents).toEqual([[{ cwd: '/my-project' }]]); - }); - }); - - describe('createAgent - explicit cwd', () => { - it('forwards cwd as second arg', async () => { - const profile = { name: 'a1', description: 'd', systemPrompt: 'sp' }; - await client.createAgent(profile as any, { cwd: '/my-project' }); - expect(calls.createAgent).toEqual([[{ cwd: '/my-project', profile }]]); - }); - - it('different cwds for the same agent name go to different settings calls', async () => { - const profile = { name: 'a1', description: 'd', systemPrompt: 'sp' }; - await client.createAgent(profile as any, { cwd: '/project-a' }); - await client.createAgent(profile as any, { cwd: '/project-b' }); - expect(calls.createAgent).toEqual([ - [{ cwd: '/project-a', profile }], - [{ cwd: '/project-b', profile }], - ]); - }); - }); - - describe('updateAgent - explicit cwd', () => { - it('forwards cwd as third arg', async () => { - const profile = { name: 'a1', description: 'd', systemPrompt: 'sp' }; - await client.updateAgent('a1', profile as any, { cwd: '/my-project' }); - expect(calls.updateAgent).toEqual([[{ cwd: '/my-project', name: 'a1', profile }]]); - }); - }); - - describe('deleteAgent - explicit cwd', () => { - it('forwards cwd as second arg', async () => { - await client.deleteAgent('a1', { cwd: '/my-project' }); - expect(calls.deleteAgent).toEqual([[{ cwd: '/my-project', name: 'a1' }]]); - }); - }); - - describe('listHooks - explicit cwd', () => { - it('forwards cwd from query', async () => { - await client.listHooks({ cwd: '/my-project' }); - expect(calls.listHooks).toEqual([[{ cwd: '/my-project' }]]); - }); - }); - - describe('createHook - explicit cwd', () => { - it('forwards cwd as second arg', async () => { - const hook = { - name: 'h1', - point: 'tool.execute.before', - type: 'observer', - command: 'echo', - enabled: true, - }; - await client.createHook(hook as any, { cwd: '/my-project' }); - expect(calls.createHook).toEqual([[{ cwd: '/my-project', hook }]]); - }); - }); - - describe('updateHook - explicit cwd', () => { - it('forwards cwd as third arg', async () => { - const hook = { - name: 'h1', - point: 'tool.execute.before', - type: 'observer', - command: 'echo', - enabled: true, - }; - await client.updateHook('h1', hook as any, { cwd: '/my-project' }); - expect(calls.updateHook).toEqual([[{ cwd: '/my-project', name: 'h1', hook }]]); - }); - }); - - describe('deleteHook - explicit cwd', () => { - it('forwards cwd as second arg', async () => { - await client.deleteHook('h1', { cwd: '/my-project' }); - expect(calls.deleteHook).toEqual([[{ cwd: '/my-project', name: 'h1' }]]); - }); - }); -}); - -describe('AgentClient SDK - body-based methods still pass through', () => { - let client: Awaited>; - - beforeEach(async () => { - for (const key of Object.keys(calls)) calls[key] = []; - const rt = ManagedRuntime.make(TestLayer); - client = await createDirectClient(noopLlm, rt); - }); - - it('toggleSkill passes body with cwd unchanged', async () => { - await client.toggleSkill({ name: 's1', enabled: true, cwd: '/my-project' }); - expect(calls.toggleSkill).toEqual([[{ name: 's1', enabled: true, cwd: '/my-project' }]]); - }); - - it('setAgentDisabled passes body with cwd unchanged', async () => { - await client.setAgentDisabled({ name: 'a1', disabled: true, cwd: '/my-project' }); - expect(calls.setAgentDisabled).toEqual([[{ name: 'a1', disabled: true, cwd: '/my-project' }]]); - }); - - it('setHookDisabled passes body with cwd unchanged', async () => { - await client.setHookDisabled({ name: 'h1', disabled: true, cwd: '/my-project' }); - expect(calls.setHookDisabled).toEqual([[{ name: 'h1', disabled: true, cwd: '/my-project' }]]); - }); -}); diff --git a/packages/codingcode/test/client/direct-todo.test.ts b/packages/codingcode/test/client/direct-todo.test.ts index 9d52d48..f03ac7e 100644 --- a/packages/codingcode/test/client/direct-todo.test.ts +++ b/packages/codingcode/test/client/direct-todo.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { agentEventToStreamChunk } from '../../src/client/direct.js'; +import { agentEventToStreamChunk } from '../../src/agent/stream-adapter.js'; describe('agentEventToStreamChunk with TodoUpdate', () => { it('should map TodoUpdate to todo_update chunk', async () => { diff --git a/packages/codingcode/test/client/direct-types.test.ts b/packages/codingcode/test/client/direct-types.test.ts index ae995db..37207a6 100644 --- a/packages/codingcode/test/client/direct-types.test.ts +++ b/packages/codingcode/test/client/direct-types.test.ts @@ -1,42 +1,28 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { Effect, Layer, ManagedRuntime } from 'effect'; -import { createDirectClient } from '../../src/client/direct.js'; -import { createDirectClients } from '../../src/client/direct/index.js'; -import { createDirectAgentClient } from '../../src/client/direct/agent-runtime.js'; -import { createDirectSessionClient } from '../../src/client/direct/sessions.js'; -import { createDirectModelClient } from '../../src/client/direct/models.js'; -import { createDirectSettingsClient } from '../../src/client/direct/settings.js'; -import { createAppRuntime, type AppRuntime } from '../../src/layer.js'; +import { createDirectAgentClient } from '../../src/direct/agent-runtime.js'; +import { createDirectSessionClient } from '../../src/direct/sessions.js'; +import { createDirectModelClient } from '../../src/direct/models.js'; +import { createDirectSettingsClient } from '../../src/direct/settings.js'; +import type { AppRuntime } from '../../src/layer.js'; import type { LLMClient } from '../../src/llm/client.js'; import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; import { WorkspaceService } from '../../src/core/workspace.js'; import { LLMFactoryService } from '../../src/llm/factory.js'; import { AgentError } from '../../src/core/error.js'; -// -- Compile-time type assertions -- -// These assertions verify that the types are not `any`. -// If any of these fail at compile time, the types are wrong. - type AssertNotAny = 0 extends 1 & T ? never : T; -// AppRuntime must not be `any` type _AppRuntimeNotAny = AssertNotAny; - -// LLMClient must not be `any` type _LLMClientNotAny = AssertNotAny; -// Parameters of createDirectClient must not be `any` -type _DirectClientParams = Parameters; -type _LlmParamNotAny = AssertNotAny<_DirectClientParams[0]>; -type _RtParamNotAny = AssertNotAny<_DirectClientParams[1]>; - -// Parameters of createDirectClients must not be `any` -type _DirectClientsParams = Parameters; -type _DirectClientsLlmNotAny = AssertNotAny<_DirectClientsParams[0]>; -type _DirectClientsRtNotAny = AssertNotAny<_DirectClientsParams[1]>; +type _AgentParams = Parameters; +type _LlmParamNotAny = AssertNotAny<_AgentParams[0]>; +type _RtParamNotAny = AssertNotAny<_AgentParams[1]>; -// -- Runtime tests -- +type _SessionParams = Parameters; +type _SessionRtNotAny = AssertNotAny<_SessionParams[0]>; const MockWorkspaceLayer = Layer.succeed(WorkspaceService, { getWorkspaceCwd: () => '/tmp/test', @@ -69,21 +55,6 @@ const noopLlm: LLMClient = { }; describe('type replacements: AppRuntime and LLMClient', () => { - it('createDirectClient accepts LLMClient and ManagedRuntime', async () => { - const client = await createDirectClient(noopLlm, rt); - expect(client).toBeDefined(); - expect(typeof client.sendMessage).toBe('function'); - }); - - it('createDirectClients accepts LLMClient and ManagedRuntime', () => { - const clients = createDirectClients(noopLlm, rt); - expect(clients).toBeDefined(); - expect(clients.agent).toBeDefined(); - expect(clients.sessions).toBeDefined(); - expect(clients.models).toBeDefined(); - expect(clients.settings).toBeDefined(); - }); - it('createDirectAgentClient accepts LLMClient and ManagedRuntime', () => { const agentClient = createDirectAgentClient(noopLlm, rt); expect(agentClient).toBeDefined(); @@ -108,14 +79,6 @@ describe('type replacements: AppRuntime and LLMClient', () => { expect(typeof settingsClient.getMemoryEnabled).toBe('function'); }); - it('approval service from runtime has getPermissionMode method', async () => { - // This verifies that `const approval = await rt.runPromise(...)` returns - // a properly typed ApprovalService (not `any`), so .getPermissionMode() works - const client = await createDirectClient(noopLlm, rt); - // getPermissionMode should be a function on the client - expect(typeof client.getPermissionMode).toBe('function'); - }); - it('waitService from runtime has registerEmitter and unregisterEmitter', async () => { const waitService = await rt.runPromise( Effect.gen(function* () { diff --git a/packages/codingcode/test/client/direct.test.ts b/packages/codingcode/test/client/direct.test.ts index 77667a7..5e1b4b7 100644 --- a/packages/codingcode/test/client/direct.test.ts +++ b/packages/codingcode/test/client/direct.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; import { Effect, Layer, ManagedRuntime } from 'effect'; -import { createDirectClient, agentEventToStreamChunk } from '../../src/client/direct.js'; +import { createDirectModelClient } from '../../src/direct/models.js'; +import { agentEventToStreamChunk } from '../../src/agent/stream-adapter.js'; import type { LLMClient } from '../../src/llm/client.js'; import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; import { AgentError } from '../../src/core/error.js'; @@ -62,10 +63,10 @@ const noopLlm: LLMClient = { }, }; -describe('createDirectClient model operations', () => { +describe('createDirectModelClient operations', () => { it('lists models from the local model catalog without HTTP', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch'); - const client = await createDirectClient(noopLlm, rt); + const client = createDirectModelClient(rt); const result = await client.listModels(); @@ -79,9 +80,9 @@ describe('createDirectClient model operations', () => { it('rejects unknown model switches without contacting server', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch'); - const client = await createDirectClient(noopLlm, rt); + const client = createDirectModelClient(rt); - await expect(client.switchModel('missing-model@MISSING_KEY')).rejects.toThrow('not found'); + await expect(client.switchModel({ id: 'missing-model@MISSING_KEY' })).rejects.toThrow('not found'); expect(fetchSpy).not.toHaveBeenCalled(); fetchSpy.mockRestore(); diff --git a/packages/codingcode/test/client/direct/settings.test.ts b/packages/codingcode/test/client/direct/settings.test.ts index 8d6c935..3b31445 100644 --- a/packages/codingcode/test/client/direct/settings.test.ts +++ b/packages/codingcode/test/client/direct/settings.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; import { Effect, Layer, ManagedRuntime } from 'effect'; -import { createDirectSettingsClient } from '../../../src/client/direct/settings.js'; +import { createDirectSettingsClient } from '../../../src/direct/settings.js'; import { SkillService } from '../../../src/skills/service.js'; import { MemoryService } from '../../../src/memory/index.js'; import { McpService } from '../../../src/mcp/index.js'; diff --git a/packages/codingcode/test/client/get-session-plan.test.ts b/packages/codingcode/test/client/get-session-plan.test.ts new file mode 100644 index 0000000..e0000cc --- /dev/null +++ b/packages/codingcode/test/client/get-session-plan.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Effect, Layer, ManagedRuntime } from 'effect'; +import { createHttpSessionClient } from '../../src/client/http/sessions.js'; +import { createDirectSessionClient } from '../../src/direct/sessions.js'; +import { SessionService } from '../../src/session/store.js'; +import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; +import { readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { setProjectBaseDir, encodeProjectPath } from '../../src/core/path.js'; + +describe('getSessionPlan: http + direct both implement', () => { + it('http calls GET /api/sessions/:id/plan?cwd=...', async () => { + const calls: string[] = []; + const c = createHttpSessionClient({ + apiGet: async (p) => { + calls.push(p); + return { content: 'plan', path: '/p', directory: '/d', exists: true }; + }, + apiPost: async () => null as any, + apiPut: async () => null as any, + apiDelete: async () => undefined, + }); + const res = await c.getSessionPlan({ sessionId: 's1', cwd: '/c' }); + expect(res.content).toBe('plan'); + expect(calls[0]).toBe('/api/sessions/s1/plan?cwd=%2Fc'); + }); + + it('direct reads latest .md from project plan directory', async () => { + const base = join(tmpdir(), `plan-test-${Date.now()}`); + const projectDir = join(base, encodeProjectPath('/my/cwd')); + mkdirSync(projectDir, { recursive: true }); + writeFileSync(join(projectDir, 'first.md'), '# first'); + writeFileSync(join(projectDir, 'second.md'), '# second'); + setProjectBaseDir(base); + try { + const TestLayer = Layer.mergeAll(SessionService.Default, ProjectRuntimeService.Default); + const rt = ManagedRuntime.make(TestLayer); + const c = createDirectSessionClient(rt as any); + const res = await c.getSessionPlan({ sessionId: 's1', cwd: '/my/cwd' }); + expect(res.exists).toBe(true); + expect(res.content === '# first' || res.content === '# second').toBe(true); + } finally { + setProjectBaseDir(undefined); + } + void readFileSync; + void Effect; + void vi; + }); +}); diff --git a/packages/codingcode/test/client/http-direct-parity.test.ts b/packages/codingcode/test/client/http-direct-parity.test.ts new file mode 100644 index 0000000..e896423 --- /dev/null +++ b/packages/codingcode/test/client/http-direct-parity.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; + +describe('http/direct sendMessage signature parity', () => { + it('http.ts sendMessage accepts (input, cwd?)', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/client/http.ts', + 'utf8' + ); + expect(src).toMatch(/sendMessage\(input: string, cwd\?: string\)/); + }); + + it('direct agent-runtime.ts exports AgentRuntimeClient with sendMessage', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/direct/agent-runtime.ts', + 'utf8' + ); + expect(src).toMatch(/sendMessage\(input,/); + }); + + it('direct agent-runtime.ts no longer uses targetCwd rename', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/direct/agent-runtime.ts', + 'utf8' + ); + expect(src).not.toMatch(/targetCwd/); + }); +}); diff --git a/packages/codingcode/test/client/missing-methods.test.ts b/packages/codingcode/test/client/missing-methods.test.ts new file mode 100644 index 0000000..19589b6 --- /dev/null +++ b/packages/codingcode/test/client/missing-methods.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi } from 'vitest'; +import { readFileSync } from 'fs'; + +vi.mock('@codingcode/infra/config', () => ({ + loadConfig: () => ({ + maxSteps: 50, + maxStopContinuations: 2, + memory: { enabled: true, disabledTypes: [], extraTypes: [], model: 'test-model' }, + context: { compactionModel: 'gpt-4o-mini' }, + }), + updateMemoryModel: vi.fn(), + updateContextCompactionModel: vi.fn(), + DEFAULT_MEMORY_TYPES: [], +})); + +import { Effect, Layer, ManagedRuntime } from 'effect'; +import { createHttpSettingsClient } from '../../src/client/http/settings.js'; +import { createDirectSettingsClient } from '../../src/direct/settings.js'; +import { ApprovalService } from '../../src/approval/index.js'; +import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; +import { HookService } from '../../src/hooks/registry.js'; +import { MemoryService } from '../../src/memory/index.js'; +import { McpService } from '../../src/mcp/index.js'; +import { SkillService } from '../../src/skills/service.js'; +import * as infraConfig from '@codingcode/infra/config'; + +const TestLayer = Layer.mergeAll( + Layer.succeed(SkillService, { + getAll: () => Effect.succeed([]), + findByName: () => Effect.succeed(undefined), + select: () => Effect.succeed(undefined), + selectImplicit: () => Effect.succeed(undefined), + extractSkill: () => Effect.succeed([undefined, '']), + enableSkill: () => Effect.void, + disableSkill: () => Effect.void, + listWithStatus: () => Effect.succeed([]), + evictProject: () => Effect.void, + } as any), + Layer.succeed(MemoryService, { + getMemoryEnabled: () => true, + setMemoryEnabled: () => {}, + loadMemoryForPrompt: () => '', + flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), + } as any), + Layer.succeed(McpService, { + syncConnections: () => Effect.void, + connectServers: () => Effect.void, + disconnectServers: () => Effect.void, + getServerToolNames: () => [], + disconnectAll: () => Effect.void, + status: () => Effect.succeed([]), + listProjectMcpTools: () => [], + disable: () => Effect.void, + enable: () => Effect.void, + } as any), + ApprovalService.Default, + HookService.Default, + ApprovalWaitService.Default +); + +const rt = ManagedRuntime.make(TestLayer); + +describe('setMemoryModel: http + direct both implement', () => { + it('http calls POST /api/settings/memory/model', async () => { + const calls: Array<{ path: string; body: unknown }> = []; + const c = createHttpSettingsClient({ + apiGet: async () => null as any, + apiPost: async (p, b) => { + calls.push({ path: p, body: b }); + return { model: (b as { model: string }).model }; + }, + apiPut: async () => null as any, + apiDelete: async () => undefined, + }); + const res = await c.setMemoryModel('claude-3'); + expect(res.model).toBe('claude-3'); + expect(calls[0]?.path).toBe('/api/settings/memory/model'); + expect(calls[0]?.body).toEqual({ model: 'claude-3' }); + }); + + it('direct calls updateMemoryModel and returns { model }', async () => { + const c = createDirectSettingsClient(rt as any); + const res = await c.setMemoryModel('claude-3'); + expect(res.model).toBe('claude-3'); + expect(infraConfig.updateMemoryModel).toHaveBeenCalledWith('claude-3'); + }); +}); + +describe('getAgentConfig: http + direct both implement', () => { + it('http calls GET /api/settings/agent/config', async () => { + const calls: string[] = []; + const c = createHttpSettingsClient({ + apiGet: async (p) => { + calls.push(p); + return { maxSteps: 100, maxStopContinuations: 3 }; + }, + apiPost: async () => null as any, + apiPut: async () => null as any, + apiDelete: async () => undefined, + }); + const res = await c.getAgentConfig(); + expect(res.maxSteps).toBe(100); + expect(calls[0]).toBe('/api/settings/agent/config'); + }); + + it('direct returns loadConfig maxSteps/maxStopContinuations', async () => { + const c = createDirectSettingsClient(rt as any); + const res = await c.getAgentConfig(); + expect(res.maxSteps).toBe(50); + expect(res.maxStopContinuations).toBe(2); + }); +}); + +describe('setCompactionModel: http + direct both implement', () => { + it('http calls POST /api/settings/context/compaction-model', async () => { + const calls: Array<{ path: string; body: unknown }> = []; + const c = createHttpSettingsClient({ + apiGet: async () => null as any, + apiPost: async (p, b) => { + calls.push({ path: p, body: b }); + return { compactionModel: (b as { compactionModel: string }).compactionModel }; + }, + apiPut: async () => null as any, + apiDelete: async () => undefined, + }); + const res = await c.setCompactionModel('claude-haiku'); + expect(res.compactionModel).toBe('claude-haiku'); + expect(calls[0]?.path).toBe('/api/settings/context/compaction-model'); + }); + + it('direct calls updateContextCompactionModel and returns { compactionModel }', async () => { + const c = createDirectSettingsClient(rt as any); + const res = await c.setCompactionModel('claude-haiku'); + expect(res.compactionModel).toBe('claude-haiku'); + expect(infraConfig.updateContextCompactionModel).toHaveBeenCalledWith('claude-haiku'); + }); +}); + +describe('getMemoryConfig returns model field', () => { + it('http typed return includes model', async () => { + const c = createHttpSettingsClient({ + apiGet: async () => ({ enabled: true, types: [], model: 'm' }), + apiPost: async () => null as any, + apiPut: async () => null as any, + apiDelete: async () => undefined, + }); + const res = await c.getMemoryConfig(); + expect(res.model).toBe('m'); + }); +}); + +void readFileSync; diff --git a/packages/codingcode/test/context/compressor/behavior.test.ts b/packages/codingcode/test/context/compressor/behavior.test.ts index d2acdff..6d4afac 100644 --- a/packages/codingcode/test/context/compressor/behavior.test.ts +++ b/packages/codingcode/test/context/compressor/behavior.test.ts @@ -203,11 +203,7 @@ describe('compressor behavior', () => { '## Compacted History\n\n### Goal\na\n\n### Instructions\nb\n\n### Discoveries\nc\n\n### Accomplished\nd\n\n### Relevant Files\ne' ); const ctx = await getCtxService(); - const result = await ctx.compactWithLLM( - fx.transcriptPath, - llm.modelInfo.maxTokens, - llm - ); + const result = await ctx.compactWithLLM(fx.transcriptPath, llm.modelInfo.maxTokens, llm); expect(result.didCompress).toBe(true); expect(result.promptEstimate).toBeGreaterThan(0); expect(result.promptEstimate).toBeLessThan(before); diff --git a/packages/codingcode/test/core/error-code.test.ts b/packages/codingcode/test/core/error-code.test.ts new file mode 100644 index 0000000..878f71b --- /dev/null +++ b/packages/codingcode/test/core/error-code.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { NotFoundError, AlreadyExistsError, AgentError } from '../../src/core/error.js'; + +describe('NotFoundError has code+httpStatus', () => { + it('code is NOT_FOUND and httpStatus returns 404', () => { + const err = new NotFoundError('missing'); + expect(err.code).toBe('NOT_FOUND'); + expect(err.httpStatus()).toBe(404); + }); +}); + +describe('AlreadyExistsError has code+httpStatus', () => { + it('code is ALREADY_EXISTS and httpStatus returns 409', () => { + const err = new AlreadyExistsError('exists'); + expect(err.code).toBe('ALREADY_EXISTS'); + expect(err.httpStatus()).toBe(409); + }); +}); + +describe('AgentError unchanged', () => { + it('sessionNotFound still returns SESSION_NOT_FOUND with 404', () => { + const err = AgentError.sessionNotFound('abc'); + expect(err.code).toBe('SESSION_NOT_FOUND'); + expect(err.httpStatus()).toBe(404); + }); +}); diff --git a/packages/codingcode/test/core/error.test.ts b/packages/codingcode/test/core/error.test.ts index 8aa5bf5..ee20513 100644 --- a/packages/codingcode/test/core/error.test.ts +++ b/packages/codingcode/test/core/error.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { AgentError } from '../../src/core/error.js'; +import { AgentError, ApiError } from '../../src/core/error.js'; describe('AgentError.httpStatus', () => { it('returns 400 for CONFIG_MISSING', () => { @@ -37,3 +37,23 @@ describe('AgentError.httpStatus', () => { expect(err.httpStatus()).toBe(500); }); }); + +describe('ApiError', () => { + it('formats message from body.message when provided', () => { + const err = new ApiError(404, '/api/agent/permission-mode', { + code: 'NOT_FOUND', + message: 'gone', + }); + expect(err.message).toBe('gone'); + expect(err.status).toBe(404); + expect(err.path).toBe('/api/agent/permission-mode'); + expect(err.body?.code).toBe('NOT_FOUND'); + expect(err.name).toBe('ApiError'); + }); + + it('falls back to "HTTP : " when body missing', () => { + const err = new ApiError(500, '/x'); + expect(err.message).toBe('HTTP 500: /x'); + expect(err.body).toBeUndefined(); + }); +}); diff --git a/packages/codingcode/test/core/paths.test.ts b/packages/codingcode/test/core/paths.test.ts new file mode 100644 index 0000000..cea3cda --- /dev/null +++ b/packages/codingcode/test/core/paths.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { computePaths, projectSessionsDir, sessionJsonlPathFromCwd } from '../../src/core/path.js'; + +describe('core/path.ts contains path computation functions', () => { + it('does not import from session/types — no core→session dependency', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/core/path.ts', + 'utf8' + ); + expect(src).not.toMatch(/from\s+['"]\.\.\/session\//); + }); + + it('exports computePaths, projectSessionsDir, sessionJsonlPathFromCwd', () => { + expect(typeof computePaths).toBe('function'); + expect(typeof projectSessionsDir).toBe('function'); + expect(typeof sessionJsonlPathFromCwd).toBe('function'); + }); +}); + +describe('session/file-ops.ts re-exports paths from core', () => { + it('file-ops.ts no longer defines computePaths inline', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/session/file-ops.ts', + 'utf8' + ); + expect(src).not.toMatch(/export function computePaths\s*\(/); + expect(src).not.toMatch(/export function projectSessionsDir\s*\(/); + expect(src).toMatch(/from\s+['"]\.\.\/core\/path\.js['"]/); + }); +}); diff --git a/packages/codingcode/test/layer/system-hook-layer.test.ts b/packages/codingcode/test/layer/system-hook-layer.test.ts index a88515b..6377464 100644 --- a/packages/codingcode/test/layer/system-hook-layer.test.ts +++ b/packages/codingcode/test/layer/system-hook-layer.test.ts @@ -1,20 +1,16 @@ import { describe, it, expect } from 'vitest'; import { Effect } from 'effect'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { HookService } from '../../src/hooks/registry.js'; import { SystemHookLayer } from '../../src/layer.js'; -import { markSessionPlanMode, clearPlanModeSession } from '../../src/plan/index.js'; +import { computePaths } from '../../src/core/path.js'; describe('SystemHookLayer', () => { it('builds without "Service not found: HookService" (regression: was a self-referential Layer.effect)', async () => { - // The previous implementation used `Layer.effect(HookService, body-yielding-HookService)` - // which Effect-TS does NOT support as a self-referential layer: the runtime - // does not place a placeholder HookService in the environment while - // building the layer, so the body's first `yield* HookService` would Die - // with "Service not found: HookService". This test would fail to even - // build the layer before the fix. const program = Effect.gen(function* () { const hooks = yield* HookService; - // touch the service to ensure it's resolvable from the build's output return typeof hooks.register; }); @@ -23,45 +19,55 @@ describe('SystemHookLayer', () => { }); it('registers the remaining plan-mode system hooks', async () => { - // After the plan approval decoupling: - // - planModeGateHook stays — it's the right abstraction for tool-allow - // policy. Registered on tool.approval.pre with priority -1000. - // - afterPlanSubmittedObserver REMOVED — plan.ready is now emitted by - // agentLoop on turn-end, not by an observer on tool.execute.after. - // - planApprovalHook REMOVED — submit_plan tool handles its own 3-option - // approval via ApprovalWaitService directly. - // - planSubagentWhitelistHook REMOVED — now an inline function - // (checkSubagentAllowedInPlanMode) called by dispatch_agent. - const program = Effect.gen(function* () { - const hooks = yield* HookService; - - // (1) planModeGateHook denies write tools in plan mode - markSessionPlanMode('s', true); - const denied = yield* hooks.emitDecision('tool.approval.pre', { - toolName: 'write_file', - args: { path: '/x' }, + const cwd = mkdtempSync(join(tmpdir(), 'codingcode-syshook-')); + try { + const paths = computePaths(cwd, 's'); + mkdirSync(paths.transcriptPath.replace(/\.jsonl$/, ''), { recursive: true }); + const idx = { sessionId: 's', - projectPath: '/p', - }); - expect(denied).not.toBeNull(); - expect(denied?.decision).toBe('deny'); - expect(denied?.reason).toMatch(/plan mode/i); - clearPlanModeSession('s'); + projectPath: paths.projectPath, + cwd: paths.cwd, + model: 'test', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + messageCount: 0, + title: 's', + currentTurnId: 0, + usage: undefined, + mode: 'plan', + permissionMode: 'default', + }; + writeFileSync(paths.indexPath, JSON.stringify(idx, null, 2), 'utf8'); - // (2) planModeGateHook lets submit_plan through - markSessionPlanMode('s', true); - const allowed = yield* hooks.emitDecision('tool.approval.pre', { - toolName: 'submit_plan', - args: { plan_content: '## plan' }, - sessionId: 's', - projectPath: '/p', - }); - expect(allowed).toBeNull(); - clearPlanModeSession('s'); + const program = Effect.gen(function* () { + const hooks = yield* HookService; - return true; - }); + // (1) planModeGateHook denies write tools in plan mode + const denied = yield* hooks.emitDecision('tool.approval.pre', { + toolName: 'write_file', + args: { path: '/x' }, + sessionId: 's', + projectPath: cwd, + }); + expect(denied).not.toBeNull(); + expect(denied?.decision).toBe('deny'); + expect(denied?.reason).toMatch(/plan mode/i); + + // (2) planModeGateHook lets submit_plan through + const allowed = yield* hooks.emitDecision('tool.approval.pre', { + toolName: 'submit_plan', + args: { plan_content: '## plan' }, + sessionId: 's', + projectPath: cwd, + }); + expect(allowed).toBeNull(); + + return true; + }); - await Effect.runPromise(program.pipe(Effect.provide(SystemHookLayer) as any)); + await Effect.runPromise(program.pipe(Effect.provide(SystemHookLayer) as any)); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } }); }); diff --git a/packages/codingcode/test/plan/active-sessions.test.ts b/packages/codingcode/test/plan/active-sessions.test.ts deleted file mode 100644 index 2315444..0000000 --- a/packages/codingcode/test/plan/active-sessions.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { - markSessionPlanMode, - isSessionInPlanMode, - clearPlanModeSession, -} from '../../src/plan/index.js'; - -describe('plan/active-sessions side channel', () => { - beforeEach(() => { - // Clear any leftover state between tests - clearPlanModeSession('s1'); - clearPlanModeSession('s2'); - }); - - it('starts as false for an unmarked session', () => { - expect(isSessionInPlanMode('s1')).toBe(false); - }); - - it('markSessionPlanMode(id, true) marks the session as plan mode', () => { - markSessionPlanMode('s1', true); - expect(isSessionInPlanMode('s1')).toBe(true); - }); - - it('markSessionPlanMode(id, false) unmarks a previously plan-mode session', () => { - markSessionPlanMode('s1', true); - markSessionPlanMode('s1', false); - expect(isSessionInPlanMode('s1')).toBe(false); - }); - - it('clearPlanModeSession always removes the session', () => { - markSessionPlanMode('s1', true); - clearPlanModeSession('s1'); - expect(isSessionInPlanMode('s1')).toBe(false); - }); - - it('is per-session: marking s1 does not affect s2', () => { - markSessionPlanMode('s1', true); - expect(isSessionInPlanMode('s1')).toBe(true); - expect(isSessionInPlanMode('s2')).toBe(false); - }); -}); diff --git a/packages/codingcode/test/plan/gate-pipeline.test.ts b/packages/codingcode/test/plan/gate-pipeline.test.ts index af14f46..168885b 100644 --- a/packages/codingcode/test/plan/gate-pipeline.test.ts +++ b/packages/codingcode/test/plan/gate-pipeline.test.ts @@ -1,16 +1,19 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Effect, Layer } from 'effect'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { runPipeline } from '../../src/approval/pipeline.js'; import { createRuleEngine } from '../../src/approval/rule-engine.js'; import { READONLY_TOOL_NAMES } from '../../src/approval/presets.js'; import { HookService } from '../../src/hooks/registry.js'; import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; -import { - planModeGateHook, - markSessionPlanMode, - clearPlanModeSession, -} from '../../src/plan/index.js'; +import { planModeGateHook } from '../../src/plan/index.js'; +import { computePaths } from '../../src/core/path.js'; import type { DecisionHandler } from '../../src/hooks/types.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +const base = useTempProjectBase(); const decisionHandlers: DecisionHandler[] = []; @@ -39,7 +42,6 @@ const mockHookService = { disposeProject: () => Effect.succeed(undefined), }; -// Capture the payload of emitApprovalRequest so we can verify the Layer 4 → Layer 5 handoff let capturedApproval: any = null; function makeMockApprovalWait() { @@ -58,19 +60,40 @@ function makeMockApprovalWait() { }; } +function makeIndex(cwd: string, sessionId: string, mode: 'plan' | 'build') { + const paths = computePaths(cwd, sessionId); + mkdirSync(paths.transcriptPath.replace(/\.jsonl$/, ''), { recursive: true }); + const idx = { + sessionId, + projectPath: paths.projectPath, + cwd: paths.cwd, + model: 'test', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + messageCount: 0, + title: sessionId.slice(0, 8), + currentTurnId: 0, + usage: undefined, + mode, + permissionMode: 'default', + }; + writeFileSync(paths.indexPath, JSON.stringify(idx, null, 2), 'utf8'); +} + function runPipelineWithMock(opts: { tool: string; input: any; permissionMode: 'default' | 'acceptEdits' | 'bypass'; sessionId: string; planMode: boolean; + cwd: string; }) { capturedApproval = null; decisionHandlers.length = 0; decisionHandlers.push(planModeGateHook); - if (opts.planMode) markSessionPlanMode(opts.sessionId, true); - else markSessionPlanMode(opts.sessionId, false); + if (opts.planMode) makeIndex(opts.cwd, opts.sessionId, 'plan'); + else makeIndex(opts.cwd, opts.sessionId, 'build'); const mockWait = makeMockApprovalWait(); const HookTestLayer = Layer.succeed(HookService, mockHookService as any); @@ -85,16 +108,22 @@ function runPipelineWithMock(opts: { destructiveTools: new Set(), permissionMode: opts.permissionMode, sessionId: opts.sessionId, + projectPath: opts.cwd, } ).pipe(Effect.provide(TestLayer) as any) ); } -describe('Plan mode gate hook integration (planApprovalHook removed — submit_plan self-handles)', () => { +describe('Plan mode gate hook integration', () => { + let cwd: string; beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'codingcode-gate-pipeline-')); capturedApproval = null; decisionHandlers.length = 0; }); + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); + }); it('plan mode + write_file: gate denies before reaching user confirmation', async () => { const decision: any = await runPipelineWithMock({ @@ -103,13 +132,11 @@ describe('Plan mode gate hook integration (planApprovalHook removed — submit_p permissionMode: 'default', sessionId: 's2', planMode: true, + cwd, }); - // Gate denied, so no user confirmation fired. expect(decision.type).toBe('deny'); expect(decision.reason).toMatch(/plan mode/i); expect(capturedApproval).toBeNull(); - - clearPlanModeSession('s2'); }); it('plan mode + execute_command: gate denies with plan-mode reason', async () => { @@ -119,30 +146,24 @@ describe('Plan mode gate hook integration (planApprovalHook removed — submit_p permissionMode: 'default', sessionId: 's3', planMode: true, + cwd, }); expect(decision.type).toBe('deny'); expect(decision.reason).toMatch(/plan mode/i); expect(capturedApproval).toBeNull(); - - clearPlanModeSession('s3'); }); - it('plan mode + dispatch_agent: gate lets it through (subagent-whitelist inline at dispatch time)', async () => { + it('plan mode + dispatch_agent: gate lets it through', async () => { const decision: any = await runPipelineWithMock({ tool: 'dispatch_agent', input: { agent: 'build', prompt: 'do something' }, permissionMode: 'default', sessionId: 's4', planMode: true, + cwd, }); - // The gate does not deny dispatch_agent (it's in PLAN_MODE_ALLOWED_TOOLS). - // The pipeline may short-circuit at Layer 2 (readonly-whitelist) since - // dispatch_agent is in READONLY_TOOL_NAMES. The subagent-whitelist check - // is now inline in dispatch_agent (not a hook) and runs at dispatch time. expect(decision.type).toBe('allow'); expect(decision.type).not.toBe('deny'); - - clearPlanModeSession('s4'); }); it('build mode + write_file: gate does not fire, pipeline falls through normally', async () => { @@ -152,30 +173,23 @@ describe('Plan mode gate hook integration (planApprovalHook removed — submit_p permissionMode: 'default', sessionId: 's5', planMode: false, + cwd, }); - // build mode: write_file is not in any allowlist, pipeline reaches user confirm expect(capturedApproval).not.toBeNull(); expect(decision.source).toBe('user-confirm'); - - clearPlanModeSession('s5'); }); - it('submit_plan: pipeline short-circuits at Layer 5 (no 2-option modal)', async () => { - // The plan approval is no longer triggered by a hook. The pipeline - // recognizes submit_plan by name at Layer 5 and short-circuits with - // 'allow' + source 'system-plan-self-handles'. The plan modal is - // driven by submit_plan.execute itself, not by the pipeline. + it('submit_plan: pipeline short-circuits at Layer 5', async () => { const decision: any = await runPipelineWithMock({ tool: 'submit_plan', input: { plan_content: '# plan' }, permissionMode: 'default', sessionId: 's6', planMode: true, + cwd, }); expect(decision.type).toBe('allow'); expect(decision.source).toBe('system-plan-self-handles'); expect(capturedApproval).toBeNull(); - - clearPlanModeSession('s6'); }); }); diff --git a/packages/codingcode/test/plan/gate.test.ts b/packages/codingcode/test/plan/gate.test.ts index 63ec5a7..fee2ddc 100644 --- a/packages/codingcode/test/plan/gate.test.ts +++ b/packages/codingcode/test/plan/gate.test.ts @@ -1,17 +1,46 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { - planModeGateHook, - markSessionPlanMode, - clearPlanModeSession, -} from '../../src/plan/index.js'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { planModeGateHook, isSessionInPlanMode } from '../../src/plan/index.js'; +import { computePaths } from '../../src/core/path.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +const base = useTempProjectBase(); + +function makeSessionIndex(cwd: string, sessionId: string, mode: 'plan' | 'build') { + const paths = computePaths(cwd, sessionId); + mkdirSync(paths.transcriptPath.replace(/\.jsonl$/, ''), { recursive: true }); + const idx = { + sessionId, + projectPath: paths.projectPath, + cwd: paths.cwd, + model: 'test', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + messageCount: 0, + title: sessionId.slice(0, 8), + currentTurnId: 0, + usage: undefined, + mode, + permissionMode: 'default', + }; + writeFileSync(paths.indexPath, JSON.stringify(idx, null, 2), 'utf8'); + return paths; +} describe('planModeGateHook', () => { + let cwd: string; + let sessionId: string; + beforeEach(() => { - clearPlanModeSession('sess'); + cwd = join(base.dir, 'gate'); + mkdirSync(cwd, { recursive: true }); + sessionId = 'sess-gate'; }); afterEach(() => { - clearPlanModeSession('sess'); + rmSync(cwd, { recursive: true, force: true }); }); it('returns null when no sessionId is present', () => { @@ -19,29 +48,37 @@ describe('planModeGateHook', () => { }); it('returns null when the session is not in plan mode', () => { - expect(planModeGateHook({ toolName: 'write_file', sessionId: 'sess' } as any)).toBeNull(); + makeSessionIndex(cwd, sessionId, 'build'); + expect( + planModeGateHook({ toolName: 'write_file', sessionId, projectPath: cwd } as any) + ).toBeNull(); }); it('returns null when the tool is not provided', () => { - markSessionPlanMode('sess', true); - expect(planModeGateHook({ sessionId: 'sess' } as any)).toBeNull(); + makeSessionIndex(cwd, sessionId, 'plan'); + expect(planModeGateHook({ sessionId, projectPath: cwd } as any)).toBeNull(); }); it('allows submit_plan in plan mode', () => { - markSessionPlanMode('sess', true); - expect(planModeGateHook({ toolName: 'submit_plan', sessionId: 'sess' } as any)).toBeNull(); + makeSessionIndex(cwd, sessionId, 'plan'); + expect( + planModeGateHook({ toolName: 'submit_plan', sessionId, projectPath: cwd } as any) + ).toBeNull(); }); - it('allows dispatch_agent in plan mode (subagent-whitelist hook further restricts)', () => { - markSessionPlanMode('sess', true); - expect(planModeGateHook({ toolName: 'dispatch_agent', sessionId: 'sess' } as any)).toBeNull(); + it('allows dispatch_agent in plan mode', () => { + makeSessionIndex(cwd, sessionId, 'plan'); + expect( + planModeGateHook({ toolName: 'dispatch_agent', sessionId, projectPath: cwd } as any) + ).toBeNull(); }); it('denies write_file in plan mode with the plan-mode reason', () => { - markSessionPlanMode('sess', true); + makeSessionIndex(cwd, sessionId, 'plan'); const result = planModeGateHook({ toolName: 'write_file', - sessionId: 'sess', + sessionId, + projectPath: cwd, } as any); expect(result).toEqual({ decision: 'deny', @@ -49,22 +86,51 @@ describe('planModeGateHook', () => { }); }); - it('denies execute_command in plan mode', async () => { - markSessionPlanMode('sess', true); - const result = await planModeGateHook({ + it('denies execute_command in plan mode', () => { + makeSessionIndex(cwd, sessionId, 'plan'); + const result = planModeGateHook({ toolName: 'execute_command', - sessionId: 'sess', + sessionId, + projectPath: cwd, } as any); expect(result?.decision).toBe('deny'); expect(result?.reason).toMatch(/plan mode/i); }); - it('denies edit_file in plan mode', async () => { - markSessionPlanMode('sess', true); - const result = await planModeGateHook({ + it('denies edit_file in plan mode', () => { + makeSessionIndex(cwd, sessionId, 'plan'); + const result = planModeGateHook({ toolName: 'edit_file', - sessionId: 'sess', + sessionId, + projectPath: cwd, } as any); expect(result?.decision).toBe('deny'); }); }); + +describe('isSessionInPlanMode', () => { + let cwd: string; + + beforeEach(() => { + cwd = join(base.dir, 'is-session-in-plan'); + mkdirSync(cwd, { recursive: true }); + }); + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + it('returns true when index has mode=plan', () => { + makeSessionIndex(cwd, 's-plan', 'plan'); + expect(isSessionInPlanMode('s-plan', cwd)).toBe(true); + }); + + it('returns false when index has mode=build', () => { + makeSessionIndex(cwd, 's-build', 'build'); + expect(isSessionInPlanMode('s-build', cwd)).toBe(false); + }); + + it('returns false when index file does not exist', () => { + expect(isSessionInPlanMode('s-missing', cwd)).toBe(false); + }); +}); diff --git a/packages/codingcode/test/runtime/set-session-profile.test.ts b/packages/codingcode/test/runtime/set-session-profile.test.ts index b3c08de..b4be41b 100644 --- a/packages/codingcode/test/runtime/set-session-profile.test.ts +++ b/packages/codingcode/test/runtime/set-session-profile.test.ts @@ -45,12 +45,20 @@ function makeLayer() { const RulesTestLayer = Layer.succeed(RulesService, mockRulesService); const SessionTestLayer = SessionService.Default; const ProjectRuntimeTestLayer = ProjectRuntimeService.Default.pipe( - Layer.provide(Layer.mergeAll(HookTestLayer, McpTestLayer, SubagentTestLayer, RulesTestLayer, SessionTestLayer)) + Layer.provide( + Layer.mergeAll( + HookTestLayer, + McpTestLayer, + SubagentTestLayer, + RulesTestLayer, + SessionTestLayer + ) + ) ); return Layer.mergeAll(ProjectRuntimeTestLayer, SessionTestLayer); } -describe('ProjectRuntimeService.setSessionProfile', () => { +describe('ProjectRuntimeService.setSessionProfile (disk-only)', () => { let cwd: string; let sessionId: string; let indexPath: string; @@ -81,64 +89,42 @@ describe('ProjectRuntimeService.setSessionProfile', () => { await rt.dispose(); }); - it('does NOT write idx.permissionMode when switching to plan (preserves build preference)', async () => { - // Plan mode: in-memory map is forced to 'default', but the on-disk - // SessionIndex.permissionMode is left untouched so the build preference - // survives plan→build transitions. + it('writes mode + permissionMode + activeProfile when switching to plan', async () => { await rt.runPromise( Effect.gen(function* () { const runtime = yield* ProjectRuntimeService; yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); }) ); - expect(existsSync(indexPath)).toBe(true); const idx = JSON.parse(readFileSync(indexPath, 'utf8')); - // build preference (default from create) is preserved on disk + expect(idx.mode).toBe('plan'); expect(idx.permissionMode).toBe('default'); - // runtime memory is forced to 'default' - await rt.runPromise( - Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; - expect(runtime.getSessionPermissionMode(sessionId)).toBe('default'); - }) - ); + expect(idx.activeProfile).toBe('plan'); }); - it('writes idx.permissionMode AND idx.activeProfile when switching to build', async () => { + it('writes mode + permissionMode + activeProfile when switching to build (with override)', async () => { await rt.runPromise( Effect.gen(function* () { const runtime = yield* ProjectRuntimeService; yield* runtime.setSessionProfile(cwd, sessionId, BUILD_PROFILE, 'bypass'); }) ); - const idx = JSON.parse(readFileSync(indexPath, 'utf8')); + expect(idx.mode).toBe('build'); expect(idx.permissionMode).toBe('bypass'); expect(idx.activeProfile).toBe('build'); }); - it('records profile in runtime memory (getSessionProfile returns it)', async () => { - await rt.runPromise( - Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; - yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); - const profile = runtime.getSessionProfile(sessionId); - expect(profile?.name).toBe('plan'); - expect(runtime.getSessionPermissionMode(sessionId)).toBe('default'); - }) - ); - }); - - it('explore profile (with explicit permissionMode=bypass) writes correctly', async () => { + it('writes activeProfile when switching to explore', async () => { await rt.runPromise( Effect.gen(function* () { const runtime = yield* ProjectRuntimeService; yield* runtime.setSessionProfile(cwd, sessionId, EXPLORE_PROFILE); - const idx = JSON.parse(readFileSync(indexPath, 'utf8')); - expect(idx.permissionMode).toBe('bypass'); - expect(idx.activeProfile).toBe('explore'); }) ); + const idx = JSON.parse(readFileSync(indexPath, 'utf8')); + expect(idx.permissionMode).toBe('bypass'); + expect(idx.activeProfile).toBe('explore'); }); }); diff --git a/packages/codingcode/test/scheduler/approval-bypass.test.ts b/packages/codingcode/test/scheduler/approval-bypass.test.ts new file mode 100644 index 0000000..201ac8b --- /dev/null +++ b/packages/codingcode/test/scheduler/approval-bypass.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; + +describe('scheduler uses real forked ApprovalService', () => { + it('scheduler/service.ts no longer passes literal { permissionMode: "bypass" } as approvalOverride', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/scheduler/service.ts', + 'utf8' + ); + expect(src).not.toMatch(/approvalOverride:\s*\{\s*permissionMode:\s*['"]bypass['"]\s*\}/); + }); + + it('scheduler imports ApprovalService', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/scheduler/service.ts', + 'utf8' + ); + expect(src).toMatch(/import\s*\{[^}]*ApprovalService[^}]*\}\s*from\s*['"]\.\.\/approval\/index\.js['"]/); + }); + + it('scheduler resolves ApprovalService and forks with bypass', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/scheduler/service.ts', + 'utf8' + ); + expect(src).toMatch(/yield\*\s*ApprovalService/); + expect(src).toMatch(/\.fork\(\s*\{\s*permissionMode:\s*['"]bypass['"]\s*\}\s*\)/); + }); +}); diff --git a/packages/codingcode/test/security/plan-mode-restart.test.ts b/packages/codingcode/test/security/plan-mode-restart.test.ts index 632072b..2a2d7a9 100644 --- a/packages/codingcode/test/security/plan-mode-restart.test.ts +++ b/packages/codingcode/test/security/plan-mode-restart.test.ts @@ -11,12 +11,7 @@ import { SubagentService } from '../../src/subagent/registry.js'; import { RulesService } from '../../src/rules/index.js'; import { ApprovalService } from '../../src/approval/index.js'; import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; -import { - planModeGateHook, - markSessionPlanMode, - clearPlanModeSession, - isSessionInPlanMode, -} from '../../src/plan/index.js'; +import { planModeGateHook, isSessionInPlanMode } from '../../src/plan/index.js'; import { PLAN_PROFILE, BUILD_PROFILE } from '../../src/subagent/registry.js'; import type { DecisionHandler } from '../../src/hooks/types.js'; import { useTempProjectBase } from '../helpers/project-base.js'; @@ -108,7 +103,7 @@ function makeLayer() { return TestLayer; } -describe('plan mode security boundary (cross-restart)', () => { +describe('plan mode security boundary (cross-restart, disk only)', () => { let cwd: string; let sessionId: string; let indexPath: string; @@ -137,21 +132,17 @@ describe('plan mode security boundary (cross-restart)', () => { afterEach(async () => { await rt.dispose(); rmSync(cwd, { recursive: true, force: true }); - clearPlanModeSession(sessionId); }); - // Helper: simulate the real sendMessage path — fork approval, set the - // session's permission mode (from the runtime's in-memory map), then evaluate. - // The plan-mode side channel is kept in sync by `setSessionProfile`, so the - // gate hook fires correctly even via the approval pipeline. async function evaluateAsSession(tool: string, input: any): Promise { return rt.runPromise( Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; const approval = yield* ApprovalService; - const mode = runtime.getSessionPermissionMode(sessionId); - const forked = yield* approval.fork({}); - yield* forked.setPermissionMode(mode); + const mode = yield* Effect.sync(() => { + const idx = JSON.parse(readFileSync(indexPath, 'utf8')); + return idx.permissionMode; + }); + const forked = yield* approval.fork({ permissionMode: mode }); return yield* forked.evaluate({ tool, input, @@ -170,8 +161,7 @@ describe('plan mode security boundary (cross-restart)', () => { yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); }) ); - // setSessionProfile also marks the plan-mode side channel - expect(isSessionInPlanMode(sessionId)).toBe(true); + expect(isSessionInPlanMode(sessionId, cwd)).toBe(true); const decision = await evaluateAsSession('write_file', { path: '/tmp/x', content: 'foo' }); expect(decision.type).toBe('deny'); @@ -194,7 +184,7 @@ describe('plan mode security boundary (cross-restart)', () => { expect(decision.source).toBe('hook'); }); - it('scenario 3: switch to plan, submit_plan is short-circuited by the pipeline (self-handles plan approval)', async () => { + it('scenario 3: switch to plan, submit_plan is short-circuited by the pipeline', async () => { await rt.runPromise( Effect.gen(function* () { const runtime = yield* ProjectRuntimeService; @@ -204,18 +194,11 @@ describe('plan mode security boundary (cross-restart)', () => { ); const decision: any = await evaluateAsSession('submit_plan', { plan_content: 'do things' }); - // submit_plan is in PLAN_MODE_ALLOWED_TOOLS, so the gate does not fire. - // The pipeline recognizes submit_plan by name at Layer 5 and short-circuits - // to 'allow' with source 'system-plan-self-handles'. The plan modal is - // driven by agentLoop emitting plan.ready on turn-end, not by the pipeline. expect(decision.type).toBe('allow'); expect(decision.source).toBe('system-plan-self-handles'); }); it('scenario 4: after restart (state reloaded from disk), plan mode still enforced', async () => { - // First: switch to plan. In the new design, plan mode does NOT write - // idx.permissionMode (the build preference is preserved on disk), - // but the in-memory planModeSessions side channel is updated. await rt.runPromise( Effect.gen(function* () { const runtime = yield* ProjectRuntimeService; @@ -224,27 +207,19 @@ describe('plan mode security boundary (cross-restart)', () => { }) ); - // After plan: permissionMode is preserved (build preference from create). const idx = JSON.parse(readFileSync(indexPath, 'utf8')); - expect(idx.permissionMode).toBe('default'); + expect(idx.mode).toBe('plan'); - // Simulate restart: build a new runtime, load state, restore profile. await rt.dispose(); decisionHandlers.length = 0; decisionHandlers.push(planModeGateHook); rt = ManagedRuntime.make(makeLayer() as any); await rt.runPromise( Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; const session = yield* SessionService; - yield* runtime.prepareProject(cwd); const state = yield* session.load(cwd, sessionId); - // state.mode is 'build' (set by create; plan mode didn't write to disk) - expect(state.mode).toBe('build'); - // To re-enter plan mode after restart, the client calls setSessionMode. - yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); - // After restore, the plan-mode side channel is re-marked. - expect(isSessionInPlanMode(sessionId)).toBe(true); + expect(state.mode).toBe('plan'); + expect(isSessionInPlanMode(sessionId, cwd)).toBe(true); }) ); @@ -262,11 +237,9 @@ describe('plan mode security boundary (cross-restart)', () => { yield* runtime.setSessionProfile(cwd, sessionId, BUILD_PROFILE); }) ); - // After switching to build, the plan-mode side channel is cleared. - expect(isSessionInPlanMode(sessionId)).toBe(false); + expect(isSessionInPlanMode(sessionId, cwd)).toBe(false); const decision: any = await evaluateAsSession('write_file', { path: '/tmp/x', content: 'foo' }); - // Gate no longer fires; pipeline falls through to user confirm (no emitter → system deny). if (decision.type === 'deny') { expect(decision.source).not.toBe('hook'); expect(decision.reason).not.toMatch(/plan mode/i); diff --git a/packages/codingcode/test/server/agent-routes.test.ts b/packages/codingcode/test/server/agent-routes.test.ts deleted file mode 100644 index 5442905..0000000 --- a/packages/codingcode/test/server/agent-routes.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { Effect, Layer, ManagedRuntime } from 'effect'; -import { createAgentRouter } from '../../src/server/routes/agent.js'; -import { ApprovalService } from '../../src/approval/index.js'; -import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; -import { HookService } from '../../src/hooks/registry.js'; - -const MockApprovalLayer = ApprovalService.Default.pipe( - Layer.provide(Layer.mergeAll(HookService.Default, ApprovalWaitService.Default)) -); - -const TestLayer = Layer.mergeAll( - MockApprovalLayer, - HookService.Default, - ApprovalWaitService.Default -); - -const rt = ManagedRuntime.make(TestLayer); -const agentRouter = createAgentRouter(rt); - -describe('GET /permission-mode', () => { - it('returns 200 with current permission mode', async () => { - const res = await agentRouter.request('/permission-mode'); - expect(res.status).toBe(200); - const body = (await res.json()) as { mode: string }; - expect(body).toHaveProperty('mode'); - expect(typeof body.mode).toBe('string'); - }); -}); - -describe('POST /permission-mode', () => { - it('returns 200 for valid mode', async () => { - const res = await agentRouter.request('/permission-mode', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mode: 'default' }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { mode: string }; - expect(body.mode).toBe('default'); - }); - - it('returns 400 for invalid mode', async () => { - const res = await agentRouter.request('/permission-mode', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mode: 'invalid_mode' }), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain('Invalid mode'); - }); -}); - -describe('old /api/agent/* routes now return 404', () => { - it('GET /skills returns 404', async () => { - const res = await agentRouter.request('/skills'); - expect(res.status).toBe(404); - }); - - it('GET /mcp returns 404', async () => { - const res = await agentRouter.request('/mcp'); - expect(res.status).toBe(404); - }); - - it('GET /subagent returns 404', async () => { - const res = await agentRouter.request('/subagent'); - expect(res.status).toBe(404); - }); - - it('GET /memory returns 404', async () => { - const res = await agentRouter.request('/memory'); - expect(res.status).toBe(404); - }); -}); diff --git a/packages/codingcode/test/server/create-session-active-profile.test.ts b/packages/codingcode/test/server/create-session-active-profile.test.ts index 9745c9c..c926bdf 100644 --- a/packages/codingcode/test/server/create-session-active-profile.test.ts +++ b/packages/codingcode/test/server/create-session-active-profile.test.ts @@ -140,7 +140,7 @@ describe('POST /api/sessions — atomic mode + permissionMode + model', () => { expect(idx.permissionMode).toBe('bypass'); }); - it('rejects plan mode with non-default permissionMode', async () => { + it('allows plan mode with any permissionMode (plan no longer overrides perm)', async () => { const res = await app.request('/api/sessions', { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -151,7 +151,19 @@ describe('POST /api/sessions — atomic mode + permissionMode + model', () => { model: 'gpt-4', }), }); - expect(res.status).toBe(400); + expect(res.status).toBe(200); + const { sessionId } = await res.json(); + const indexPath = await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + const state = yield* session.load(cwd, sessionId); + return state.indexPath; + }) + ); + const idx = JSON.parse(readFileSync(indexPath, 'utf8')); + expect(idx.mode).toBe('plan'); + expect(idx.permissionMode).toBe('bypass'); + expect(idx.activeProfile).toBe('plan'); }); it('rejects missing model', async () => { @@ -172,7 +184,7 @@ describe('POST /api/sessions — atomic mode + permissionMode + model', () => { expect(res.status).toBe(400); }); - it('new session with plan: state.mode is set, getSessionPermissionMode returns default', async () => { + it('new session with plan: state.mode, activeProfile, permissionMode are all on disk', async () => { const res = await app.request('/api/sessions', { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -188,14 +200,11 @@ describe('POST /api/sessions — atomic mode + permissionMode + model', () => { await rt.runPromise( Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; const session = yield* SessionService; const state = yield* session.load(cwd, sessionId); expect(state.mode).toBe('plan'); - const profile = runtime.getSessionProfile(sessionId); - expect(profile?.name).toBe('plan'); - // plan-mode forces in-memory permissionMode to 'default' - expect(runtime.getSessionPermissionMode(sessionId)).toBe('default'); + expect(state.permissionMode).toBe('default'); + expect(state.activeProfile).toBe('plan'); }) ); }); diff --git a/packages/codingcode/test/server/messages-fork-permission-mode.test.ts b/packages/codingcode/test/server/messages-fork-permission-mode.test.ts new file mode 100644 index 0000000..2192a3d --- /dev/null +++ b/packages/codingcode/test/server/messages-fork-permission-mode.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Effect, Layer, ManagedRuntime } from 'effect'; +import { Hono } from 'hono'; +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { createMessagesRouter } from '../../src/server/routes/messages.js'; +import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; +import { SessionService } from '../../src/session/store.js'; +import { HookService } from '../../src/hooks/registry.js'; +import { McpService } from '../../src/mcp/index.js'; +import { SubagentService } from '../../src/subagent/registry.js'; +import { RulesService } from '../../src/rules/index.js'; +import { ApprovalService } from '../../src/approval/index.js'; +import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; +import { LLMFactoryService } from '../../src/llm/factory.js'; +import { WorkspaceService } from '../../src/core/workspace.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +useTempProjectBase(); + +const mockHookService = { + register: () => Effect.succeed(() => {}), + registerDecision: () => Effect.succeed(() => {}), + emit: () => Effect.succeed(undefined), + emitDecision: () => Effect.succeed(null), + reloadUserHooks: () => Effect.succeed(undefined), + attachSessionHooks: () => Effect.succeed(undefined), + disableHook: () => Effect.succeed(undefined), + enableHook: () => Effect.succeed(undefined), + disposeSession: () => Effect.succeed(undefined), + disposeProject: () => Effect.succeed(undefined), +}; + +const mockMcpService = { + syncConnections: () => Effect.succeed(undefined), + connectServers: () => Effect.succeed(undefined), + listProjectMcpTools: () => [], + disposeSession: () => Effect.succeed(undefined), +} as any; + +const mockRulesService = { + getAllRules: () => '', + evictProjectRules: () => undefined, +} as any; + +const mockApprovalWaitService = { + waitForConfirm: () => Effect.dieMessage('not implemented'), + resolveConfirm: () => Effect.succeed(false), + getPending: () => Effect.succeed([]), + emitApprovalRequest: () => Effect.succeed(undefined), + registerEmitter: () => Effect.succeed(undefined), + delegateEmitter: () => Effect.succeed(undefined), + unregisterEmitter: () => Effect.succeed(undefined), + hasEmitter: () => Effect.succeed(false), +}; + +const mockLLMFactory = { + getLLMClient: () => Effect.dieMessage('not used in this test'), + listModels: () => Effect.succeed([]), + getActiveEntry: () => Effect.dieMessage('not used'), + findModel: () => Effect.succeed(null), + createClient: () => Effect.dieMessage('not used'), +} as any; + +const mockWorkspace = { + resolveWorkspaceCwd: (cwd: string | undefined) => Effect.succeed(cwd || '/tmp'), +} as any; + +function makeLayer() { + return Layer.mergeAll( + ProjectRuntimeService.Default.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(HookService, mockHookService as any), + Layer.succeed(McpService, mockMcpService), + SubagentService.Default, + Layer.succeed(RulesService, mockRulesService), + SessionService.Default + ) + ) + ), + SessionService.Default, + Layer.succeed(HookService, mockHookService as any), + Layer.succeed(ApprovalWaitService, mockApprovalWaitService as any), + ApprovalService.Default.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(HookService, mockHookService as any), + Layer.succeed(ApprovalWaitService, mockApprovalWaitService as any) + ) + ) + ), + Layer.succeed(LLMFactoryService, mockLLMFactory as any), + Layer.succeed(WorkspaceService, mockWorkspace as any) + ); +} + +describe('POST /api/sessions/:id/messages — reads permissionMode from disk', () => { + let cwd: string; + let sessionId: string; + let rt: ManagedRuntime.ManagedRuntime; + let app: Hono; + + beforeEach(async () => { + cwd = mkdtempSync(join(tmpdir(), 'codingcode-msg-fork-')); + rt = ManagedRuntime.make(makeLayer() as any); + const state = await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + return yield* session.create(cwd, { + model: 'm', + mode: 'build', + permissionMode: 'default', + }); + }) + ); + sessionId = state.sessionId; + const indexPath = state.indexPath; + const idx = JSON.parse(readFileSync(indexPath, 'utf8')); + idx.permissionMode = 'bypass'; + writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); + + app = new Hono(); + app.route('/api', createMessagesRouter(rt)); + }); + + afterEach(async () => { + await rt.dispose(); + rmSync(cwd, { recursive: true, force: true }); + }); + + it('does not crash and reaches the sendMessage path (fork uses disk permissionMode)', async () => { + const res = await app.request('/api/sessions/' + sessionId + '/messages', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ input: 'hello', cwd }), + }); + expect(res.status).not.toBe(404); + }); +}); diff --git a/packages/codingcode/test/server/plan-mode-reject-perm-mode.test.ts b/packages/codingcode/test/server/plan-mode-reject-perm-mode.test.ts deleted file mode 100644 index 4c91279..0000000 --- a/packages/codingcode/test/server/plan-mode-reject-perm-mode.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { Effect, Layer, ManagedRuntime } from 'effect'; -import { Hono } from 'hono'; -import { mkdtempSync, rmSync } from 'fs'; -import { tmpdir } from 'os'; -import { join } from 'path'; -import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; -import { SessionService } from '../../src/session/store.js'; -import { HookService } from '../../src/hooks/registry.js'; -import { McpService } from '../../src/mcp/index.js'; -import { SubagentService } from '../../src/subagent/registry.js'; -import { RulesService } from '../../src/rules/index.js'; -import { ApprovalService } from '../../src/approval/index.js'; -import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; -import { createAgentRouter } from '../../src/server/routes/agent.js'; -import { PLAN_PROFILE, BUILD_PROFILE } from '../../src/subagent/registry.js'; -import { useTempProjectBase } from '../helpers/project-base.js'; - -useTempProjectBase(); - -const mockHookService = { - register: () => Effect.succeed(() => {}), - registerDecision: () => Effect.succeed(() => {}), - emit: () => Effect.succeed(undefined), - emitDecision: () => Effect.succeed(null), - reloadUserHooks: () => Effect.succeed(undefined), - attachSessionHooks: () => Effect.succeed(undefined), - disableHook: () => Effect.succeed(undefined), - enableHook: () => Effect.succeed(undefined), - disposeSession: () => Effect.succeed(undefined), - disposeProject: () => Effect.succeed(undefined), -}; - -const mockMcpService = { - syncConnections: () => Effect.succeed(undefined), - connectServers: () => Effect.succeed(undefined), - listProjectMcpTools: () => [], - disposeSession: () => Effect.succeed(undefined), -} as any; - -const mockRulesService = { - getAllRules: () => '', - evictProjectRules: () => undefined, -} as any; - -const mockApprovalWaitService = { - waitForConfirm: () => Effect.dieMessage('not implemented'), - resolveConfirm: () => Effect.succeed(false), - getPending: () => Effect.succeed([]), - emitApprovalRequest: () => Effect.succeed(undefined), - registerEmitter: () => Effect.succeed(undefined), - delegateEmitter: () => Effect.succeed(undefined), - unregisterEmitter: () => Effect.succeed(undefined), - hasEmitter: () => Effect.succeed(false), -}; - -describe('POST /api/agent/permission-mode rejects when session is in plan mode', () => { - let cwd: string; - let sessionId: string; - let rt: ManagedRuntime.ManagedRuntime; - let app: Hono; - - beforeEach(async () => { - cwd = mkdtempSync(join(tmpdir(), 'codingcode-server-test-')); - const HookTestLayer = Layer.succeed(HookService, mockHookService as any); - const McpTestLayer = Layer.succeed(McpService, mockMcpService); - const SubagentTestLayer = SubagentService.Default; - const RulesTestLayer = Layer.succeed(RulesService, mockRulesService); - const SessionTestLayer = SessionService.Default; - const ProjectRuntimeTestLayer = ProjectRuntimeService.Default.pipe( - Layer.provide( - Layer.mergeAll( - HookTestLayer, - McpTestLayer, - SubagentTestLayer, - RulesTestLayer, - SessionTestLayer - ) - ) - ); - const ApprovalTestLayer = ApprovalService.Default.pipe( - Layer.provide( - Layer.mergeAll( - HookTestLayer, - Layer.succeed(ApprovalWaitService, mockApprovalWaitService as any) - ) - ) - ); - const TestLayer = Layer.mergeAll( - ProjectRuntimeTestLayer, - SessionTestLayer, - HookTestLayer, - ApprovalTestLayer, - Layer.succeed(ApprovalWaitService, mockApprovalWaitService as any) - ); - rt = ManagedRuntime.make(TestLayer as any); - app = new Hono(); - app.route('/api/agent', createAgentRouter(rt)); - - sessionId = await rt.runPromise( - Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; - const session = yield* SessionService; - yield* runtime.prepareProject(cwd); - const state = yield* session.create(cwd, { - model: 'test-model', - mode: 'build', - permissionMode: 'default', - }); - return state.sessionId; - }) - ); - }); - - afterEach(async () => { - await rt.dispose(); - rmSync(cwd, { recursive: true, force: true }); - }); - - it('returns 409 when session is in plan profile', async () => { - // Switch session to plan - await rt.runPromise( - Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; - yield* runtime.prepareProject(cwd); - yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); - }) - ); - - const res = await app.request('/api/agent/permission-mode', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ mode: 'bypass', cwd, sessionId }), - }); - expect(res.status).toBe(409); - const body = await res.json(); - expect(body.error).toMatch(/plan mode/i); - }); - - it('allows the change when session is in build profile', async () => { - // Switch to build (default) - await rt.runPromise( - Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; - yield* runtime.prepareProject(cwd); - yield* runtime.setSessionProfile(cwd, sessionId, BUILD_PROFILE); - }) - ); - - const res = await app.request('/api/agent/permission-mode', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ mode: 'bypass', cwd, sessionId }), - }); - expect(res.status).toBe(200); - }); - - it('falls back to global when cwd+sessionId not provided (legacy clients)', async () => { - await rt.runPromise( - Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; - yield* runtime.prepareProject(cwd); - yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); - }) - ); - - // No cwd/sessionId — bypass check, change applies to global ApprovalService - const res = await app.request('/api/agent/permission-mode', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ mode: 'bypass' }), - }); - expect(res.status).toBe(200); - }); - - it('rejects invalid mode value with 400', async () => { - const res = await app.request('/api/agent/permission-mode', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ mode: 'invalid', cwd, sessionId }), - }); - expect(res.status).toBe(400); - }); -}); diff --git a/packages/codingcode/test/server/routes-use-compute-paths.test.ts b/packages/codingcode/test/server/routes-use-compute-paths.test.ts new file mode 100644 index 0000000..69d2099 --- /dev/null +++ b/packages/codingcode/test/server/routes-use-compute-paths.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; + +describe('server routes use computePaths not hand-rolled replace', () => { + it('server/routes/sessions.ts no longer uses sessionJsonlPathFromCwd + replace .jsonl/.index.json', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/server/routes/sessions.ts', + 'utf8' + ); + expect(src).not.toMatch(/sessionJsonlPathFromCwd\([^)]+\)\.replace\(['"]\.jsonl['"]/); + }); + + it('server/routes/messages.ts uses computePaths(cwd, sessionId).indexPath', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/server/routes/messages.ts', + 'utf8' + ); + expect(src).toMatch(/computePaths\([^)]+\)\.indexPath/); + expect(src).not.toMatch(/sessionJsonlPathFromCwd\(/); + }); +}); diff --git a/packages/codingcode/test/session/compute-paths.test.ts b/packages/codingcode/test/session/compute-paths.test.ts index 338ed37..5d3b058 100644 --- a/packages/codingcode/test/session/compute-paths.test.ts +++ b/packages/codingcode/test/session/compute-paths.test.ts @@ -8,11 +8,10 @@ import { computePaths, sessionJsonlPathFromCwd, projectSessionsDir, -} from '../../src/session/file-ops.js'; +} from '../../src/core/path.js'; import { normalizePath, encodeProjectPath } from '../../src/core/path.js'; import { useTempProjectBase } from '../helpers/project-base.js'; - const base = useTempProjectBase(); function run(eff: Effect.Effect): Promise { @@ -87,13 +86,17 @@ describe('computePaths', () => { const childState = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.create(cwd, { - model: 'subagent-model', - mode: 'build', - permissionMode: 'default', - }, { - parentSessionId: state.sessionId, - }); + return yield* svc.create( + cwd, + { + model: 'subagent-model', + mode: 'build', + permissionMode: 'default', + }, + { + parentSessionId: state.sessionId, + } + ); }) ); diff --git a/packages/codingcode/test/session/create-active-profile.test.ts b/packages/codingcode/test/session/create-active-profile.test.ts new file mode 100644 index 0000000..2daf2e3 --- /dev/null +++ b/packages/codingcode/test/session/create-active-profile.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { randomUUID } from 'crypto'; +import { Effect } from 'effect'; +import { SessionService } from '../../src/session/store.js'; +import { encodeProjectPath } from '../../src/core/path.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +const base = useTempProjectBase(); + +function run(eff: Effect.Effect): Promise { + return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); +} + +describe('create writes activeProfile in one updateIndex', () => { + it('top-level create with explicit activeProfile writes once, no separate setActiveProfile IO', async () => { + const cwd = '/tmp/test-active-profile-once'; + const state = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.createSessionWithProfile( + cwd, + { model: 'gpt-4o', mode: 'build', permissionMode: 'default' }, + { activeProfile: 'custom-profile' } + ); + }) + ); + + const idx = JSON.parse(readFileSync(state.indexPath, 'utf8')); + expect(idx.activeProfile).toBe('custom-profile'); + expect(idx.mode).toBe('build'); + expect(idx.permissionMode).toBe('default'); + void base; + void randomUUID; + void join; + void encodeProjectPath; + }); + + it('create without activeProfile in opts falls back to modeToProfile default', async () => { + const cwd = '/tmp/test-active-profile-default'; + const state = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.createSessionWithProfile(cwd, { + model: 'gpt-4o', + mode: 'plan', + permissionMode: 'default', + }); + }) + ); + + const idx = JSON.parse(readFileSync(state.indexPath, 'utf8')); + expect(idx.activeProfile).toBe('plan'); + expect(idx.mode).toBe('plan'); + }); + + it('switch profile via setActiveProfile then record preserves new activeProfile (no stale overwrite)', async () => { + const cwd = '/tmp/test-active-profile-switch'; + const state = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.createSessionWithProfile(cwd, { + model: 'gpt-4o', + mode: 'build', + permissionMode: 'default', + }); + }) + ); + + await run( + Effect.gen(function* () { + const svc = yield* SessionService; + yield* svc.setActiveProfile(cwd, state.sessionId, 'explore'); + }) + ); + + const after = JSON.parse(readFileSync(state.indexPath, 'utf8')); + expect(after.activeProfile).toBe('explore'); + + await run( + Effect.gen(function* () { + const svc = yield* SessionService; + yield* svc.recordUser(state, 'hello'); + }) + ); + + const afterRecord = JSON.parse(readFileSync(state.indexPath, 'utf8')); + expect(afterRecord.activeProfile).toBe('explore'); + }); +}); diff --git a/packages/codingcode/test/session/create-session-with-profile.test.ts b/packages/codingcode/test/session/create-session-with-profile.test.ts new file mode 100644 index 0000000..5dbcab6 --- /dev/null +++ b/packages/codingcode/test/session/create-session-with-profile.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { Effect } from 'effect'; +import { SessionService } from '../../src/session/store.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +const base = useTempProjectBase(); + +function run(eff: Effect.Effect): Promise { + return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); +} + +describe('createSessionWithProfile helper', () => { + it('modeToProfile default activeProfile when not overridden', async () => { + const cwd = '/tmp/test-cswp-default'; + const state = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.createSessionWithProfile(cwd, { + model: 'gpt-4o', + mode: 'plan', + permissionMode: 'default', + }); + }) + ); + expect(state.activeProfile).toBe('plan'); + }); + + it('explicit activeProfile in opts overrides modeToProfile default', async () => { + const cwd = '/tmp/test-cswp-override'; + const state = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.createSessionWithProfile( + cwd, + { model: 'gpt-4o', mode: 'build', permissionMode: 'default' }, + { activeProfile: 'explore' } + ); + }) + ); + expect(state.activeProfile).toBe('explore'); + expect(state.mode).toBe('build'); + }); +}); + +describe('setSessionProfile 5th param removed', () => { + it('runtime/project-runtime.ts no longer accepts _parentSessionId in setSessionProfile', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/runtime/project-runtime.ts', + 'utf8' + ); + expect(src).not.toMatch(/_parentSessionId\?:/); + }); +}); + +void base; diff --git a/packages/codingcode/test/session/disk-setters.test.ts b/packages/codingcode/test/session/disk-setters.test.ts new file mode 100644 index 0000000..ae43d4e --- /dev/null +++ b/packages/codingcode/test/session/disk-setters.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Effect, Layer, ManagedRuntime } from 'effect'; +import { existsSync, readFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { SessionService } from '../../src/session/store.js'; +import { HookService } from '../../src/hooks/registry.js'; +import { McpService } from '../../src/mcp/index.js'; +import { SubagentService } from '../../src/subagent/registry.js'; +import { RulesService } from '../../src/rules/index.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +const base = useTempProjectBase(); + +const mockHookService = { + register: () => Effect.succeed(() => {}), + registerDecision: () => Effect.succeed(() => {}), + emit: () => Effect.succeed(undefined), + emitDecision: () => Effect.succeed(null), + reloadUserHooks: () => Effect.succeed(undefined), + attachSessionHooks: () => Effect.succeed(undefined), + disableHook: () => Effect.succeed(undefined), + enableHook: () => Effect.succeed(undefined), + disposeSession: () => Effect.succeed(undefined), + disposeProject: () => Effect.succeed(undefined), +}; + +const mockMcpService = { + syncConnections: () => Effect.succeed(undefined), + connectServers: () => Effect.succeed(undefined), + listProjectMcpTools: () => [], + disposeSession: () => Effect.succeed(undefined), +} as any; + +const mockRulesService = { + getAllRules: () => '', + evictProjectRules: () => undefined, +} as any; + +function makeLayer() { + return SessionService.Default.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(HookService, mockHookService as any), + Layer.succeed(McpService, mockMcpService), + SubagentService.Default, + Layer.succeed(RulesService, mockRulesService) + ) + ) + ); +} + +describe('SessionService disk setter/getter consistency', () => { + let cwd: string; + let sessionId: string; + let rt: ManagedRuntime.ManagedRuntime; + + beforeEach(async () => { + cwd = join(base.dir, 'disk-setters'); + mkdirSync(cwd, { recursive: true }); + rt = ManagedRuntime.make(makeLayer() as any); + const state = await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + return yield* session.create(cwd, { + model: 'm', + mode: 'build', + permissionMode: 'default', + }); + }) + ); + sessionId = state.sessionId; + }); + + afterEach(async () => { + await rt.dispose(); + }); + + it('setModeOnDisk + getModeFromDisk are consistent', async () => { + await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + yield* session.setModeOnDisk(cwd, sessionId, 'plan'); + }) + ); + const mode = await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + return yield* session.getModeFromDisk(cwd, sessionId); + }) + ); + expect(mode).toBe('plan'); + }); + + it('setPermissionModeOnDisk + getPermissionModeFromDisk are consistent', async () => { + await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + yield* session.setPermissionModeOnDisk(cwd, sessionId, 'bypass'); + }) + ); + const mode = await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + return yield* session.getPermissionModeFromDisk(cwd, sessionId); + }) + ); + expect(mode).toBe('bypass'); + }); + + it('setActiveProfile + getActiveProfile are consistent', async () => { + await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + yield* session.setActiveProfile(cwd, sessionId, 'plan'); + }) + ); + const profile = await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + return yield* session.getActiveProfile(cwd, sessionId); + }) + ); + expect(profile).toBe('plan'); + }); + + it('setActiveProfile is durable across reload (file exists on disk)', async () => { + await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + yield* session.setActiveProfile(cwd, sessionId, 'explore'); + }) + ); + const state = await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + return yield* session.load(cwd, sessionId); + }) + ); + expect(existsSync(state.indexPath)).toBe(true); + const idx = JSON.parse(readFileSync(state.indexPath, 'utf8')); + expect(idx.activeProfile).toBe('explore'); + }); +}); diff --git a/packages/codingcode/test/session/load-create.test.ts b/packages/codingcode/test/session/load-create.test.ts index 9507bc4..52a4dac 100644 --- a/packages/codingcode/test/session/load-create.test.ts +++ b/packages/codingcode/test/session/load-create.test.ts @@ -9,7 +9,6 @@ import { encodeProjectPath } from '../../src/core/path.js'; import type { SessionIndex } from '../../src/session/types.js'; import { useTempProjectBase } from '../helpers/project-base.js'; - const base = useTempProjectBase(); function run(eff: Effect.Effect): Promise { diff --git a/packages/codingcode/test/session/load-restore-profile.test.ts b/packages/codingcode/test/session/load-restore-profile.test.ts index 5d5dccf..d699e3f 100644 --- a/packages/codingcode/test/session/load-restore-profile.test.ts +++ b/packages/codingcode/test/session/load-restore-profile.test.ts @@ -45,12 +45,20 @@ function makeLayer() { const RulesTestLayer = Layer.succeed(RulesService, mockRulesService); const SessionTestLayer = SessionService.Default; const ProjectRuntimeTestLayer = ProjectRuntimeService.Default.pipe( - Layer.provide(Layer.mergeAll(HookTestLayer, McpTestLayer, SubagentTestLayer, RulesTestLayer, SessionTestLayer)) + Layer.provide( + Layer.mergeAll( + HookTestLayer, + McpTestLayer, + SubagentTestLayer, + RulesTestLayer, + SessionTestLayer + ) + ) ); return Layer.mergeAll(ProjectRuntimeTestLayer, SessionTestLayer); } -describe('SessionStoreState.activeProfile persistence', () => { +describe('SessionStoreState.activeProfile persistence (disk only)', () => { let cwd: string; let sessionId: string; let indexPath: string; @@ -79,9 +87,7 @@ describe('SessionStoreState.activeProfile persistence', () => { await rt.dispose(); }); - it('state.activeProfile is undefined for new sessions (set by setSessionProfile)', async () => { - // session.create() no longer writes activeProfile. After explicitly - // calling setSessionProfile, activeProfile is written to disk. + it('state.activeProfile is undefined for new sessions', async () => { const stateBefore = await rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; @@ -89,7 +95,9 @@ describe('SessionStoreState.activeProfile persistence', () => { }) ); expect(stateBefore.activeProfile).toBeUndefined(); + }); + it('state.activeProfile is set when setSessionProfile writes to disk', async () => { await rt.runPromise( Effect.gen(function* () { const runtime = yield* ProjectRuntimeService; @@ -106,11 +114,9 @@ describe('SessionStoreState.activeProfile persistence', () => { expect(stateAfter.activeProfile).toBe('build'); }); - it('state.activeProfile is set when index has activeProfile field', async () => { + it('state.activeProfile is set when index file has activeProfile field', async () => { const idx = JSON.parse(readFileSync(indexPath, 'utf8')); idx.activeProfile = 'plan'; - // After the plan refactor, `permissionMode` no longer encodes plan-mode. - // Set it to 'default' to match what the runtime now writes. idx.permissionMode = 'default'; writeFileSync(indexPath, JSON.stringify(idx, null, 2)); @@ -123,25 +129,15 @@ describe('SessionStoreState.activeProfile persistence', () => { expect(state.activeProfile).toBe('plan'); }); - it('runtime.getSessionProfile reflects restored profile after restoreSessionProfile', async () => { - const idx = JSON.parse(readFileSync(indexPath, 'utf8')); - idx.activeProfile = 'plan'; - idx.permissionMode = 'default'; - writeFileSync(indexPath, JSON.stringify(idx, null, 2)); - + it('restoreSessionProfile writes the profile to disk', async () => { await rt.runPromise( Effect.gen(function* () { const runtime = yield* ProjectRuntimeService; - const session = yield* SessionService; yield* runtime.prepareProject(cwd); - const state = yield* session.load(cwd, sessionId); - expect(state.activeProfile).toBe('plan'); - yield* runtime.restoreSessionProfile(cwd, sessionId, state.activeProfile); - const profile = runtime.getSessionProfile(sessionId); - expect(profile?.name).toBe('plan'); - // Approval-side permission mode is 'default' (pipeline is plan-blind). - expect(runtime.getSessionPermissionMode(sessionId)).toBe('default'); + yield* runtime.restoreSessionProfile(cwd, sessionId, 'plan'); }) ); + const idx = JSON.parse(readFileSync(indexPath, 'utf8')); + expect(idx.activeProfile).toBe('plan'); }); }); diff --git a/packages/codingcode/test/session/parent-session-id.test.ts b/packages/codingcode/test/session/parent-session-id.test.ts new file mode 100644 index 0000000..09a5f9b --- /dev/null +++ b/packages/codingcode/test/session/parent-session-id.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { Effect } from 'effect'; +import { SessionService } from '../../src/session/store.js'; +import { encodeProjectPath } from '../../src/core/path.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +const base = useTempProjectBase(); + +function run(eff: Effect.Effect): Promise { + return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); +} + +describe('parentSessionId in index.json', () => { + it('write parentSessionId to index.json when passed to create opts', async () => { + const cwd = '/tmp/test-parent-session-id'; + const parentId = '00000000-0000-0000-0000-000000000001'; + const state = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.create( + cwd, + { model: 'gpt-4o', mode: 'build', permissionMode: 'default' }, + { parentSessionId: parentId } + ); + }) + ); + + const idxRaw = readFileSync(state.indexPath, 'utf8'); + const idx = JSON.parse(idxRaw); + expect(idx.parentSessionId).toBe(parentId); + expect(idx.sessionId).toBe(state.sessionId); + + const projectDir = join(base.dir, encodeProjectPath(cwd)); + void projectDir; + }); +}); diff --git a/packages/codingcode/test/session/record-tool-result-persist.test.ts b/packages/codingcode/test/session/record-tool-result-persist.test.ts index f1fa1c4..9ace9cc 100644 --- a/packages/codingcode/test/session/record-tool-result-persist.test.ts +++ b/packages/codingcode/test/session/record-tool-result-persist.test.ts @@ -7,7 +7,7 @@ import { useTempProjectBase } from '../helpers/project-base.js'; useTempProjectBase(); function run(eff: Effect.Effect): Promise { - return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); + return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); } describe('recordToolResult', () => { diff --git a/packages/codingcode/test/session/session-jsonl-path.test.ts b/packages/codingcode/test/session/session-jsonl-path.test.ts index eca8144..d172690 100644 --- a/packages/codingcode/test/session/session-jsonl-path.test.ts +++ b/packages/codingcode/test/session/session-jsonl-path.test.ts @@ -4,7 +4,8 @@ import { join } from 'path'; import { Effect } from 'effect'; import { SessionService } from '../../src/session/store.js'; -import { sessionJsonlPathFromCwd, deleteSession } from '../../src/session/file-ops.js'; +import { deleteSession } from '../../src/session/file-ops.js'; +import { sessionJsonlPathFromCwd } from '../../src/core/path.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); diff --git a/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts b/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts index c058be1..b509790 100644 --- a/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts +++ b/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts @@ -13,17 +13,14 @@ import { encodeProjectPath, normalizePath, setProjectBaseDir } from '../../src/c import type { LLMClient } from '../../src/llm/client.js'; import { Result } from '../../src/core/result.js'; -const TestLLMLayer = Layer.succeed( - LLMFactoryService, - ({ - listModels: () => Effect.succeed([]), - findModel: () => Effect.succeed(null), - getActiveEntry: () => Effect.fail(new Error('no active')), - switchModel: () => Effect.fail(new Error('no models')), - getLLMClient: () => Effect.succeed(makeMockLLM('subagent final answer')), - createClient: () => Effect.succeed(makeMockLLM('subagent final answer')), - } as any) -); +const TestLLMLayer = Layer.succeed(LLMFactoryService, { + listModels: () => Effect.succeed([]), + findModel: () => Effect.succeed(null), + getActiveEntry: () => Effect.fail(new Error('no active')), + switchModel: () => Effect.fail(new Error('no models')), + getLLMClient: () => Effect.succeed(makeMockLLM('subagent final answer')), + createClient: () => Effect.succeed(makeMockLLM('subagent final answer')), +} as any); function makeMockLLM(content: string): LLMClient { return { @@ -90,11 +87,7 @@ describe('dispatch_agent end-to-end (subagent reads its own jsonl)', () => { expect(typeof result.output).toBe('string'); expect(result.output.length).toBeGreaterThan(0); - const sessionsRoot = join( - projectBase, - encodeProjectPath(normalizePath(cwd)), - 'sessions' - ); + const sessionsRoot = join(projectBase, encodeProjectPath(normalizePath(cwd)), 'sessions'); const subagentDir = join(sessionsRoot, result.parentId, 'subagents'); expect(existsSync(subagentDir)).toBe(true); @@ -134,19 +127,15 @@ describe('dispatch_agent end-to-end (subagent reads its own jsonl)', () => { permissionMode: 'default', }); const dispatchTool = yield* createDispatchAgentTool(); - yield* dispatchTool.execute( - { agent: 'explore', prompt: 'p' }, - { projectPath: cwd, sessionId: parent.sessionId } as any - ); + yield* dispatchTool.execute({ agent: 'explore', prompt: 'p' }, { + projectPath: cwd, + sessionId: parent.sessionId, + } as any); return { parentId: parent.sessionId }; }) ); - const sessionsRoot = join( - projectBase, - encodeProjectPath(normalizePath(cwd)), - 'sessions' - ); + const sessionsRoot = join(projectBase, encodeProjectPath(normalizePath(cwd)), 'sessions'); const subagentDir = join(sessionsRoot, result.parentId, 'subagents'); const childFiles = readdirSync(subagentDir).filter((f) => f.endsWith('.jsonl')); const childId = childFiles[0]!.replace('.jsonl', ''); diff --git a/packages/codingcode/test/subagent/dispatch.test.ts b/packages/codingcode/test/subagent/dispatch.test.ts index 575c330..61c81bc 100644 --- a/packages/codingcode/test/subagent/dispatch.test.ts +++ b/packages/codingcode/test/subagent/dispatch.test.ts @@ -1,4 +1,4 @@ -import { expect, it, describe, vi, beforeEach, afterEach } from 'vitest'; +import { expect, it, describe, vi } from 'vitest'; import { Effect, Layer } from 'effect'; import { createDispatchAgentTool } from '../../src/tools/domains/subagent/dispatch.js'; import { SessionService } from '../../src/session/store.js'; @@ -7,515 +7,221 @@ import { HookService } from '../../src/hooks/registry.js'; import { McpService } from '../../src/mcp/index.js'; import { LLMFactoryService } from '../../src/llm/factory.js'; import { RulesService } from '../../src/rules/index.js'; -import { SubagentService } from '../../src/subagent/registry.js'; +import { SubagentService, EXPLORE_PROFILE, BUILD_PROFILE } from '../../src/subagent/registry.js'; import { SubagentRunnerService } from '../../src/subagent/runner-service.js'; import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; -import { EXPLORE_PROFILE } from '../../src/subagent/registry.js'; -import type { ToolDefinition } from '../../src/tools/types.js'; +import type { ToolDefinition, ToolExecCtx } from '../../src/tools/types.js'; +import type { AgentEvent } from '../../src/agent/types.js'; +import type { LLMClient } from '../../src/llm/client.js'; -const mockMcp = { - connectServers: (_p: string, _s: string, _n: string[]) => Effect.void, - disconnectServers: (_p: string, _s: string, _n: string[]) => Effect.void, - getServerToolNames: (_p: string, _n: string) => [] as string[], - syncConnections: (_p: string) => Effect.void, - status: (_p: string) => Effect.succeed([]), - disable: (_p: string, _n: string) => Effect.void, - enable: (_p: string, _n: string) => Effect.void, - listProjectMcpTools: (_p: string) => [], - disposeSession: (_s: string) => Effect.void, - disposeProject: (_p: string) => Effect.void, +const mockLlm: Partial = { + modelInfo: { model: 'test-model', provider: 'test', maxTokens: 8192, displayName: 'test' }, }; -const mockHooks = { - register: () => Effect.succeed(() => {}), - registerDecision: () => Effect.succeed(() => {}), - emit: (_p: string, _pl: any) => Effect.void, - emitDecision: (_p: string, _pl: any) => Effect.succeed(null), - reloadUserHooks: (_p: string) => Effect.void, - attachSessionHooks: (_s: string, _h: any[]) => Effect.void, - disableHook: (_p: string, _n: string) => Effect.void, - enableHook: (_p: string, _n: string) => Effect.void, - disposeSession: (_s: string) => Effect.void, - disposeProject: (_p: string) => Effect.void, -}; - -const mockApproval = { - evaluate: () => Effect.succeed({ type: 'allow' as const }), - addRule: () => Effect.void, - removeRule: () => Effect.void, - setPermissionMode: () => Effect.void, - getPermissionMode: () => 'default' as const, - fork: (_opts?: any) => Effect.succeed(mockApproval), -}; - -const mockSession = { - create: (_cwd: string, _model: string, _sid?: string, _opts?: any) => +function makeMockSession(parentPermissionMode: 'default' | 'bypass' | 'acceptEdits' = 'default') { + const createImpl = ( + _cwd: string, + options: { model: string; mode: 'plan' | 'build'; permissionMode: any } + ) => Effect.succeed({ - sessionId: 'child-123', + sessionId: 'child-1', cwd: '/test', - projectPath: 'test', - transcriptPath: '/tmp/test.jsonl', - indexPath: '/tmp/test.index.json', + projectPath: '/test', + transcriptPath: '/tmp/child.jsonl', + indexPath: '/tmp/child.index.json', messageCount: 0, currentTurnId: 0, sessionMeta: null, - + model: options.model, + mode: options.mode, + permissionMode: options.permissionMode, title: 'child', usage: undefined, memorySnapshot: '', - }), - incrementTurn: () => 0, - recordUser: () => Effect.succeed({ type: 'user', content: '', turnId: 0 }), - recordAssistant: () => - Effect.succeed({ - type: 'assistant', - content: '', - toolCalls: [], - turnId: 0, - }), - recordToolResult: () => - Effect.succeed({ - type: 'tool_result', - toolName: 'test', - toolCallId: 'tc1', - output: '', - turnId: 0, - }), - rollbackToTurn: () => - Effect.succeed({ - type: 'rollback', - throughTurnId: 0, - reason: '', - }), - forkSession: () => Effect.succeed('forked-session-id'), - renameSession: () => Effect.succeed(undefined), - readHistory: () => Effect.succeed([]), - readMessages: () => Effect.succeed([]), - listSessions: () => Effect.succeed([]), - getSessionId: () => 'test-session', - getMessageCount: () => 0, + }); + return { + create: createImpl, + createSessionWithProfile: createImpl, + load: (_cwd: string, _sid: string) => + Effect.succeed({ + sessionId: 'parent-1', + cwd: '/test', + projectPath: '/test', + transcriptPath: '/tmp/parent.jsonl', + indexPath: '/tmp/parent.index.json', + messageCount: 0, + currentTurnId: 0, + sessionMeta: null, + model: 'parent-model', + mode: 'build' as const, + permissionMode: parentPermissionMode, + title: 'parent', + usage: undefined, + memorySnapshot: '', + }), + incrementTurn: () => 0, + recordUser: () => Effect.succeed({ type: 'user', content: '', turnId: 0 } as any), + setActiveProfile: () => Effect.void, + setModeOnDisk: () => Effect.void, + setPermissionModeOnDisk: () => Effect.void, + }; +} + +const mockApproval = { + evaluate: () => Effect.succeed({ type: 'allow' as const, source: 'system' }), + addRule: () => Effect.void, + removeRule: () => Effect.void, setPermissionMode: () => Effect.void, - getPermissionMode: () => Effect.succeed('default'), + getPermissionMode: () => 'default' as any, + fork: (opts?: { permissionMode?: any; readonly?: boolean }) => + Effect.succeed(mockApproval as any), +}; + +const mockHooks = { + register: () => Effect.succeed(() => {}), + registerDecision: () => Effect.succeed(() => {}), + emit: () => Effect.succeed(undefined), + emitDecision: () => Effect.succeed(null), + reloadUserHooks: () => Effect.succeed(undefined), + attachSessionHooks: () => Effect.succeed(undefined), + disableHook: () => Effect.succeed(undefined), + enableHook: () => Effect.succeed(undefined), + disposeSession: () => Effect.succeed(undefined), + disposeProject: () => Effect.succeed(undefined), }; -const mockModelEntry = { - id: 'fast-model@API_KEY_B', - provider: 'provider-b', - driver: 'openai', - name: 'Fast Model', - model: 'fast-model', - base_url: 'https://api.b.com', - api_key_env: 'API_KEY_B', +const mockMcp = { + connectServers: () => Effect.void, + syncConnections: () => Effect.void, + listProjectMcpTools: () => [], + disposeSession: () => Effect.void, }; -const mockSubagentLlm = { _tag: 'subagent-llm', modelInfo: { model: 'subagent-model' } }; -const mockDefaultLlm = { _tag: 'default-llm', modelInfo: { model: 'default-model' } }; -const mockLLMFactory = { - listModels: vi.fn(() => Effect.succeed([])), - findModel: vi.fn((target: string) => { - if (target === 'fast-model@API_KEY_B') { - return Effect.succeed(mockModelEntry); - } - return Effect.succeed(null); - }), - getActiveEntry: vi.fn(() => Effect.succeed(mockModelEntry)), - switchModel: vi.fn(() => Effect.succeed(mockModelEntry)), - createClient: vi.fn(() => Effect.succeed(mockSubagentLlm)), - getLLMClient: vi.fn(() => Effect.succeed(mockDefaultLlm)), +const mockLlmFactory = { + getLLMClient: () => Effect.succeed(mockLlm as LLMClient), + findModel: () => Effect.succeed(null), + createClient: () => Effect.succeed(mockLlm as LLMClient), }; -const mockRulesService = { - getAllRules: vi.fn(() => ''), - evictProjectRules: vi.fn(), +const mockRules = { + getAllRules: () => '', + evictProjectRules: () => undefined, }; -const mockSubagentService = { - registerGlobal: vi.fn(), - registerProject: vi.fn(), - get: vi.fn((_projectPath: string, name: string) => { +const mockSubagent = { + registerGlobal: () => undefined, + registerProject: () => undefined, + get: (_p: string, name: string) => { if (name === 'explore') return EXPLORE_PROFILE; - if (name === 'custom-model-agent') - return { name: 'custom-model-agent', description: 'test', model: 'fast-model@API_KEY_B' }; - if (name === 'bad-model-agent') - return { name: 'bad-model-agent', description: 'test', model: 'nonexistent-model' }; + if (name === 'build') return BUILD_PROFILE; + if (name === 'custom') return { name: 'custom', description: 'custom agent' } as any; return undefined; - }), - list: vi.fn((_projectPath: string) => [EXPLORE_PROFILE]), - resetProject: vi.fn(), + }, + list: () => [EXPLORE_PROFILE, BUILD_PROFILE], + resetProject: () => undefined, }; const mockProjectRuntime = { - prepareProject: vi.fn(() => Effect.void), - resolveMainAgentProfile: vi.fn(), - resolveSubagentProfile: vi.fn((_projectPath: string, name: string) => { - return mockSubagentService.get(_projectPath, name); - }), - listAgentProfiles: vi.fn(() => [EXPLORE_PROFILE]), - getToolPolicy: vi.fn(() => ({ + prepareProject: () => Effect.void, + resolveMainAgentProfile: () => undefined, + resolveSubagentProfile: (_p: string, name: string) => mockSubagent.get(_p, name), + listAgentProfiles: () => [EXPLORE_PROFILE, BUILD_PROFILE], + getToolPolicy: () => ({ allowedTools: undefined, allowedMcpServers: undefined, allowToolSearch: true, allowDeferredTools: false, - })), - setSessionProfile: vi.fn(() => Effect.void), - restoreSessionProfile: vi.fn(() => Effect.void), - getSessionProfile: vi.fn(), - disposeSession: vi.fn(() => Effect.void), - disposeProject: vi.fn(() => Effect.void), -}; - -const defaultRunStream = async function* () { - yield { _tag: 'Done' as const, content: 'done' }; -}; - -const mockSubagentRunner = { - runStream: vi.fn(defaultRunStream), + }), + setSessionProfile: () => Effect.void, + restoreSessionProfile: () => Effect.void, + getSessionProfile: () => Effect.succeed(undefined), + getSessionPermissionMode: () => Effect.succeed('default' as any), + disposeSession: () => Effect.void, + disposeProject: () => Effect.void, }; -const MockSessionLayer = Layer.succeed(SessionService, SessionService.make(mockSession as any)); -const MockApprovalLayer = Layer.succeed(ApprovalService, ApprovalService.make(mockApproval as any)); -const MockHooksLayer = Layer.succeed(HookService, HookService.make(mockHooks as any)); -const MockMcpLayer = Layer.succeed(McpService, McpService.make(mockMcp as any)); -const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, mockLLMFactory as any); -const MockRulesLayer = Layer.succeed(RulesService, mockRulesService as any); -const MockSubagentLayer = Layer.succeed(SubagentService, mockSubagentService as any); -const MockProjectRuntimeLayer = Layer.succeed(ProjectRuntimeService, mockProjectRuntime as any); -const MockSubagentRunnerLayer = Layer.succeed(SubagentRunnerService, mockSubagentRunner as any); - -const MockLayer = Layer.mergeAll( - MockSessionLayer, - MockApprovalLayer, - MockHooksLayer, - MockMcpLayer, - MockLLMFactoryLayer, - MockRulesLayer, - MockSubagentLayer, - MockProjectRuntimeLayer, - MockSubagentRunnerLayer -); +function makeRunStream(): AsyncGenerator { + return (async function* () { + yield { _tag: 'Done', content: 'done' } as AgentEvent; + })(); +} -async function makeTool(): Promise { - const result = await Effect.runPromise( - (createDispatchAgentTool() as any).pipe(Effect.provide(MockLayer as any)) +function makeLayers(parentPermissionMode: 'default' | 'bypass' | 'acceptEdits' = 'default') { + const subagentRunner = { runStream: vi.fn().mockReturnValue(makeRunStream()) }; + return Layer.mergeAll( + Layer.succeed( + SessionService, + SessionService.make(makeMockSession(parentPermissionMode) as any) + ), + Layer.succeed(ApprovalService, ApprovalService.make(mockApproval as any)), + Layer.succeed(HookService, HookService.make(mockHooks as any)), + Layer.succeed(McpService, McpService.make(mockMcp as any)), + Layer.succeed(LLMFactoryService, mockLlmFactory as any), + Layer.succeed(RulesService, mockRules as any), + Layer.succeed(SubagentService, mockSubagent as any), + Layer.succeed(ProjectRuntimeService, ProjectRuntimeService.make(mockProjectRuntime as any)), + Layer.succeed(SubagentRunnerService, subagentRunner as any) ); - return result as ToolDefinition; } -function makeMockLayer(overrides: Record = {}) { - const layers: Layer.Layer[] = [ - overrides.session ?? MockSessionLayer, - overrides.approval ?? MockApprovalLayer, - overrides.hooks ?? MockHooksLayer, - overrides.mcp ?? MockMcpLayer, - overrides.llmFactory ?? MockLLMFactoryLayer, - overrides.rules ?? MockRulesLayer, - overrides.subagent ?? MockSubagentLayer, - overrides.runtime ?? MockProjectRuntimeLayer, - overrides.runner ?? MockSubagentRunnerLayer, - ]; - return (Layer.mergeAll as any)(...layers); +async function dispatchTool( + parentPermissionMode: 'default' | 'bypass' | 'acceptEdits' = 'default', + agentName: string, + ctx: ToolExecCtx +) { + const all = makeLayers(parentPermissionMode); + const capturePerm: any = { value: undefined }; + const localApproval = { + ...mockApproval, + fork: vi.fn((opts: any) => { + capturePerm.value = opts?.permissionMode; + return Effect.succeed(mockApproval as any); + }), + }; + const allWithCapture = Layer.mergeAll( + Layer.succeed( + SessionService, + SessionService.make(makeMockSession(parentPermissionMode) as any) + ), + Layer.succeed(ApprovalService, ApprovalService.make(localApproval as any)), + Layer.succeed(HookService, HookService.make(mockHooks as any)), + Layer.succeed(McpService, McpService.make(mockMcp as any)), + Layer.succeed(LLMFactoryService, mockLlmFactory as any), + Layer.succeed(RulesService, mockRules as any), + Layer.succeed(SubagentService, mockSubagent as any), + Layer.succeed(ProjectRuntimeService, ProjectRuntimeService.make(mockProjectRuntime as any)), + Layer.succeed(SubagentRunnerService, { + runStream: vi.fn().mockReturnValue(makeRunStream()), + } as any) + ); + const tool = (await Effect.runPromise( + createDispatchAgentTool().pipe(Effect.provide(allWithCapture) as any) + )) as ToolDefinition; + await Effect.runPromise(tool.execute({ agent: agentName, prompt: 'go' }, ctx) as any); + return capturePerm.value; } -describe('dispatch_agent tool', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockSubagentRunner.runStream.mockImplementation(defaultRunStream); - mockLLMFactory.getLLMClient.mockReturnValue(Effect.succeed(mockDefaultLlm)); - }); - - it('should create dispatch tool with description mentioning profiles', async () => { - const tool = await makeTool(); - expect(tool.name).toBe('dispatch_agent'); - expect(tool.description).toContain('Spawn'); - expect(tool.description).toContain('subagent'); - }); - - it('should be a core tool (not deferred)', async () => { - const tool = await makeTool(); - expect(tool.deferred).toBeUndefined(); - }); - - it('should validate agent profile exists', async () => { - const tool = await makeTool(); - try { - await Effect.runPromise( - tool.execute({ agent: 'nonexistent', prompt: 'do something' }, { projectPath: '/test' }) - ); - expect.fail('Should have thrown error'); - } catch (e: any) { - expect(e.message).toContain('Unknown subagent'); - } - }); - - it('should use SubagentRunnerService.runStream to run the subagent', async () => { - const tool = await makeTool(); - await Effect.runPromise( - tool.execute( - { agent: 'explore', prompt: 'test' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) - ); - expect(mockSubagentRunner.runStream).toHaveBeenCalled(); +describe('dispatch_agent permission-mode priority (profile > parent > default)', () => { + it('case 1: profile has explicit permissionMode → child uses profile value', async () => { + const perm = await dispatchTool('default', 'explore', { + projectPath: '/test', + sessionId: 'parent-1', + } as ToolExecCtx); + expect(perm).toBe('bypass'); }); - it('should emit spawn.before hook', async () => { - const emitDecisionFn = vi.fn().mockReturnValue(Effect.succeed(null)); - const customHooks = { ...mockHooks, emitDecision: emitDecisionFn }; - const customHooksLayer = Layer.succeed(HookService, HookService.make(customHooks as any)); - const customLayer = makeMockLayer({ hooks: customHooksLayer }); - - const tool = (await Effect.runPromise( - (createDispatchAgentTool() as any).pipe(Effect.provide(customLayer as any)) - )) as ToolDefinition; - await Effect.runPromise( - tool.execute( - { agent: 'explore', prompt: 'test' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) - ); - expect(emitDecisionFn).toHaveBeenCalledWith( - 'agent.subagent.spawn.before', - expect.objectContaining({ profile: 'explore' }) - ); - }); - - it('should respect spawn.before deny decision', async () => { - const emitDecisionFn = vi - .fn() - .mockReturnValue(Effect.succeed({ decision: 'deny', reason: 'Not allowed' })); - const customHooks = { ...mockHooks, emitDecision: emitDecisionFn }; - const customHooksLayer = Layer.succeed(HookService, HookService.make(customHooks as any)); - const customLayer = makeMockLayer({ hooks: customHooksLayer }); - - const tool = (await Effect.runPromise( - (createDispatchAgentTool() as any).pipe(Effect.provide(customLayer as any)) - )) as ToolDefinition; - try { - await Effect.runPromise( - tool.execute( - { agent: 'explore', prompt: 'test' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) as any - ); - expect.fail('Should have thrown error'); - } catch (e: any) { - expect(e.message).toContain('Subagent spawn denied'); - } - }); - - it('should emit completion hook', async () => { - const emitFn = vi.fn().mockReturnValue(Effect.void); - const customHooks = { ...mockHooks, emit: emitFn }; - const customHooksLayer = Layer.succeed(HookService, HookService.make(customHooks as any)); - const customLayer = makeMockLayer({ hooks: customHooksLayer }); - - const tool = (await Effect.runPromise( - (createDispatchAgentTool() as any).pipe(Effect.provide(customLayer as any)) - )) as ToolDefinition; - const result = await Effect.runPromise( - tool.execute( - { agent: 'explore', prompt: 'test' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) - ); - expect(emitFn).toHaveBeenCalledWith( - 'agent.subagent.complete', - expect.objectContaining({ status: 'done' }) - ); - }); - - it('observer for agent.subagent.complete can yield* services from dispatch_agent fiber', async () => { - // Pin the dispatch.ts fix: `agent.subagent.complete` must be emitted in - // the dispatch_agent tool's Effect.gen fiber (not inside the - // Effect.async callback's async IIFE), so observers can yield* services - // like SessionService. Before the fix the emit was wrapped in - // `await Effect.runPromise(emit)`, which jumped to a fresh fiber with - // no services and would Die for any observer that yield*'d a service. - let observerRan = false; - let sessionResolved = false; - - const realHooksLayer = HookService.Default; - const customLayer = makeMockLayer({ hooks: realHooksLayer }); - - // Register observer, create the tool, and run the tool all in the same - // Effect.gen so they share the same HookService instance (a fresh - // HookService is built each time a layer is provided, so splitting this - // across multiple Effect.runPromise calls would register on one - // instance and emit on a different one). - const program = Effect.gen(function* () { - const hooks = yield* HookService; - yield* hooks.register( - 'agent.subagent.complete', - (_payload) => - Effect.gen(function* () { - const session = yield* SessionService; - observerRan = true; - sessionResolved = typeof session.create === 'function'; - }), - { source: 'system' } - ); - const tool = yield* createDispatchAgentTool(); - return yield* tool.execute( - { agent: 'explore', prompt: 'test' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) as Effect.Effect; - }); - - await Effect.runPromise(Effect.provide(program, customLayer as any)); - - expect(observerRan).toBe(true); - expect(sessionResolved).toBe(true); - }); - - it('should pass systemOverride with profile prompt, environment info, and user rules', async () => { - let capturedSystemOverride: string | undefined; - mockSubagentRunner.runStream.mockImplementation(async function* (opts: any) { - capturedSystemOverride = opts.systemOverride; - yield { _tag: 'Done' as const, content: 'done' }; - } as any); - const tool = await makeTool(); - await Effect.runPromise( - tool.execute( - { agent: 'explore', prompt: 'test' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) - ); - expect(capturedSystemOverride).toBeTruthy(); - // Should contain the profile's system prompt content - expect(capturedSystemOverride).toContain('read-only'); - // Should contain inherited environment info - expect(capturedSystemOverride).toContain('Working directory'); - expect(capturedSystemOverride).toContain('/test'); - expect(capturedSystemOverride).toContain('Operating system'); - }); - - it('should handle subagent error', async () => { - mockSubagentRunner.runStream.mockImplementation(async function* () { - yield { _tag: 'Error' as const, error: { message: 'Something went wrong' } }; - } as any); - const tool = await makeTool(); - try { - await Effect.runPromise( - tool.execute( - { agent: 'explore', prompt: 'test' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) as any - ); - expect.fail('Should have thrown error'); - } catch (e: any) { - expect(e.message).toContain('Subagent failed'); - } - }); - - it('should use LLM from factory.getLLMClient when profile has no model field', async () => { - let capturedLlm: any; - mockSubagentRunner.runStream.mockImplementation(async function* (opts: any) { - capturedLlm = opts.llm; - yield { _tag: 'Done' as const, content: 'done' }; - } as any); - const tool = await makeTool(); - await Effect.runPromise( - tool.execute( - { agent: 'explore', prompt: 'test' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) - ); - expect(mockLLMFactory.getLLMClient).toHaveBeenCalled(); - expect(capturedLlm).toBe(mockDefaultLlm); - }); - - it('should create a new llm client when profile specifies a model', async () => { - let capturedLlm: any; - mockSubagentRunner.runStream.mockImplementation(async function* (opts: any) { - capturedLlm = opts.llm; - yield { _tag: 'Done' as const, content: 'done' }; - } as any); - const tool = await makeTool(); - await Effect.runPromise( - tool.execute( - { agent: 'custom-model-agent', prompt: 'test' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) as any - ); - expect(mockLLMFactory.findModel).toHaveBeenCalledWith('fast-model@API_KEY_B'); - expect(mockLLMFactory.createClient).toHaveBeenCalledWith(mockModelEntry); - expect(capturedLlm).toBe(mockSubagentLlm); - }); - - it('should throw when profile model is not found in catalog', async () => { - const tool = await makeTool(); - try { - await Effect.runPromise( - tool.execute( - { agent: 'bad-model-agent', prompt: 'test' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) as any - ); - expect.fail('Should have thrown error'); - } catch (e: any) { - expect(e.message).toContain('unknown model'); - } - }); - - it('should call session.create with model and parentSessionId in opts', async () => { - const createFn = vi.fn().mockReturnValue( - Effect.succeed({ - sessionId: 'child-456', - cwd: '/test', - projectPath: 'test', - transcriptPath: '/tmp/test.jsonl', - indexPath: '/tmp/test.index.json', - messageCount: 0, - currentTurnId: 0, - sessionMeta: null, - title: 'child', - usage: undefined, - memorySnapshot: '', - }) - ); - const customSession = { ...mockSession, create: createFn }; - const customSessionLayer = Layer.succeed( - SessionService, - SessionService.make(customSession as any) - ); - const customLayer = makeMockLayer({ session: customSessionLayer }); - - const tool = (await Effect.runPromise( - (createDispatchAgentTool() as any).pipe(Effect.provide(customLayer as any)) - )) as ToolDefinition; - await Effect.runPromise( - tool.execute( - { agent: 'explore', prompt: 'test child' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) - ); - expect(createFn).toHaveBeenCalledWith( - '/test', - expect.objectContaining({ - model: expect.any(String), - mode: 'build', - // EXPLORE_PROFILE.permissionMode === 'bypass', which the dispatch - // tool now reads from the subagent's own profile. - permissionMode: 'bypass', - }), - expect.objectContaining({ parentSessionId: 'parent-1', agentName: 'explore' }) - ); + it('case 2: profile has no permissionMode + parent has bypass → child uses parent value', async () => { + const perm = await dispatchTool('bypass', 'custom', { + projectPath: '/test', + sessionId: 'parent-1', + } as ToolExecCtx); + expect(perm).toBe('bypass'); }); - it('runStream receives state with child sessionId', async () => { - let capturedState: any; - mockSubagentRunner.runStream.mockImplementation(async function* (opts: any) { - capturedState = opts.state; - yield { _tag: 'Done' as const, content: 'done' }; - } as any); - const tool = await makeTool(); - await Effect.runPromise( - tool.execute( - { agent: 'explore', prompt: 'test' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) - ); - expect(capturedState).toBeDefined(); - expect(capturedState.sessionId).toBe('child-123'); + it('case 3: profile has no permissionMode + no parent (top-level) → child uses default', async () => { + const perm = await dispatchTool('default', 'custom', { + projectPath: '/test', + } as ToolExecCtx); + expect(perm).toBe('default'); }); }); diff --git a/packages/codingcode/test/tools/submit-plan-slug.test.ts b/packages/codingcode/test/tools/submit-plan-slug.test.ts index fdcd90c..ecc7f84 100644 --- a/packages/codingcode/test/tools/submit-plan-slug.test.ts +++ b/packages/codingcode/test/tools/submit-plan-slug.test.ts @@ -28,9 +28,7 @@ describe('slug()', () => { }); it('preserves CJK characters in the slug', () => { - expect(slug('写一篇《如果AI有了工资》幽默短文')).toBe( - '写一篇-如果ai有了工资-幽默短文' - ); + expect(slug('写一篇《如果AI有了工资》幽默短文')).toBe('写一篇-如果ai有了工资-幽默短文'); }); it('preserves mixed CJK + ASCII', () => { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 6d31feb..c41fb61 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -9,6 +9,7 @@ "bundle:backend": "pnpm --filter @codingcode/core run bundle", "preview": "electron-vite preview", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json", + "clean:out": "node -e \"require('fs').rmSync('out',{recursive:true,force:true})\"", "verify": "electron-vite build && node scripts/verify.mjs", "test": "vitest run", "pack": "electron-builder --dir", diff --git a/packages/desktop/src/agent/AgentWorkspace.tsx b/packages/desktop/src/agent/AgentWorkspace.tsx index 5216c71..2b23b8e 100644 --- a/packages/desktop/src/agent/AgentWorkspace.tsx +++ b/packages/desktop/src/agent/AgentWorkspace.tsx @@ -325,27 +325,27 @@ function InputBox({ {/* Row 2: toolbar */}
{!isPlanMode && ( - + )}
{planExists && onOpenPlanPanel && ( diff --git a/packages/desktop/src/agent/ModeIndicator.tsx b/packages/desktop/src/agent/ModeIndicator.tsx index a647d38..de3dbf3 100644 --- a/packages/desktop/src/agent/ModeIndicator.tsx +++ b/packages/desktop/src/agent/ModeIndicator.tsx @@ -37,9 +37,7 @@ export default function ModeIndicator({ sessionId, cwd }: ModeIndicatorProps) { const [loading, setLoading] = useState(false); const [busy, setBusy] = useState(false); - const mode = useAgentStore((s) => - sessionId ? (s.modeByThreadId[sessionId] ?? null) : null - ); + const mode = useAgentStore((s) => (sessionId ? (s.modeByThreadId[sessionId] ?? null) : null)); const pendingProfile = useAgentStore((s) => s.pendingProfile); const setPendingProfile = useAgentStore((s) => s.setPendingProfile); const setModeForThread = useAgentStore((s) => s.setModeForThread); @@ -85,8 +83,7 @@ export default function ModeIndicator({ sessionId, cwd }: ModeIndicatorProps) { }; }, [sessionId, cwd, fetchMode, pendingProfile, setModeForThread, setOptimisticModeForThread]); - const current: SessionMode = - sessionId === null ? pendingProfile : (mode?.mode ?? 'build'); + const current: SessionMode = sessionId === null ? pendingProfile : (mode?.mode ?? 'build'); const target: SessionMode = current === 'plan' ? 'build' : 'plan'; const handleToggle = async () => { diff --git a/packages/desktop/src/hooks/useAgent.ts b/packages/desktop/src/hooks/useAgent.ts index a41d174..393a03c 100644 --- a/packages/desktop/src/hooks/useAgent.ts +++ b/packages/desktop/src/hooks/useAgent.ts @@ -303,7 +303,7 @@ export function useAgentCore() { const permissionMode: PermissionMode = pendingProfile === 'plan' ? 'default' - : APPROVAL_POLICY_TO_PERMISSION_MODE[approvalPolicy] ?? 'default'; + : (APPROVAL_POLICY_TO_PERMISSION_MODE[approvalPolicy] ?? 'default'); const model = modelId; if (!model) { throw new Error('No model selected. Please select a model first.'); @@ -612,23 +612,20 @@ export function useAgentRollback() { [workspace.rootPath, setRollbackState, initRevertedFilesFromState] ); - const deleteThread = useCallback( - async (threadId: string) => { - abortAndClear(threadId); - const currentCwd = useWorkspaceStore.getState().rootPath; - const wasCurrent = useAgentStore.getState().currentThreadId === threadId; - try { - await deleteSession(threadId, currentCwd); - } catch (e) { - console.error('Failed to delete session:', e); - } - useAgentStore.getState().removeThread(threadId); - if (wasCurrent) { - useAgentStore.getState().setCurrentThread(null); - } - }, - [] - ); + const deleteThread = useCallback(async (threadId: string) => { + abortAndClear(threadId); + const currentCwd = useWorkspaceStore.getState().rootPath; + const wasCurrent = useAgentStore.getState().currentThreadId === threadId; + try { + await deleteSession(threadId, currentCwd); + } catch (e) { + console.error('Failed to delete session:', e); + } + useAgentStore.getState().removeThread(threadId); + if (wasCurrent) { + useAgentStore.getState().setCurrentThread(null); + } + }, []); return { loadCheckpointDiff, diff --git a/packages/desktop/src/lib/api.ts b/packages/desktop/src/lib/api.ts index f1feb78..b829fc5 100644 --- a/packages/desktop/src/lib/api.ts +++ b/packages/desktop/src/lib/api.ts @@ -1,14 +1,8 @@ +import { ApiError } from '@codingcode/core/core/error'; + export const API_BASE = `http://127.0.0.1:${new URLSearchParams(window.location.search).get('apiPort')}`; -export class ApiError extends Error { - constructor( - public readonly status: number, - public readonly path: string, - public readonly body?: { code: string; message: string } - ) { - super(body?.message ?? `HTTP ${status}: ${path}`); - } -} +export { ApiError }; export async function api(path: string, init?: RequestInit): Promise { const res = await fetch(`${API_BASE}${path}`, init); diff --git a/packages/desktop/src/lib/core-api.ts b/packages/desktop/src/lib/core-api.ts index a383de0..a93c8ee 100644 --- a/packages/desktop/src/lib/core-api.ts +++ b/packages/desktop/src/lib/core-api.ts @@ -74,9 +74,7 @@ export function getSessionPlan( sessionId: string, cwd: string ): Promise<{ content: string; path: string; directory: string; exists: boolean }> { - return api<{ content: string; path: string; directory: string; exists: boolean }>( - `/api/sessions/${sessionId}/plan?cwd=${encodeURIComponent(cwd)}` - ); + return clients.sessions.getSessionPlan({ sessionId, cwd }); } // ---- Plan/Build mode switching ---- @@ -89,7 +87,7 @@ export type SessionModeInfo = { }; export function getSessionMode(sessionId: string, cwd: string): Promise { - return api(`/api/sessions/${sessionId}/mode?cwd=${encodeURIComponent(cwd)}`); + return clients.sessions.getSessionMode({ sessionId, cwd }); } export function setSessionMode( @@ -97,14 +95,7 @@ export function setSessionMode( cwd: string, mode: SessionMode ): Promise<{ mode: SessionMode; permissionMode: PermissionMode }> { - return api<{ mode: SessionMode; permissionMode: PermissionMode }>( - `/api/sessions/${sessionId}/mode`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ cwd, mode }), - } - ); + return clients.sessions.setSessionMode({ sessionId, cwd, mode }); } // ---- Settings: Memory ---- @@ -114,7 +105,7 @@ export function getMemoryConfig(): Promise<{ types: Array<{ name: string; description: string; isBuiltIn: boolean; disabled: boolean }>; model: string; }> { - return api('/api/settings/memory/config'); + return clients.settings.getMemoryConfig(); } export function setMemoryEnabled(enabled: boolean): Promise { @@ -141,11 +132,7 @@ export function deleteMemoryExtraType(name: string): Promise { } export function setMemoryModel(model: string): Promise<{ model: string }> { - return api('/api/settings/memory/model', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model }), - }); + return clients.settings.setMemoryModel(model); } // ---- Settings: Agent config ---- @@ -154,7 +141,7 @@ export async function getAgentConfig(): Promise<{ maxSteps: number; maxStopContinuations: number; }> { - return api('/api/settings/agent/config'); + return clients.settings.getAgentConfig(); } export async function setAgentConfig(partial: { @@ -173,11 +160,7 @@ export async function setAgentConfig(partial: { export async function setCompactionModel( compactionModel: string ): Promise<{ compactionModel: string }> { - return api('/api/settings/context/compaction-model', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ compactionModel }), - }); + return clients.settings.setCompactionModel(compactionModel); } // ---- Settings: MCP ---- diff --git a/packages/desktop/src/shared/PlanPanel.tsx b/packages/desktop/src/shared/PlanPanel.tsx index 1d09720..3c2046b 100644 --- a/packages/desktop/src/shared/PlanPanel.tsx +++ b/packages/desktop/src/shared/PlanPanel.tsx @@ -80,7 +80,10 @@ export default function PlanPanel({ sessionId, cwd, onClose }: PlanPanelProps) {
{plan?.path && ( -
+
{plan.path}
)} diff --git a/packages/desktop/src/stores/agent.store.ts b/packages/desktop/src/stores/agent.store.ts index b1910bf..59a27ee 100644 --- a/packages/desktop/src/stores/agent.store.ts +++ b/packages/desktop/src/stores/agent.store.ts @@ -173,11 +173,7 @@ export const useAgentStore = create()( setModeForThread: (id, info) => set((s) => { const current = s.modeByThreadId[id]; - if ( - current && - info.requestedAt !== undefined && - current.fetchedAt > info.requestedAt - ) { + if (current && info.requestedAt !== undefined && current.fetchedAt > info.requestedAt) { return; } s.modeByThreadId[id] = { diff --git a/packages/desktop/test/core-api-clients.test.ts b/packages/desktop/test/core-api-clients.test.ts new file mode 100644 index 0000000..8a391b0 --- /dev/null +++ b/packages/desktop/test/core-api-clients.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; + +describe('desktop core-api uses clients.* not raw api() for the 5 settings/session calls', () => { + it('getMemoryConfig delegates to clients.settings.getMemoryConfig', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/desktop/src/lib/core-api.ts', + 'utf8' + ); + expect(src).toMatch(/function getMemoryConfig[\s\S]*return clients\.settings\.getMemoryConfig\(\)/); + }); + + it('setMemoryModel delegates to clients.settings.setMemoryModel', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/desktop/src/lib/core-api.ts', + 'utf8' + ); + expect(src).toMatch(/function setMemoryModel[\s\S]*return clients\.settings\.setMemoryModel/); + }); + + it('getAgentConfig delegates to clients.settings.getAgentConfig', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/desktop/src/lib/core-api.ts', + 'utf8' + ); + expect(src).toMatch(/function getAgentConfig[\s\S]*return clients\.settings\.getAgentConfig/); + }); + + it('setCompactionModel delegates to clients.settings.setCompactionModel', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/desktop/src/lib/core-api.ts', + 'utf8' + ); + expect(src).toMatch(/function setCompactionModel[\s\S]*return clients\.settings\.setCompactionModel/); + }); + + it('getSessionPlan delegates to clients.sessions.getSessionPlan', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/desktop/src/lib/core-api.ts', + 'utf8' + ); + expect(src).toMatch(/function getSessionPlan[\s\S]*return clients\.sessions\.getSessionPlan/); + }); +}); diff --git a/packages/desktop/test/core-api.test.ts b/packages/desktop/test/core-api.test.ts index f865cdd..0c3186d 100644 --- a/packages/desktop/test/core-api.test.ts +++ b/packages/desktop/test/core-api.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const { mockSettings, mockApi, mockAgent } = vi.hoisted(() => { +const { mockSettings, mockApi, mockAgent, mockSessions } = vi.hoisted(() => { const mockSettings = { getSubagentEnabled: vi.fn(), setSubagentEnabled: vi.fn(), @@ -12,12 +12,22 @@ const { mockSettings, mockApi, mockAgent } = vi.hoisted(() => { setHookDisabled: vi.fn(), resetHookDisabled: vi.fn(), toggleSkill: vi.fn(), + getMemoryConfig: vi.fn(), + setMemoryModel: vi.fn(), + getAgentConfig: vi.fn(), + setCompactionModel: vi.fn(), }; const mockApi = vi.fn(); const mockAgent = { sendApprovalResponse: vi.fn(), }; - return { mockSettings, mockApi, mockAgent }; + const mockSessions = { + listSessions: vi.fn(), + getSessionMode: vi.fn(), + setSessionMode: vi.fn(), + getSessionPlan: vi.fn(), + }; + return { mockSettings, mockApi, mockAgent, mockSessions }; }); vi.mock('../src/lib/api', () => ({ @@ -29,7 +39,7 @@ vi.mock('@codingcode/core/client/http-clients', () => ({ createHttpClients: () => ({ settings: mockSettings, models: { listModels: vi.fn(), switchModel: vi.fn() }, - sessions: { listSessions: vi.fn() }, + sessions: mockSessions, agent: mockAgent, }), })); @@ -244,30 +254,28 @@ describe('toggleSkill', () => { // ---- New config API functions ---- describe('getMemoryConfig', () => { - it('calls api with correct path', async () => { - mockApi.mockResolvedValue({ enabled: false, types: [], model: '' }); - await getMemoryConfig(); - expect(mockApi).toHaveBeenCalledWith('/api/settings/memory/config'); + it('calls clients.settings.getMemoryConfig', async () => { + mockSettings.getMemoryConfig.mockResolvedValue({ enabled: false, types: [], model: '' }); + const result = await getMemoryConfig(); + expect(mockSettings.getMemoryConfig).toHaveBeenCalled(); + expect(result).toEqual({ enabled: false, types: [], model: '' }); }); }); describe('setMemoryModel', () => { - it('calls api with correct POST body', async () => { - mockApi.mockResolvedValue({ model: 'gpt-4o' }); + it('calls clients.settings.setMemoryModel', async () => { + mockSettings.setMemoryModel.mockResolvedValue({ model: 'gpt-4o' }); await setMemoryModel('gpt-4o'); - expect(mockApi).toHaveBeenCalledWith('/api/settings/memory/model', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model: 'gpt-4o' }), - }); + expect(mockSettings.setMemoryModel).toHaveBeenCalledWith('gpt-4o'); }); }); describe('getAgentConfig', () => { - it('calls api with correct path', async () => { - mockApi.mockResolvedValue({ maxSteps: 200, maxStopContinuations: 2 }); - await getAgentConfig(); - expect(mockApi).toHaveBeenCalledWith('/api/settings/agent/config'); + it('calls clients.settings.getAgentConfig', async () => { + mockSettings.getAgentConfig.mockResolvedValue({ maxSteps: 200, maxStopContinuations: 2 }); + const result = await getAgentConfig(); + expect(mockSettings.getAgentConfig).toHaveBeenCalled(); + expect(result.maxSteps).toBe(200); }); }); @@ -284,54 +292,58 @@ describe('setAgentConfig', () => { }); describe('setCompactionModel', () => { - it('calls api with compactionModel', async () => { - mockApi.mockResolvedValue({ compactionModel: 'gpt-4o-mini' }); + it('calls clients.settings.setCompactionModel', async () => { + mockSettings.setCompactionModel.mockResolvedValue({ compactionModel: 'gpt-4o-mini' }); await setCompactionModel('gpt-4o-mini'); - expect(mockApi).toHaveBeenCalledWith('/api/settings/context/compaction-model', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ compactionModel: 'gpt-4o-mini' }), - }); + expect(mockSettings.setCompactionModel).toHaveBeenCalledWith('gpt-4o-mini'); }); }); // ---- Plan file API ---- describe('getSessionPlan', () => { - it('encodes cwd and hits the plan endpoint', async () => { - mockApi.mockResolvedValue({ content: '', path: '/x', directory: '/x', exists: false }); + it('calls clients.sessions.getSessionPlan with sessionId and cwd', async () => { + mockSessions.getSessionPlan.mockResolvedValue({ + content: '', + path: '/x', + directory: '/x', + exists: false, + }); await getSessionPlan('s-1', '/some path with space'); - expect(mockApi).toHaveBeenCalledWith( - '/api/sessions/s-1/plan?cwd=' + encodeURIComponent('/some path with space') - ); + expect(mockSessions.getSessionPlan).toHaveBeenCalledWith({ + sessionId: 's-1', + cwd: '/some path with space', + }); }); }); // ---- Mode switching API ---- describe('getSessionMode', () => { - it('encodes cwd and hits the mode GET endpoint', async () => { - mockApi.mockResolvedValue({ + it('delegates to clients.sessions.getSessionMode', async () => { + mockSessions.getSessionMode.mockResolvedValue({ mode: 'build', permissionMode: 'default', cwd: '/tmp', available: [], }); - await getSessionMode('s-1', '/tmp'); - expect(mockApi).toHaveBeenCalledWith( - '/api/sessions/s-1/mode?cwd=' + encodeURIComponent('/tmp') - ); + const result = await getSessionMode('s-1', '/tmp'); + expect(mockSessions.getSessionMode).toHaveBeenCalledWith({ + sessionId: 's-1', + cwd: '/tmp', + }); + expect(result.mode).toBe('build'); }); }); describe('setSessionMode', () => { - it('POSTs the mode to the mode endpoint', async () => { - mockApi.mockResolvedValue({ mode: 'plan', permissionMode: 'default' }); + it('delegates to clients.sessions.setSessionMode', async () => { + mockSessions.setSessionMode.mockResolvedValue({ mode: 'plan', permissionMode: 'default' }); await setSessionMode('s-1', '/tmp', 'plan'); - expect(mockApi).toHaveBeenCalledWith('/api/sessions/s-1/mode', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ cwd: '/tmp', mode: 'plan' }), + expect(mockSessions.setSessionMode).toHaveBeenCalledWith({ + sessionId: 's-1', + cwd: '/tmp', + mode: 'plan', }); }); }); diff --git a/packages/desktop/test/input-box-plan-button.test.tsx b/packages/desktop/test/input-box-plan-button.test.tsx index c7d6b95..ad03d4b 100644 --- a/packages/desktop/test/input-box-plan-button.test.tsx +++ b/packages/desktop/test/input-box-plan-button.test.tsx @@ -15,9 +15,7 @@ describe('Desktop: InputBox "查看计划" button', () => { it('derives planExists from the agent store', () => { expect(agentWorkspaceSource).toMatch(/planExists/); - expect(agentWorkspaceSource).toMatch( - /pendingPlanByThreadId\[s\.currentThreadId\]\s*!=\s*null/ - ); + expect(agentWorkspaceSource).toMatch(/pendingPlanByThreadId\[s\.currentThreadId\]\s*!=\s*null/); }); it('does not call useAgentMode in AgentWorkspace', () => { diff --git a/packages/desktop/test/plan-panel.test.tsx b/packages/desktop/test/plan-panel.test.tsx index 457ed88..00b3560 100644 --- a/packages/desktop/test/plan-panel.test.tsx +++ b/packages/desktop/test/plan-panel.test.tsx @@ -38,9 +38,7 @@ describe('PlanPanel', () => { directory: '/tmp/.codingcode/plans', exists: true, }); - const { getByText } = render( - {}} /> - ); + const { getByText } = render( {}} />); expect(fetchPlanMock).toHaveBeenCalledWith('s-1', '/tmp'); await waitFor(() => { expect(getByText('Hello')).toBeInTheDocument(); @@ -55,9 +53,7 @@ describe('PlanPanel', () => { directory: '/tmp', exists: true, }); - const { getByText } = render( - {}} /> - ); + const { getByText } = render( {}} />); await waitFor(() => { expect(getByText('/tmp/plan.md')).toBeInTheDocument(); }); @@ -70,9 +66,7 @@ describe('PlanPanel', () => { directory: '/tmp/.codingcode/plans', exists: false, }); - const { getByText } = render( - {}} /> - ); + const { getByText } = render( {}} />); await waitFor(() => { expect(getByText(/暂无计划/)).toBeInTheDocument(); }); @@ -80,9 +74,7 @@ describe('PlanPanel', () => { it('renders an error message when fetchPlan rejects', async () => { fetchPlanMock.mockRejectedValue(new Error('boom')); - const { getByText } = render( - {}} /> - ); + const { getByText } = render( {}} />); await waitFor(() => { expect(getByText(/加载失败/)).toBeInTheDocument(); }); @@ -96,18 +88,13 @@ describe('PlanPanel', () => { directory: '/tmp', exists: true, }); - const { getByLabelText } = render( - {}} /> - ); + const { getByLabelText } = render( {}} />); // Wait for the initial mount fetch to land await waitFor(() => expect(fetchPlanMock).toHaveBeenCalledTimes(1)); // Click refresh; the component should re-invoke fetchPlan even though // sessionId/cwd haven't changed. fireEvent.click(getByLabelText('刷新计划')); - await waitFor( - () => expect(fetchPlanMock).toHaveBeenCalledTimes(2), - { timeout: 2000 } - ); + await waitFor(() => expect(fetchPlanMock).toHaveBeenCalledTimes(2), { timeout: 2000 }); }); it('invokes onClose when the close button is clicked', async () => { @@ -118,9 +105,7 @@ describe('PlanPanel', () => { exists: true, }); const onClose = vi.fn(); - const { getByLabelText } = render( - - ); + const { getByLabelText } = render(); fireEvent.click(getByLabelText('关闭计划面板')); expect(onClose).toHaveBeenCalledTimes(1); }); diff --git a/packages/tui/src/components/App.tsx b/packages/tui/src/components/App.tsx index d604060..041d086 100644 --- a/packages/tui/src/components/App.tsx +++ b/packages/tui/src/components/App.tsx @@ -4,7 +4,7 @@ import { useAgentRunner } from '../hooks/useAgentRunner.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { generateId, historyToUIMessages } from '../utils.js'; import type { PanelState } from '../types.js'; -import type { StreamChunk, AgentClient } from '../index.js'; +import type { StreamChunk, TuiClient } from '../index.js'; import { MessageItem } from './MessageItem.js'; import { InputBox } from './InputBox.js'; import { LoadingIndicator } from './LoadingIndicator.js'; @@ -24,7 +24,7 @@ const PERMISSION_MODE_LABELS: Record = { bypass: '完全放行', }; interface AppProps { - client: AgentClient; + client: TuiClient; } export function App({ client }: AppProps) { @@ -61,10 +61,6 @@ export function App({ client }: AppProps) { }, ]); setActiveMessages([]); - client - .getPermissionMode() - .then(setPermissionMode) - .catch(() => {}); }, []); // only on mount useEffect(() => { @@ -260,7 +256,7 @@ export function App({ client }: AppProps) { } if (parsed.name === 'mcp') { try { - const servers = await client.getMcpStatus(); + const servers = await client.getMcpStatus({ cwd: '' }); setPanel({ type: 'mcp', servers }); } catch (e: any) { setStaticMessages((prev) => [ @@ -294,7 +290,8 @@ export function App({ client }: AppProps) { } if (parsed.name === 'approve') { try { - const mode = await client.getPermissionMode(); + const sid = client.getSessionId(); + const mode = await client.getPermissionMode({ sessionId: sid, cwd: '' }); setPermissionMode(mode); setPanel({ type: 'permission', currentMode: mode }); } catch (e: any) { @@ -471,7 +468,7 @@ export function App({ client }: AppProps) { } else { await client.setMcpDisabled({ name: value, disabled: true, cwd: '' }); } - const updated = await client.getMcpStatus(); + const updated = await client.getMcpStatus({ cwd: '' }); setPanel({ type: 'mcp', servers: updated }); } catch { setPanel({ type: 'none' }); @@ -530,7 +527,8 @@ export function App({ client }: AppProps) { onSelect={async (value) => { if (!value) return; try { - await client.setPermissionMode(value as any); + const sid = client.getSessionId(); + await client.setPermissionMode({ sessionId: sid, cwd: '', mode: value as any }); setPermissionMode(value); } catch { /* ignore */ diff --git a/packages/tui/src/index.tsx b/packages/tui/src/index.tsx index 70e1eee..82f5f99 100644 --- a/packages/tui/src/index.tsx +++ b/packages/tui/src/index.tsx @@ -1,21 +1,87 @@ import React from 'react'; import { render } from 'ink'; import { App } from './components/App.js'; -import { createDirectClient } from '@codingcode/core/client/direct'; -import type { AgentClient, StreamChunk } from '@codingcode/core/client/types'; +import type { StreamChunk } from '@codingcode/core/client/types'; +import { createDirectAgentClient } from '@codingcode/core/direct/agent-runtime'; +import { createDirectSessionClient } from '@codingcode/core/direct/sessions'; +import { createDirectSettingsClient } from '@codingcode/core/direct/settings'; +import { createDirectModelClient } from '@codingcode/core/direct/models'; +import type { LLMClient } from '@codingcode/core/llm/client'; +import type { AppRuntime } from '@codingcode/core/layer'; -export type { AgentClient, StreamChunk }; +export type { StreamChunk }; -type DirectClientParams = Parameters; +export interface TuiClient { + sendMessage(input: string): AsyncGenerator; + sendApprovalResponse(id: string, response: string): Promise; + getSessionId(): string; + compact(): Promise; + setMemoryEnabled(enabled: boolean): Promise; + getMemoryEnabled(): Promise; + setSubagentEnabled(body: { enabled: boolean; cwd: string }): Promise; + getSubagentEnabled(query: { cwd: string }): Promise<{ enabled: boolean; source: string }>; + listModels(): Promise<{ models: any[]; activeId: string | null }>; + switchModel(id: string): Promise; + listSessions(): Promise; + getMcpStatus(query: { cwd: string }): Promise; + setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; + listSkills(): Promise; + toggleSkill(body: { name: string; enabled: boolean; cwd: string }): Promise; + getPermissionMode(input: { + sessionId: string; + cwd: string; + }): Promise; + setPermissionMode(input: { + sessionId: string; + cwd: string; + mode: import('@codingcode/core/approval/types').PermissionMode; + }): Promise; + resumeSession(sid: string): Promise; +} + +export function createTuiClientFromFacades(llm: LLMClient, rt: AppRuntime): TuiClient { + const agent = createDirectAgentClient(llm, rt); + const sessions = createDirectSessionClient(rt); + const settings = createDirectSettingsClient(rt); + const models = createDirectModelClient(rt); + + let currentSessionId = ''; + + return { + async *sendMessage(input: string): AsyncGenerator { + const stream = agent.sendMessage(input, { sessionId: currentSessionId, cwd: '' }); + for await (const chunk of stream) { + if (chunk.type === 'session_id') { + currentSessionId = chunk.sessionId as string; + } + yield chunk; + } + }, + sendApprovalResponse: (id, response) => + agent.sendApprovalResponse({ sessionId: currentSessionId, approvalId: id, response }), + getSessionId: () => currentSessionId, + compact: () => agent.compact({ sessionId: currentSessionId, cwd: '' }), + setMemoryEnabled: (enabled) => settings.setMemoryEnabled(enabled), + getMemoryEnabled: () => settings.getMemoryEnabled(), + setSubagentEnabled: (body) => settings.setSubagentEnabled(body), + getSubagentEnabled: (query) => settings.getSubagentEnabled(query), + listModels: () => models.listModels(), + switchModel: (id) => models.switchModel({ id }), + listSessions: () => sessions.listSessions({ cwd: '' }), + getMcpStatus: (query) => settings.getMcpStatus(query), + setMcpDisabled: (body) => settings.setMcpDisabled(body), + listSkills: () => settings.listSkills(), + toggleSkill: (body) => settings.toggleSkill(body), + getPermissionMode: (input) => settings.getGlobalPermissionMode(input), + setPermissionMode: (input) => settings.setGlobalPermissionMode(input), + resumeSession: (sid) => sessions.resumeSession({ sessionId: sid, cwd: '' }), + }; +} interface TuiOptions { - llm?: DirectClientParams[0]; - rt?: DirectClientParams[1]; - client?: AgentClient; + client: TuiClient; } -export async function runTui(options: TuiOptions = {}) { - const client: AgentClient = - options.client ?? (await createDirectClient(options.llm!, options.rt!)); - render(); +export async function runTui(options: TuiOptions) { + render(); }