diff --git a/eslint.config.mjs b/eslint.config.mjs index 5250c61..0ec1788 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -19,7 +19,7 @@ const compat = new FlatCompat({ export default [ { - ignores: ["**/dist", "**/build", "**/node_modules", "**/*.md", ".contexts"] + ignores: ["**/dist", "**/build", "**/node_modules", "**/*.md", ".contexts", ".mini-agent/**"] }, ...compat.extends( "eslint:recommended", diff --git a/src/cli/chat-ui.ts b/src/cli/chat-ui.ts index 4a6b290..e339b46 100644 --- a/src/cli/chat-ui.ts +++ b/src/cli/chat-ui.ts @@ -6,19 +6,18 @@ */ import type { AiError, LanguageModel } from "@effect/ai" import type { Error as PlatformError, FileSystem } from "@effect/platform" -import { Cause, Context, Effect, Fiber, Layer, Mailbox, Stream } from "effect" -import { is } from "effect/Schema" +import { Cause, Context, Effect, Fiber, Layer, Mailbox, Schema, Stream } from "effect" import { AssistantMessageEvent, - type ContextEvent, + CodemodeResultEvent, + CodemodeValidationErrorEvent, LLMRequestInterruptedEvent, TextDeltaEvent, UserMessageEvent } from "../context.model.ts" -import { ContextService } from "../context.service.ts" -import type { ContextLoadError, ContextSaveError } from "../errors.ts" +import { type ContextOrCodemodeEvent, ContextService } from "../context.service.ts" +import type { CodeStorageError, ContextLoadError, ContextSaveError } from "../errors.ts" import type { CurrentLlmConfig } from "../llm-config.ts" -import { streamLLMResponse } from "../llm.ts" import { type ChatController, runOpenTUIChat } from "./components/opentui-chat.tsx" type ChatSignal = @@ -32,7 +31,7 @@ export class ChatUI extends Context.Tag("@app/ChatUI")< contextName: string ) => Effect.Effect< void, - AiError.AiError | PlatformError.PlatformError | ContextLoadError | ContextSaveError, + AiError.AiError | PlatformError.PlatformError | ContextLoadError | ContextSaveError | CodeStorageError, LanguageModel.LanguageModel | FileSystem.FileSystem | CurrentLlmConfig > } @@ -81,7 +80,7 @@ const runChatLoop = ( mailbox: Mailbox.Mailbox ): Effect.Effect< void, - AiError.AiError | PlatformError.PlatformError | ContextLoadError | ContextSaveError, + AiError.AiError | PlatformError.PlatformError | ContextLoadError | ContextSaveError | CodeStorageError, LanguageModel.LanguageModel | FileSystem.FileSystem | CurrentLlmConfig > => Effect.fn("ChatUI.runChatLoop")(function*() { @@ -97,6 +96,18 @@ type TurnResult = | { readonly _tag: "continue" } | { readonly _tag: "exit" } +/** Check if event is displayable in the chat feed */ +const isDisplayableEvent = (event: ContextOrCodemodeEvent): boolean => + Schema.is(TextDeltaEvent)(event) || + Schema.is(AssistantMessageEvent)(event) || + Schema.is(CodemodeResultEvent)(event) || + Schema.is(CodemodeValidationErrorEvent)(event) + +/** Check if event triggers continuation (agent loop) */ +const triggersContinuation = (event: ContextOrCodemodeEvent): boolean => + (Schema.is(CodemodeResultEvent)(event) && event.triggerAgentTurn === "after-current-turn") || + (Schema.is(CodemodeValidationErrorEvent)(event) && event.triggerAgentTurn === "after-current-turn") + const runChatTurn = ( contextName: string, contextService: Context.Tag.Service, @@ -105,7 +116,7 @@ const runChatTurn = ( pendingMessage: string | null ): Effect.Effect< TurnResult, - AiError.AiError | PlatformError.PlatformError | ContextLoadError | ContextSaveError, + AiError.AiError | PlatformError.PlatformError | ContextLoadError | ContextSaveError | CodeStorageError, LanguageModel.LanguageModel | FileSystem.FileSystem | CurrentLlmConfig > => Effect.fn("ChatUI.runChatTurn")(function*() { @@ -129,28 +140,105 @@ const runChatTurn = ( } const userEvent = new UserMessageEvent({ content: userMessage }) - - yield* contextService.persistEvent(contextName, userEvent) chat.addEvent(userEvent) - const events = yield* contextService.load(contextName) let accumulatedText = "" + let needsContinuation = false + + // Use contextService.addEvents with codemode enabled + const eventStream = contextService.addEvents(contextName, [userEvent], { codemode: true }) const streamFiber = yield* Effect.fork( - streamLLMResponse(events).pipe( - Stream.tap((event: ContextEvent) => + eventStream.pipe( + Stream.tap((event: ContextOrCodemodeEvent) => Effect.sync(() => { - if (is(TextDeltaEvent)(event)) { + if (Schema.is(TextDeltaEvent)(event)) { accumulatedText += event.delta + } + if (triggersContinuation(event)) { + needsContinuation = true + } + if (isDisplayableEvent(event)) { chat.addEvent(event) } }) ), - Stream.filter(is(AssistantMessageEvent)), - Stream.tap((event) => - Effect.gen(function*() { - yield* contextService.persistEvent(contextName, event) - chat.addEvent(event) + Stream.runDrain + ) + ) + + const result = yield* awaitStreamCompletion(streamFiber, mailbox) + + if (result._tag === "completed") { + // If we need continuation (codemode result with output), run another turn + if (needsContinuation) { + return yield* runAgentContinuation(contextName, contextService, chat, mailbox) + } + return { _tag: "continue" } as const + } + + if (result._tag === "exit") { + if (accumulatedText.length > 0) { + const interruptedEvent = new LLMRequestInterruptedEvent({ + requestId: crypto.randomUUID(), + reason: "user_cancel", + partialResponse: accumulatedText + }) + yield* contextService.persistEvent(contextName, interruptedEvent) + chat.addEvent(interruptedEvent) + } + return { _tag: "exit" } as const + } + + // result._tag === "interrupted" - user hit return during streaming + if (accumulatedText.length > 0) { + const interruptedEvent = new LLMRequestInterruptedEvent({ + requestId: crypto.randomUUID(), + reason: result.newMessage ? "user_new_message" : "user_cancel", + partialResponse: accumulatedText + }) + yield* contextService.persistEvent(contextName, interruptedEvent) + chat.addEvent(interruptedEvent) + } + + if (result.newMessage) { + return yield* runChatTurn(contextName, contextService, chat, mailbox, result.newMessage) + } + + return { _tag: "continue" } as const + })() + +/** Run agent continuation loop (for codemode results that need follow-up) */ +const runAgentContinuation = ( + contextName: string, + contextService: Context.Tag.Service, + chat: ChatController, + mailbox: Mailbox.Mailbox +): Effect.Effect< + TurnResult, + AiError.AiError | PlatformError.PlatformError | ContextLoadError | ContextSaveError | CodeStorageError, + LanguageModel.LanguageModel | FileSystem.FileSystem | CurrentLlmConfig +> => + Effect.fn("ChatUI.runAgentContinuation")(function*() { + let accumulatedText = "" + let needsContinuation = false + + // Empty input events - the persisted CodemodeResult triggers the turn + const eventStream = contextService.addEvents(contextName, [], { codemode: true }) + + const streamFiber = yield* Effect.fork( + eventStream.pipe( + Stream.tap((event: ContextOrCodemodeEvent) => + Effect.sync(() => { + if (Schema.is(TextDeltaEvent)(event)) { + accumulatedText += event.delta + } + if (triggersContinuation(event)) { + needsContinuation = true + } + if (isDisplayableEvent(event)) { + chat.addEvent(event) + } }) ), Stream.runDrain @@ -160,6 +248,9 @@ const runChatTurn = ( const result = yield* awaitStreamCompletion(streamFiber, mailbox) if (result._tag === "completed") { + if (needsContinuation) { + return yield* runAgentContinuation(contextName, contextService, chat, mailbox) + } return { _tag: "continue" } as const } @@ -176,7 +267,7 @@ const runChatTurn = ( return { _tag: "exit" } as const } - // result._tag === "interrupted" - user hit return during streaming + // Interrupted - save partial and return to wait for input if (accumulatedText.length > 0) { const interruptedEvent = new LLMRequestInterruptedEvent({ requestId: crypto.randomUUID(), @@ -200,9 +291,15 @@ type StreamResult = | { readonly _tag: "interrupted"; readonly newMessage: string | null } const awaitStreamCompletion = ( - fiber: Fiber.RuntimeFiber, + fiber: Fiber.RuntimeFiber< + void, + AiError.AiError | PlatformError.PlatformError | ContextLoadError | ContextSaveError | CodeStorageError + >, mailbox: Mailbox.Mailbox -): Effect.Effect => +): Effect.Effect< + StreamResult, + AiError.AiError | PlatformError.PlatformError | ContextLoadError | ContextSaveError | CodeStorageError +> => Effect.fn("ChatUI.awaitStreamCompletion")(function*() { const waitForFiber = Fiber.join(fiber).pipe(Effect.as({ _tag: "completed" } as StreamResult)) const waitForInterrupt = Effect.gen(function*() { diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 92f939e..8076c3a 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -8,17 +8,20 @@ import { Command, Options, Prompt as CliPrompt } from "@effect/cli" import { type Error as PlatformError, FileSystem, HttpServer, Terminal } from "@effect/platform" import { BunHttpServer, BunStream } from "@effect/platform-bun" import { Chunk, Console, Effect, Layer, Option, Schema, Stream } from "effect" +import { codemodeCommand } from "../codemode-run.ts" +import type { CodemodeStreamEvent } from "../codemode.service.ts" import { AppConfig, resolveBaseDir } from "../config.ts" import { AssistantMessageEvent, - type ContextEvent, + CodemodeResultEvent, + CodemodeValidationErrorEvent, FileAttachmentEvent, type InputEvent, SystemPromptEvent, TextDeltaEvent, UserMessageEvent } from "../context.model.ts" -import { ContextService } from "../context.service.ts" +import { type ContextOrCodemodeEvent, ContextService } from "../context.service.ts" import { makeRouter } from "../http.ts" import { layercodeCommand } from "../layercode/index.ts" import { AgentServer } from "../server.service.ts" @@ -116,16 +119,108 @@ interface OutputOptions { showEphemeral: boolean } -/** - * Handle a single context event based on output options. - */ +const green = (s: string) => `\x1b[32m${s}\x1b[0m` +const yellow = (s: string) => `\x1b[33m${s}\x1b[0m` +const red = (s: string) => `\x1b[31m${s}\x1b[0m` +const dim = (s: string) => `\x1b[90m${s}\x1b[0m` + +/** Maximum agent loop iterations to prevent infinite loops */ +const MAX_AGENT_LOOP_ITERATIONS = 15 + +/** Handle codemode streaming events with colored output */ +const handleCodemodeStreamEvent = ( + event: CodemodeStreamEvent, + options: OutputOptions +): Effect.Effect => + Effect.gen(function*() { + const terminal = yield* Terminal.Terminal + + if (options.raw) { + yield* Console.log(JSON.stringify(event)) + return + } + + switch (event._tag) { + case "CodeBlock": + yield* Console.log(`\n${yellow("◆ Code block detected")} ${dim(`(attempt ${event.attempt})`)}`) + break + case "TypecheckStart": + yield* terminal.display(dim(" Typechecking...")) + break + case "TypecheckPass": + yield* Console.log(` ${green("✓")}`) + break + case "TypecheckFail": + yield* Console.log(` ${red("✗")}`) + yield* Console.log(red(event.errors)) + break + case "ExecutionStart": + yield* Console.log(dim(" Executing...")) + break + case "ExecutionOutput": + if (event.stream === "stdout") { + yield* terminal.display(event.data) + } else { + yield* terminal.display(red(event.data)) + } + break + case "ExecutionComplete": + if (event.exitCode === 0) { + yield* Console.log(dim(` Exit: ${event.exitCode}`)) + } else { + yield* Console.log(red(` Exit: ${event.exitCode}`)) + } + break + default: + break + } + }) + +/** Check if an event is a codemode streaming event */ +const isCodemodeStreamEvent = (event: ContextOrCodemodeEvent): event is CodemodeStreamEvent => + event._tag === "CodeBlock" || + event._tag === "TypecheckStart" || + event._tag === "TypecheckPass" || + event._tag === "TypecheckFail" || + event._tag === "ExecutionStart" || + event._tag === "ExecutionOutput" || + event._tag === "ExecutionComplete" + +/** Handle a single context or codemode event based on output options. */ const handleEvent = ( - event: ContextEvent, + event: ContextOrCodemodeEvent, options: OutputOptions ): Effect.Effect => Effect.gen(function*() { const terminal = yield* Terminal.Terminal + // Handle codemode streaming events + if (isCodemodeStreamEvent(event)) { + yield* handleCodemodeStreamEvent(event, options) + return + } + + // Handle CodemodeResult (persisted result, shown differently) + if (Schema.is(CodemodeResultEvent)(event)) { + if (options.raw) { + yield* Console.log(JSON.stringify(event)) + } else { + yield* Console.log(dim(` [Result persisted to context]`)) + } + return + } + + // Handle CodemodeValidationError (LLM didn't output codemode) + if (Schema.is(CodemodeValidationErrorEvent)(event)) { + if (options.raw) { + yield* Console.log(JSON.stringify(event)) + } else { + yield* Console.log(red(`\n⚠ LLM response missing tags. Retrying...`)) + } + return + } + + // Handle standard context events if (options.raw) { if (Schema.is(TextDeltaEvent)(event) && !options.showEphemeral) { return @@ -144,7 +239,15 @@ const handleEvent = ( } }) -/** Run the event stream, handling each event */ +/** + * Run the event stream with agent loop. + * + * The agent loop handles codemode execution flow: + * 1. Initial user message triggers LLM response + * 2. If LLM outputs codemode, code is executed + * 3. If CodemodeResult has triggerAgentTurn="after-current-turn", loop continues + * 4. Loop continues until max iterations or no continuation needed + */ const runEventStream = ( contextName: string, userMessage: string, @@ -180,9 +283,59 @@ const runEventStream = ( inputEvents.push(new UserMessageEvent({ content: userMessage })) - yield* contextService.addEvents(contextName, inputEvents).pipe( - Stream.runForEach((event) => handleEvent(event, options)) + // Track last CodemodeResult for agent loop decision + let lastCodemodeResult: CodemodeResultEvent | undefined + + // Initial turn + yield* contextService.addEvents(contextName, inputEvents, { codemode: true }).pipe( + Stream.runForEach((event) => + Effect.gen(function*() { + yield* handleEvent(event, options) + if (Schema.is(CodemodeResultEvent)(event)) { + lastCodemodeResult = event + } + }) + ) ) + + // Agent loop: continue if CodemodeResult requests another turn + let iteration = 1 + while ( + lastCodemodeResult && + lastCodemodeResult.triggerAgentTurn === "after-current-turn" && + iteration < MAX_AGENT_LOOP_ITERATIONS + ) { + iteration++ + yield* Effect.logDebug(`Agent loop continuing (iteration ${iteration})`) + + if (!options.raw) { + yield* Console.log(dim(`\n[Agent continuing... (iteration ${iteration})]`)) + yield* Console.log(`\n${green("Assistant:")}`) + } + + // Reset for next turn - the persisted CodemodeResult will trigger LLM + lastCodemodeResult = undefined + + // Empty input events - the persisted CodemodeResult already triggers the turn + yield* contextService.addEvents(contextName, [], { codemode: true }).pipe( + Stream.runForEach((event) => + Effect.gen(function*() { + yield* handleEvent(event, options) + if (Schema.is(CodemodeResultEvent)(event)) { + lastCodemodeResult = event + } + }) + ) + ) + } + + // Warn if max iterations reached + if (iteration >= MAX_AGENT_LOOP_ITERATIONS && lastCodemodeResult?.triggerAgentTurn === "after-current-turn") { + yield* Effect.logWarning(`Agent loop reached max iterations (${MAX_AGENT_LOOP_ITERATIONS}), stopping`) + if (!options.raw) { + yield* Console.log(yellow(`\n[Agent loop stopped: max iterations (${MAX_AGENT_LOOP_ITERATIONS}) reached]`)) + } + } }) /** CLI interaction mode - determines how input/output is handled */ @@ -233,7 +386,7 @@ const scriptInteractiveLoop = (contextName: string, options: OutputOptions) => yield* Console.log(JSON.stringify(event)) if (Schema.is(UserMessageEvent)(event)) { - yield* contextService.addEvents(contextName, [event]).pipe( + yield* contextService.addEvents(contextName, [event], { codemode: true }).pipe( Stream.runForEach((outputEvent) => handleEvent(outputEvent, options)) ) } else if (Schema.is(SystemPromptEvent)(event)) { @@ -327,7 +480,12 @@ const runChat = (options: { case "pipe": { const input = yield* readAllStdin if (input !== "") { - yield* runEventStream(contextName, input, { raw: false, showEphemeral: false }, imagePath) + yield* runEventStream( + contextName, + input, + { raw: false, showEphemeral: false }, + imagePath + ) } break } @@ -546,6 +704,7 @@ const rootCommand = Command.make( Command.withSubcommands([ chatCommand, serveCommand, + codemodeCommand, layercodeCommand, logTestCommand, traceTestCommand, diff --git a/src/cli/components/opentui-chat.tsx b/src/cli/components/opentui-chat.tsx index a037cca..ba64bfa 100644 --- a/src/cli/components/opentui-chat.tsx +++ b/src/cli/components/opentui-chat.tsx @@ -15,8 +15,9 @@ import { Option, Schema } from "effect" import { createCliRenderer, TextAttributes } from "@opentui/core" import { createRoot } from "@opentui/react/renderer" import { memo, useCallback, useMemo, useReducer, useRef, useState } from "react" -import type { ContextEvent, PersistedEvent } from "../../context.model.ts" +import type { PersistedEvent } from "../../context.model.ts" import { AttachmentSource } from "../../context.model.ts" +import type { ContextOrCodemodeEvent } from "../../context.service.ts" /** User's message in the conversation */ class UserMessageItem extends Schema.TaggedClass()("UserMessageItem", { @@ -54,6 +55,21 @@ class FileAttachmentItem extends Schema.TaggedClass()("FileA isHistory: Schema.Boolean }) {} +/** Codemode execution result */ +class CodemodeResultItem extends Schema.TaggedClass()("CodemodeResultItem", { + id: Schema.String, + stdout: Schema.String, + stderr: Schema.String, + exitCode: Schema.Number, + isHistory: Schema.Boolean +}) {} + +/** Codemode validation error - LLM didn't output codemode */ +class CodemodeValidationErrorItem extends Schema.TaggedClass()("CodemodeValidationErrorItem", { + id: Schema.String, + isHistory: Schema.Boolean +}) {} + /** Fallback for unknown event types - displays muted warning */ class UnknownEventItem extends Schema.TaggedClass()("UnknownEventItem", { id: Schema.String, @@ -67,11 +83,13 @@ const FeedItem = Schema.Union( AssistantMessageItem, LLMInterruptionItem, FileAttachmentItem, + CodemodeResultItem, + CodemodeValidationErrorItem, UnknownEventItem ) type FeedItem = typeof FeedItem.Type -type FeedAction = { event: ContextEvent; isHistory: boolean } +type FeedAction = { event: ContextOrCodemodeEvent; isHistory: boolean } /** * Folds a context event into accumulated feed items. @@ -141,10 +159,41 @@ function feedReducer(items: FeedItem[], action: FeedAction): FeedItem[] { }) ] + case "CodemodeResult": + return [ + ...items, + new CodemodeResultItem({ + id: crypto.randomUUID(), + stdout: event.stdout, + stderr: event.stderr, + exitCode: event.exitCode, + isHistory + }) + ] + + case "CodemodeValidationError": + return [ + ...items, + new CodemodeValidationErrorItem({ + id: crypto.randomUUID(), + isHistory + }) + ] + case "SystemPrompt": case "SetLlmConfig": return items + // Codemode streaming events - ephemeral, don't display in feed + case "CodeBlock": + case "TypecheckStart": + case "TypecheckPass": + case "TypecheckFail": + case "ExecutionStart": + case "ExecutionOutput": + case "ExecutionComplete": + return items + default: return [ ...items, @@ -256,6 +305,36 @@ const FileAttachmentRenderer = memo<{ item: FileAttachmentItem }>(({ item }) => ) }) +const CodemodeResultRenderer = memo<{ item: CodemodeResultItem }>(({ item }) => { + const labelColor = item.isHistory ? colors.dim : colors.yellow + const textColor = item.isHistory ? colors.dim : colors.white + const hasOutput = item.stdout || item.stderr + const isError = item.exitCode !== 0 + + return ( + + + {isError ? "⚠ Code execution failed" : "✓ Code executed"} (exit: {item.exitCode}) + + {hasOutput && ( + + {item.stdout && {item.stdout}} + {item.stderr && {item.stderr}} + + )} + + ) +}) + +const CodemodeValidationErrorRenderer = memo<{ item: CodemodeValidationErrorItem }>(({ item }) => { + const textColor = item.isHistory ? colors.dim : colors.red + return ( + + ⚠ LLM response missing codemode tags. Retrying... + + ) +}) + const UnknownEventRenderer = memo<{ item: UnknownEventItem }>(({ item }) => { return ( @@ -276,6 +355,10 @@ const FeedItemRenderer = memo<{ item: FeedItem }>(({ item }) => { return case "FileAttachmentItem": return + case "CodemodeResultItem": + return + case "CodemodeValidationErrorItem": + return case "UnknownEventItem": return } @@ -310,7 +393,7 @@ export interface ChatCallbacks { } export interface ChatController { - addEvent: (event: ContextEvent) => void + addEvent: (event: ContextOrCodemodeEvent) => void cleanup: () => void } @@ -353,7 +436,7 @@ function ChatApp({ contextName, initialEvents, callbacks, controllerRef }: ChatA // Set up controller synchronously during first render if (!controllerRef.current) { controllerRef.current = { - addEvent(event: ContextEvent) { + addEvent(event: ContextOrCodemodeEvent) { dispatchRef.current({ event, isHistory: false }) }, cleanup() { @@ -462,7 +545,7 @@ export async function runOpenTUIChat( renderer.start() return { - addEvent(event: ContextEvent) { + addEvent(event: ContextOrCodemodeEvent) { controllerRef.current?.addEvent(event) }, cleanup() { diff --git a/src/cli/main.ts b/src/cli/main.ts index 06a43a6..98da269 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -7,6 +7,9 @@ import { OpenAiClient, OpenAiLanguageModel } from "@effect/ai-openai" import { FetchHttpClient } from "@effect/platform" import { BunContext, BunRuntime } from "@effect/platform-bun" import { Cause, Effect, Layer } from "effect" +import { CodeExecutor } from "../code-executor.service.ts" +import { CodemodeRepository } from "../codemode.repository.ts" +import { CodemodeService } from "../codemode.service.ts" import { AppConfig, extractConfigPath, @@ -21,6 +24,7 @@ import { CurrentLlmConfig, getApiKey, type LlmConfig, resolveLlmConfig } from ". import { createLoggingLayer } from "../logging.ts" import { OpenAiChatClient, OpenAiChatLanguageModel } from "../openai-chat-completions-client.ts" import { createTracingLayer } from "../tracing.ts" +import { TypecheckService } from "../typechecker.service.ts" import { cli, GenAISpanTransformerLayer } from "./commands.ts" const makeLanguageModelLayer = (llmConfig: LlmConfig) => { @@ -97,8 +101,16 @@ const makeMainLayer = (args: ReadonlyArray) => const languageModelLayer = makeLanguageModelLayer(llmConfig) const tracingLayer = createTracingLayer("mini-agent") + // Build codemode layer stack + const codemodeLayer = CodemodeService.layer.pipe( + Layer.provide(CodemodeRepository.layer), + Layer.provide(TypecheckService.layer), + Layer.provide(CodeExecutor.layer) + ) + return ContextService.layer.pipe( Layer.provideMerge(ContextRepository.layer), + Layer.provideMerge(codemodeLayer), Layer.provideMerge(languageModelLayer), Layer.provideMerge(llmConfigLayer), Layer.provideMerge(tracingLayer), diff --git a/src/code-executor.service.ts b/src/code-executor.service.ts new file mode 100644 index 0000000..582b44e --- /dev/null +++ b/src/code-executor.service.ts @@ -0,0 +1,148 @@ +/** + * Code Executor Service + * + * Executes generated TypeScript code via the `mini-agent codemode run` CLI command. + * Streams stdout/stderr as events for real-time feedback. + * + * The CLI command handles: + * - Loading and executing the generated module + * - Providing tools (sendMessage, readFile, writeFile, exec, fetch, etc.) + * - Outputting __CODEMODE_RESULT__ marker on completion + */ +import { Command, CommandExecutor, Path } from "@effect/platform" +import type { Error as PlatformError } from "@effect/platform" +import { Context, Effect, Layer, pipe, Stream } from "effect" +import { CODEMODE_RESULT_MARKER } from "./codemode-run.ts" +import { + type CodeblockId, + ExecutionCompleteEvent, + ExecutionOutputEvent, + ExecutionStartEvent, + type RequestId +} from "./codemode.model.ts" + +// Compute absolute path to main.ts from this module's location +// This allows calling the CLI without relying on package.json scripts +const MAIN_PATH = (() => { + const thisFile = new URL(import.meta.url).pathname + const srcDir = thisFile.substring(0, thisFile.lastIndexOf("/")) + return `${srcDir}/cli/main.ts` +})() + +/** Union of execution events for streaming */ +export type ExecutionEvent = ExecutionStartEvent | ExecutionOutputEvent | ExecutionCompleteEvent + +/** Interface for code executor */ +interface CodeExecutorInterface { + /** + * Execute a TypeScript file via the codemode run CLI command. + * Streams execution events: start, output chunks, complete. + * Note: Scope is managed internally - stream is self-scoped. + */ + readonly execute: ( + indexPath: string, + requestId: RequestId, + codeblockId: CodeblockId + ) => Stream.Stream +} + +export class CodeExecutor extends Context.Tag("@app/CodeExecutor")< + CodeExecutor, + CodeExecutorInterface +>() { + static readonly layer = Layer.effect( + CodeExecutor, + Effect.gen(function*() { + const executor = yield* CommandExecutor.CommandExecutor + const pathService = yield* Path.Path + + const execute = ( + indexPath: string, + requestId: RequestId, + codeblockId: CodeblockId + ): Stream.Stream => + pipe( + Stream.make(new ExecutionStartEvent({ requestId, codeblockId })), + Stream.concat( + // Use unwrapScoped to manage subprocess lifecycle internally + Stream.unwrapScoped( + Effect.gen(function*() { + // Get the directory containing index.ts + const blockDir = pathService.dirname(indexPath) + + // Call the CLI command: bun codemode run + // Using absolute path to main.ts to avoid relying on package.json scripts + const cmd = Command.make("bun", MAIN_PATH, "codemode", "run", blockDir) + const process = yield* executor.start(cmd) + + // Stream stdout and stderr + // Note: stdout may contain __CODEMODE_RESULT__ marker - we filter it out + const stdoutStream = pipe( + process.stdout, + Stream.decodeText(), + Stream.map((data) => { + // Remove the result marker from output + const cleaned = data.replace(new RegExp(`\\n?${CODEMODE_RESULT_MARKER}\\n?`, "g"), "") + return new ExecutionOutputEvent({ + requestId, + codeblockId, + stream: "stdout", + data: cleaned + }) + }), + // Filter out empty chunks after marker removal + Stream.filter((event) => event.data.length > 0) + ) + + const stderrStream = pipe( + process.stderr, + Stream.decodeText(), + Stream.map( + (data) => + new ExecutionOutputEvent({ + requestId, + codeblockId, + stream: "stderr", + data + }) + ) + ) + + // Merge streams and append completion event + return pipe( + Stream.merge(stdoutStream, stderrStream), + Stream.concat( + Stream.fromEffect( + Effect.gen(function*() { + const exitCode = yield* process.exitCode + return new ExecutionCompleteEvent({ requestId, codeblockId, exitCode }) + }) + ) + ) + ) + }) + ) + ) + ) + + return CodeExecutor.of({ execute }) + }) + ) + + static readonly testLayer = Layer.succeed( + CodeExecutor, + CodeExecutor.of({ + execute: (_indexPath, requestId, codeblockId) => + Stream.make( + new ExecutionStartEvent({ requestId, codeblockId }), + new ExecutionOutputEvent({ + requestId, + codeblockId, + stream: "stdout", + data: "mock execution output\n" + }), + new ExecutionCompleteEvent({ requestId, codeblockId, exitCode: 0 }) + ) + }) + ) +} diff --git a/src/codemode-run.ts b/src/codemode-run.ts new file mode 100644 index 0000000..6d2ee6c --- /dev/null +++ b/src/codemode-run.ts @@ -0,0 +1,151 @@ +/** + * Codemode Run Command + * + * Standalone CLI command to execute a codemode block directory. + * Called by the agent loop via subprocess for clean separation. + * + * Usage: mini-agent codemode run + * + * The path should contain: + * - index.ts: The generated code with `export default async (t: Tools) => { ... }` + * - types.ts: Type definitions (not used at runtime, just for typecheck) + * + * Output channels: + * - stdout: Agent-visible output (triggers loop continuation if non-empty) + * - stderr: User-visible output (sendMessage writes here) + * + * Outputs __CODEMODE_RESULT__ marker when execution completes. + */ +import { Args, Command } from "@effect/cli" +import { Path } from "@effect/platform" +import { Console, Effect } from "effect" + +/** Result marker - signals execution complete, separates output from noise */ +export const CODEMODE_RESULT_MARKER = "__CODEMODE_RESULT__" + +/** + * Tools implementation provided to executed code. + * Combines Montreal tools (readFile, writeFile, exec, fetch, getSecret) + * with Kathmandu utilities (calculate, now, sleep). + */ +const createTools = () => ({ + // Send message to user (stderr - user sees, agent doesn't, no turn trigger) + sendMessage: async (message: string): Promise => { + process.stderr.write(message + "\n") + }, + + // Filesystem operations + readFile: async (path: string): Promise => { + return await Bun.file(path).text() + }, + + writeFile: async (path: string, content: string): Promise => { + await Bun.write(path, content) + }, + + // Shell execution + exec: async (command: string): Promise<{ stdout: string; stderr: string; exitCode: number }> => { + const proc = Bun.spawn(["sh", "-c", command], { + stdout: "pipe", + stderr: "pipe" + }) + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + const exitCode = await proc.exited + return { stdout, stderr, exitCode } + }, + + // HTTP fetch + fetch: async (url: string): Promise => { + const response = await globalThis.fetch(url) + return await response.text() + }, + + // Secret access (reads CODEMODE_SECRET_* env vars) + getSecret: async (name: string): Promise => { + const envKey = "CODEMODE_SECRET_" + name.toUpperCase().replace(/-/g, "_") + return process.env[envKey] + }, + + // Kathmandu utilities + calculate: async (expression: string): Promise<{ result: number; steps: Array }> => { + const steps: Array = [] + steps.push(`Parsing expression: ${expression}`) + steps.push("Evaluating...") + // Simple eval - in production use a proper math parser + const result = Function(`"use strict"; return (${expression})`)() as number + steps.push(`Result: ${result}`) + return { result, steps } + }, + + now: async (): Promise => { + return new Date().toISOString() + }, + + sleep: async (ms: number): Promise => { + await new Promise((r) => setTimeout(r, ms)) + } +}) + +/** Execute a codemode block from a directory */ +const runCodemodeBlock = (blockDir: string) => + Effect.gen(function*() { + const pathService = yield* Path.Path + + const indexPath = pathService.join(blockDir, "index.ts") + + yield* Effect.logDebug("Executing codemode block", { blockDir, indexPath }) + + // Import the module dynamically + const mod = yield* Effect.tryPromise({ + try: () => import(indexPath), + catch: (error) => new Error(`Failed to import module: ${error}`) + }) + + const main = mod.default + + if (typeof main !== "function") { + yield* Console.error("Generated code must export a default function") + return yield* Effect.fail(new Error("No default export function")) + } + + // Create tools and execute + const tools = createTools() + + yield* Effect.tryPromise({ + try: () => main(tools), + catch: (error) => { + // Runtime errors go to stderr for user visibility + process.stderr.write(`Runtime error: ${error}\n`) + return new Error(`Execution failed: ${error}`) + } + }) + + // Output completion marker (stdout - agent sees this) + yield* Console.log(`\n${CODEMODE_RESULT_MARKER}`) + }).pipe( + Effect.catchAllDefect((defect) => + Effect.gen(function*() { + yield* Console.error(`Fatal error: ${defect}`) + return yield* Effect.fail(defect) + }) + ), + Effect.provide(Path.layer) + ) + +/** The codemode run subcommand */ +export const codemodeRunCommand = Command.make( + "run", + { + path: Args.directory({ name: "path" }).pipe( + Args.withDescription("Path to codeblock directory containing index.ts") + ) + }, + ({ path }) => runCodemodeBlock(path) +).pipe(Command.withDescription("Execute a codemode block from a directory")) + +/** Parent codemode command with subcommands */ +export const codemodeCommand = Command.make("codemode", {}).pipe( + Command.withSubcommands([codemodeRunCommand]), + Command.withDescription("Codemode execution commands") +) diff --git a/src/codemode.model.test.ts b/src/codemode.model.test.ts new file mode 100644 index 0000000..7d71263 --- /dev/null +++ b/src/codemode.model.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect, Option } from "effect" +import { countCodeBlocks, hasCodeBlock, makeCodeblockId, parseCodeBlock, parseCodeBlocks } from "./codemode.model.ts" + +describe("parseCodeBlock", () => { + it.effect("extracts code from simple codemode block", () => + Effect.gen(function*() { + const text = `Here is some code: + +const x = 1 +console.log(x) + +That's it!` + + const result = yield* parseCodeBlock(text) + expect(Option.isSome(result)).toBe(true) + expect(Option.getOrThrow(result)).toBe("const x = 1\nconsole.log(x)") + })) + + it.effect("extracts code with markdown fences", () => + Effect.gen(function*() { + const text = ` +\`\`\`typescript +export default async function(t: Tools) { + const result = await t.add() + console.log(result) +} +\`\`\` +` + + const result = yield* parseCodeBlock(text) + expect(Option.isSome(result)).toBe(true) + const code = Option.getOrThrow(result) + expect(code).toContain("export default async function") + expect(code).not.toContain("```") + })) + + it.effect("returns none when no markers present", () => + Effect.gen(function*() { + const text = "Just some regular text without code" + const result = yield* parseCodeBlock(text) + expect(Option.isNone(result)).toBe(true) + })) + + it.effect("returns none when only start marker present", () => + Effect.gen(function*() { + const text = "some code without end" + const result = yield* parseCodeBlock(text) + expect(Option.isNone(result)).toBe(true) + })) + + it.effect("returns none when only end marker present", () => + Effect.gen(function*() { + const text = "some text" + const result = yield* parseCodeBlock(text) + expect(Option.isNone(result)).toBe(true) + })) + + it.effect("returns none for empty code block", () => + Effect.gen(function*() { + const text = " " + const result = yield* parseCodeBlock(text) + expect(Option.isNone(result)).toBe(true) + })) +}) + +describe("parseCodeBlocks", () => { + it.effect("extracts single codeblock", () => + Effect.gen(function*() { + const text = `const x = 1` + const blocks = yield* parseCodeBlocks(text) + expect(blocks.length).toBe(1) + expect(blocks[0]!.code).toBe("const x = 1") + expect(blocks[0]!.codeblockId).toBe(makeCodeblockId(1)) + })) + + it.effect("extracts multiple codeblocks with sequential IDs", () => + Effect.gen(function*() { + const text = `First block: + +const a = 1 + +Some text in between. + +const b = 2 + +And a third: + +const c = 3 +` + + const blocks = yield* parseCodeBlocks(text) + expect(blocks.length).toBe(3) + + expect(blocks[0]!.code).toBe("const a = 1") + expect(blocks[0]!.codeblockId).toBe(makeCodeblockId(1)) + + expect(blocks[1]!.code).toBe("const b = 2") + expect(blocks[1]!.codeblockId).toBe(makeCodeblockId(2)) + + expect(blocks[2]!.code).toBe("const c = 3") + expect(blocks[2]!.codeblockId).toBe(makeCodeblockId(3)) + })) + + it.effect("returns empty array when no codeblocks", () => + Effect.gen(function*() { + const text = "Just plain text" + const blocks = yield* parseCodeBlocks(text) + expect(blocks.length).toBe(0) + })) + + it.effect("skips empty codeblocks", () => + Effect.gen(function*() { + const text = ` +valid code` + const blocks = yield* parseCodeBlocks(text) + expect(blocks.length).toBe(1) + expect(blocks[0]!.code).toBe("valid code") + expect(blocks[0]!.codeblockId).toBe(makeCodeblockId(1)) // ID starts at 1, not 2 + })) + + it.effect("handles markdown fences in multiple blocks", () => + Effect.gen(function*() { + const text = ` +\`\`\`typescript +const a = 1 +\`\`\` + + +\`\`\`ts +const b = 2 +\`\`\` +` + + const blocks = yield* parseCodeBlocks(text) + expect(blocks.length).toBe(2) + expect(blocks[0]!.code).not.toContain("```") + expect(blocks[1]!.code).not.toContain("```") + })) +}) + +describe("countCodeBlocks", () => { + it("returns 0 for no codeblocks", () => { + expect(countCodeBlocks("just text")).toBe(0) + }) + + it("returns 1 for single codeblock", () => { + expect(countCodeBlocks("code")).toBe(1) + }) + + it("returns correct count for multiple codeblocks", () => { + const text = "a text b more c" + expect(countCodeBlocks(text)).toBe(3) + }) + + it("handles unclosed blocks correctly", () => { + const text = "a unclosed" + expect(countCodeBlocks(text)).toBe(1) + }) +}) + +describe("hasCodeBlock", () => { + it("returns true when both markers present", () => { + expect(hasCodeBlock("code")).toBe(true) + }) + + it("returns false when start marker missing", () => { + expect(hasCodeBlock("code")).toBe(false) + }) + + it("returns false when end marker missing", () => { + expect(hasCodeBlock("code")).toBe(false) + }) + + it("returns false for plain text", () => { + expect(hasCodeBlock("just some text")).toBe(false) + }) +}) diff --git a/src/codemode.model.ts b/src/codemode.model.ts new file mode 100644 index 0000000..706e021 --- /dev/null +++ b/src/codemode.model.ts @@ -0,0 +1,188 @@ +/** + * Codemode Event Schemas + * + * Codemode allows the LLM to emit TypeScript code blocks that get: + * 1. Parsed from ... markers in assistant responses + * 2. Stored to filesystem with proper structure + * 3. Typechecked with TypeScript compiler + * 4. Executed via bun subprocess + * + * Events flow through the system as the code is processed. + * Each codeblock in a response gets its own ID and lifecycle. + */ +import { Effect, Option, Schema } from "effect" + +/** Branded type for request IDs - timestamps like "2025-12-04_15-30-00-123" */ +export const RequestId = Schema.String.pipe(Schema.brand("RequestId")) +export type RequestId = typeof RequestId.Type + +/** Branded type for codeblock IDs - sequential within a request ("1", "2", "3"...) */ +export const CodeblockId = Schema.String.pipe(Schema.brand("CodeblockId")) +export type CodeblockId = typeof CodeblockId.Type + +/** @deprecated Alias for RequestId for backwards compatibility */ +export const ResponseId = RequestId +export type ResponseId = RequestId + +/** Parsed codeblock with its ID */ +export interface ParsedCodeBlock { + readonly code: string + readonly codeblockId: CodeblockId +} + +/** Code block extracted from assistant response */ +export class CodeBlockEvent extends Schema.TaggedClass()("CodeBlock", { + code: Schema.String, + requestId: RequestId, + codeblockId: CodeblockId, + attempt: Schema.Number +}) {} + +/** Typecheck started */ +export class TypecheckStartEvent extends Schema.TaggedClass()("TypecheckStart", { + requestId: RequestId, + codeblockId: CodeblockId, + attempt: Schema.Number +}) {} + +/** Typecheck passed */ +export class TypecheckPassEvent extends Schema.TaggedClass()("TypecheckPass", { + requestId: RequestId, + codeblockId: CodeblockId, + attempt: Schema.Number +}) {} + +/** Typecheck failed with errors */ +export class TypecheckFailEvent extends Schema.TaggedClass()("TypecheckFail", { + requestId: RequestId, + codeblockId: CodeblockId, + attempt: Schema.Number, + errors: Schema.String +}) {} + +/** Code execution started */ +export class ExecutionStartEvent extends Schema.TaggedClass()("ExecutionStart", { + requestId: RequestId, + codeblockId: CodeblockId +}) {} + +/** Streaming output from code execution */ +export class ExecutionOutputEvent extends Schema.TaggedClass()("ExecutionOutput", { + requestId: RequestId, + codeblockId: CodeblockId, + stream: Schema.Literal("stdout", "stderr"), + data: Schema.String +}) {} + +/** Code execution completed */ +export class ExecutionCompleteEvent extends Schema.TaggedClass()("ExecutionComplete", { + requestId: RequestId, + codeblockId: CodeblockId, + exitCode: Schema.Number +}) {} + +/** All codemode events */ +export const CodemodeEvent = Schema.Union( + CodeBlockEvent, + TypecheckStartEvent, + TypecheckPassEvent, + TypecheckFailEvent, + ExecutionStartEvent, + ExecutionOutputEvent, + ExecutionCompleteEvent +) +export type CodemodeEvent = typeof CodemodeEvent.Type + +/** Code block extraction markers */ +const CODEMODE_START = "" +const CODEMODE_END = "" + +/** Extract code from markdown fences if present */ +const stripMarkdownFences = (code: string): string => { + const trimmed = code.trim() + const match = trimmed.match(/^```(?:typescript|ts)?\n?([\s\S]*?)\n?```$/) + return match ? match[1]! : trimmed +} + +/** + * Parse ALL codemode blocks from text content. + * Returns array of parsed blocks, each with its codeblock ID. + */ +export const parseCodeBlocks = (text: string): Effect.Effect> => + Effect.sync(() => { + const blocks: Array = [] + let searchStart = 0 + let blockIndex = 1 + + while (true) { + const startIdx = text.indexOf(CODEMODE_START, searchStart) + if (startIdx === -1) break + + const afterStart = startIdx + CODEMODE_START.length + const endIdx = text.indexOf(CODEMODE_END, afterStart) + if (endIdx === -1) break + + const rawCode = text.slice(afterStart, endIdx) + const code = stripMarkdownFences(rawCode) + + if (code.trim()) { + blocks.push({ + code, + codeblockId: makeCodeblockId(blockIndex) + }) + blockIndex++ + } + + searchStart = endIdx + CODEMODE_END.length + } + + return blocks + }) + +/** + * Parse first codemode block from text content. + * Returns Option.some with the extracted code if markers are found. + * @deprecated Use parseCodeBlocks for multiple block support + */ +export const parseCodeBlock = (text: string): Effect.Effect> => + Effect.map(parseCodeBlocks(text), (blocks) => blocks.length > 0 ? Option.some(blocks[0]!.code) : Option.none()) + +/** Check if text contains codemode markers */ +export const hasCodeBlock = (text: string): boolean => text.includes(CODEMODE_START) && text.includes(CODEMODE_END) + +/** Count codemode blocks in text */ +export const countCodeBlocks = (text: string): number => { + let count = 0 + let searchStart = 0 + + while (true) { + const startIdx = text.indexOf(CODEMODE_START, searchStart) + if (startIdx === -1) break + + const afterStart = startIdx + CODEMODE_START.length + const endIdx = text.indexOf(CODEMODE_END, afterStart) + if (endIdx === -1) break + + count++ + searchStart = endIdx + CODEMODE_END.length + } + + return count +} + +/** Generate a request ID from current timestamp with milliseconds for uniqueness */ +export const generateRequestId = (): Effect.Effect => + Effect.sync(() => { + const now = new Date() + const pad = (n: number, len = 2) => n.toString().padStart(len, "0") + const id = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${ + pad(now.getMinutes()) + }-${pad(now.getSeconds())}-${pad(now.getMilliseconds(), 3)}` + return id as RequestId + }) + +/** @deprecated Use generateRequestId instead */ +export const generateResponseId = generateRequestId + +/** Generate a codeblock ID from a sequence number */ +export const makeCodeblockId = (n: number): CodeblockId => String(n) as CodeblockId diff --git a/src/codemode.repository.ts b/src/codemode.repository.ts new file mode 100644 index 0000000..c7c3d27 --- /dev/null +++ b/src/codemode.repository.ts @@ -0,0 +1,235 @@ +/** + * Codemode Repository + * + * Manages storage of generated code files in context-scoped directories. + * Structure: .mini-agent/contexts//// + * + * Each codeblock directory contains: + * - index.ts: The generated code + * - types.ts: Type definitions for available tools + * - tsconfig.json: TypeScript compiler config + */ +import { FileSystem, Path } from "@effect/platform" +import { Context, Effect, Layer, Option } from "effect" +import type { CodeblockId, RequestId } from "./codemode.model.ts" +import { AppConfig } from "./config.ts" +import { CodeStorageError } from "./errors.ts" + +/** Default tsconfig for generated code */ +const DEFAULT_TSCONFIG = JSON.stringify( + { + compilerOptions: { + target: "ESNext", + module: "ESNext", + moduleResolution: "bundler", + strict: true, + noEmit: true, + skipLibCheck: true, + noUncheckedIndexedAccess: true, + lib: ["ESNext"] + } + }, + null, + 2 +) + +/** Default types.ts defining available tools */ +const DEFAULT_TYPES = `/** + * Tools available to generated code. + * The default function receives this interface and returns Promise. + * + * Output channels: + * - t.sendMessage(): writes to stderr -> user sees, agent does NOT + * - console.log(): writes to stdout -> agent sees, may trigger continuation + */ +export interface Tools { + /** Send a message to the USER. They see this. Does NOT trigger another turn. */ + readonly sendMessage: (message: string) => Promise + + /** Read a file from the filesystem */ + readonly readFile: (path: string) => Promise + + /** Write a file to the filesystem */ + readonly writeFile: (path: string, content: string) => Promise + + /** Execute a shell command */ + readonly exec: (command: string) => Promise<{ stdout: string; stderr: string; exitCode: number }> + + /** Fetch a URL and return its content */ + readonly fetch: (url: string) => Promise + + /** Get a secret value. The implementation is hidden from the LLM. */ + readonly getSecret: (name: string) => Promise + + /** Evaluate a mathematical expression */ + readonly calculate: (expression: string) => Promise<{ result: number; steps: Array }> + + /** Get current timestamp as ISO string */ + readonly now: () => Promise + + /** Sleep for specified milliseconds */ + readonly sleep: (ms: number) => Promise +} +` + +/** Location of a codeblock within the context structure */ +export interface CodeblockLocation { + readonly contextName: string + readonly requestId: RequestId + readonly codeblockId: CodeblockId +} + +/** CodemodeRepository interface */ +interface CodemodeRepositoryService { + /** Get the codeblock directory path */ + readonly getCodeblockDir: (loc: CodeblockLocation) => Effect.Effect + + /** Create the codeblock directory with all necessary files */ + readonly createCodeblockDir: (loc: CodeblockLocation) => Effect.Effect + + /** Write the generated code to index.ts */ + readonly writeCode: ( + loc: CodeblockLocation, + code: string, + attempt: number + ) => Effect.Effect + + /** Get the index.ts path for a codeblock */ + readonly getCodePath: (loc: CodeblockLocation) => Effect.Effect +} + +export class CodemodeRepository extends Context.Tag("@app/CodemodeRepository")< + CodemodeRepository, + CodemodeRepositoryService +>() { + static readonly layer = Layer.effect( + CodemodeRepository, + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const pathService = yield* Path.Path + const config = yield* AppConfig + const cwd = Option.getOrElse(config.cwd, () => process.cwd()) + const contextsDir = pathService.join(cwd, config.dataStorageDir, "contexts") + + /** Build path to codeblock directory */ + const buildCodeblockPath = (loc: CodeblockLocation) => + pathService.join(contextsDir, loc.contextName, loc.requestId, loc.codeblockId) + + const getCodeblockDir = (loc: CodeblockLocation) => Effect.succeed(buildCodeblockPath(loc)) + + const createCodeblockDir = (loc: CodeblockLocation) => + Effect.gen(function*() { + const dir = buildCodeblockPath(loc) + + yield* fs.makeDirectory(dir, { recursive: true }).pipe( + Effect.mapError( + (e) => + new CodeStorageError({ + message: `Failed to create directory: ${dir}`, + cause: e + }) + ) + ) + + // Write tsconfig.json + yield* fs.writeFileString(pathService.join(dir, "tsconfig.json"), DEFAULT_TSCONFIG).pipe( + Effect.mapError( + (e) => + new CodeStorageError({ + message: "Failed to write tsconfig.json", + cause: e + }) + ) + ) + + // Write types.ts + yield* fs.writeFileString(pathService.join(dir, "types.ts"), DEFAULT_TYPES).pipe( + Effect.mapError( + (e) => + new CodeStorageError({ + message: "Failed to write types.ts", + cause: e + }) + ) + ) + + return dir + }) + + const writeCode = (loc: CodeblockLocation, code: string, attempt: number) => + Effect.gen(function*() { + const dir = buildCodeblockPath(loc) + + // Prepend import statement + const fullCode = `import type { Tools } from "./types.ts"\n\n${code}` + + // For attempt > 1, save previous attempts + const filename = attempt > 1 ? `index.attempt-${attempt}.ts` : "index.ts" + const filePath = pathService.join(dir, filename) + + yield* fs.writeFileString(filePath, fullCode).pipe( + Effect.mapError( + (e) => + new CodeStorageError({ + message: `Failed to write code to ${filename}`, + cause: e + }) + ) + ) + + // Always update index.ts with latest attempt + if (attempt > 1) { + yield* fs.writeFileString(pathService.join(dir, "index.ts"), fullCode).pipe( + Effect.mapError( + (e) => + new CodeStorageError({ + message: "Failed to write index.ts", + cause: e + }) + ) + ) + } + + return filePath + }) + + const getCodePath = (loc: CodeblockLocation) => + Effect.succeed(pathService.join(buildCodeblockPath(loc), "index.ts")) + + return CodemodeRepository.of({ + getCodeblockDir, + createCodeblockDir, + writeCode, + getCodePath + }) + }) + ) + + static readonly testLayer = Layer.sync(CodemodeRepository, () => { + const store = new Map>() + + const getKey = (loc: CodeblockLocation) => `${loc.contextName}/${loc.requestId}/${loc.codeblockId}` + + const getOrCreateDir = (loc: CodeblockLocation) => { + const key = getKey(loc) + if (!store.has(key)) { + store.set(key, new Map()) + } + return store.get(key)! + } + + return CodemodeRepository.of({ + getCodeblockDir: (loc) => Effect.succeed(`/tmp/.mini-agent/contexts/${getKey(loc)}`), + createCodeblockDir: (loc) => { + getOrCreateDir(loc) + return Effect.succeed(`/tmp/.mini-agent/contexts/${getKey(loc)}`) + }, + writeCode: (loc, code, _attempt) => { + const dir = getOrCreateDir(loc) + dir.set("index.ts", code) + return Effect.succeed(`/tmp/.mini-agent/contexts/${getKey(loc)}/index.ts`) + }, + getCodePath: (loc) => Effect.succeed(`/tmp/.mini-agent/contexts/${getKey(loc)}/index.ts`) + }) + }) +} diff --git a/src/codemode.service.test.ts b/src/codemode.service.test.ts new file mode 100644 index 0000000..ef8e293 --- /dev/null +++ b/src/codemode.service.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect, Option, Stream } from "effect" +import { CodeBlockEvent, TypecheckPassEvent, TypecheckStartEvent } from "./codemode.model.ts" +import { CodemodeService } from "./codemode.service.ts" + +describe("CodemodeService", () => { + const testLayer = CodemodeService.testLayer + + it.effect("returns none for content without code block", () => + Effect.gen(function*() { + const service = yield* CodemodeService + const result = yield* service.processResponse("test-context", "Just some regular text") + expect(Option.isNone(result)).toBe(true) + }).pipe(Effect.provide(testLayer))) + + it.effect("returns stream for content with code block", () => + Effect.gen(function*() { + const service = yield* CodemodeService + const content = `Here is some code: + +export default async function(t) { + await t.log("Hello!") +} +` + + const result = yield* service.processResponse("test-context", content) + expect(Option.isSome(result)).toBe(true) + + if (Option.isSome(result)) { + const events = yield* Stream.runCollect(result.value).pipe(Effect.scoped) + const eventArray = Array.from(events) + + expect(eventArray.length).toBe(3) + expect(eventArray[0]).toBeInstanceOf(CodeBlockEvent) + expect(eventArray[1]).toBeInstanceOf(TypecheckStartEvent) + expect(eventArray[2]).toBeInstanceOf(TypecheckPassEvent) + } + }).pipe(Effect.provide(testLayer))) + + it.effect("hasCodeBlock returns true for valid markers", () => + Effect.gen(function*() { + const service = yield* CodemodeService + expect(service.hasCodeBlock("code")).toBe(true) + expect(service.hasCodeBlock("no markers here")).toBe(false) + }).pipe(Effect.provide(testLayer))) +}) diff --git a/src/codemode.service.ts b/src/codemode.service.ts new file mode 100644 index 0000000..2e881fe --- /dev/null +++ b/src/codemode.service.ts @@ -0,0 +1,201 @@ +/** + * Codemode Service + * + * Orchestrates the codemode workflow: + * 1. Detects code blocks in assistant responses + * 2. Stores code to filesystem + * 3. Typechecks with TypeScript compiler + * 4. Executes via bun subprocess + * 5. Streams events back for real-time feedback + * + * Supports multiple codeblocks per assistant message. + */ +import type { Error as PlatformError } from "@effect/platform" +import { Context, Effect, Layer, Option, pipe, Stream } from "effect" +import { CodeExecutor, type ExecutionEvent } from "./code-executor.service.ts" +import { + CodeBlockEvent, + type CodemodeEvent, + generateRequestId, + hasCodeBlock, + parseCodeBlocks, + type ParsedCodeBlock, + type RequestId, + TypecheckFailEvent, + TypecheckPassEvent, + TypecheckStartEvent +} from "./codemode.model.ts" +import { type CodeblockLocation, CodemodeRepository } from "./codemode.repository.ts" +import type { CodeStorageError } from "./errors.ts" +import { TypecheckService } from "./typechecker.service.ts" + +/** All events that flow through codemode processing */ +export type CodemodeStreamEvent = CodemodeEvent | ExecutionEvent + +/** Interface for codemode service */ +interface CodemodeServiceInterface { + /** + * Process assistant response text for code blocks. + * If code blocks found, store/typecheck/execute each and stream events. + * Returns Option.none if no code blocks, Option.some(stream) if code found. + */ + readonly processResponse: ( + contextName: string, + content: string + ) => Effect.Effect< + Option.Option>, + never, + never + > + + /** + * Check if content contains a code block. + */ + readonly hasCodeBlock: (content: string) => boolean +} + +export class CodemodeService extends Context.Tag("@app/CodemodeService")< + CodemodeService, + CodemodeServiceInterface +>() { + static readonly layer = Layer.effect( + CodemodeService, + Effect.gen(function*() { + const repo = yield* CodemodeRepository + const typechecker = yield* TypecheckService + const executor = yield* CodeExecutor + + /** Process a single codeblock and return its event stream */ + const processBlock = ( + loc: CodeblockLocation, + block: ParsedCodeBlock, + requestId: RequestId + ): Stream.Stream => + Stream.unwrap( + Effect.gen(function*() { + const { code, codeblockId } = block + + // Step 1: Create codeblock directory + yield* repo.createCodeblockDir(loc) + + // Step 2: Write code + const codePath = yield* repo.writeCode(loc, code, 1) + + // Step 3: Typecheck + const typecheckResult = yield* typechecker.check([codePath]) + + if (Option.isSome(typecheckResult)) { + // Typecheck failed - emit events and stop + yield* Effect.logWarning("Typecheck failed", { + contextName: loc.contextName, + requestId, + codeblockId, + diagnostics: typecheckResult.value.diagnostics + }) + + return Stream.make( + new CodeBlockEvent({ code, requestId, codeblockId, attempt: 1 }) as CodemodeStreamEvent, + new TypecheckStartEvent({ requestId, codeblockId, attempt: 1 }) as CodemodeStreamEvent, + new TypecheckFailEvent({ + requestId, + codeblockId, + attempt: 1, + errors: typecheckResult.value.diagnostics + }) as CodemodeStreamEvent + ) + } + + // Typecheck passed - emit events and execute + yield* Effect.logDebug("Typecheck passed", { contextName: loc.contextName, requestId, codeblockId }) + + return pipe( + Stream.make( + new CodeBlockEvent({ code, requestId, codeblockId, attempt: 1 }) as CodemodeStreamEvent, + new TypecheckStartEvent({ requestId, codeblockId, attempt: 1 }) as CodemodeStreamEvent, + new TypecheckPassEvent({ requestId, codeblockId, attempt: 1 }) as CodemodeStreamEvent + ), + Stream.concat(executor.execute(codePath, requestId, codeblockId)) + ) + }) + ) + + const processResponse = ( + contextName: string, + content: string + ): Effect.Effect< + Option.Option>, + never, + never + > => + Effect.gen(function*() { + const blocks = yield* parseCodeBlocks(content) + + if (blocks.length === 0) { + return Option.none() + } + + const requestId = yield* generateRequestId() + + // Process all blocks sequentially, concatenating their event streams + const stream: Stream.Stream< + CodemodeStreamEvent, + PlatformError.PlatformError | CodeStorageError, + never + > = Stream.fromIterable(blocks).pipe( + Stream.flatMap((block) => { + const loc: CodeblockLocation = { + contextName, + requestId, + codeblockId: block.codeblockId + } + return processBlock(loc, block, requestId) + }) + ) + + return Option.some(stream) + }) + + return CodemodeService.of({ + processResponse, + hasCodeBlock + }) + }) + ) + + static readonly testLayer = Layer.succeed( + CodemodeService, + CodemodeService.of({ + processResponse: (_contextName, content) => + Effect.gen(function*() { + const blocks = yield* parseCodeBlocks(content) + + if (blocks.length === 0) { + return Option.none< + Stream.Stream + >() + } + + const requestId = "test-response-id" as RequestId + + // Create events for each block + const allEvents: Array = [] + for (const block of blocks) { + allEvents.push( + new CodeBlockEvent({ code: block.code, requestId, codeblockId: block.codeblockId, attempt: 1 }), + new TypecheckStartEvent({ requestId, codeblockId: block.codeblockId, attempt: 1 }), + new TypecheckPassEvent({ requestId, codeblockId: block.codeblockId, attempt: 1 }) + ) + } + + const stream: Stream.Stream< + CodemodeStreamEvent, + PlatformError.PlatformError | CodeStorageError, + never + > = Stream.fromIterable(allEvents) + + return Option.some(stream) + }), + hasCodeBlock + }) + ) +} diff --git a/src/context.model.ts b/src/context.model.ts index 532faff..5e873d5 100644 --- a/src/context.model.ts +++ b/src/context.model.ts @@ -18,6 +18,10 @@ import { LlmConfig } from "./llm-config.ts" export const ContextName = Schema.String.pipe(Schema.brand("ContextName")) export type ContextName = typeof ContextName.Type +/** Controls whether an event triggers an agent turn after it's processed */ +export const TriggerAgentTurn = Schema.Literal("after-current-turn", "never") +export type TriggerAgentTurn = typeof TriggerAgentTurn.Type + /** Message format for LLM APIs and tracing */ export interface LLMMessage { readonly role: "system" | "user" | "assistant" @@ -26,7 +30,8 @@ export interface LLMMessage { /** System prompt event - sets the AI's behavior */ export class SystemPromptEvent extends Schema.TaggedClass()("SystemPrompt", { - content: Schema.String + content: Schema.String, + triggerAgentTurn: Schema.optionalWith(TriggerAgentTurn, { default: () => "never" as const }) }) { toLLMMessage(): LLMMessage { return { role: "system", content: this.content } @@ -35,7 +40,8 @@ export class SystemPromptEvent extends Schema.TaggedClass()(" /** User message event - input from the user */ export class UserMessageEvent extends Schema.TaggedClass()("UserMessage", { - content: Schema.String + content: Schema.String, + triggerAgentTurn: Schema.optionalWith(TriggerAgentTurn, { default: () => "after-current-turn" as const }) }) { toLLMMessage(): LLMMessage { return { role: "user", content: this.content } @@ -44,7 +50,8 @@ export class UserMessageEvent extends Schema.TaggedClass()("Us /** Assistant message event - complete response from the AI */ export class AssistantMessageEvent extends Schema.TaggedClass()("AssistantMessage", { - content: Schema.String + content: Schema.String, + triggerAgentTurn: Schema.optionalWith(TriggerAgentTurn, { default: () => "never" as const }) }) { toLLMMessage(): LLMMessage { return { role: "assistant", content: this.content } @@ -86,16 +93,64 @@ export class FileAttachmentEvent extends Schema.TaggedClass { source: AttachmentSource, mediaType: Schema.String, - fileName: Schema.optional(Schema.String) + fileName: Schema.optional(Schema.String), + triggerAgentTurn: Schema.optionalWith(TriggerAgentTurn, { default: () => "never" as const }) } ) {} /** Sets the LLM config for this context. Added when context is created. */ export class SetLlmConfigEvent extends Schema.TaggedClass()( "SetLlmConfig", - { config: LlmConfig } + { + config: LlmConfig, + triggerAgentTurn: Schema.optionalWith(TriggerAgentTurn, { default: () => "never" as const }) + } ) {} +/** Codemode execution result - persisted, included in next LLM request as user message */ +export class CodemodeResultEvent extends Schema.TaggedClass()( + "CodemodeResult", + { + stdout: Schema.String, + stderr: Schema.String, + exitCode: Schema.Number, + triggerAgentTurn: TriggerAgentTurn + } +) { + toLLMMessage(): LLMMessage { + const parts: Array = [] + if (this.stdout) parts.push(this.stdout) + if (this.stderr) parts.push(`stderr:\n${this.stderr}`) + if (this.exitCode !== 0) parts.push(`(exit code: ${this.exitCode})`) + const output = parts.join("\n") || "(no output)" + return { + role: "user", + content: `Code execution result:\n\`\`\`\n${output}\n\`\`\`` + } + } +} + +/** Emitted when LLM response doesn't contain codemode when it should - triggers retry */ +export class CodemodeValidationErrorEvent extends Schema.TaggedClass()( + "CodemodeValidationError", + { + assistantContent: Schema.String, + triggerAgentTurn: Schema.optionalWith(TriggerAgentTurn, { default: () => "after-current-turn" as const }) + } +) { + toLLMMessage(): LLMMessage { + return { + role: "user", + content: + `ERROR: Your response MUST contain tags with TypeScript code. You wrote plain text instead:\n\n"${ + this.assistantContent.slice(0, 200) + }${ + this.assistantContent.length > 200 ? "..." : "" + }"\n\nYou are a codemode agent. ALL responses must be TypeScript code wrapped in tags. Use t.sendMessage() to communicate with the user. Try again.` + } + } +} + /** Events that get persisted to the context file */ export const PersistedEvent = Schema.Union( SystemPromptEvent, @@ -103,7 +158,9 @@ export const PersistedEvent = Schema.Union( AssistantMessageEvent, LLMRequestInterruptedEvent, FileAttachmentEvent, - SetLlmConfigEvent + SetLlmConfigEvent, + CodemodeResultEvent, + CodemodeValidationErrorEvent ) export type PersistedEvent = typeof PersistedEvent.Type @@ -115,6 +172,8 @@ export const ContextEvent = Schema.Union( LLMRequestInterruptedEvent, FileAttachmentEvent, SetLlmConfigEvent, + CodemodeResultEvent, + CodemodeValidationErrorEvent, TextDeltaEvent ) export type ContextEvent = typeof ContextEvent.Type @@ -126,3 +185,81 @@ export type InputEvent = typeof InputEvent.Type export const DEFAULT_SYSTEM_PROMPT = `You are a helpful, friendly assistant. Keep your responses concise but informative. Use markdown formatting when helpful.` + +export const CODEMODE_SYSTEM_PROMPT = `You are a coding assistant that executes TypeScript code to accomplish tasks. + +## How Codemode Works + +When you need to perform an action, you MUST write TypeScript code wrapped in codemode tags. +Your code will be typechecked and executed in a Bun subprocess. + +## Available Tools + +Your code receives a \`t\` object with these methods: + +\`\`\`typescript +interface Tools { + /** Send a message to the USER. They see this. Does NOT trigger another turn. */ + readonly sendMessage: (message: string) => Promise + + /** Read a file from the filesystem */ + readonly readFile: (path: string) => Promise + + /** Write a file to the filesystem */ + readonly writeFile: (path: string, content: string) => Promise + + /** Execute a shell command */ + readonly exec: (command: string) => Promise<{ stdout: string; stderr: string; exitCode: number }> + + /** Fetch a URL and return its content */ + readonly fetch: (url: string) => Promise + + /** Get a secret value by name */ + readonly getSecret: (name: string) => Promise +} +\`\`\` + +## What the User Sees vs What You See + +- **User sees**: Only what you pass to \`t.sendMessage()\` +- **You see**: Only what you \`console.log()\` — this triggers another turn + +Most tasks complete in ONE turn: do the work, call \`t.sendMessage()\` with the result, done. + +## Code Format + +Your code MUST: +- Be wrapped in \`\` and \`\` tags +- Export a default async function with EXPLICIT type annotations: \`(t: Tools): Promise\` +- Do NOT add import statements — \`Tools\` is automatically available + +CRITICAL: Always include the type annotations. The code is typechecked with strict mode (\`noImplicitAny\`). + +## Examples + +### Single-turn (most common) +User asks: "What is 2+2?" + +export default async function(t: Tools): Promise { + await t.sendMessage("2+2 = 4") +} + + +### Multi-turn (when you need to see data first) +User asks: "Summarize today's news" + +export default async function(t: Tools): Promise { + await t.sendMessage("Stand by - fetching news...") + const html = await t.fetch("https://news.ycombinator.com") + console.log(html) // You'll see this and can summarize in next turn +} + + +Then in your next turn, you see the fetched content and can respond with a summary. + +## Rules + +1. ALWAYS output executable code — never ask clarifying questions instead of acting +2. Use \`t.sendMessage()\` for messages the USER should see +3. Use \`console.log()\` only when YOU need to see data for a follow-up turn +4. Do NOT wrap code in markdown fences inside the codemode tags` diff --git a/src/context.repository.ts b/src/context.repository.ts index d4fbe4c..dab16ab 100644 --- a/src/context.repository.ts +++ b/src/context.repository.ts @@ -51,6 +51,7 @@ export class ContextRepository extends Context.Tag("@app/ContextRepository")< ) => Effect.Effect readonly list: () => Effect.Effect, ContextLoadError> readonly getContextsDir: () => string + readonly getContextDir: (contextName: string) => string } >() { /** @@ -67,7 +68,11 @@ export class ContextRepository extends Context.Tag("@app/ContextRepository")< const cwd = Option.getOrElse(config.cwd, () => process.cwd()) const contextsDir = path.join(cwd, config.dataStorageDir, "contexts") - const getContextPath = (contextName: string) => path.join(contextsDir, `${contextName}.yaml`) + /** Get directory for a context (each context gets its own folder) */ + const getContextDir = (contextName: string) => path.join(contextsDir, contextName) + + /** Get path to events.yaml for a context */ + const getContextPath = (contextName: string) => path.join(getContextDir(contextName), "events.yaml") // Service methods wrapped with Effect.fn for call-site tracing // See: https://www.effect.solutions/services-and-layers @@ -77,10 +82,11 @@ export class ContextRepository extends Context.Tag("@app/ContextRepository")< */ const save = Effect.fn("ContextRepository.save")( function*(contextName: string, events: ReadonlyArray) { + const contextDir = getContextDir(contextName) const filePath = getContextPath(contextName) - // Ensure directory exists - yield* fs.makeDirectory(contextsDir, { recursive: true }).pipe( + // Ensure context directory exists (each context gets its own folder) + yield* fs.makeDirectory(contextDir, { recursive: true }).pipe( Effect.catchAll(() => Effect.void) ) @@ -184,6 +190,7 @@ export class ContextRepository extends Context.Tag("@app/ContextRepository")< /** * List all existing context names, sorted by most recently modified first. + * Looks for directories containing events.yaml. */ const list = Effect.fn("ContextRepository.list")( function*() { @@ -198,7 +205,6 @@ export class ContextRepository extends Context.Tag("@app/ContextRepository")< if (!exists) return [] as Array const entries = yield* fs.readDirectory(contextsDir).pipe( - Effect.map((names) => names.filter((name) => name.endsWith(".yaml"))), Effect.catchAll((error) => new ContextLoadError({ name: ContextName.make(""), @@ -207,21 +213,22 @@ export class ContextRepository extends Context.Tag("@app/ContextRepository")< ) ) - // Get modification times for each file - const entriesWithTimes = yield* Effect.all( - entries.map((name) => - fs.stat(path.join(contextsDir, name)).pipe( - Effect.map((stat) => ({ - name: name.replace(/\.yaml$/, ""), - mtime: Option.getOrElse(stat.mtime, () => new Date(0)) - })), - Effect.catchAll(() => Effect.succeed({ name: name.replace(/\.yaml$/, ""), mtime: new Date(0) })) + // Filter to only directories that have events.yaml, and get mod times + const contextsWithTimes: Array<{ name: string; mtime: Date }> = [] + for (const entry of entries) { + const eventsPath = path.join(contextsDir, entry, "events.yaml") + const hasEvents = yield* fs.exists(eventsPath).pipe(Effect.catchAll(() => Effect.succeed(false))) + if (hasEvents) { + const stat = yield* fs.stat(eventsPath).pipe( + Effect.map((s) => Option.getOrElse(s.mtime, () => new Date(0))), + Effect.catchAll(() => Effect.succeed(new Date(0))) ) - ) - ) + contextsWithTimes.push({ name: entry, mtime: stat }) + } + } // Sort by modification time, most recent first - return entriesWithTimes + return contextsWithTimes .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()) .map((entry) => entry.name) } @@ -233,7 +240,8 @@ export class ContextRepository extends Context.Tag("@app/ContextRepository")< save, append, list, - getContextsDir: () => contextsDir + getContextsDir: () => contextsDir, + getContextDir }) }) ) @@ -263,7 +271,8 @@ export class ContextRepository extends Context.Tag("@app/ContextRepository")< store.set(contextName, [...existing, ...newEvents]) }), list: () => Effect.sync(() => Array.from(store.keys()).sort()), - getContextsDir: () => "/test/contexts" + getContextsDir: () => "/test/contexts", + getContextDir: (contextName: string) => `/test/contexts/${contextName}` }) }) } diff --git a/src/context.service.ts b/src/context.service.ts index 6669d59..34a484c 100644 --- a/src/context.service.ts +++ b/src/context.service.ts @@ -4,17 +4,26 @@ * The main domain service for working with Contexts. * * A Context is a named, ordered list of events representing a conversation. - * The only supported operation is `addEvents`: + * The `addEvents` operation handles a single turn: * 1. Appends input events (typically UserMessage) to the context * 2. Triggers an LLM request with the full event history * 3. Streams back new events (TextDelta ephemeral, AssistantMessage persisted) - * 4. Persists the new events to the context file + * 4. If codemode enabled, executes code blocks and streams codemode events + * 5. Persists new events as they complete + * + * The agent loop (iteration based on triggerAgentTurn) is handled by CLI. */ import type { AiError, LanguageModel } from "@effect/ai" import type { Error as PlatformError, FileSystem } from "@effect/platform" -import { Context, Effect, Layer, pipe, Schema, Stream } from "effect" +import { Context, Effect, Layer, Option, pipe, Schema, Stream } from "effect" +import { parseCodeBlock } from "./codemode.model.ts" +import type { CodemodeStreamEvent } from "./codemode.service.ts" +import { CodemodeService } from "./codemode.service.ts" import { AssistantMessageEvent, + CODEMODE_SYSTEM_PROMPT, + CodemodeResultEvent, + CodemodeValidationErrorEvent, type ContextEvent, DEFAULT_SYSTEM_PROMPT, type InputEvent, @@ -22,37 +31,44 @@ import { type PersistedEvent as PersistedEventType, SetLlmConfigEvent, SystemPromptEvent, - TextDeltaEvent, - UserMessageEvent + TextDeltaEvent } from "./context.model.ts" import { ContextRepository } from "./context.repository.ts" -import type { ContextLoadError, ContextSaveError } from "./errors.ts" +import type { CodeStorageError, ContextLoadError, ContextSaveError } from "./errors.ts" import { CurrentLlmConfig, LlmConfig } from "./llm-config.ts" import { streamLLMResponse } from "./llm.ts" -// ============================================================================= -// Context Service -// ============================================================================= +/** Options for addEvents */ +export interface AddEventsOptions { + readonly codemode?: boolean +} + +/** Union of context events and codemode streaming events */ +export type ContextOrCodemodeEvent = ContextEvent | CodemodeStreamEvent export class ContextService extends Context.Tag("@app/ContextService")< ContextService, { /** - * Add events to a context, triggering LLM processing if UserMessage present. + * Add events to a context, triggering LLM processing for a single turn. * - * This is the core operation on a Context: + * This handles one turn of the conversation: * 1. Loads existing events (or creates context with system prompt) - * 2. Appends the input events (UserMessage and/or FileAttachment) - * 3. Runs LLM with full history (only if UserMessage present) + * 2. Appends the input events (UserMessage, FileAttachment, CodemodeResult) + * 3. Runs LLM with full history (only if an event has triggerAgentTurn) * 4. Streams back TextDelta (ephemeral) and AssistantMessage (persisted) - * 5. Persists new events as they complete + * 5. If codemode enabled, executes code blocks and streams codemode events + * 6. Persists new events as they complete (including CodemodeResult) + * + * The caller (CLI) is responsible for iterating based on CodemodeResult.triggerAgentTurn. */ readonly addEvents: ( contextName: string, - inputEvents: ReadonlyArray + inputEvents: ReadonlyArray, + options?: AddEventsOptions ) => Stream.Stream< - ContextEvent, - AiError.AiError | PlatformError.PlatformError | ContextLoadError | ContextSaveError, + ContextOrCodemodeEvent, + AiError.AiError | PlatformError.PlatformError | ContextLoadError | ContextSaveError | CodeStorageError, LanguageModel.LanguageModel | FileSystem.FileSystem | CurrentLlmConfig > @@ -67,6 +83,12 @@ export class ContextService extends Context.Tag("@app/ContextService")< contextName: string, event: PersistedEventType ) => Effect.Effect + + /** Save events to a context (used by CLI for persisting CodemodeResult) */ + readonly save: ( + contextName: string, + events: ReadonlyArray + ) => Effect.Effect } >() { /** @@ -76,20 +98,177 @@ export class ContextService extends Context.Tag("@app/ContextService")< ContextService, Effect.gen(function*() { const repo = yield* ContextRepository - - // Service methods wrapped with Effect.fn for call-site tracing - // See: https://www.effect.solutions/services-and-layers + const codemodeService = yield* CodemodeService const addEvents = ( contextName: string, - inputEvents: ReadonlyArray + inputEvents: ReadonlyArray, + options?: AddEventsOptions ): Stream.Stream< - ContextEvent, - AiError.AiError | PlatformError.PlatformError | ContextLoadError | ContextSaveError, + ContextOrCodemodeEvent, + AiError.AiError | PlatformError.PlatformError | ContextLoadError | ContextSaveError | CodeStorageError, LanguageModel.LanguageModel | FileSystem.FileSystem | CurrentLlmConfig > => { - // Check if any UserMessage is present (triggers LLM) - const hasUserMessage = inputEvents.some(Schema.is(UserMessageEvent)) + // Check if any input event should trigger an agent turn + const inputTriggers = inputEvents.some( + (e) => "triggerAgentTurn" in e && e.triggerAgentTurn === "after-current-turn" + ) + const codemodeEnabled = options?.codemode ?? false + + /** Check if the last event in context triggers a turn (for agent loop continuation) */ + const contextTriggers = (events: ReadonlyArray): boolean => { + if (events.length === 0) return false + const lastEvent = events[events.length - 1] + return lastEvent !== undefined && + "triggerAgentTurn" in lastEvent && + lastEvent.triggerAgentTurn === "after-current-turn" + } + + /** Persist a single event to the context */ + const persistEvent = (event: PersistedEventType) => + Effect.gen(function*() { + const current = yield* repo.load(contextName) + yield* repo.save(contextName, [...current, event]) + }) + + /** Check if stdout has non-whitespace output (determines agent loop continuation) */ + const hasNonWhitespaceOutput = (stdout: string): boolean => stdout.trim().length > 0 + + /** Process codemode if enabled and assistant has code blocks */ + const processCodemodeIfNeeded = ( + assistantContent: string + ): Stream.Stream< + ContextOrCodemodeEvent, + PlatformError.PlatformError | CodeStorageError | ContextLoadError | ContextSaveError, + never + > => { + if (!codemodeEnabled) { + return Stream.empty + } + + return Stream.unwrap( + Effect.gen(function*() { + // Check if there's a code block + const codeOpt = yield* parseCodeBlock(assistantContent) + if (Option.isNone(codeOpt)) { + // LLM didn't output codemode - emit validation error to trigger retry + yield* Effect.logWarning("LLM response missing codemode tags", { + contentPreview: assistantContent.slice(0, 100) + }) + const validationError = new CodemodeValidationErrorEvent({ + assistantContent + }) + yield* persistEvent(validationError) + return Stream.make(validationError as ContextOrCodemodeEvent) + } + + // Get the codemode stream + const streamOpt = yield* codemodeService.processResponse(contextName, assistantContent) + if (Option.isNone(streamOpt)) { + return Stream.empty + } + + // Track stdout/stderr/exitCode for CodemodeResult + let stdout = "" + let stderr = "" + let exitCode = 0 + let typecheckFailed = false + let typecheckErrors = "" + + // Process codemode events and collect output + return pipe( + streamOpt.value, + Stream.tap((event) => + Effect.sync(() => { + switch (event._tag) { + case "ExecutionOutput": + if (event.stream === "stdout") { + stdout += event.data + } else { + stderr += event.data + } + break + case "ExecutionComplete": + exitCode = event.exitCode + break + case "TypecheckFail": + typecheckFailed = true + typecheckErrors = event.errors + break + } + }) + ), + // After codemode stream completes, emit CodemodeResult + Stream.concat( + Stream.fromEffect( + Effect.gen(function*() { + if (typecheckFailed) { + // Typecheck failed - create result with errors so LLM can retry + const result = new CodemodeResultEvent({ + stdout: "", + stderr: `TypeScript errors:\n${typecheckErrors}`, + exitCode: 1, + triggerAgentTurn: "after-current-turn" // Continue loop so LLM can fix + }) + yield* persistEvent(result) + return result as ContextOrCodemodeEvent + } + + const result = new CodemodeResultEvent({ + stdout, + stderr, + exitCode, + triggerAgentTurn: hasNonWhitespaceOutput(stdout) ? "after-current-turn" : "never" + }) + yield* persistEvent(result) + return result as ContextOrCodemodeEvent + }) + ) + ) + ) + }) + ) + } + + /** Single turn: LLM response + codemode processing (no iteration) */ + const singleTurnStream = ( + currentEvents: ReadonlyArray + ): Stream.Stream< + ContextOrCodemodeEvent, + AiError.AiError | PlatformError.PlatformError | ContextLoadError | ContextSaveError | CodeStorageError, + LanguageModel.LanguageModel | FileSystem.FileSystem | CurrentLlmConfig + > => + pipe( + streamLLMResponse(currentEvents), + Stream.tap((event) => + Schema.is(PersistedEvent)(event) ? persistEvent(event as PersistedEventType) : Effect.void + ), + // After AssistantMessage, process codemode if enabled + Stream.flatMap((event) => + Schema.is(AssistantMessageEvent)(event) + ? pipe( + Stream.make(event as ContextOrCodemodeEvent), + Stream.concat(processCodemodeIfNeeded(event.content)) + ) + : Stream.make(event as ContextOrCodemodeEvent) + ) + ) + + /** Replace the system prompt with codemode prompt if codemode is enabled */ + const ensureCodemodePrompt = (events: Array): Array => { + if (!codemodeEnabled) return events + if (events.length === 0) return events + + // If first event is a SystemPrompt, replace it with codemode prompt + const first = events[0] + if (first && Schema.is(SystemPromptEvent)(first)) { + return [ + new SystemPromptEvent({ content: CODEMODE_SYSTEM_PROMPT }), + ...events.slice(1) + ] + } + return events + } return pipe( // Load or create context, append input events @@ -110,25 +289,23 @@ export class ContextService extends Context.Tag("@app/ContextService")< const newPersistedInputs = inputEvents.filter(Schema.is(PersistedEvent)) as Array + // Apply codemode system prompt if needed + const eventsWithPrompt = ensureCodemodePrompt(baseEvents) + if (isNewContext || newPersistedInputs.length > 0) { - const allEvents = [...baseEvents, ...newPersistedInputs] + const allEvents = [...eventsWithPrompt, ...newPersistedInputs] yield* repo.save(contextName, allEvents) return allEvents } - return baseEvents + return eventsWithPrompt })(), - // Only stream LLM response if there's a UserMessage - Effect.andThen((events) => hasUserMessage ? streamLLMResponse(events) : Stream.empty), - Stream.unwrap, - // Persist events as they complete (only persisted ones) - Stream.tap((event) => - Schema.is(PersistedEvent)(event) - ? Effect.gen(function*() { - const current = yield* repo.load(contextName) - yield* repo.save(contextName, [...current, event]) - }) - : Effect.void - ) + // Only stream LLM response if an event triggers agent turn + // This can be from input events OR from the last event in context (for agent loop continuation) + Effect.andThen((events) => { + const shouldTrigger = inputTriggers || contextTriggers(events) + return shouldTrigger ? singleTurnStream(events) : Stream.empty + }), + Stream.unwrap ) } @@ -151,18 +328,24 @@ export class ContextService extends Context.Tag("@app/ContextService")< } ) + const save = Effect.fn("ContextService.save")( + function*(contextName: string, events: ReadonlyArray) { + yield* repo.save(contextName, [...events]) + } + ) + return ContextService.of({ addEvents, load, list, - persistEvent + persistEvent, + save }) }) ) /** * Test layer with mock LLM responses for unit tests. - * See: https://www.effect.solutions/testing */ static readonly testLayer = Layer.sync(ContextService, () => { // In-memory store for test contexts @@ -179,8 +362,9 @@ export class ContextService extends Context.Tag("@app/ContextService")< return ContextService.of({ addEvents: ( contextName: string, - inputEvents: ReadonlyArray - ): Stream.Stream => { + inputEvents: ReadonlyArray, + _options?: AddEventsOptions + ): Stream.Stream => { // Load or create context let events = store.get(contextName) if (!events) { @@ -198,27 +382,30 @@ export class ContextService extends Context.Tag("@app/ContextService")< store.set(contextName, events) } - // Check if any UserMessage is present - const hasUserMessage = inputEvents.some(Schema.is(UserMessageEvent)) + // Check if any event should trigger an agent turn + const shouldTriggerAgent = inputEvents.some( + (e) => "triggerAgentTurn" in e && e.triggerAgentTurn === "after-current-turn" + ) - // Only generate mock LLM response if there's a UserMessage - if (!hasUserMessage) { + // Only generate mock LLM response if an event triggers agent turn + if (!shouldTriggerAgent) { return Stream.empty } - // Mock LLM response stream + // Mock LLM response stream (codemode not implemented in test layer) const mockResponse = "This is a mock response for testing." const assistantEvent = new AssistantMessageEvent({ content: mockResponse }) - return Stream.make( - new TextDeltaEvent({ delta: mockResponse }), - assistantEvent - ).pipe( + return pipe( + Stream.make( + new TextDeltaEvent({ delta: mockResponse }) as ContextOrCodemodeEvent, + assistantEvent as ContextOrCodemodeEvent + ), Stream.tap((event) => Schema.is(PersistedEvent)(event) ? Effect.sync(() => { const current = store.get(contextName) ?? [] - store.set(contextName, [...current, event]) + store.set(contextName, [...current, event as PersistedEventType]) }) : Effect.void ) @@ -233,6 +420,11 @@ export class ContextService extends Context.Tag("@app/ContextService")< Effect.sync(() => { const current = store.get(contextName) ?? [] store.set(contextName, [...current, event]) + }), + + save: (contextName: string, events: ReadonlyArray) => + Effect.sync(() => { + store.set(contextName, [...events]) }) }) }) diff --git a/src/errors.ts b/src/errors.ts index fe08d68..d697266 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -7,10 +7,6 @@ import { Schema } from "effect" import { ContextName } from "./context.model.ts" -// ============================================================================= -// Context Errors -// ============================================================================= - /** Error when a context is not found */ export class ContextNotFound extends Schema.TaggedError()( "ContextNotFound", @@ -43,10 +39,6 @@ export const ContextError = Schema.Union( ) export type ContextError = typeof ContextError.Type -// ============================================================================= -// Configuration Errors -// ============================================================================= - /** Error when configuration is invalid or missing */ export class ConfigurationError extends Schema.TaggedError()( "ConfigurationError", @@ -56,10 +48,6 @@ export class ConfigurationError extends Schema.TaggedError() } ) {} -// ============================================================================= -// LLM Errors -// ============================================================================= - /** Error when LLM request fails */ export class LLMError extends Schema.TaggedError()( "LLMError", @@ -68,3 +56,38 @@ export class LLMError extends Schema.TaggedError()( cause: Schema.optional(Schema.Defect) } ) {} + +/** Error when TypeScript typechecking fails */ +export class TypecheckError extends Schema.TaggedError()( + "TypecheckError", + { + diagnostics: Schema.String, + filePath: Schema.String + } +) {} + +/** Error when code execution fails */ +export class CodeExecutionError extends Schema.TaggedError()( + "CodeExecutionError", + { + exitCode: Schema.Number, + stderr: Schema.String + } +) {} + +/** Error when code storage fails */ +export class CodeStorageError extends Schema.TaggedError()( + "CodeStorageError", + { + message: Schema.String, + cause: Schema.optional(Schema.Defect) + } +) {} + +/** Union of codemode errors */ +export const CodemodeError = Schema.Union( + TypecheckError, + CodeExecutionError, + CodeStorageError +) +export type CodemodeError = typeof CodemodeError.Type diff --git a/src/http.ts b/src/http.ts index 0deb697..67e4862 100644 --- a/src/http.ts +++ b/src/http.ts @@ -7,12 +7,14 @@ import { LanguageModel } from "@effect/ai" import { FileSystem, HttpRouter, HttpServerRequest, HttpServerResponse } from "@effect/platform" import { Effect, Schema, Stream } from "effect" -import type { ContextEvent } from "./context.model.ts" +import { type InputEvent, UserMessageEvent } from "./context.model.ts" +import type { ContextOrCodemodeEvent } from "./context.service.ts" import { CurrentLlmConfig } from "./llm-config.ts" import { AgentServer, ScriptInputEvent } from "./server.service.ts" -/** Encode a ContextEvent as an SSE data line */ -const encodeSSE = (event: ContextEvent): Uint8Array => new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`) +/** Encode an event as an SSE data line */ +const encodeSSE = (event: ContextOrCodemodeEvent): Uint8Array => + new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`) /** Error for JSONL parsing failures */ class JsonParseError extends Error { @@ -77,12 +79,21 @@ const contextHandler = Effect.gen(function*() { return HttpServerResponse.text(message, { status: 400 }) } - const events = parseResult.right - if (events.length === 0) { + const parsedEvents = parseResult.right + if (parsedEvents.length === 0) { return HttpServerResponse.text("No valid events in body", { status: 400 }) } - // Stream SSE events directly - provide services to remove context requirements + // Filter to InputEvent only (exclude SystemPromptEvent which isn't an InputEvent) + const isUserMessage = (e: ScriptInputEvent): e is UserMessageEvent => Schema.is(UserMessageEvent)(e) + const events: Array = parsedEvents.filter(isUserMessage) + if (events.length === 0) { + return HttpServerResponse.text("No valid input events in body (SystemPrompt alone is not supported)", { + status: 400 + }) + } + + // Stream SSE events - provide services to remove context requirements const sseStream = agentServer.handleRequest(contextName, events).pipe( Stream.map(encodeSSE), Stream.provideService(LanguageModel.LanguageModel, langModel), diff --git a/src/layercode/layercode.adapter.ts b/src/layercode/layercode.adapter.ts index 71de0e6..8890163 100644 --- a/src/layercode/layercode.adapter.ts +++ b/src/layercode/layercode.adapter.ts @@ -18,7 +18,8 @@ import { LanguageModel } from "@effect/ai" import { FileSystem, HttpRouter, HttpServerRequest, HttpServerResponse } from "@effect/platform" import { Effect, Option, Schema, Stream } from "effect" import { AppConfig } from "../config.ts" -import { AssistantMessageEvent, type ContextEvent, TextDeltaEvent, UserMessageEvent } from "../context.model.ts" +import { AssistantMessageEvent, TextDeltaEvent, UserMessageEvent } from "../context.model.ts" +import type { ContextOrCodemodeEvent } from "../context.service.ts" import { CurrentLlmConfig } from "../llm-config.ts" import { AgentServer } from "../server.service.ts" import { maybeVerifySignature } from "./signature.ts" @@ -86,7 +87,7 @@ const encodeLayerCodeSSE = (response: LayerCodeResponse): Uint8Array => /** Convert our ContextEvent to LayerCode response */ const toLayerCodeResponse = ( - event: ContextEvent, + event: ContextOrCodemodeEvent, turnId: string ): LayerCodeResponse | null => { if (Schema.is(TextDeltaEvent)(event)) { @@ -166,7 +167,7 @@ const layercodeWebhookHandler = (welcomeMessage: Option.Option) => // Convert to our format const userMessage = new UserMessageEvent({ content: webhookEvent.text }) - // Stream SSE events directly - provide services to remove context requirements + // Stream SSE events - provide services to remove context requirements const sseStream = agentServer.handleRequest(contextName, [userMessage]).pipe( Stream.map((event) => toLayerCodeResponse(event, turnId)), Stream.filter((r): r is LayerCodeResponse => r !== null), diff --git a/src/llm.ts b/src/llm.ts index 0cb69b2..236209c 100644 --- a/src/llm.ts +++ b/src/llm.ts @@ -8,6 +8,8 @@ import { type Error as PlatformError, FileSystem } from "@effect/platform" import { Clock, Effect, Option, pipe, Ref, Schema, Stream } from "effect" import { AssistantMessageEvent, + CodemodeResultEvent, + CodemodeValidationErrorEvent, type ContextEvent, FileAttachmentEvent, LLMRequestInterruptedEvent, @@ -27,6 +29,8 @@ const isAssistant = Schema.is(AssistantMessageEvent) const isUser = Schema.is(UserMessageEvent) const isFile = Schema.is(FileAttachmentEvent) const isInterrupted = Schema.is(LLMRequestInterruptedEvent) +const isCodemodeResult = Schema.is(CodemodeResultEvent) +const isCodemodeValidationError = Schema.is(CodemodeValidationErrorEvent) /** * Groups consecutive user events (messages + attachments) into single multi-part messages. @@ -74,8 +78,8 @@ export const eventsToPrompt = ( ) } i++ - } else if (isUser(event) || isFile(event)) { - // Consecutive user/file events become a single multi-part user message + } else if (isUser(event) || isFile(event) || isCodemodeResult(event) || isCodemodeValidationError(event)) { + // Consecutive user/file/codemode events become a single multi-part user message const userParts: Array = [] while (i < events.length) { @@ -103,6 +107,12 @@ export const eventsToPrompt = ( } else if (isUser(e)) { userParts.push(Prompt.makePart("text", { text: e.content })) i++ + } else if (isCodemodeResult(e)) { + userParts.push(Prompt.makePart("text", { text: e.toLLMMessage().content })) + i++ + } else if (isCodemodeValidationError(e)) { + userParts.push(Prompt.makePart("text", { text: e.toLLMMessage().content })) + i++ } else { break } diff --git a/src/server.service.ts b/src/server.service.ts index 18e22ff..017ed30 100644 --- a/src/server.service.ts +++ b/src/server.service.ts @@ -7,10 +7,10 @@ import type { AiError, LanguageModel } from "@effect/ai" import type { Error as PlatformError, FileSystem } from "@effect/platform" import { Context, Effect, Layer, Schema, Stream } from "effect" -import type { ContextEvent, InputEvent } from "./context.model.ts" +import type { InputEvent } from "./context.model.ts" import { SystemPromptEvent, UserMessageEvent } from "./context.model.ts" -import { ContextService } from "./context.service.ts" -import type { ContextLoadError, ContextSaveError } from "./errors.ts" +import { type ContextOrCodemodeEvent, ContextService } from "./context.service.ts" +import type { CodeStorageError, ContextLoadError, ContextSaveError } from "./errors.ts" import type { CurrentLlmConfig } from "./llm-config.ts" /** Script mode input events - schema for HTTP parsing */ @@ -31,8 +31,8 @@ export class AgentServer extends Context.Tag("@app/AgentServer")< contextName: string, events: ReadonlyArray ) => Stream.Stream< - ContextEvent, - AiError.AiError | PlatformError.PlatformError | ContextLoadError | ContextSaveError, + ContextOrCodemodeEvent, + AiError.AiError | PlatformError.PlatformError | ContextLoadError | ContextSaveError | CodeStorageError, LanguageModel.LanguageModel | FileSystem.FileSystem | CurrentLlmConfig > } @@ -45,7 +45,7 @@ export class AgentServer extends Context.Tag("@app/AgentServer")< const handleRequest = ( contextName: string, events: ReadonlyArray - ) => contextService.addEvents(contextName, events) + ) => contextService.addEvents(contextName, events, { codemode: true }) return AgentServer.of({ handleRequest }) }) diff --git a/src/typechecker.service.ts b/src/typechecker.service.ts new file mode 100644 index 0000000..ae05c2e --- /dev/null +++ b/src/typechecker.service.ts @@ -0,0 +1,99 @@ +/** + * TypeScript Typechecker Service + * + * Wraps the TypeScript compiler API to typecheck generated code files. + * Returns typed errors with formatted diagnostics for LLM feedback. + */ +import { FileSystem } from "@effect/platform" +import { Context, Effect, Layer, Option } from "effect" +import ts from "typescript" +import { TypecheckError } from "./errors.ts" + +/** Interface for the typechecker service - doesn't expose internal deps */ +interface TypecheckServiceInterface { + /** + * Typecheck files with TypeScript compiler. + * Returns Option.none on success, Option.some(error) on type errors. + */ + readonly check: ( + filePaths: ReadonlyArray, + configPath?: string + ) => Effect.Effect> +} + +export class TypecheckService extends Context.Tag("@app/TypecheckService")< + TypecheckService, + TypecheckServiceInterface +>() { + static readonly layer = Layer.effect( + TypecheckService, + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + + const check = ( + filePaths: ReadonlyArray, + configPath?: string + ): Effect.Effect> => + Effect.gen(function*() { + // Load compiler options from tsconfig if provided + let compilerOptions: ts.CompilerOptions = { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + strict: true, + noEmit: true, + skipLibCheck: true, + noUncheckedIndexedAccess: true, + lib: ["lib.esnext.d.ts"] + } + + if (configPath) { + const configExists = yield* fs.exists(configPath) + if (configExists) { + const configText = yield* fs.readFileString(configPath) + const configJson = ts.parseConfigFileTextToJson(configPath, configText) + if (!configJson.error) { + const parsed = ts.parseJsonConfigFileContent( + configJson.config, + ts.sys, + configPath.slice(0, configPath.lastIndexOf("/")) + ) + compilerOptions = { ...compilerOptions, ...parsed.options } + } + } + } + + // Create program and get diagnostics + const program = ts.createProgram(filePaths as Array, compilerOptions) + const diagnostics = ts.getPreEmitDiagnostics(program) + + if (diagnostics.length === 0) { + return Option.none() + } + + // Format diagnostics for readability + const formatted = ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCurrentDirectory: () => process.cwd(), + getCanonicalFileName: (fileName) => fileName, + getNewLine: () => "\n" + }) + + return Option.some( + new TypecheckError({ + diagnostics: formatted, + filePath: filePaths[0] ?? "" + }) + ) + }).pipe(Effect.orDie) // File read errors become defects - shouldn't happen in normal operation + + return TypecheckService.of({ check }) + }) + ) + + static readonly testLayer = Layer.succeed( + TypecheckService, + TypecheckService.of({ + check: () => Effect.succeed(Option.none()) + }) + ) +} diff --git a/test/cli.e2e.test.ts b/test/cli.e2e.test.ts index cdd168f..6286c25 100644 --- a/test/cli.e2e.test.ts +++ b/test/cli.e2e.test.ts @@ -112,11 +112,14 @@ describe("CLI", () => { expect(result.stdout.length).toBeGreaterThan(0) - // Context file should exist with random name (chat-xxxxx pattern) + // Context directory should exist with random name (chat-xxxxx pattern) const contextsDir = path.join(testDir, ".mini-agent", "contexts") - const files = fs.readdirSync(contextsDir) - expect(files.length).toBe(1) - expect(files[0]).toMatch(/^chat-[a-z0-9]{5}\.yaml$/) + const dirs = fs.readdirSync(contextsDir) + expect(dirs.length).toBe(1) + expect(dirs[0]).toMatch(/^chat-[a-z0-9]{5}$/) + // Verify it contains events.yaml + const eventsPath = path.join(contextsDir, dirs[0]!, "events.yaml") + expect(fs.existsSync(eventsPath)).toBe(true) }) }) @@ -302,8 +305,8 @@ describe("CLI", () => { runCli(["chat", "-n", TEST_CONTEXT, "-m", "Hello"], { cwd: testDir, env: llmEnv }) ) - // Context file should exist in testDir/.mini-agent/contexts/ - const contextPath = path.join(testDir, ".mini-agent", "contexts", `${TEST_CONTEXT}.yaml`) + // Context directory should exist with events.yaml inside + const contextPath = path.join(testDir, ".mini-agent", "contexts", TEST_CONTEXT, "events.yaml") expect(fs.existsSync(contextPath)).toBe(true) }) diff --git a/test/codemode.e2e.test.ts b/test/codemode.e2e.test.ts new file mode 100644 index 0000000..172ae79 --- /dev/null +++ b/test/codemode.e2e.test.ts @@ -0,0 +1,351 @@ +/** + * Codemode E2E Tests + * + * Tests the full codemode pipeline: parse, store, typecheck, execute. + */ +import { FileSystem, Path } from "@effect/platform" +import { BunContext } from "@effect/platform-bun" +import { Effect, Layer, Option, Stream } from "effect" +import { describe, expect } from "vitest" +import { CodeExecutor } from "../src/code-executor.service.ts" +import { CodemodeRepository } from "../src/codemode.repository.ts" +import { CodemodeService } from "../src/codemode.service.ts" +import { AppConfig } from "../src/config.ts" +import { TypecheckService } from "../src/typechecker.service.ts" +import { test } from "./fixtures.ts" + +describe("Codemode E2E", () => { + // Test config layer - uses defaults appropriate for tests + const testConfigLayer = Layer.succeed(AppConfig, { + llm: "openai:gpt-4.1-mini", + dataStorageDir: ".mini-agent", + configFile: "mini-agent.config.yaml", + cwd: Option.none(), + stdoutLogLevel: { _tag: "Warning", label: "WARN", ordinal: 3, syslog: 4 } as never, + fileLogLevel: { _tag: "Debug", label: "DEBUG", ordinal: 1, syslog: 7 } as never, + port: 3000, + host: "0.0.0.0", + layercodeWebhookSecret: Option.none() + }) + + // Full layer stack for real codemode processing with BunContext providing FileSystem, Path, CommandExecutor + const serviceLayer = CodemodeService.layer.pipe( + Layer.provide(CodemodeRepository.layer), + Layer.provide(TypecheckService.layer), + Layer.provide(CodeExecutor.layer), + Layer.provide(testConfigLayer), + Layer.provide(BunContext.layer) + ) + // Also expose BunContext services for tests that need FileSystem/Path directly + const fullLayer = Layer.merge(serviceLayer, BunContext.layer) + + const TEST_CONTEXT = "test-context" + + test("processes valid code block and executes it", async () => { + const program = Effect.gen(function*() { + const service = yield* CodemodeService + + // Simulate an assistant response with a valid codemode block + const response = `Here's some code that sends a message: + + +export default async function(t: Tools): Promise { + await t.sendMessage("Hello from codemode!") +} + + +This code will greet you!` + + const streamOpt = yield* service.processResponse(TEST_CONTEXT, response) + expect(streamOpt._tag).toBe("Some") + + if (streamOpt._tag === "Some") { + const events: Array<{ _tag: string }> = [] + yield* streamOpt.value.pipe( + Stream.runForEach((event) => { + events.push({ _tag: event._tag }) + return Effect.void + }) + ) + + // Should have: CodeBlock, TypecheckStart, TypecheckPass, ExecutionStart, ExecutionOutput*, ExecutionComplete + const tags = events.map((e) => e._tag) + expect(tags).toContain("CodeBlock") + expect(tags).toContain("TypecheckStart") + expect(tags).toContain("TypecheckPass") + expect(tags).toContain("ExecutionStart") + expect(tags).toContain("ExecutionComplete") + } + }).pipe( + Effect.provide(fullLayer) + ) + + await Effect.runPromise(program) + }) + + test("detects typecheck errors in invalid code", async () => { + const program = Effect.gen(function*() { + const service = yield* CodemodeService + + // Code with a type error + const response = ` +export default async function(t: Tools): Promise { + // This will cause a type error - nonExistentMethod doesn't exist + await t.nonExistentMethod() +} +` + + const streamOpt = yield* service.processResponse(TEST_CONTEXT, response) + expect(streamOpt._tag).toBe("Some") + + if (streamOpt._tag === "Some") { + const events: Array<{ _tag: string; errors?: string }> = [] + yield* streamOpt.value.pipe( + Stream.runForEach((event) => { + const e: { _tag: string; errors?: string } = { _tag: event._tag } + if (event._tag === "TypecheckFail") { + e.errors = (event as { errors: string }).errors + } + events.push(e) + return Effect.void + }) + ) + + // Should have TypecheckFail, not ExecutionStart + const tags = events.map((e) => e._tag) + expect(tags).toContain("TypecheckFail") + expect(tags).not.toContain("ExecutionStart") + + // The error should mention the missing property + const failEvent = events.find((e) => e._tag === "TypecheckFail") + expect(failEvent?.errors).toContain("nonExistentMethod") + } + }).pipe( + Effect.provide(fullLayer) + ) + + await Effect.runPromise(program) + }) + + test("returns none for response without code block", async () => { + const program = Effect.gen(function*() { + const service = yield* CodemodeService + + const response = "Just a regular response without any code blocks." + const streamOpt = yield* service.processResponse(TEST_CONTEXT, response) + + expect(streamOpt._tag).toBe("None") + }).pipe( + Effect.provide(fullLayer) + ) + + await Effect.runPromise(program) + }) + + test("creates files in context directory structure", async ({ testDir }) => { + // Change to test directory so files are created there + const originalCwd = process.cwd() + process.chdir(testDir) + + try { + const program = Effect.gen(function*() { + const service = yield* CodemodeService + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + const contextName = "file-test-context" + const response = ` +export default async function(t: Tools): Promise { + await t.sendMessage("test") +} +` + + const streamOpt = yield* service.processResponse(contextName, response) + expect(streamOpt._tag).toBe("Some") + + if (streamOpt._tag === "Some") { + // Consume the stream to trigger file creation + yield* streamOpt.value.pipe( + Stream.runForEach(() => Effect.void), + Effect.scoped + ) + + // Check that context directory was created + const contextDir = path.join(testDir, ".mini-agent", "contexts", contextName) + const exists = yield* fs.exists(contextDir) + expect(exists).toBe(true) + + // Check that there's at least one request directory + const requestDirs = yield* fs.readDirectory(contextDir) + expect(requestDirs.length).toBeGreaterThan(0) + + // Check that the request directory has a codeblock directory + const requestDir = path.join(contextDir, requestDirs[0]!) + const codeblockDirs = yield* fs.readDirectory(requestDir) + expect(codeblockDirs.length).toBeGreaterThan(0) + + // Check that the codeblock directory has the expected files + const codeblockDir = path.join(requestDir, codeblockDirs[0]!) + const indexExists = yield* fs.exists(path.join(codeblockDir, "index.ts")) + const typesExists = yield* fs.exists(path.join(codeblockDir, "types.ts")) + const tsconfigExists = yield* fs.exists(path.join(codeblockDir, "tsconfig.json")) + + expect(indexExists).toBe(true) + expect(typesExists).toBe(true) + expect(tsconfigExists).toBe(true) + } + }).pipe( + Effect.provide(fullLayer) + ) + + await Effect.runPromise(program) + } finally { + process.chdir(originalCwd) + } + }) + + test("captures execution output", async ({ testDir }) => { + const originalCwd = process.cwd() + process.chdir(testDir) + + try { + const program = Effect.gen(function*() { + const service = yield* CodemodeService + + // console.log goes to stdout (agent sees), sendMessage goes to stderr (user sees) + const response = ` +export default async function(t: Tools): Promise { + await t.sendMessage("First message") + await t.sendMessage("Second message") +} +` + + const streamOpt = yield* service.processResponse(TEST_CONTEXT, response) + expect(streamOpt._tag).toBe("Some") + + if (streamOpt._tag === "Some") { + const outputs: Array = [] + yield* streamOpt.value.pipe( + Stream.runForEach((event) => { + // sendMessage goes to stderr, so check stderr + if (event._tag === "ExecutionOutput" && (event as { stream: string }).stream === "stderr") { + outputs.push((event as { data: string }).data) + } + return Effect.void + }), + Effect.scoped + ) + + const fullOutput = outputs.join("") + expect(fullOutput).toContain("First message") + expect(fullOutput).toContain("Second message") + } + }).pipe( + Effect.provide(fullLayer) + ) + + await Effect.runPromise(program) + } finally { + process.chdir(originalCwd) + } + }) + + test("getSecret tool retrieves secrets from environment", async ({ testDir }) => { + const originalCwd = process.cwd() + process.chdir(testDir) + + // Set test secret via environment variable (format: CODEMODE_SECRET_) + const originalEnv = process.env.CODEMODE_SECRET_DEMO_SECRET + process.env.CODEMODE_SECRET_DEMO_SECRET = "The secret value is: SUPERSECRET42" + + try { + const program = Effect.gen(function*() { + const service = yield* CodemodeService + + // Code that uses getSecret - reads from CODEMODE_SECRET_DEMO_SECRET env var + const response = ` +export default async function(t: Tools): Promise { + const secret = await t.getSecret("demo-secret") + console.log("Got secret: " + secret) +} +` + + const streamOpt = yield* service.processResponse(TEST_CONTEXT, response) + expect(streamOpt._tag).toBe("Some") + + if (streamOpt._tag === "Some") { + const outputs: Array = [] + yield* streamOpt.value.pipe( + Stream.runForEach((event) => { + if (event._tag === "ExecutionOutput" && (event as { stream: string }).stream === "stdout") { + outputs.push((event as { data: string }).data) + } + return Effect.void + }), + Effect.scoped + ) + + const fullOutput = outputs.join("") + // The secret should be revealed by the execution + expect(fullOutput).toContain("SUPERSECRET42") + } + }).pipe( + Effect.provide(fullLayer) + ) + + await Effect.runPromise(program) + } finally { + process.chdir(originalCwd) + // Restore original env + if (originalEnv === undefined) { + delete process.env.CODEMODE_SECRET_DEMO_SECRET + } else { + process.env.CODEMODE_SECRET_DEMO_SECRET = originalEnv + } + } + }) + + test("output determines agent loop continuation", async ({ testDir }) => { + const originalCwd = process.cwd() + process.chdir(testDir) + + try { + const program = Effect.gen(function*() { + const service = yield* CodemodeService + + // console.log produces stdout which triggers another agent turn + const response = ` +export default async function(t: Tools): Promise { + console.log("Processing...") +} +` + + const streamOpt = yield* service.processResponse(TEST_CONTEXT, response) + expect(streamOpt._tag).toBe("Some") + + if (streamOpt._tag === "Some") { + const outputs: Array = [] + yield* streamOpt.value.pipe( + Stream.runForEach((event) => { + if (event._tag === "ExecutionOutput" && (event as { stream: string }).stream === "stdout") { + outputs.push((event as { data: string }).data) + } + return Effect.void + }), + Effect.scoped + ) + + const fullOutput = outputs.join("") + // console.log output goes to stdout + expect(fullOutput).toContain("Processing...") + } + }).pipe( + Effect.provide(fullLayer) + ) + + await Effect.runPromise(program) + } finally { + process.chdir(originalCwd) + } + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 6bed5b8..b6812d3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,7 @@ const numCpus = cpus().length export default defineConfig({ test: { - include: ["./test/**/*.test.ts"], + include: ["./test/**/*.test.ts", "./src/**/*.test.ts"], globals: true, disableConsoleIntercept: true, // Show console.log during tests (for fixture path logging)