diff --git a/bun.lock b/bun.lock index 8e662f9..aa46b09 100644 --- a/bun.lock +++ b/bun.lock @@ -131,6 +131,7 @@ "devDependencies": { "@effect/ai-anthropic": "catalog:", "@effect/ai-openai": "catalog:", + "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", "@humanlayer/fold-vitest-config": "workspace:*", "effect": "catalog:", diff --git a/packages/fold-agent/examples/ApplyPatchAgent.ts b/packages/fold-agent/examples/ApplyPatchAgent.ts index 2971fd3..b65e4f5 100644 --- a/packages/fold-agent/examples/ApplyPatchAgent.ts +++ b/packages/fold-agent/examples/ApplyPatchAgent.ts @@ -11,6 +11,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { defineAgent, openaiModel, startSession } from '@humanlayer/fold-core' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Console, Effect } from 'effect' import { codingTools } from '../src/index' @@ -49,7 +50,7 @@ const makeProgram = (apiKey: string) => yield* Console.log(`finished: ${finished.outcome}`) yield* Console.log(`result: ${finished.resultText ?? '(no text)'}`) yield* Console.log(`tools called: ${toolNames.join(', ')}`) - }).pipe(Effect.scoped) + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)) if (apiKey === undefined || apiKey === '') { console.error('Set OPENAI_API_KEY to run this example.') diff --git a/packages/fold-agent/examples/CodingAgent.ts b/packages/fold-agent/examples/CodingAgent.ts index 2dbefe6..e586811 100644 --- a/packages/fold-agent/examples/CodingAgent.ts +++ b/packages/fold-agent/examples/CodingAgent.ts @@ -11,6 +11,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { anthropicModel, defineAgent, startSession } from '@humanlayer/fold-core' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Console, Effect } from 'effect' import { codingTools, jsonlEventLog } from '../src/index' @@ -47,7 +48,7 @@ const makeProgram = (apiKey: string) => yield* Console.log(`result: ${finished.resultText ?? '(no text)'}`) yield* Console.log(`log rows: ${entries.length} (persisted to ${logPath})`) yield* Console.log(`tools used: ${entries.filter((entry) => entry._tag === 'tool-result').length} tool results`) - }).pipe(Effect.scoped) + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)) if (apiKey === undefined || apiKey === '') { console.error('Set ANTHROPIC_API_KEY to run this example.') diff --git a/packages/fold-agent/examples/ConfigAgent.ts b/packages/fold-agent/examples/ConfigAgent.ts index 90aa050..cbc316c 100644 --- a/packages/fold-agent/examples/ConfigAgent.ts +++ b/packages/fold-agent/examples/ConfigAgent.ts @@ -9,6 +9,7 @@ * Then: bun packages/fold-agent/examples/ConfigAgent.ts "your prompt" */ import { layerLiveIdFactory } from '@humanlayer/fold-core' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Console, Effect } from 'effect' import { configInit, launchSession, loadFoldConfigOrNull } from '../src/index' @@ -29,7 +30,7 @@ const program = Effect.gen(function* () { const finished = yield* session.send(prompt) yield* Console.log(`\n[${finished.outcome}] ${finished.resultText ?? '(no text)'}`) -}).pipe(Effect.provide(layerLiveIdFactory), Effect.scoped) +}).pipe(Effect.provide(layerLiveIdFactory), Effect.provide(NodeFileSystem.layer), Effect.scoped) Effect.runPromise(program).catch((error) => { console.error(error) diff --git a/packages/fold-agent/examples/SkillsFromDisk.ts b/packages/fold-agent/examples/SkillsFromDisk.ts index 1c3df2b..522d0b6 100644 --- a/packages/fold-agent/examples/SkillsFromDisk.ts +++ b/packages/fold-agent/examples/SkillsFromDisk.ts @@ -12,6 +12,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { anthropicModel, defineAgent, skillTool, startSession } from '@humanlayer/fold-core' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Console, Effect } from 'effect' import { skillsFromDisk } from '../src/index' @@ -73,7 +74,7 @@ const makeProgram = (apiKey: string) => yield* Console.log(`finished: ${finished.outcome}`) yield* Console.log(`result:\n${finished.resultText ?? '(no text)'}`) - }).pipe(Effect.scoped) + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)) if (apiKey === undefined || apiKey === '') { console.error('Set ANTHROPIC_API_KEY to run this example.') diff --git a/packages/fold-agent/examples/SubagentsAgent.ts b/packages/fold-agent/examples/SubagentsAgent.ts index 9cad37a..346f2a2 100644 --- a/packages/fold-agent/examples/SubagentsAgent.ts +++ b/packages/fold-agent/examples/SubagentsAgent.ts @@ -12,6 +12,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { anthropicModel, defineAgent, defineSubagent, startSession, subagentTool } from '@humanlayer/fold-core' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Console, Effect } from 'effect' import { bashTool, jsonlEventLog, readTool } from '../src/index' @@ -89,7 +90,7 @@ const makeProgram = (apiKey: string) => yield* Console.log(`\nlog: ${entries.length} rows persisted to ${logPath}`) yield* Console.log(`subagents started: ${subagentStarts.length} (researcher: ${researcherId ?? 'none'})`) yield* Console.log(`researcher turns: ${researcherTurns} across ${researcherCalls} dispatch/resume calls`) - }).pipe(Effect.scoped) + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)) if (apiKey === undefined || apiKey === '') { console.error('Set ANTHROPIC_API_KEY to run this example.') diff --git a/packages/fold-agent/src/Bin/ManagedBinaries.ts b/packages/fold-agent/src/Bin/ManagedBinaries.ts index 2bbe97e..90639e2 100644 --- a/packages/fold-agent/src/Bin/ManagedBinaries.ts +++ b/packages/fold-agent/src/Bin/ManagedBinaries.ts @@ -24,9 +24,7 @@ import { accessSync, constants, statSync } from 'node:fs' import { delimiter, join } from 'node:path' import { promisify } from 'node:util' -import { Cause, Effect, Schema, type FileSystem } from 'effect' - -import { fileSystemFor, type FsToolOptions } from '../Fs/DefaultFileSystem' +import { Cause, Effect, FileSystem, Schema } from 'effect' import { managedBinaryRegistry, type ManagedBinaryAsset, type ManagedBinaryDefinition } from './Registry' /** Environment variable that, when set (non-empty), disables managed-binary downloads entirely. */ @@ -88,8 +86,8 @@ export type ExecSeam = ( export type EnsureManagedBinariesOptions = { /** The fold home directory; binaries install into `/bin`. */ readonly foldHome: string - /** FileSystem override for hermetic tests. Defaults to the Node platform filesystem. */ - readonly fileSystem?: FsToolOptions['fileSystem'] + /** @deprecated FileSystem is now provided through the Effect R channel. */ + readonly fileSystem?: FileSystem.FileSystem /** Environment lookup for {@link FOLD_DISABLE_BINARY_DOWNLOADS} and PATH. Defaults to `process.env`. */ readonly env?: (name: string) => string | undefined /** PATH lookup seam. Defaults to scanning the env seam's PATH for an executable file. */ @@ -438,14 +436,15 @@ const resolveOneNeverFailing = ( }), ) -const ensureOnce = (options: EnsureManagedBinariesOptions): Effect.Effect> => +const ensureOnce = (options: EnsureManagedBinariesOptions): Effect.Effect, never, FileSystem.FileSystem> => Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem const env = options.env ?? ((name: string) => process.env[name]) const platform = options.platform ?? process.platform const disableFlag = env(FOLD_DISABLE_BINARY_DOWNLOADS) const context: ResolveContext = { foldHome: options.foldHome, - fs: fileSystemFor(options.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }), + fs, env, which: options.which ?? defaultWhich(env, platform), download: options.download ?? defaultDownload, @@ -463,7 +462,7 @@ const ensureOnce = (options: EnsureManagedBinariesOptions): Effect.Effect>>() +const memoizedResults = new Map>() /** * Ensure every managed binary is resolvable, returning one status per registry entry (in registry @@ -472,18 +471,18 @@ const memoizedRuns = new Map> => +): Effect.Effect, never, FileSystem.FileSystem> => Effect.suspend(() => { if (options.memoize === false) return ensureOnce(options) const key = `${options.foldHome}${options.disableDownloads === true}${options.requireManagedInstall === true}` - const existing = memoizedRuns.get(key) - if (existing !== undefined) return existing - - // Effect.cached construction is synchronous; the Map get/set pair runs without a yield point in - // between, so concurrent callers cannot race past each other into two resolution passes. - const run = Effect.runSync(Effect.cached(ensureOnce(options))) - memoizedRuns.set(key, run) - - return run + const existing = memoizedResults.get(key) + if (existing !== undefined) return Effect.succeed(existing) + + // Run once and cache the result. The suspend boundary is synchronous so concurrent + // callers cannot race past the get into two resolution passes; the second caller's + // ensureOnce is idempotent in the unlikely event of an async interleave. + return ensureOnce(options).pipe( + Effect.tap((result) => Effect.sync(() => { memoizedResults.set(key, result) })), + ) }) diff --git a/packages/fold-agent/src/Catalog/LoadCatalog.ts b/packages/fold-agent/src/Catalog/LoadCatalog.ts index f8d3c20..f1569ca 100644 --- a/packages/fold-agent/src/Catalog/LoadCatalog.ts +++ b/packages/fold-agent/src/Catalog/LoadCatalog.ts @@ -14,9 +14,7 @@ import { dirname, join } from 'node:path' import { ModelCatalogEntry } from '@humanlayer/fold-core' -import { Clock, Effect, Predicate, Schema, type FileSystem } from 'effect' - -import { fileSystemFor, type FsToolOptions } from '../Fs/DefaultFileSystem' +import { Clock, Effect, FileSystem, Predicate, Schema } from 'effect' import { bakedModelCatalog } from './BakedCatalog' import { decodeModelsDevModels, ModelsDevDecodeError } from './ModelsDevSchema' import { modelCatalogEntriesFromModelsDev } from './Normalize' @@ -58,8 +56,6 @@ export type ModelCatalogCache = typeof ModelCatalogCache.Type export type LoadModelCatalogOptions = { /** The fold home directory; the cache lives at `/cache/models-dev.json`. */ readonly foldHome: string - /** FileSystem override for hermetic tests. Defaults to the Node platform filesystem. */ - readonly fileSystem?: FsToolOptions['fileSystem'] /** Environment lookup for {@link FOLD_DISABLE_MODELS_FETCH}. Defaults to reading `process.env`. */ readonly env?: (name: string) => string | undefined /** Fetch seam returning the parsed JSON payload. Defaults to global `fetch` with a 10s timeout. */ @@ -154,9 +150,9 @@ const fetchCatalogEntries = ( * Load the model catalog entries for a launch. Never fails: fresh cache, else fetch-and-cache, else * stale cache, else the baked snapshot. */ -export const loadModelCatalog = (options: LoadModelCatalogOptions): Effect.Effect> => +export const loadModelCatalog = (options: LoadModelCatalogOptions): Effect.Effect, never, FileSystem.FileSystem> => Effect.gen(function* () { - const fs = fileSystemFor(options.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }) + const fs = yield* FileSystem.FileSystem const env = options.env ?? ((name: string) => process.env[name]) const now = yield* options.now ?? Clock.currentTimeMillis const ttlMs = options.ttlMs ?? defaultCatalogTtlMs diff --git a/packages/fold-agent/src/Config/ConfigSchemaJson.ts b/packages/fold-agent/src/Config/ConfigSchemaJson.ts index 7b54dab..1161cb5 100644 --- a/packages/fold-agent/src/Config/ConfigSchemaJson.ts +++ b/packages/fold-agent/src/Config/ConfigSchemaJson.ts @@ -10,9 +10,7 @@ */ import { join } from 'node:path' -import { JsonSchema, Effect, Schema } from 'effect' - -import { fileSystemFor, type FsToolOptions } from '../Fs/DefaultFileSystem' +import { JsonSchema, Effect, FileSystem, Schema } from 'effect' import { FoldConfig } from './ConfigSchema' import { writeFoldInfo } from './FoldInfo' import { defaultConfigPath, defaultFoldHome } from './Load' @@ -120,14 +118,12 @@ export const starterConfigJsonc = (): string => export type ConfigInitOptions = { /** The fold home directory. Defaults to `~/.fold`. */ readonly foldHome?: string - /** FileSystem override for hermetic tests. Defaults to the Node platform filesystem. */ - readonly fileSystem?: FsToolOptions['fileSystem'] } /** Write `config.schema.json` under the fold home, creating the directory if needed. Returns its path. */ -export const writeFoldConfigSchema = (options?: ConfigInitOptions): Effect.Effect => +export const writeFoldConfigSchema = (options?: ConfigInitOptions): Effect.Effect => Effect.gen(function* () { - const fs = fileSystemFor(options?.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }) + const fs = yield* FileSystem.FileSystem const home = options?.foldHome ?? defaultFoldHome() yield* fs.makeDirectory(home, { recursive: true }).pipe(Effect.orDie) @@ -159,9 +155,9 @@ export type ConfigInitResult = { * `foldcode auth codex login` runs or the `apiKeyEnv` variables are exported. Only the two generated * files are ever overwritten. */ -export const bootstrapFoldHome = (options?: ConfigInitOptions): Effect.Effect => +export const bootstrapFoldHome = (options?: ConfigInitOptions): Effect.Effect => Effect.gen(function* () { - const fs = fileSystemFor(options?.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }) + const fs = yield* FileSystem.FileSystem const schemaPath = yield* writeFoldConfigSchema(options) const infoPath = yield* writeFoldInfo(options) @@ -189,4 +185,4 @@ export const bootstrapFoldHome = (options?: ConfigInitOptions): Effect.Effect => bootstrapFoldHome(options) +export const configInit = (options?: ConfigInitOptions): Effect.Effect => bootstrapFoldHome(options) diff --git a/packages/fold-agent/src/Config/FoldInfo.ts b/packages/fold-agent/src/Config/FoldInfo.ts index 503dc7a..7bc7bd2 100644 --- a/packages/fold-agent/src/Config/FoldInfo.ts +++ b/packages/fold-agent/src/Config/FoldInfo.ts @@ -9,9 +9,7 @@ */ import { join } from 'node:path' -import { Effect } from 'effect' - -import { fileSystemFor } from '../Fs/DefaultFileSystem' +import { Effect, FileSystem } from 'effect' import type { ConfigInitOptions } from './ConfigSchemaJson' import { defaultFoldHome } from './Load' @@ -215,9 +213,9 @@ and \`fd\` over find. Disable downloads with \`FOLD_DISABLE_BINARY_DOWNLOADS=1\` ` /** Write (always overwrite) `/FOLD_INFO.md`, creating the directory if needed. Returns its path. */ -export const writeFoldInfo = (options?: ConfigInitOptions): Effect.Effect => +export const writeFoldInfo = (options?: ConfigInitOptions): Effect.Effect => Effect.gen(function* () { - const fs = fileSystemFor(options?.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }) + const fs = yield* FileSystem.FileSystem const home = options?.foldHome ?? defaultFoldHome() yield* fs.makeDirectory(home, { recursive: true }).pipe(Effect.orDie) const path = foldInfoPath(home) diff --git a/packages/fold-agent/src/Config/Load.ts b/packages/fold-agent/src/Config/Load.ts index a15ad07..9ec7355 100644 --- a/packages/fold-agent/src/Config/Load.ts +++ b/packages/fold-agent/src/Config/Load.ts @@ -12,9 +12,7 @@ import { homedir } from 'node:os' import { join } from 'node:path' -import { Effect, Predicate, Schema } from 'effect' - -import { fileSystemFor, type FsToolOptions } from '../Fs/DefaultFileSystem' +import { Effect, FileSystem, Predicate, Schema } from 'effect' import { FoldConfig } from './ConfigSchema' /** The config file could not be found at the resolved path. */ @@ -40,8 +38,6 @@ export type LoadConfigOptions = { readonly path?: string /** The fold home directory. Defaults to `~/.fold`. */ readonly foldHome?: string - /** FileSystem override for hermetic tests. Defaults to the Node platform filesystem. */ - readonly fileSystem?: FsToolOptions['fileSystem'] } /** The fold home directory: `~/.fold`. */ @@ -149,9 +145,9 @@ export const parseFoldConfig = ( */ export const loadFoldConfig = ( options?: LoadConfigOptions, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { - const fs = fileSystemFor(options?.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }) + const fs = yield* FileSystem.FileSystem const path = configPathFor(options) const exists = yield* fs.exists(path).pipe(Effect.catch(() => Effect.succeed(false))) @@ -167,5 +163,5 @@ export const loadFoldConfig = ( */ export const loadFoldConfigOrNull = ( options?: LoadConfigOptions, -): Effect.Effect => +): Effect.Effect => loadFoldConfig(options).pipe(Effect.catchTag('ConfigFileNotFoundError', () => Effect.succeed(null))) diff --git a/packages/fold-agent/src/Config/ProviderConfig.ts b/packages/fold-agent/src/Config/ProviderConfig.ts index 7cb30a5..bdad359 100644 --- a/packages/fold-agent/src/Config/ProviderConfig.ts +++ b/packages/fold-agent/src/Config/ProviderConfig.ts @@ -8,9 +8,7 @@ import { dirname } from 'node:path' import { DEFAULT_CODEX_MODEL_ID } from '@humanlayer/fold-codex' import { DEFAULT_OPENCODE_MODEL_ID } from '@humanlayer/fold-opencode' import { DEFAULT_XAI_MODEL_ID } from '@humanlayer/fold-xai' -import { Clock, Effect, Match, Random, Schema } from 'effect' - -import { fileSystemFor } from '../Fs/DefaultFileSystem' +import { Clock, Effect, FileSystem, Match, Random, Schema } from 'effect' import type { FoldConfig, ProviderKind } from './ConfigSchema' import { configPathFor, @@ -88,9 +86,9 @@ const validBaseUrl = (value: string): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { - const fs = fileSystemFor(options?.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }) + const fs = yield* FileSystem.FileSystem const path = configPathFor(options) // A unique temp path for the atomic write-rename. Clock/Random are the seams here (not Date.now/crypto), // so a test can pin the temporary filename deterministically. @@ -122,7 +120,7 @@ const writeConfig = ( export const configureProvider = ( input: ConfigureProviderInput, options?: LoadConfigOptions, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const name = yield* required(input.name, 'name') const baseUrl = yield* validBaseUrl(input.baseUrl) diff --git a/packages/fold-agent/src/EventLog/JsonlDescriptor.ts b/packages/fold-agent/src/EventLog/JsonlDescriptor.ts index 6e43d0b..5bae9c5 100644 --- a/packages/fold-agent/src/EventLog/JsonlDescriptor.ts +++ b/packages/fold-agent/src/EventLog/JsonlDescriptor.ts @@ -6,17 +6,14 @@ import { eventLogSource, EventLog, type FoldEventLog } from '@humanlayer/fold-core' import { Context, Effect, FileSystem, Layer } from 'effect' -import { fileSystemFor, type FsToolOptions } from '../Fs/DefaultFileSystem' import { layerJsonl } from './JsonlLayer' -/** Options for {@link jsonlEventLog}: the FileSystem seam (Node default, overridable for tests). */ -export type JsonlEventLogOptions = Pick - /** Back a session's durable log with one JSONL file. Existing entries replay on start (resume). */ -export const jsonlEventLog = (filePath: string, options?: JsonlEventLogOptions): FoldEventLog => +export const jsonlEventLog = (filePath: string): FoldEventLog => eventLogSource( Effect.gen(function* () { - const fsLayer = Layer.succeed(FileSystem.FileSystem, fileSystemFor(options)) + const fs = yield* FileSystem.FileSystem + const fsLayer = Layer.succeed(FileSystem.FileSystem, fs) const context = yield* Layer.build(layerJsonl(filePath).pipe(Layer.provide(fsLayer))) return Context.get(context, EventLog) diff --git a/packages/fold-agent/src/Fs/DefaultFileSystem.ts b/packages/fold-agent/src/Fs/DefaultFileSystem.ts index e3ca2cd..0558104 100644 --- a/packages/fold-agent/src/Fs/DefaultFileSystem.ts +++ b/packages/fold-agent/src/Fs/DefaultFileSystem.ts @@ -1,42 +1,2 @@ -/** - * This file provides the default-or-override FileSystem seam every fold-agent tool uses: handlers close - * over a FileSystem service implementation resolved at tool construction - the caller's override when - * given (custom/in-memory filesystems for tests and sandboxes), otherwise the Node platform filesystem - * built once per process. Effect v4 models defaultable services as `Context.Reference`, but platform - * FileSystem is deliberately a required service with no default, so the fallback lives at this - * descriptor seam instead (no `Layer` in any public signature, per the composition-root ruling). - */ -import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' -import { Context, Effect, FileSystem, Layer } from 'effect' - -/** Options shared by every filesystem-backed tool factory in fold-agent. */ -export type FsToolOptions = { - /** Working directory for resolving relative paths. Defaults to `process.cwd()` at call time. */ - readonly cwd?: string - /** FileSystem implementation override. Defaults to the Node platform filesystem. */ - readonly fileSystem?: FileSystem.FileSystem -} - -let nodeFileSystem: FileSystem.FileSystem | null = null - -/** The process-wide Node FileSystem service, built lazily once (layer construction is synchronous). */ -export const defaultNodeFileSystem = (): FileSystem.FileSystem => { - if (nodeFileSystem === null) { - nodeFileSystem = Effect.runSync( - Effect.scoped( - Layer.build(NodeFileSystem.layer).pipe( - Effect.map((context) => Context.get(context, FileSystem.FileSystem)), - ), - ), - ) - } - - return nodeFileSystem -} - -/** Resolve the FileSystem a tool handler should use. */ -export const fileSystemFor = (options?: FsToolOptions): FileSystem.FileSystem => - options?.fileSystem ?? defaultNodeFileSystem() - /** Resolve the working directory a tool handler should resolve relative paths against. */ -export const cwdFor = (options?: FsToolOptions): string => options?.cwd ?? process.cwd() +export const cwdFor = (options?: { readonly cwd?: string }): string => options?.cwd ?? process.cwd() diff --git a/packages/fold-agent/src/Memory/AgentFiles.ts b/packages/fold-agent/src/Memory/AgentFiles.ts index e067f1c..279536d 100644 --- a/packages/fold-agent/src/Memory/AgentFiles.ts +++ b/packages/fold-agent/src/Memory/AgentFiles.ts @@ -18,9 +18,7 @@ import { homedir } from 'node:os' import { dirname, join } from 'node:path' -import { Effect, type FileSystem, Schema } from 'effect' - -import { fileSystemFor, type FsToolOptions } from '../Fs/DefaultFileSystem' +import { Effect, FileSystem, Schema } from 'effect' /** One loaded agentfile. */ export const MemoryFile = Schema.Struct({ @@ -36,8 +34,6 @@ export type AgentFilesOptions = { readonly cwd?: string /** Home directory for the global chain. Defaults to `os.homedir()`. */ readonly home?: string - /** FileSystem override for hermetic tests. Defaults to the Node platform filesystem. */ - readonly fileSystem?: FsToolOptions['fileSystem'] } /** Per-directory base filenames, in preference order (first existing wins). */ @@ -73,9 +69,9 @@ const fileExists = (fs: FileSystem.FileSystem, path: string): Effect.Effect> => +export const loadMemoryFiles = (options?: AgentFilesOptions): Effect.Effect, never, FileSystem.FileSystem> => Effect.gen(function* () { - const fs = fileSystemFor(options?.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }) + const fs = yield* FileSystem.FileSystem const cwd = options?.cwd ?? process.cwd() const home = options?.home ?? homedir() @@ -136,5 +132,5 @@ export const renderMemoryFiles = (files: ReadonlyArray): string | nu } /** Load and render the agentfiles for a working directory as one leading prompt block (null when none). */ -export const memoryPromptBlock = (options?: AgentFilesOptions): Effect.Effect => +export const memoryPromptBlock = (options?: AgentFilesOptions): Effect.Effect => loadMemoryFiles(options).pipe(Effect.map(renderMemoryFiles)) diff --git a/packages/fold-agent/src/Mode/Launch.ts b/packages/fold-agent/src/Mode/Launch.ts index bf9ff93..4826bec 100644 --- a/packages/fold-agent/src/Mode/Launch.ts +++ b/packages/fold-agent/src/Mode/Launch.ts @@ -35,7 +35,7 @@ import { type FoldTool, type Ids, } from '@humanlayer/fold-core' -import { Effect, Match, Schema, Semaphore, type Scope } from 'effect' +import { Effect, FileSystem, Layer, Match, Schema, Semaphore, type Scope } from 'effect' import { loadModelCatalog } from '../Catalog/LoadCatalog' import { agentModelsFromConfig, type EnvLookup, type RoleResolutionError } from '../Config/AgentModels' @@ -193,7 +193,8 @@ const resolveProfileSelection = ( opts: LaunchSessionOptions, ): Effect.Effect< { readonly options: LaunchSessionOptions; readonly profileMode: ProfileModeName | null }, - LaunchModelError + LaunchModelError, + FileSystem.FileSystem > => Effect.gen(function* () { if (opts.profile === undefined) return { options: opts, profileMode: null } @@ -274,7 +275,7 @@ const resolveModeModels = ( options: LaunchSessionOptions, mode: FoldMode, catalog: ReadonlyArray, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { if (options.model !== undefined) { const model = options.model @@ -338,7 +339,7 @@ const buildAgentDefinition = ( cwd: string, config: FoldConfig | null, outputStore: OutputStoreService, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const memoryBlock = yield* memoryPromptBlock({ cwd, @@ -387,7 +388,7 @@ const sessionProfilesFor = (models: ModeModels): SessionProfiles => ({ export const switchSessionMode = ( session: FoldSession, options: SwitchSessionModeOptions, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const { options: profiled } = yield* resolveProfileSelection(options) const mode = options.mode @@ -395,7 +396,7 @@ export const switchSessionMode = ( const catalog = yield* catalogFor(profiled) const models = yield* resolveModeModels(profiled, mode, catalog) const config = yield* runtimeConfigFor(profiled) - const outputStore = makeOutputStore({ + const outputStore = yield* makeOutputStore({ sessionId: session.sessionId, ...(profiled.foldHome === undefined ? {} : { foldHome: profiled.foldHome }), }) @@ -414,11 +415,14 @@ const withGeneratedTitles = ( session: FoldSession, model: FoldModel, options: { readonly cwd: string; readonly foldHome?: string }, -): Effect.Effect => - Semaphore.make(1).pipe( +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const fsLayer = Layer.succeed(FileSystem.FileSystem, fs) + return yield* Semaphore.make(1).pipe( Effect.map((titleLock) => ({ ...session, - send: (text, target) => + send: (text: string, target?: Parameters[1]) => session.send(text, target).pipe( Effect.tap(() => { if (target?.agentId !== undefined && target.agentId !== session.rootAgentId) return Effect.void @@ -445,7 +449,9 @@ const withGeneratedTitles = ( }) .pipe( Effect.andThen( - refreshSessionSummaryIndex(session.sessionId, options), + refreshSessionSummaryIndex(session.sessionId, options).pipe( + Effect.provide(fsLayer), + ), ), ) }), @@ -458,8 +464,9 @@ const withGeneratedTitles = ( ), })), ) + }) -const runtimeConfigFor = (options: LaunchSessionOptions): Effect.Effect => { +const runtimeConfigFor = (options: LaunchSessionOptions): Effect.Effect => { if (options.config !== undefined) return Effect.succeed(options.config) if (options.model !== undefined) return Effect.succeed(null) @@ -467,7 +474,7 @@ const runtimeConfigFor = (options: LaunchSessionOptions): Effect.Effect> => +const catalogFor = (options: LaunchSessionOptions): Effect.Effect, never, FileSystem.FileSystem> => options.catalog !== undefined ? Effect.succeed(options.catalog) : loadModelCatalog({ @@ -481,7 +488,7 @@ const catalogFor = (options: LaunchSessionOptions): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const { options: opts, profileMode } = yield* resolveProfileSelection(options ?? {}) const mode = modeFor(opts, profileMode) @@ -495,7 +502,7 @@ export const launchSession = ( cwd, ...(opts.foldHome === undefined ? {} : { foldHome: opts.foldHome }), }) - const outputStore = makeOutputStore({ + const outputStore = yield* makeOutputStore({ sessionId: prepared.sessionId, ...(opts.foldHome === undefined ? {} : { foldHome: opts.foldHome }), }) @@ -528,13 +535,13 @@ const resumeFromLog = ( options: LaunchSessionOptions, mode: FoldMode, cwd: string, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { // Same order as launchSession: the catalog loads before model resolution (D23 validation). const catalog = yield* catalogFor(options) const models = yield* resolveModeModels(options, mode, catalog) const config = yield* runtimeConfigFor(options) - const outputStore = makeOutputStore({ + const outputStore = yield* makeOutputStore({ sessionId: log.sessionId, ...(options.foldHome === undefined ? {} : { foldHome: options.foldHome }), }) @@ -562,7 +569,7 @@ const resumeFromLog = ( */ export const resumeLatestSession = ( options?: LaunchSessionOptions, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const { options: opts, profileMode } = yield* resolveProfileSelection(options ?? {}) const mode = modeFor(opts, profileMode) @@ -585,7 +592,7 @@ export const resumeLatestSession = ( export const resumeSessionById = ( sessionId: SessionId, options?: LaunchSessionOptions, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const { options: opts, profileMode } = yield* resolveProfileSelection(options ?? {}) const mode = modeFor(opts, profileMode) diff --git a/packages/fold-agent/src/OutputStore/OutputStore.ts b/packages/fold-agent/src/OutputStore/OutputStore.ts index 4555841..2590f33 100644 --- a/packages/fold-agent/src/OutputStore/OutputStore.ts +++ b/packages/fold-agent/src/OutputStore/OutputStore.ts @@ -7,10 +7,9 @@ import { join } from 'node:path' import { SessionId, ToolCallId } from '@humanlayer/fold-core' -import { Cause, Clock, Context, Effect, Layer, Option, Schema } from 'effect' +import { Cause, Clock, Context, Effect, FileSystem, Layer, Option, Schema } from 'effect' import { defaultFoldHome } from '../Config/Load' -import { fileSystemFor, type FsToolOptions } from '../Fs/DefaultFileSystem' const dayMs = 24 * 60 * 60 * 1000 @@ -65,8 +64,6 @@ export type MakeOutputStoreOptions = { readonly foldHome?: string /** Files older than this are deleted by `sweep`. Defaults to 7 days. */ readonly retentionMs?: number - /** Filesystem override for tests. Defaults to Node's filesystem. */ - readonly fileSystem?: FsToolOptions['fileSystem'] } /** Root directory for all stored tool output. */ @@ -112,8 +109,8 @@ const lineSlice = (content: string, options?: OutputStoreReadOptions): string => } /** Construct a file-backed OutputStore service for one session. */ -export const makeOutputStore = (options: MakeOutputStoreOptions): OutputStoreService => { - const fs = fileSystemFor(options.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }) +export const makeOutputStore = (options: MakeOutputStoreOptions): Effect.Effect => + Effect.map(FileSystem.FileSystem, (fs) => { const foldHome = options.foldHome ?? defaultFoldHome() const sessionId = options.sessionId const directory = toolOutputSessionDirFor({ sessionId, foldHome }) @@ -191,8 +188,8 @@ export const makeOutputStore = (options: MakeOutputStoreOptions): OutputStoreSer ) return { sessionId, directory, refFor, prepare, append, read, sweep } -} + }) /** Layer constructor for hosts that want OutputStore in `R`. */ -export const outputStoreLayer = (options: MakeOutputStoreOptions): Layer.Layer => - Layer.succeed(OutputStore, makeOutputStore(options)) +export const outputStoreLayer = (options: MakeOutputStoreOptions): Layer.Layer => + Layer.effect(OutputStore, makeOutputStore(options)) diff --git a/packages/fold-agent/src/Session/SessionLayout.ts b/packages/fold-agent/src/Session/SessionLayout.ts index ac7695d..c476f39 100644 --- a/packages/fold-agent/src/Session/SessionLayout.ts +++ b/packages/fold-agent/src/Session/SessionLayout.ts @@ -12,10 +12,9 @@ import { join } from 'node:path' import { SessionId, makeSessionId, usageInputTotal } from '@humanlayer/fold-core' import type { ActiveModel, LogEntry, FoldEventLog, Ids } from '@humanlayer/fold-core' -import { Clock, Effect, Exit, Match, Option, Schema, Stream } from 'effect' +import { Clock, Effect, Exit, FileSystem, Match, Option, Schema, Stream } from 'effect' import { jsonlEventLog } from '../EventLog/JsonlDescriptor' -import { fileSystemFor, type FsToolOptions } from '../Fs/DefaultFileSystem' import { toolOutputSessionDirFor } from '../OutputStore/OutputStore' /** Options shared by the layout helpers. */ @@ -24,8 +23,6 @@ export type SessionLayoutOptions = { readonly cwd?: string /** The fold home directory. Defaults to `~/.fold`. */ readonly foldHome?: string - /** Filesystem override for discovery (tests); defaults to the Node platform filesystem. */ - readonly fileSystem?: FsToolOptions['fileSystem'] } /** One discovered session log. */ @@ -107,20 +104,21 @@ type SessionIndexRecord = typeof SessionIndexRecordSchema.Type const decodeIndexRecord = Schema.decodeUnknownOption(SessionIndexRecordSchema) -const appendSessionIndexRecord = (record: SessionIndexRecord, options?: SessionLayoutOptions): Effect.Effect => { - const fs = fileSystemFor(options?.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }) - const directory = sessionsDirFor(options) - return fs.makeDirectory(directory, { recursive: true }).pipe( - Effect.andThen( - fs.writeFileString(join(directory, 'index.jsonl'), `${JSON.stringify(record)}\n`, { flag: 'a' }), - ), - Effect.catch((error) => - Effect.logWarning( - `could not append session index record at ${join(directory, 'index.jsonl')}: ${error.message}`, +const appendSessionIndexRecord = (record: SessionIndexRecord, options?: SessionLayoutOptions): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const directory = sessionsDirFor(options) + yield* fs.makeDirectory(directory, { recursive: true }).pipe( + Effect.andThen( + fs.writeFileString(join(directory, 'index.jsonl'), `${JSON.stringify(record)}\n`, { flag: 'a' }), ), - ), - ) -} + Effect.catch((error) => + Effect.logWarning( + `could not append session index record at ${join(directory, 'index.jsonl')}: ${error.message}`, + ), + ), + ) + }) const sessionIdFromIndexRecord = Match.type().pipe( Match.tag('summary', ({ summary }) => summary.sessionId), @@ -128,25 +126,26 @@ const sessionIdFromIndexRecord = Match.type().pipe( Match.exhaustive, ) -const loadSessionIndex = (options?: SessionLayoutOptions): Effect.Effect> => { - const fs = fileSystemFor(options?.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }) - return fs.readFileString(join(sessionsDirFor(options), 'index.jsonl')).pipe( - Effect.map((contents) => { - const latest = new Map() - for (const line of contents.split('\n')) { - if (line.trim().length === 0) continue - try { - const record = decodeIndexRecord(JSON.parse(line)) - if (Option.isSome(record)) latest.set(sessionIdFromIndexRecord(record.value), record.value) - } catch { - // A partial/corrupt cache row is independently recoverable from the source log. +const loadSessionIndex = (options?: SessionLayoutOptions): Effect.Effect, never, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + return yield* fs.readFileString(join(sessionsDirFor(options), 'index.jsonl')).pipe( + Effect.map((contents) => { + const latest = new Map() + for (const line of contents.split('\n')) { + if (line.trim().length === 0) continue + try { + const record = decodeIndexRecord(JSON.parse(line)) + if (Option.isSome(record)) latest.set(sessionIdFromIndexRecord(record.value), record.value) + } catch { + // A partial/corrupt cache row is independently recoverable from the source log. + } } - } - return latest - }), - Effect.catch(() => Effect.succeed(new Map())), - ) -} + return latest + }), + Effect.catch(() => Effect.succeed(new Map())), + ) + }) /** * Mint a session id and prepare its log location: the directory exists, the path is derived from the @@ -155,9 +154,9 @@ const loadSessionIndex = (options?: SessionLayoutOptions): Effect.Effect => +): Effect.Effect<{ readonly sessionId: SessionId; readonly path: string; readonly log: FoldEventLog }, never, Ids | FileSystem.FileSystem> => Effect.gen(function* () { - const fs = fileSystemFor(options?.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }) + const fs = yield* FileSystem.FileSystem const sessionId = yield* makeSessionId const directory = sessionsDirFor(options) yield* fs.makeDirectory(directory, { recursive: true }).pipe(Effect.orDie) @@ -167,9 +166,9 @@ export const prepareSessionLog = ( }) /** Discover this project's session logs, newest first (by file mtime). */ -export const listSessionLogs = (options?: SessionLayoutOptions): Effect.Effect> => +export const listSessionLogs = (options?: SessionLayoutOptions): Effect.Effect, never, FileSystem.FileSystem> => Effect.gen(function* () { - const fs = fileSystemFor(options?.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }) + const fs = yield* FileSystem.FileSystem const directory = sessionsDirFor(options) const names = yield* fs @@ -285,7 +284,7 @@ const sessionSummary = (ref: SessionLogRef, entries: ReadonlyArray): S } } -const loadSessionSummary = (ref: SessionLogRef): Effect.Effect => +const loadSessionSummary = (ref: SessionLogRef): Effect.Effect => Match.value(jsonlEventLog(ref.path)).pipe( Match.tag('source', (descriptor) => Effect.exit( @@ -311,13 +310,13 @@ const isCacheHit = ( cached.sourceSize === (ref.size ?? 0) /** Read the one-file picker cache, rebuilding only stale/missing records from authoritative logs. */ -export const listSessionSummaries = (options?: SessionLayoutOptions): Effect.Effect> => +export const listSessionSummaries = (options?: SessionLayoutOptions): Effect.Effect, never, FileSystem.FileSystem> => Effect.gen(function* () { const refs = yield* listSessionLogs(options) const index = yield* loadSessionIndex(options) const summaries = yield* Effect.forEach( refs, - (ref): Effect.Effect => { + (ref): Effect.Effect => { const cached = index.get(ref.sessionId) if (isCacheHit(cached, ref)) { // Explicitly construct to ensure size conforms to SessionLogRef's optional semantics. @@ -364,9 +363,9 @@ export type DeleteSessionResult = { export const deleteSession = ( sessionId: SessionId, options?: SessionLayoutOptions, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { - const fs = fileSystemFor(options?.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }) + const fs = yield* FileSystem.FileSystem const logPath = sessionLogPathFor(sessionId, options) const exists = yield* fs.exists(logPath).pipe(Effect.orDie) if (!exists) return { deleted: false, outputRemoved: true } @@ -384,16 +383,16 @@ export const deleteSession = ( }) /** The newest session log for this project, or null when none exist ("resume latest" - D5). */ -export const latestSessionLog = (options?: SessionLayoutOptions): Effect.Effect => +export const latestSessionLog = (options?: SessionLayoutOptions): Effect.Effect => listSessionLogs(options).pipe(Effect.map((refs) => refs[0] ?? null)) /** Resolve an exact session id under this project's session directory, or null when it is absent. */ export const sessionLogById = ( sessionId: SessionId, options?: SessionLayoutOptions, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { - const fs = fileSystemFor(options?.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }) + const fs = yield* FileSystem.FileSystem const path = sessionLogPathFor(sessionId, options) const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.succeed(null))) @@ -408,7 +407,7 @@ export const sessionLogById = ( }) /** Rebuild and append one authoritative summary after session metadata changes. */ -export const refreshSessionSummaryIndex = (sessionId: SessionId, options?: SessionLayoutOptions): Effect.Effect => +export const refreshSessionSummaryIndex = (sessionId: SessionId, options?: SessionLayoutOptions): Effect.Effect => sessionLogById(sessionId, options).pipe( Effect.flatMap((ref) => { if (ref === null) return Effect.void diff --git a/packages/fold-agent/src/Session/ViewedChanges.ts b/packages/fold-agent/src/Session/ViewedChanges.ts index 125171f..249b66b 100644 --- a/packages/fold-agent/src/Session/ViewedChanges.ts +++ b/packages/fold-agent/src/Session/ViewedChanges.ts @@ -1,9 +1,7 @@ import { join } from 'node:path' import { SessionId } from '@humanlayer/fold-core' -import { Clock, Effect, Option, Schema } from 'effect' - -import { fileSystemFor } from '../Fs/DefaultFileSystem' +import { Clock, Effect, FileSystem, Option, Schema } from 'effect' import { sessionsDirFor, type SessionLayoutOptions } from './SessionLayout' const ViewedChangeRecord = Schema.Struct({ @@ -23,37 +21,38 @@ const viewedChangesPath = (options?: SessionLayoutOptions): string => export const loadViewedPatchHashes = ( sessionId: SessionId, options?: SessionLayoutOptions, -): Effect.Effect => { - const fs = fileSystemFor(options?.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }) - return fs.readFileString(viewedChangesPath(options)).pipe( - Effect.map((contents) => { - const viewed: Record = {} - for (const line of contents.split('\n')) { - if (line.trim().length === 0) continue - try { - const record = decodeViewedChangeRecord(JSON.parse(line)) - if (Option.isSome(record) && record.value.sessionId === sessionId) { - viewed[record.value.changeKey] = record.value.patchHash +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + return yield* fs.readFileString(viewedChangesPath(options)).pipe( + Effect.map((contents) => { + const viewed: Record = {} + for (const line of contents.split('\n')) { + if (line.trim().length === 0) continue + try { + const record = decodeViewedChangeRecord(JSON.parse(line)) + if (Option.isSome(record) && record.value.sessionId === sessionId) { + viewed[record.value.changeKey] = record.value.patchHash + } + } catch { + // A partial record does not invalidate the rest of this derived UI index. } - } catch { - // A partial record does not invalidate the rest of this derived UI index. } - } - return viewed - }), - Effect.catch(() => Effect.succeed({})), - ) -} + return viewed + }), + Effect.catch(() => Effect.succeed({})), + ) + }) export const saveViewedPatchHash = ( sessionId: SessionId, changeKey: string, patchHash: string, options?: SessionLayoutOptions, -): Effect.Effect => { - const fs = fileSystemFor(options?.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }) - const directory = sessionsDirFor(options) - return Effect.gen(function* () { +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const directory = sessionsDirFor(options) const ts = yield* Clock.currentTimeMillis const record = { sessionId, changeKey, patchHash, ts } yield* fs.makeDirectory(directory, { recursive: true }) @@ -63,4 +62,3 @@ export const saveViewedPatchHash = ( Effect.logWarning(`could not save viewed change for session ${sessionId}: ${error.message}`), ), ) -} diff --git a/packages/fold-agent/src/Skills/DiskSkills.ts b/packages/fold-agent/src/Skills/DiskSkills.ts index 51d3247..ec8014d 100644 --- a/packages/fold-agent/src/Skills/DiskSkills.ts +++ b/packages/fold-agent/src/Skills/DiskSkills.ts @@ -21,10 +21,10 @@ import { type SkillSourceService, type FoldSkills, } from '@humanlayer/fold-core' -import { Effect, type FileSystem } from 'effect' +import { Effect, FileSystem } from 'effect' import { parse as parseYaml } from 'yaml' -import { cwdFor, fileSystemFor } from '../Fs/DefaultFileSystem' +import { cwdFor } from '../Fs/DefaultFileSystem' /** Options for {@link skillsFromDisk}. */ export type DiskSkillsOptions = { @@ -32,8 +32,6 @@ export type DiskSkillsOptions = { readonly cwd?: string /** Home directory for global `~/.claude/skills` and `~/.fold/skills`. Defaults to `os.homedir()`. */ readonly home?: string - /** FileSystem implementation override. Defaults to the Node platform filesystem. */ - readonly fileSystem?: FileSystem.FileSystem /** Extra scan roots appended after the standard chain (highest shadowing precedence). */ readonly extraPaths?: ReadonlyArray } @@ -175,9 +173,8 @@ const scanRoots = ( }) /** Build the disk SkillSource service. Each list/load runs a fresh scan (refresh sees new skills). */ -export const makeDiskSkillSource = (options?: DiskSkillsOptions): Effect.Effect => - Effect.sync(() => { - const fs = fileSystemFor(options) +export const makeDiskSkillSource = (options?: DiskSkillsOptions): Effect.Effect => + Effect.map(FileSystem.FileSystem, (fs) => { const cwd = cwdFor(options) const home = options?.home ?? homedir() const extraPaths = options?.extraPaths ?? [] diff --git a/packages/fold-agent/src/Tools/ApplyPatchTool.ts b/packages/fold-agent/src/Tools/ApplyPatchTool.ts index 502ce1a..85e33e1 100644 --- a/packages/fold-agent/src/Tools/ApplyPatchTool.ts +++ b/packages/fold-agent/src/Tools/ApplyPatchTool.ts @@ -15,9 +15,9 @@ import { type PatchOp, type FoldTool, } from '@humanlayer/fold-core' -import { Effect } from 'effect' +import { Effect, FileSystem } from 'effect' -import { cwdFor, fileSystemFor, type FsToolOptions } from '../Fs/DefaultFileSystem' +import { cwdFor } from '../Fs/DefaultFileSystem' import { withFileMutationLocks } from '../Fs/MutationQueue' import { resolveToCwd } from '../Fs/PathResolve' import { platformErrorMessage } from './ReadTool' @@ -30,13 +30,13 @@ const verificationFailed = (detail: string): { message: string } => ({ const opPaths = (op: PatchOp): ReadonlyArray => op._tag === 'update' && op.movePath !== null ? [op.path, op.movePath] : [op.path] -/** Build the apply_patch tool over the default or provided filesystem. */ -export const applyPatchTool = (options?: FsToolOptions): FoldTool => +/** Build the apply_patch tool over the ambient FileSystem service. */ +export const applyPatchTool = (options?: { readonly cwd?: string }): FoldTool => defineTool({ ...applyPatchToolContract, handler: (params) => Effect.gen(function* () { - const fs = fileSystemFor(options) + const fs = yield* FileSystem.FileSystem const cwd = cwdFor(options) const ops = yield* parsePatch(params.patch_text).pipe( Effect.mapError((error) => verificationFailed(error.message)), diff --git a/packages/fold-agent/src/Tools/BashTool.ts b/packages/fold-agent/src/Tools/BashTool.ts index 8c580a6..87c9ed8 100644 --- a/packages/fold-agent/src/Tools/BashTool.ts +++ b/packages/fold-agent/src/Tools/BashTool.ts @@ -28,10 +28,10 @@ import { utf8ByteLength, type FoldTool, } from '@humanlayer/fold-core' -import { type Context, Duration, Effect, Fiber, Layer, Option, Random, Ref, Schema, Semaphore, Stream } from 'effect' +import { type Context, Duration, Effect, Fiber, FileSystem, Layer, Option, Random, Ref, Schema, Semaphore, Stream } from 'effect' import { ChildProcess, type ChildProcessSpawner } from 'effect/unstable/process' -import { cwdFor, fileSystemFor, type FsToolOptions } from '../Fs/DefaultFileSystem' +import { cwdFor } from '../Fs/DefaultFileSystem' import type { OutputStoreService } from '../OutputStore/OutputStore' import { platformErrorMessage } from './ReadTool' @@ -82,7 +82,9 @@ const killGrace = Duration.millis(200) const inMemoryRetentionBytes = 4 * defaultMaxBytes /** Options for {@link bashTool}. */ -export type BashToolOptions = FsToolOptions & { +export type BashToolOptions = { + /** Working directory for resolving relative paths. Defaults to `process.cwd()` at call time. */ + readonly cwd?: string /** Base directory for spill files holding full untruncated output. Defaults to `os.tmpdir()`. */ readonly spillDir?: string /** Deterministic per-session output store. When absent, bash uses the legacy temp spill file. */ @@ -263,7 +265,7 @@ export const bashTool = (options?: BashToolOptions): FoldTool => failure: BashFailure, handler: (params) => Effect.gen(function* () { - const fs = fileSystemFor(options) + const fs = yield* FileSystem.FileSystem const cwd = params.workdir ?? cwdFor(options) const timeoutSeconds = params.timeout ?? defaultTimeoutSeconds diff --git a/packages/fold-agent/src/Tools/CodingTools.ts b/packages/fold-agent/src/Tools/CodingTools.ts index c83ce88..5c7d779 100644 --- a/packages/fold-agent/src/Tools/CodingTools.ts +++ b/packages/fold-agent/src/Tools/CodingTools.ts @@ -6,7 +6,6 @@ */ import type { FoldTool } from '@humanlayer/fold-core' -import type { FsToolOptions } from '../Fs/DefaultFileSystem' import { applyPatchTool } from './ApplyPatchTool' import { bashTool, type BashToolOptions } from './BashTool' import { editTool } from './EditTool' @@ -14,8 +13,8 @@ import { readTool } from './ReadTool' import { webTools, type WebToolsOptions } from './WebTools' import { writeTool } from './WriteTool' -/** Options for {@link codingTools}: the shared filesystem seam plus bash output-spill configuration. */ -export type CodingToolsOptions = FsToolOptions & Pick & WebToolsOptions +/** Options for {@link codingTools}: the shared cwd plus bash output-spill configuration. */ +export type CodingToolsOptions = Pick & WebToolsOptions /** * The standard coding toolset: read, write, edit, apply_patch, bash, and web tools. The model-family policy decides diff --git a/packages/fold-agent/src/Tools/EditTool.ts b/packages/fold-agent/src/Tools/EditTool.ts index b006db5..49d3e4a 100644 --- a/packages/fold-agent/src/Tools/EditTool.ts +++ b/packages/fold-agent/src/Tools/EditTool.ts @@ -5,20 +5,20 @@ * parallel edits of one file cannot interleave. */ import { defineTool, applyEdits, editToolContract, normalizeEditInput, type FoldTool } from '@humanlayer/fold-core' -import { Effect } from 'effect' +import { Effect, FileSystem } from 'effect' -import { cwdFor, fileSystemFor, type FsToolOptions } from '../Fs/DefaultFileSystem' +import { cwdFor } from '../Fs/DefaultFileSystem' import { withFileMutationLock } from '../Fs/MutationQueue' import { resolveToCwd } from '../Fs/PathResolve' import { errnoCode, platformErrorMessage } from './ReadTool' -/** Build the edit tool over the default or provided filesystem. */ -export const editTool = (options?: FsToolOptions): FoldTool => +/** Build the edit tool over the ambient FileSystem service. */ +export const editTool = (options?: { readonly cwd?: string }): FoldTool => defineTool({ ...editToolContract, handler: (params) => Effect.gen(function* () { - const fs = fileSystemFor(options) + const fs = yield* FileSystem.FileSystem const absolutePath = resolveToCwd(params.path, cwdFor(options)) const edits = yield* normalizeEditInput(params).pipe( Effect.mapError((error) => ({ message: error.message })), diff --git a/packages/fold-agent/src/Tools/ReadTool.ts b/packages/fold-agent/src/Tools/ReadTool.ts index 25e2744..37743ce 100644 --- a/packages/fold-agent/src/Tools/ReadTool.ts +++ b/packages/fold-agent/src/Tools/ReadTool.ts @@ -14,9 +14,9 @@ import { type FoldTool, type ToolResultBlock, } from '@humanlayer/fold-core' -import { Effect, type PlatformError } from 'effect' +import { Effect, FileSystem, type PlatformError } from 'effect' -import { cwdFor, fileSystemFor, type FsToolOptions } from '../Fs/DefaultFileSystem' +import { cwdFor } from '../Fs/DefaultFileSystem' import { resolveReadPath } from '../Fs/PathResolve' import { detectSupportedImageMimeType, imageSniffBytes } from './Image/Mime' import { processImage } from './Image/Process' @@ -52,13 +52,13 @@ export const errnoCode = (error: PlatformError.PlatformError): string => { } } -/** Build the read tool over the default or provided filesystem. */ -export const readTool = (options?: FsToolOptions): FoldTool => +/** Build the read tool over the ambient FileSystem service. */ +export const readTool = (options?: { readonly cwd?: string }): FoldTool => defineTool({ ...readToolContract, handler: (params) => Effect.gen(function* () { - const fs = fileSystemFor(options) + const fs = yield* FileSystem.FileSystem const cwd = cwdFor(options) const absolutePath = yield* resolveReadPath(params.path, cwd, fs) diff --git a/packages/fold-agent/src/Tools/WriteTool.ts b/packages/fold-agent/src/Tools/WriteTool.ts index e6fbc3b..c4161bc 100644 --- a/packages/fold-agent/src/Tools/WriteTool.ts +++ b/packages/fold-agent/src/Tools/WriteTool.ts @@ -6,20 +6,20 @@ import { dirname } from 'node:path' import { defineTool, utf8ByteLength, writeToolContract, type FoldTool } from '@humanlayer/fold-core' -import { Effect } from 'effect' +import { Effect, FileSystem } from 'effect' -import { cwdFor, fileSystemFor, type FsToolOptions } from '../Fs/DefaultFileSystem' +import { cwdFor } from '../Fs/DefaultFileSystem' import { withFileMutationLock } from '../Fs/MutationQueue' import { resolveToCwd } from '../Fs/PathResolve' import { platformErrorMessage } from './ReadTool' -/** Build the write tool over the default or provided filesystem. */ -export const writeTool = (options?: FsToolOptions): FoldTool => +/** Build the write tool over the ambient FileSystem service. */ +export const writeTool = (options?: { readonly cwd?: string }): FoldTool => defineTool({ ...writeToolContract, handler: (params) => Effect.gen(function* () { - const fs = fileSystemFor(options) + const fs = yield* FileSystem.FileSystem const absolutePath = resolveToCwd(params.path, cwdFor(options)) yield* withFileMutationLock( diff --git a/packages/fold-agent/test/Bin/ManagedBinaries.vi.test.ts b/packages/fold-agent/test/Bin/ManagedBinaries.vi.test.ts index 6ed61dd..118df00 100644 --- a/packages/fold-agent/test/Bin/ManagedBinaries.vi.test.ts +++ b/packages/fold-agent/test/Bin/ManagedBinaries.vi.test.ts @@ -9,6 +9,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Effect } from 'effect' import { @@ -99,7 +100,7 @@ it.effect('a system alias hit short-circuits the ladder without downloading', () expect(status?.path).toBe('/usr/bin/fdfind') expect(status?.detail).toContain('fdfind') expect(download.urls).toEqual([]) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('requireManagedInstall installs the canonical managed binary even when a system binary exists', () => @@ -122,7 +123,7 @@ it.effect('requireManagedInstall installs the canonical managed binary even when expect(status?.path).toBe(join(managedBinDir(home), 'rg')) expect(existsSync(join(managedBinDir(home), 'rg'))).toBe(true) expect(download.urls).toEqual(['https://example.com/rg-1.0.0.tar.gz']) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('requireManagedInstall plus disabled downloads can still report a usable system binary', () => @@ -141,7 +142,7 @@ it.effect('requireManagedInstall plus disabled downloads can still report a usab expect(status?.resolution).toBe('system') expect(status?.path).toBe('/opt/homebrew/bin/rg') expect(existsSync(join(managedBinDir(home), 'rg'))).toBe(false) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('a system binary below the version floor falls through past the system rung', () => @@ -160,7 +161,7 @@ it.effect('a system binary below the version floor falls through past the system // Not 'system': the old binary was rejected; with downloads disabled the ladder ends unavailable. expect(status?.resolution).toBe('unavailable') expect(status?.detail).toContain('downloads disabled') - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('an already-installed managed binary resolves without downloading', () => @@ -182,7 +183,7 @@ it.effect('an already-installed managed binary resolves without downloading', () expect(status?.resolution).toBe('managed') expect(status?.path).toBe(join(managedBinDir(home), 'rg')) expect(download.urls).toEqual([]) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('a missing binary downloads, extracts, and installs into /bin', () => @@ -204,7 +205,7 @@ it.effect('a missing binary downloads, extracts, and installs into /bi expect(status?.path).toBe(join(managedBinDir(home), 'rg')) expect(existsSync(join(managedBinDir(home), 'rg'))).toBe(true) expect(download.urls).toEqual(['https://example.com/rg-1.0.0.tar.gz']) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('a sha256 mismatch degrades to unavailable and writes nothing', () => @@ -232,7 +233,7 @@ it.effect('a sha256 mismatch degrades to unavailable and writes nothing', () => expect(status?.resolution).toBe('unavailable') expect(status?.detail).toContain('sha256 mismatch') expect(existsSync(join(managedBinDir(home), 'rg'))).toBe(false) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('the env kill switch skips downloads entirely', () => @@ -251,7 +252,7 @@ it.effect('the env kill switch skips downloads entirely', () => expect(status?.resolution).toBe('unavailable') expect(download.urls).toEqual([]) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('one failing binary never blocks the rest (ensure never fails)', () => @@ -271,7 +272,7 @@ it.effect('one failing binary never blocks the rest (ensure never fails)', () => expect(statuses.map((status) => status.resolution)).toEqual(['unavailable', 'system']) expect(statuses[0]?.detail).toContain('network down') - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('an exec failure during extraction also degrades to unavailable', () => @@ -292,7 +293,7 @@ it.effect('an exec failure during extraction also degrades to unavailable', () = expect(status?.resolution).toBe('unavailable') expect(status?.detail).toContain('exploded') - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('memoized ensures share one resolution pass per (foldHome, mode)', () => @@ -315,7 +316,7 @@ it.effect('memoized ensures share one resolution pass per (foldHome, mode)', () expect(second[0]?.resolution).toBe('installed-now') // One download despite two ensure calls: the memoized run was shared. expect(download.urls).toEqual(['https://example.com/rg-1.0.0.tar.gz']) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it('parseBinaryVersion pulls the first semver triple out of arbitrary --version output', () => { diff --git a/packages/fold-agent/test/Catalog/LoadCatalog.vi.test.ts b/packages/fold-agent/test/Catalog/LoadCatalog.vi.test.ts index 5f82964..1342e51 100644 --- a/packages/fold-agent/test/Catalog/LoadCatalog.vi.test.ts +++ b/packages/fold-agent/test/Catalog/LoadCatalog.vi.test.ts @@ -7,7 +7,7 @@ */ import { expect, it } from '@effect/vitest' import type { ModelCatalogEntry } from '@humanlayer/fold-core' -import { Effect, Ref } from 'effect' +import { Effect, FileSystem, Layer, Ref } from 'effect' import { bakedModelCatalog, @@ -68,29 +68,26 @@ const failingOutcome = Effect.fail(new CatalogFetchError({ message: 'network unr it.effect('a fresh cache short-circuits the fetch', () => Effect.gen(function* () { - const fs = memoryFileSystem({ [cachePath]: cacheFile(fixedNow - hourMs) }) const fetch = yield* recordingFetch(Effect.succeed(fetchedPayload)) const entries = yield* loadModelCatalog({ foldHome, - fileSystem: fs, fetchJson: fetch.fetchJson, now: Effect.succeed(fixedNow), }) expect(entries).toEqual([cachedEntry]) expect(yield* fetch.calls).toBe(0) - }), + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, memoryFileSystem({ [cachePath]: cacheFile(fixedNow - hourMs) })))), ) it.effect('a stale cache refetches, returns the live entries, and rewrites the cache', () => Effect.gen(function* () { - const fs = memoryFileSystem({ [cachePath]: cacheFile(fixedNow - 25 * hourMs) }) + const fs = yield* FileSystem.FileSystem const fetch = yield* recordingFetch(Effect.succeed(fetchedPayload)) const entries = yield* loadModelCatalog({ foldHome, - fileSystem: fs, fetchJson: fetch.fetchJson, now: Effect.succeed(fixedNow), }) @@ -103,67 +100,59 @@ it.effect('a stale cache refetches, returns the live entries, and rewrites the c // The cache was rewritten with the fresh fetch time and the normalized entries. const written: unknown = JSON.parse(yield* fs.readFileString(cachePath)) expect(written).toEqual({ version: 1, fetchedAt: fixedNow, entries }) - }), + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, memoryFileSystem({ [cachePath]: cacheFile(fixedNow - 25 * hourMs) })))), ) it.effect('a fetch failure degrades to the stale cache with a warning', () => Effect.gen(function* () { - const fs = memoryFileSystem({ [cachePath]: cacheFile(fixedNow - 25 * hourMs) }) const fetch = yield* recordingFetch(failingOutcome) const entries = yield* loadModelCatalog({ foldHome, - fileSystem: fs, fetchJson: fetch.fetchJson, now: Effect.succeed(fixedNow), }) expect(yield* fetch.calls).toBe(1) expect(entries).toEqual([cachedEntry]) - }), + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, memoryFileSystem({ [cachePath]: cacheFile(fixedNow - 25 * hourMs) })))), ) it.effect('no cache plus a fetch failure degrades to the baked snapshot', () => Effect.gen(function* () { - const fs = memoryFileSystem({}) const fetch = yield* recordingFetch(failingOutcome) const entries = yield* loadModelCatalog({ foldHome, - fileSystem: fs, fetchJson: fetch.fetchJson, now: Effect.succeed(fixedNow), }) expect(entries).toBe(bakedModelCatalog) - }), + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, memoryFileSystem({})))), ) it.effect('FOLD_DISABLE_MODELS_FETCH skips the fetch: stale cache when present, baked otherwise', () => Effect.gen(function* () { const env = (name: string): string | undefined => (name === FOLD_DISABLE_MODELS_FETCH ? '1' : undefined) - const withStale = memoryFileSystem({ [cachePath]: cacheFile(fixedNow - 25 * hourMs) }) const fetchA = yield* recordingFetch(Effect.succeed(fetchedPayload)) const staleEntries = yield* loadModelCatalog({ foldHome, - fileSystem: withStale, env, fetchJson: fetchA.fetchJson, now: Effect.succeed(fixedNow), - }) + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, memoryFileSystem({ [cachePath]: cacheFile(fixedNow - 25 * hourMs) })))) expect(staleEntries).toEqual([cachedEntry]) expect(yield* fetchA.calls).toBe(0) - const withoutCache = memoryFileSystem({}) const fetchB = yield* recordingFetch(Effect.succeed(fetchedPayload)) const bakedEntries = yield* loadModelCatalog({ foldHome, - fileSystem: withoutCache, env, fetchJson: fetchB.fetchJson, now: Effect.succeed(fixedNow), - }) + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, memoryFileSystem({})))) expect(bakedEntries).toBe(bakedModelCatalog) expect(yield* fetchB.calls).toBe(0) }), @@ -180,10 +169,9 @@ it.effect('corrupt or wrong-version caches read as absent: the fetch runs and re const entries = yield* loadModelCatalog({ foldHome, - fileSystem: fs, fetchJson: fetch.fetchJson, now: Effect.succeed(fixedNow), - }) + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) expect(yield* fetch.calls).toBe(1) expect(entries[0]?.modelId).toBe('fetched-model') diff --git a/packages/fold-agent/test/Config/ConfigLoad.vi.test.ts b/packages/fold-agent/test/Config/ConfigLoad.vi.test.ts index 5385bab..a8ba936 100644 --- a/packages/fold-agent/test/Config/ConfigLoad.vi.test.ts +++ b/packages/fold-agent/test/Config/ConfigLoad.vi.test.ts @@ -4,7 +4,7 @@ * FileSystem (never touches the real disk). */ import { expect, it } from '@effect/vitest' -import { Effect } from 'effect' +import { Effect, FileSystem, Layer } from 'effect' import { loadFoldConfig, loadFoldConfigOrNull, parseFoldConfig, stripJsonc } from '../../src/index' import { memoryFileSystem } from '../TestHelpers' @@ -79,22 +79,22 @@ it.effect('fails with ConfigParseError on malformed JSON', () => }), ) -it.effect('loads and decodes the config file from the fold home', () => - Effect.gen(function* () { - const fs = memoryFileSystem({ '/home/user/.fold/config.jsonc': validConfig }) - const config = yield* loadFoldConfig({ foldHome: '/home/user/.fold', fileSystem: fs }) +it.effect('loads and decodes the config file from the fold home', () => { + const fs = memoryFileSystem({ '/home/user/.fold/config.jsonc': validConfig }) + return Effect.gen(function* () { + const config = yield* loadFoldConfig({ foldHome: '/home/user/.fold' }) expect(config.roles.smart.model).toBe('claude-opus-4-8') - }), -) + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) +}) -it.effect('fails with ConfigFileNotFoundError when the file is absent; OrNull returns null', () => - Effect.gen(function* () { - const fs = memoryFileSystem({}) - const error = yield* loadFoldConfig({ foldHome: '/home/user/.fold', fileSystem: fs }).pipe(Effect.flip) +it.effect('fails with ConfigFileNotFoundError when the file is absent; OrNull returns null', () => { + const fs = memoryFileSystem({}) + return Effect.gen(function* () { + const error = yield* loadFoldConfig({ foldHome: '/home/user/.fold' }).pipe(Effect.flip) expect(error._tag).toBe('ConfigFileNotFoundError') - const orNull = yield* loadFoldConfigOrNull({ foldHome: '/home/user/.fold', fileSystem: fs }) + const orNull = yield* loadFoldConfigOrNull({ foldHome: '/home/user/.fold' }) expect(orNull).toBeNull() - }), -) + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) +}) diff --git a/packages/fold-agent/test/Config/ConfigSchemaJson.vi.test.ts b/packages/fold-agent/test/Config/ConfigSchemaJson.vi.test.ts index f6f2696..221ff1f 100644 --- a/packages/fold-agent/test/Config/ConfigSchemaJson.vi.test.ts +++ b/packages/fold-agent/test/Config/ConfigSchemaJson.vi.test.ts @@ -5,7 +5,7 @@ * existing config. All filesystem work is over an in-memory FileSystem. */ import { expect, it } from '@effect/vitest' -import { Effect, JsonSchema } from 'effect' +import { Effect, FileSystem, JsonSchema, Layer } from 'effect' import { configInit, @@ -62,11 +62,10 @@ it.effect('the starter config is valid against the schema (round-trips through t }), ) -it.effect('configInit writes the schema and a starter config, then never clobbers the config', () => - Effect.gen(function* () { - const fs = memoryFileSystem({}) - - const first = yield* configInit({ foldHome: '/home/user/.fold', fileSystem: fs }) +it.effect('configInit writes the schema and a starter config, then never clobbers the config', () => { + const fs = memoryFileSystem({}) + return Effect.gen(function* () { + const first = yield* configInit({ foldHome: '/home/user/.fold' }) expect(first.createdConfig).toBe(true) expect(first.configPath).toBe('/home/user/.fold/config.jsonc') expect(first.schemaPath).toBe('/home/user/.fold/config.schema.json') @@ -102,12 +101,12 @@ it.effect('configInit writes the schema and a starter config, then never clobber // A user edits their config and logs in; a second init refreshes the generated files but leaves both alone. yield* fs.writeFileString('/home/user/.fold/config.jsonc', '{ "edited": true }').pipe(Effect.orDie) yield* fs.writeFileString('/home/user/.fold/auth.json', '{ "codex": { "access": "tok" } }').pipe(Effect.orDie) - const second = yield* configInit({ foldHome: '/home/user/.fold', fileSystem: fs }) + const second = yield* configInit({ foldHome: '/home/user/.fold' }) expect(second.createdConfig).toBe(false) expect(second.createdAuth).toBe(false) const configFile = yield* memoryFileFor(fs, second.configPath) expect(configFile).toBe('{ "edited": true }') expect(yield* memoryFileFor(fs, second.authPath)).toBe('{ "codex": { "access": "tok" } }') - }), -) + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) +}) diff --git a/packages/fold-agent/test/Config/ProviderConfig.vi.test.ts b/packages/fold-agent/test/Config/ProviderConfig.vi.test.ts index cd7bd47..b67b4d9 100644 --- a/packages/fold-agent/test/Config/ProviderConfig.vi.test.ts +++ b/packages/fold-agent/test/Config/ProviderConfig.vi.test.ts @@ -3,7 +3,8 @@ import { readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { expect, it } from '@effect/vitest' -import { Effect } from 'effect' +import { Effect, FileSystem, Layer } from 'effect' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { configureProvider, describeModelConfiguration, loadFoldConfig, starterConfigJsonc } from '../../src/index' import { memoryFileSystem, tempDir } from '../TestHelpers' @@ -44,7 +45,7 @@ it.effect('adds a provider and model without changing roles, profiles, or policy ).toContain('company-model-1') expect(statSync(path).mode & 0o777).toBe(0o600) }), - ), + ).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('updates a provider, retaining configured models when no new model is supplied', () => @@ -86,7 +87,7 @@ it.effect('updates a provider, retaining configured models when no new model is }) expect(updated.roles.smart).toEqual({ provider: 'custom', model: 'existing-model' }) }), - ), + ).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('stores an API key environment variable name without resolving or persisting its value', () => @@ -114,7 +115,7 @@ it.effect('stores an API key environment variable name without resolving or pers configuredModels: ['anthropic/claude-sonnet-4'], }) }), - ), + ).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('rejects supplying both inline and environment API key sources', () => @@ -137,7 +138,7 @@ it.effect('rejects supplying both inline and environment API key sources', () => expect(error._tag).toBe('ProviderConfigurationValidationError') }), - ), + ).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('adds OAuth profiles without an API key and supplies their default model', () => @@ -157,7 +158,7 @@ it.effect('adds OAuth profiles without an API key and supplies their default mod configuredModels: ['gpt-5.6-sol'], }) }), - ), + ).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('rejects accidental API keys for OAuth profiles before writing', () => @@ -174,19 +175,19 @@ it.effect('rejects accidental API keys for OAuth profiles before writing', () => expect(error._tag).toBe('ProviderConfigurationKindError') expect(yield* Effect.promise(() => readFile(path, 'utf8'))).toBe(before) }), - ), + ).pipe(Effect.provide(NodeFileSystem.layer)), ) -it.effect('does not replace a malformed existing config', () => - Effect.gen(function* () { - const fileSystem = memoryFileSystem({ - '/home/user/.fold/config.jsonc': '{ malformed', - }) +it.effect('does not replace a malformed existing config', () => { + const fs = memoryFileSystem({ + '/home/user/.fold/config.jsonc': '{ malformed', + }) + return Effect.gen(function* () { const error = yield* configureProvider( { name: 'custom', kind: 'anthropic', baseUrl: 'https://example.test', apiKey: 'secret' }, - { foldHome: '/home/user/.fold', fileSystem }, + { foldHome: '/home/user/.fold' }, ).pipe(Effect.flip) expect(error._tag).toBe('ConfigParseError') - }), -) + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) +}) diff --git a/packages/fold-agent/test/Memory/AgentFiles.vi.test.ts b/packages/fold-agent/test/Memory/AgentFiles.vi.test.ts index 487e16d..472aaae 100644 --- a/packages/fold-agent/test/Memory/AgentFiles.vi.test.ts +++ b/packages/fold-agent/test/Memory/AgentFiles.vi.test.ts @@ -5,22 +5,22 @@ * and the `` rendering shape. */ import { expect, it } from '@effect/vitest' -import { Effect } from 'effect' +import { Effect, FileSystem, Layer } from 'effect' import { loadMemoryFiles, memoryPromptBlock, renderMemoryFiles } from '../../src/index' import { memoryFileSystem } from '../TestHelpers' -it.effect('collects global then root..cwd, base first then local overlay', () => - Effect.gen(function* () { - const fs = memoryFileSystem({ - '/home/user/.fold/AGENTS.md': 'global memory', - '/repo/AGENTS.md': 'repo base', - '/repo/CLAUDE.md': 'repo claude (should be shadowed by AGENTS.md)', - '/repo/pkg/CLAUDE.md': 'pkg base', - '/repo/pkg/AGENTS.local.md': 'pkg local overlay', - }) +it.effect('collects global then root..cwd, base first then local overlay', () => { + const fs = memoryFileSystem({ + '/home/user/.fold/AGENTS.md': 'global memory', + '/repo/AGENTS.md': 'repo base', + '/repo/CLAUDE.md': 'repo claude (should be shadowed by AGENTS.md)', + '/repo/pkg/CLAUDE.md': 'pkg base', + '/repo/pkg/AGENTS.local.md': 'pkg local overlay', + }) - const files = yield* loadMemoryFiles({ cwd: '/repo/pkg', home: '/home/user', fileSystem: fs }) + return Effect.gen(function* () { + const files = yield* loadMemoryFiles({ cwd: '/repo/pkg', home: '/home/user' }) expect(files.map((file) => file.path)).toEqual([ '/home/user/.fold/AGENTS.md', @@ -31,52 +31,52 @@ it.effect('collects global then root..cwd, base first then local overlay', () => // AGENTS.md wins over CLAUDE.md in /repo. expect(files.some((file) => file.path === '/repo/CLAUDE.md')).toBe(false) expect(files[1]?.content).toBe('repo base') - }), -) + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) +}) -it.effect('loads a local overlay even when the directory has no base file', () => - Effect.gen(function* () { - const fs = memoryFileSystem({ - '/repo/CLAUDE.local.md': 'local only', - }) +it.effect('loads a local overlay even when the directory has no base file', () => { + const fs = memoryFileSystem({ + '/repo/CLAUDE.local.md': 'local only', + }) - const files = yield* loadMemoryFiles({ cwd: '/repo', home: '/home/user', fileSystem: fs }) + return Effect.gen(function* () { + const files = yield* loadMemoryFiles({ cwd: '/repo', home: '/home/user' }) expect(files.map((file) => file.path)).toEqual(['/repo/CLAUDE.local.md']) - }), -) + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) +}) -it.effect('global chain: falls through to ~/.agents then ~/.codex (first existing wins)', () => - Effect.gen(function* () { - const fs = memoryFileSystem({ - '/home/user/.codex/AGENTS.md': 'codex global', - '/work/AGENTS.md': 'project', - }) +it.effect('global chain: falls through to ~/.agents then ~/.codex (first existing wins)', () => { + const fs = memoryFileSystem({ + '/home/user/.codex/AGENTS.md': 'codex global', + '/work/AGENTS.md': 'project', + }) - const files = yield* loadMemoryFiles({ cwd: '/work', home: '/home/user', fileSystem: fs }) + return Effect.gen(function* () { + const files = yield* loadMemoryFiles({ cwd: '/work', home: '/home/user' }) expect(files.map((file) => file.path)).toEqual(['/home/user/.codex/AGENTS.md', '/work/AGENTS.md']) - }), -) + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) +}) -it.effect('renders one project_context block with a project_instructions per file', () => - Effect.gen(function* () { - const fs = memoryFileSystem({ '/repo/AGENTS.md': 'do the thing' }) - const block = yield* memoryPromptBlock({ cwd: '/repo', home: '/home/user', fileSystem: fs }) +it.effect('renders one project_context block with a project_instructions per file', () => { + const fs = memoryFileSystem({ '/repo/AGENTS.md': 'do the thing' }) + return Effect.gen(function* () { + const block = yield* memoryPromptBlock({ cwd: '/repo', home: '/home/user' }) expect(block).not.toBeNull() expect(block ?? '').toContain('') expect(block ?? '').toContain('') expect(block ?? '').toContain('do the thing') - }), -) + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) +}) it('renders null for an empty set', () => { expect(renderMemoryFiles([])).toBeNull() }) -it.effect('returns null block when no agentfiles exist for the cwd', () => - Effect.gen(function* () { - const fs = memoryFileSystem({ '/repo/README.md': 'not an agentfile' }) - const block = yield* memoryPromptBlock({ cwd: '/repo', home: '/home/user', fileSystem: fs }) +it.effect('returns null block when no agentfiles exist for the cwd', () => { + const fs = memoryFileSystem({ '/repo/README.md': 'not an agentfile' }) + return Effect.gen(function* () { + const block = yield* memoryPromptBlock({ cwd: '/repo', home: '/home/user' }) expect(block).toBeNull() - }), -) + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) +}) diff --git a/packages/fold-agent/test/Mode/Launch.vi.test.ts b/packages/fold-agent/test/Mode/Launch.vi.test.ts index 820b4ef..55418a6 100644 --- a/packages/fold-agent/test/Mode/Launch.vi.test.ts +++ b/packages/fold-agent/test/Mode/Launch.vi.test.ts @@ -10,6 +10,7 @@ import { join } from 'node:path' import { expect, it } from '@effect/vitest' import { customModel, layerLiveIdFactory, type ActiveModel, type FoldModel } from '@humanlayer/fold-core' import { Effect, Stream } from 'effect' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { LanguageModel, type Response } from 'effect/unstable/ai' import { @@ -124,7 +125,7 @@ it.effect('launchSession composes the model, agentfiles, and mode tools over sta expect(tools).toContain('subagent') }), ) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('launchSession with rpi appends the hint block after the mode prompt', () => @@ -154,7 +155,7 @@ it.effect('launchSession with rpi appends the hint block after the mode prompt', expect(leadingJson.indexOf(RPI_HINT_PROMPT)).toBeGreaterThan(leadingJson.indexOf(DEFAULT_CODING_PROMPT)) }), ) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('switchSessionMode preserves identity and writes one recomposed mode epoch', () => @@ -186,7 +187,7 @@ it.effect('switchSessionMode preserves identity and writes one recomposed mode e expect(JSON.stringify(switchedPrompt)).toContain(RPI_HINT_PROMPT) }), ) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('resumeLatestSession adopts the newest log for the working directory', () => @@ -221,7 +222,7 @@ it.effect('resumeLatestSession adopts the newest log for the working directory', expect(JSON.stringify(entries)).toContain('first message') }), ) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('resumeSessionById adopts an exact session id from the current project directory', () => @@ -250,7 +251,7 @@ it.effect('resumeSessionById adopts an exact session id from the current project expect(JSON.stringify(yield* resumed.entries)).toContain('remember this by id') }), ) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('resumeSessionById is scoped to the selected cwd project slug', () => @@ -274,7 +275,7 @@ it.effect('resumeSessionById is scoped to the selected cwd project slug', () => }).pipe(Effect.scoped, Effect.flip) expect(error._tag).toBe('SessionToResumeNotFoundError') - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('launchSession resolves CLI-style model selection overrides through fold-agent config', () => @@ -312,7 +313,7 @@ it.effect('launchSession resolves CLI-style model selection overrides through fo } }), ) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('a direct Codex launch replaces the complete mixed-provider role map', () => @@ -353,7 +354,7 @@ it.effect('a direct Codex launch replaces the complete mixed-provider role map', } }), ) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('launchSession wires session profiles end to end: role-bound roster starts and setProfile works', () => @@ -382,7 +383,7 @@ it.effect('launchSession wires session profiles end to end: role-bound roster st yield* session.setProfile('fast', alwaysTextModel('rebound')) }), ) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) const namedProfileConfigText = `{ @@ -436,7 +437,7 @@ it.effect('--profile substitutes the profile roles and applies its pinned rlm mo expect(JSON.stringify(leading)).toContain(RPI_HINT_PROMPT) }), ) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('an explicit mode option beats the profile pinned mode', () => @@ -464,7 +465,7 @@ it.effect('an explicit mode option beats the profile pinned mode', () => expect(started.tools).toContain('bash') }), ) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('an unknown --profile fails with UnknownProfileError naming what exists', () => @@ -482,7 +483,7 @@ it.effect('an unknown --profile fails with UnknownProfileError naming what exist if (error._tag !== 'UnknownProfileError') return expect(error.profile).toBe('nope') expect(error.available).toEqual(['ultratest']) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('resumeLatestSession fails with NoSessionToResumeError when none exist for the cwd', () => @@ -495,7 +496,7 @@ it.effect('resumeLatestSession fails with NoSessionToResumeError when none exist Effect.flip, ) expect(error._tag).toBe('NoSessionToResumeError') - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) // --- mergeModelSelection: the CLI --provider/--model/--reasoning merge over a config binding ---------- diff --git a/packages/fold-agent/test/OutputStore/OutputStore.vi.test.ts b/packages/fold-agent/test/OutputStore/OutputStore.vi.test.ts index 3e25ace..88404c1 100644 --- a/packages/fold-agent/test/OutputStore/OutputStore.vi.test.ts +++ b/packages/fold-agent/test/OutputStore/OutputStore.vi.test.ts @@ -3,6 +3,7 @@ import { existsSync, utimesSync } from 'node:fs' import { expect, it } from '@effect/vitest' import { SessionId, ToolCallId } from '@humanlayer/fold-core' import { Effect } from 'effect' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { makeOutputStore, toolOutputPathFor } from '../../src/OutputStore/OutputStore' import { tempDir } from '../TestHelpers' @@ -12,7 +13,7 @@ it.effect('stores tool output at a deterministic session/tool-call path', () => const root = yield* tempDir const sessionId = SessionId.make('sess_aaaaaaaaaaaaaaaaaaaaaaaa') const toolCallId = ToolCallId.make('tool_call_bbbbbbbbbbbbbbbbbbbbbbbb') - const store = makeOutputStore({ sessionId, foldHome: root }) + const store = yield* makeOutputStore({ sessionId, foldHome: root }) const expectedPath = toolOutputPathFor({ sessionId, toolCallId, foldHome: root }) const first = yield* store.append(toolCallId, 'one\n') @@ -22,7 +23,7 @@ it.effect('stores tool output at a deterministic session/tool-call path', () => expect(second.path).toBe(expectedPath) expect(yield* store.read(first)).toBe('one\ntwo\nthree') expect(yield* store.read(first, { offset: 2, limit: 1 })).toBe('two') - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.live('sweeps old stored output files best-effort', () => @@ -30,7 +31,7 @@ it.live('sweeps old stored output files best-effort', () => const root = yield* tempDir const sessionId = SessionId.make('sess_cccccccccccccccccccccccc') const toolCallId = ToolCallId.make('tool_call_dddddddddddddddddddddddd') - const store = makeOutputStore({ sessionId, foldHome: root, retentionMs: 1 }) + const store = yield* makeOutputStore({ sessionId, foldHome: root, retentionMs: 1 }) const ref = yield* store.append(toolCallId, 'old output') const old = new Date(0) @@ -39,5 +40,5 @@ it.live('sweeps old stored output files best-effort', () => yield* store.sweep expect(existsSync(ref.path)).toBe(false) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-agent/test/Session/SessionLayout.vi.test.ts b/packages/fold-agent/test/Session/SessionLayout.vi.test.ts index bb4e20a..2a8bf88 100644 --- a/packages/fold-agent/test/Session/SessionLayout.vi.test.ts +++ b/packages/fold-agent/test/Session/SessionLayout.vi.test.ts @@ -11,6 +11,7 @@ import { join } from 'node:path' import { expect, it } from '@effect/vitest' import { customModel, defineAgent, layerLiveIdFactory, SessionId, startSession } from '@humanlayer/fold-core' import { Effect, Stream } from 'effect' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { LanguageModel } from 'effect/unstable/ai' import { @@ -53,7 +54,7 @@ it.effect('prepareSessionLog mints the id, creates the directory, and derives th writeFileSync(prepared.path, '') const listed = yield* listSessionLogs({ cwd, foldHome }) expect(listed.map((ref) => ref.sessionId)).toEqual([prepared.sessionId]) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('a prepared log round-trips a session: the filename and session_started agree on the id', () => @@ -91,7 +92,7 @@ it.effect('a prepared log round-trips a session: the filename and session_starte throw new Error('expected session_started') } expect(sessionStarted.sessionId).toBe(prepared.sessionId) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('session summaries expose first-message titles, turns, and the active model', () => @@ -133,7 +134,7 @@ it.effect('session summaries expose first-message titles, turns, and the active providerId: 'scripted', modelId: 'picker-model', }) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('session summary index is a full fast path and latest valid record wins', () => @@ -176,7 +177,7 @@ it.effect('session summary index is a full fast path and latest valid record win const [fast] = yield* listSessionSummaries({ cwd, foldHome }) expect(fast?.title).toBe('Latest Wins') expect(fast?.turns).toBe(1) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('missing, corrupt, and stale summary records rebuild only their source logs', () => @@ -209,7 +210,7 @@ it.effect('missing, corrupt, and stale summary records rebuild only their source yield* session.setTitle('Fresh From Authoritative Log') expect((yield* listSessionSummaries({ cwd, foldHome }))[0]?.title).toBe('Fresh From Authoritative Log') expect(readFileSync(indexPath, 'utf8')).toContain('Fresh From Authoritative Log') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('deletion never returns cached summaries and appends a tombstone', () => @@ -224,7 +225,7 @@ it.effect('deletion never returns cached summaries and appends a tombstone', () expect(readFileSync(join(sessionsDirFor({ cwd, foldHome }), 'index.jsonl'), 'utf8')).toContain( '"_tag":"deleted"', ) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect("discovery lists a project's logs newest-first and ignores foreign files", () => @@ -257,7 +258,7 @@ it.effect("discovery lists a project's logs newest-first and ignores foreign fil // Ids parse back as branded SessionIds. expect(SessionId.make(listed[0]?.sessionId ?? '')).toBe(newer.sessionId) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('deleting a session removes its event log and full tool-output directory', () => @@ -280,5 +281,5 @@ it.effect('deleting a session removes its event log and full tool-output directo deleted: false, outputRemoved: true, }) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-agent/test/Session/ViewedChanges.vi.test.ts b/packages/fold-agent/test/Session/ViewedChanges.vi.test.ts index 06c9dec..04c8d07 100644 --- a/packages/fold-agent/test/Session/ViewedChanges.vi.test.ts +++ b/packages/fold-agent/test/Session/ViewedChanges.vi.test.ts @@ -1,16 +1,16 @@ import { expect, it } from '@effect/vitest' import { SessionId } from '@humanlayer/fold-core' -import { Effect } from 'effect' +import { Effect, FileSystem, Layer } from 'effect' import { loadViewedPatchHashes, saveViewedPatchHash } from '../../src/index' import { memoryFileSystem } from '../TestHelpers' -it.effect('persists latest viewed patch hashes per session and ignores corrupt records', () => - Effect.gen(function* () { - const fs = memoryFileSystem({}) +it.effect('persists latest viewed patch hashes per session and ignores corrupt records', () => { + const fs = memoryFileSystem({}) + return Effect.gen(function* () { const first = SessionId.make('sess_aaaaaaaaaaaaaaaaaaaaaaaa') const second = SessionId.make('sess_bbbbbbbbbbbbbbbbbbbbbbbb') - const options = { fileSystem: fs, cwd: '/repo', foldHome: '/home/user/.fold' } + const options = { cwd: '/repo', foldHome: '/home/user/.fold' } yield* saveViewedPatchHash(first, 'unstaged:app.ts', 'old', options) yield* saveViewedPatchHash(second, 'unstaged:app.ts', 'other-session', options) @@ -19,5 +19,5 @@ it.effect('persists latest viewed patch hashes per session and ignores corrupt r expect(yield* loadViewedPatchHashes(first, options)).toEqual({ 'unstaged:app.ts': 'new' }) expect(yield* loadViewedPatchHashes(second, options)).toEqual({ 'unstaged:app.ts': 'other-session' }) - }), -) + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) +}) diff --git a/packages/fold-agent/test/Skills/DiskSkills.vi.test.ts b/packages/fold-agent/test/Skills/DiskSkills.vi.test.ts index 3520490..77dbcf9 100644 --- a/packages/fold-agent/test/Skills/DiskSkills.vi.test.ts +++ b/packages/fold-agent/test/Skills/DiskSkills.vi.test.ts @@ -5,7 +5,7 @@ * handling, and baseDir wiring. */ import { expect, it } from '@effect/vitest' -import { Effect } from 'effect' +import { Effect, FileSystem, Layer } from 'effect' import { makeDiskSkillSource } from '../../src/index' import { memoryFileSystem } from '../TestHelpers' @@ -13,27 +13,27 @@ import { memoryFileSystem } from '../TestHelpers' const skillFile = (name: string | null, description: string, body = 'Do the thing.'): string => ['---', ...(name === null ? [] : [`name: ${name}`]), `description: ${description}`, '---', '', body].join('\n') -it.effect('scans Claude and Fold roots across home, git root, and cwd with later roots shadowing', () => - Effect.gen(function* () { - const fs = memoryFileSystem({ - // Global roots: Fold shadows Claude at the same scope. - '/home/user/.claude/skills/deploy/SKILL.md': skillFile('deploy', 'Claude global deploy skill'), - '/home/user/.claude/skills/claude-global/SKILL.md': skillFile('claude-global', 'Claude global skill'), - '/home/user/.fold/skills/deploy/SKILL.md': skillFile('deploy', 'Global deploy skill'), - '/home/user/.fold/skills/lint/SKILL.md': skillFile('lint', 'Global lint skill'), - // Repo root (cwd is a subdirectory): Agent Skills shadows Claude at the same scope. - '/repo/.git/HEAD': 'ref: refs/heads/main', - '/repo/.claude/skills/review/SKILL.md': skillFile('review', 'Claude repo review skill'), - '/repo/.claude/skills/claude-repo/SKILL.md': skillFile('claude-repo', 'Claude repo skill'), - '/repo/.agents/skills/deploy/SKILL.md': skillFile('deploy', 'Repo deploy skill'), - '/repo/.agents/skills/review/SKILL.md': skillFile('review', 'Repo review skill'), - // cwd roots shadow every broader scope while preserving unique Claude skills. - '/repo/packages/app/.claude/skills/deploy/SKILL.md': skillFile('deploy', 'Claude cwd deploy skill'), - '/repo/packages/app/.claude/skills/claude-cwd/SKILL.md': skillFile('claude-cwd', 'Claude cwd skill'), - '/repo/packages/app/.agents/skills/deploy/SKILL.md': skillFile('deploy', 'Cwd deploy skill'), - }) - - const source = yield* makeDiskSkillSource({ fileSystem: fs, cwd: '/repo/packages/app', home: '/home/user' }) +it.effect('scans Claude and Fold roots across home, git root, and cwd with later roots shadowing', () => { + const fs = memoryFileSystem({ + // Global roots: Fold shadows Claude at the same scope. + '/home/user/.claude/skills/deploy/SKILL.md': skillFile('deploy', 'Claude global deploy skill'), + '/home/user/.claude/skills/claude-global/SKILL.md': skillFile('claude-global', 'Claude global skill'), + '/home/user/.fold/skills/deploy/SKILL.md': skillFile('deploy', 'Global deploy skill'), + '/home/user/.fold/skills/lint/SKILL.md': skillFile('lint', 'Global lint skill'), + // Repo root (cwd is a subdirectory): Agent Skills shadows Claude at the same scope. + '/repo/.git/HEAD': 'ref: refs/heads/main', + '/repo/.claude/skills/review/SKILL.md': skillFile('review', 'Claude repo review skill'), + '/repo/.claude/skills/claude-repo/SKILL.md': skillFile('claude-repo', 'Claude repo skill'), + '/repo/.agents/skills/deploy/SKILL.md': skillFile('deploy', 'Repo deploy skill'), + '/repo/.agents/skills/review/SKILL.md': skillFile('review', 'Repo review skill'), + // cwd roots shadow every broader scope while preserving unique Claude skills. + '/repo/packages/app/.claude/skills/deploy/SKILL.md': skillFile('deploy', 'Claude cwd deploy skill'), + '/repo/packages/app/.claude/skills/claude-cwd/SKILL.md': skillFile('claude-cwd', 'Claude cwd skill'), + '/repo/packages/app/.agents/skills/deploy/SKILL.md': skillFile('deploy', 'Cwd deploy skill'), + }) + + return Effect.gen(function* () { + const source = yield* makeDiskSkillSource({ cwd: '/repo/packages/app', home: '/home/user' }) const metas = yield* source.list expect(new Map(metas.map((meta) => [meta.name, meta.description]))).toEqual( @@ -46,122 +46,122 @@ it.effect('scans Claude and Fold roots across home, git root, and cwd with later ['review', 'Repo review skill'], ]), ) - }), -) - -it.effect('loads Claude project skills independently of AGENTS.md', () => - Effect.gen(function* () { - const fs = memoryFileSystem({ - '/repo/.git/HEAD': 'ref: refs/heads/main', - '/repo/AGENTS.md': 'Project instructions.', - '/repo/.claude/skills/claude-only/SKILL.md': skillFile('claude-only', 'Claude-compatible skill'), - }) + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) +}) + +it.effect('loads Claude project skills independently of AGENTS.md', () => { + const fs = memoryFileSystem({ + '/repo/.git/HEAD': 'ref: refs/heads/main', + '/repo/AGENTS.md': 'Project instructions.', + '/repo/.claude/skills/claude-only/SKILL.md': skillFile('claude-only', 'Claude-compatible skill'), + }) - const source = yield* makeDiskSkillSource({ fileSystem: fs, cwd: '/repo', home: '/home/user' }) + return Effect.gen(function* () { + const source = yield* makeDiskSkillSource({ cwd: '/repo', home: '/home/user' }) expect(yield* source.list).toEqual([{ name: 'claude-only', description: 'Claude-compatible skill' }]) - }), -) - -it.effect('skips the git-root scan when the repo root IS the cwd (no double scan)', () => - Effect.gen(function* () { - const fs = memoryFileSystem({ - '/repo/.git/HEAD': 'ref: refs/heads/main', - '/repo/.agents/skills/solo/SKILL.md': skillFile('solo', 'Only skill'), - }) + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) +}) + +it.effect('skips the git-root scan when the repo root IS the cwd (no double scan)', () => { + const fs = memoryFileSystem({ + '/repo/.git/HEAD': 'ref: refs/heads/main', + '/repo/.agents/skills/solo/SKILL.md': skillFile('solo', 'Only skill'), + }) - const source = yield* makeDiskSkillSource({ fileSystem: fs, cwd: '/repo', home: '/home/user' }) + return Effect.gen(function* () { + const source = yield* makeDiskSkillSource({ cwd: '/repo', home: '/home/user' }) const metas = yield* source.list expect(metas).toEqual([{ name: 'solo', description: 'Only skill' }]) - }), -) + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) +}) -it.effect('defaults the name from the skill directory and sets baseDir', () => - Effect.gen(function* () { - const fs = memoryFileSystem({ - '/cwd/.agents/skills/from-dir-name/SKILL.md': skillFile(null, 'Name comes from the directory'), - }) +it.effect('defaults the name from the skill directory and sets baseDir', () => { + const fs = memoryFileSystem({ + '/cwd/.agents/skills/from-dir-name/SKILL.md': skillFile(null, 'Name comes from the directory'), + }) - const source = yield* makeDiskSkillSource({ fileSystem: fs, cwd: '/cwd', home: '/home/user' }) + return Effect.gen(function* () { + const source = yield* makeDiskSkillSource({ cwd: '/cwd', home: '/home/user' }) const skill = yield* source.load('from-dir-name') expect(skill.name).toBe('from-dir-name') expect(skill.baseDir).toBe('/cwd/.agents/skills/from-dir-name') expect(skill.content).toBe('Do the thing.') - }), -) - -it.effect('skips skills violating the spec (invalid name, missing description) without failing', () => - Effect.gen(function* () { - const fs = memoryFileSystem({ - '/cwd/.agents/skills/Bad--Name/SKILL.md': skillFile(null, 'Invalid directory-derived name'), - '/cwd/.agents/skills/no-description/SKILL.md': ['---', 'name: no-description', '---', 'body'].join('\n'), - '/cwd/.agents/skills/no-frontmatter/SKILL.md': 'just a plain markdown file', - '/cwd/.agents/skills/good/SKILL.md': skillFile('good', 'A valid skill'), - }) - - const source = yield* makeDiskSkillSource({ fileSystem: fs, cwd: '/cwd', home: '/home/user' }) + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) +}) + +it.effect('skips skills violating the spec (invalid name, missing description) without failing', () => { + const fs = memoryFileSystem({ + '/cwd/.agents/skills/Bad--Name/SKILL.md': skillFile(null, 'Invalid directory-derived name'), + '/cwd/.agents/skills/no-description/SKILL.md': ['---', 'name: no-description', '---', 'body'].join('\n'), + '/cwd/.agents/skills/no-frontmatter/SKILL.md': 'just a plain markdown file', + '/cwd/.agents/skills/good/SKILL.md': skillFile('good', 'A valid skill'), + }) + + return Effect.gen(function* () { + const source = yield* makeDiskSkillSource({ cwd: '/cwd', home: '/home/user' }) const metas = yield* source.list expect(metas).toEqual([{ name: 'good', description: 'A valid skill' }]) - }), -) - -it.effect('finds nested skill groups but does not recurse into a skill directory', () => - Effect.gen(function* () { - const fs = memoryFileSystem({ - '/cwd/.agents/skills/group/one/SKILL.md': skillFile('one', 'Grouped skill'), - // Inside a skill dir: references/ content must NOT be scanned as another skill. - '/cwd/.agents/skills/group/one/references/SKILL.md': skillFile('sneaky', 'Should not load'), - }) - - const source = yield* makeDiskSkillSource({ fileSystem: fs, cwd: '/cwd', home: '/home/user' }) + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) +}) + +it.effect('finds nested skill groups but does not recurse into a skill directory', () => { + const fs = memoryFileSystem({ + '/cwd/.agents/skills/group/one/SKILL.md': skillFile('one', 'Grouped skill'), + // Inside a skill dir: references/ content must NOT be scanned as another skill. + '/cwd/.agents/skills/group/one/references/SKILL.md': skillFile('sneaky', 'Should not load'), + }) + + return Effect.gen(function* () { + const source = yield* makeDiskSkillSource({ cwd: '/cwd', home: '/home/user' }) const metas = yield* source.list expect(metas).toEqual([{ name: 'one', description: 'Grouped skill' }]) - }), -) + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) +}) -it.effect('load fails with the roster for unknown names', () => - Effect.gen(function* () { - const fs = memoryFileSystem({ - '/cwd/.agents/skills/present/SKILL.md': skillFile('present', 'Here'), - }) +it.effect('load fails with the roster for unknown names', () => { + const fs = memoryFileSystem({ + '/cwd/.agents/skills/present/SKILL.md': skillFile('present', 'Here'), + }) - const source = yield* makeDiskSkillSource({ fileSystem: fs, cwd: '/cwd', home: '/home/user' }) + return Effect.gen(function* () { + const source = yield* makeDiskSkillSource({ cwd: '/cwd', home: '/home/user' }) const failure = yield* source.load('absent').pipe(Effect.flip) expect(failure._tag).toBe('SkillNotFoundError') if (failure._tag !== 'SkillNotFoundError') throw new Error('expected SkillNotFoundError') expect(failure.availableSkills).toEqual(['present']) - }), -) + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) +}) -it.effect('a fresh scan per list picks up newly added skills (the refresh path)', () => - Effect.gen(function* () { - const fs = memoryFileSystem({ - '/cwd/.agents/skills/first/SKILL.md': skillFile('first', 'Original'), - }) +it.effect('a fresh scan per list picks up newly added skills (the refresh path)', () => { + const fs = memoryFileSystem({ + '/cwd/.agents/skills/first/SKILL.md': skillFile('first', 'Original'), + }) - const source = yield* makeDiskSkillSource({ fileSystem: fs, cwd: '/cwd', home: '/home/user' }) + return Effect.gen(function* () { + const source = yield* makeDiskSkillSource({ cwd: '/cwd', home: '/home/user' }) expect((yield* source.list).map((meta) => meta.name)).toEqual(['first']) // Write a new skill through the same in-memory filesystem. yield* fs.writeFileString('/cwd/.agents/skills/second/SKILL.md', skillFile('second', 'Added later')) expect((yield* source.list).map((meta) => meta.name)).toEqual(['first', 'second']) - }), -) + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) +}) -it.effect('parses CRLF SKILL.md files without corrupting fields (trailing \\r regression)', () => - Effect.gen(function* () { - const crlfSkill = ['---', 'description: Written on Windows', 'name: crlf-skill', '---', '', 'Body line.'].join( - '\r\n', - ) - const fs = memoryFileSystem({ '/cwd/.agents/skills/crlf-skill/SKILL.md': crlfSkill }) +it.effect('parses CRLF SKILL.md files without corrupting fields (trailing \\r regression)', () => { + const crlfSkill = ['---', 'description: Written on Windows', 'name: crlf-skill', '---', '', 'Body line.'].join( + '\r\n', + ) + const fs = memoryFileSystem({ '/cwd/.agents/skills/crlf-skill/SKILL.md': crlfSkill }) - const source = yield* makeDiskSkillSource({ fileSystem: fs, cwd: '/cwd', home: '/home/user' }) + return Effect.gen(function* () { + const source = yield* makeDiskSkillSource({ cwd: '/cwd', home: '/home/user' }) const skill = yield* source.load('crlf-skill') // name is last in the frontmatter: without CRLF normalization it would carry a trailing \r @@ -169,23 +169,22 @@ it.effect('parses CRLF SKILL.md files without corrupting fields (trailing \\r re expect(skill.name).toBe('crlf-skill') expect(skill.description).toBe('Written on Windows') expect(skill.content).toBe('Body line.') - }), -) - -it.effect('supports extra scan roots with highest precedence', () => - Effect.gen(function* () { - const fs = memoryFileSystem({ - '/cwd/.agents/skills/tool/SKILL.md': skillFile('tool', 'From cwd'), - '/extra/skills/tool/SKILL.md': skillFile('tool', 'From extra root'), - }) + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) +}) + +it.effect('supports extra scan roots with highest precedence', () => { + const fs = memoryFileSystem({ + '/cwd/.agents/skills/tool/SKILL.md': skillFile('tool', 'From cwd'), + '/extra/skills/tool/SKILL.md': skillFile('tool', 'From extra root'), + }) + return Effect.gen(function* () { const source = yield* makeDiskSkillSource({ - fileSystem: fs, cwd: '/cwd', home: '/home/user', extraPaths: ['/extra/skills'], }) expect(yield* source.list).toEqual([{ name: 'tool', description: 'From extra root' }]) - }), -) + }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) +}) diff --git a/packages/fold-agent/test/TestHelpers.ts b/packages/fold-agent/test/TestHelpers.ts index d25eba0..a859217 100644 --- a/packages/fold-agent/test/TestHelpers.ts +++ b/packages/fold-agent/test/TestHelpers.ts @@ -21,6 +21,7 @@ import { type FoldTool, type ToolHandlerServices, } from '@humanlayer/fold-core' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Effect, FileSystem, Layer, PlatformError, Ref, type Schema } from 'effect' /** Run a tool handler effect with stubbed ambient services and recorded ToolEvents/InterruptNote feeds. */ @@ -58,6 +59,7 @@ export const makeAmbientServices = (): Effect.Effect<{ resume: () => Effect.die(new Error('Subagents not available in this test')), continueSubagent: () => Effect.die(new Error('Subagents not available in this test')), }), + NodeFileSystem.layer, ), emitted: Ref.get(events), interruptNote: Ref.get(note), diff --git a/packages/fold-agent/test/Tools/BashTool.vi.test.ts b/packages/fold-agent/test/Tools/BashTool.vi.test.ts index d6f229b..69b331f 100644 --- a/packages/fold-agent/test/Tools/BashTool.vi.test.ts +++ b/packages/fold-agent/test/Tools/BashTool.vi.test.ts @@ -8,6 +8,7 @@ import { join } from 'node:path' import { expect, it } from '@effect/vitest' import { SessionId, ToolCallId } from '@humanlayer/fold-core' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Duration, Effect, Fiber } from 'effect' import { bashTool, decodeBashOutputDelta, makeOutputStore, toolOutputPathFor } from '../../src/index' @@ -154,7 +155,7 @@ it.live('uses OutputStore for deterministic bash spill paths when provided', () const dir = yield* tempDir const sessionId = SessionId.make('sess_eeeeeeeeeeeeeeeeeeeeeeee') const toolCallId = ToolCallId.make('tool_call_aaaaaaaaaaaaaaaaaaaaaaaa') - const outputStore = makeOutputStore({ sessionId, foldHome: dir }) + const outputStore = yield* makeOutputStore({ sessionId, foldHome: dir }) const ambient = yield* makeAmbientServices() const result = yield* handlerOf(bashTool({ cwd: dir, outputStore }))({ command: 'seq 1 3000' }).pipe( @@ -165,7 +166,7 @@ it.live('uses OutputStore for deterministic bash spill paths when provided', () expect(outputOf(result)).toContain(`Full output: ${expectedPath}`) expect(readFileSync(expectedPath, 'utf-8')).toContain('1\n2\n3\n') expect(readFileSync(expectedPath, 'utf-8')).toContain('2999\n3000\n') - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.live('byte-limit truncation reports the size-limited notice with the spill path', () => diff --git a/packages/fold-cli/src/Commands.ts b/packages/fold-cli/src/Commands.ts index dd28a6f..6176da3 100644 --- a/packages/fold-cli/src/Commands.ts +++ b/packages/fold-cli/src/Commands.ts @@ -655,7 +655,7 @@ const openCodeCommands = Command.make('opencode').pipe( const xaiLogin = (flow: ResolvedCodexLoginFlow, input: ProviderLoginInput) => Effect.gen(function* () { - const store = makeXaiAuthStore(providerAuthStoreOptions(input.provider, optionValue(input.foldHome), 'xai')) + const store = yield* makeXaiAuthStore(providerAuthStoreOptions(input.provider, optionValue(input.foldHome), 'xai')) const xaiAuth = yield* makeXaiAuth({ store, onDeviceCode: (prompt) => @@ -722,7 +722,7 @@ const xaiCommands = Command.make('xai').pipe( xaiExplicitLoginCommand('device'), Command.make('status', { provider: commonFlags.provider, foldHome: commonFlags.foldHome }, (input) => Effect.gen(function* () { - const store = makeXaiAuthStore( + const store = yield* makeXaiAuthStore( providerAuthStoreOptions(input.provider, optionValue(input.foldHome), 'xai'), ) const token = yield* store.load @@ -738,7 +738,7 @@ const xaiCommands = Command.make('xai').pipe( ).pipe(Command.withDescription('Show the stored xAI credential status')), Command.make('logout', { provider: commonFlags.provider, foldHome: commonFlags.foldHome }, (input) => Effect.gen(function* () { - const store = makeXaiAuthStore( + const store = yield* makeXaiAuthStore( providerAuthStoreOptions(input.provider, optionValue(input.foldHome), 'xai'), ) const service = yield* makeXaiAuth({ store }).pipe(Effect.provide(FetchHttpClient.layer)) @@ -791,7 +791,7 @@ const auth = Command.make('auth').pipe( noOpen: input.noOpen, stdoutIsTTY: process.stdout.isTTY === true, }) - const store = makeCodexAuthStore(codexAuthStoreOptions(input.provider, foldHome)) + const store = yield* makeCodexAuthStore(codexAuthStoreOptions(input.provider, foldHome)) const codexAuth = yield* makeCodexAuth({ store, onDeviceCode: (prompt) => @@ -840,7 +840,7 @@ const auth = Command.make('auth').pipe( (input) => Effect.gen(function* () { const foldHome = optionValue(input.foldHome) - const store = makeCodexAuthStore(codexAuthStoreOptions(input.provider, foldHome)) + const store = yield* makeCodexAuthStore(codexAuthStoreOptions(input.provider, foldHome)) if (input.refresh) { const codexAuth = yield* makeCodexAuth({ store }).pipe( Effect.provide(FetchHttpClient.layer), @@ -876,7 +876,7 @@ const auth = Command.make('auth').pipe( Command.make('logout', { provider: commonFlags.provider, foldHome: commonFlags.foldHome }, (input) => Effect.gen(function* () { const foldHome = optionValue(input.foldHome) - const store = makeCodexAuthStore(codexAuthStoreOptions(input.provider, foldHome)) + const store = yield* makeCodexAuthStore(codexAuthStoreOptions(input.provider, foldHome)) const codexAuth = yield* makeCodexAuth({ store }).pipe(Effect.provide(FetchHttpClient.layer)) yield* codexAuth.logout yield* Console.log(`Removed Codex credential from ${store.path}`) diff --git a/packages/fold-cli/src/Run.ts b/packages/fold-cli/src/Run.ts index c2738f5..2d42046 100644 --- a/packages/fold-cli/src/Run.ts +++ b/packages/fold-cli/src/Run.ts @@ -26,7 +26,8 @@ import type { SessionId, FoldSession, } from '@humanlayer/fold-core' -import { Cause, Clock, Effect, Exit, Fiber, Option, Stream, type Scope } from 'effect' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' +import { Cause, Clock, Effect, Exit, Fiber, type FileSystem, Option, Stream, type Scope } from 'effect' import type { CredentialSummary, OutputRenderer, ResumeCommandFlag, SessionHeader } from './Renderer' @@ -83,7 +84,7 @@ const launchOptions = (options: CliSessionOptions) => ({ /** Start fresh, resume the project's newest log, or adopt one exact session id. */ const openSessionFor = ( options: CliSessionOptions, -): Effect.Effect => { +): Effect.Effect => { if (options.resume === undefined) return launchSession(launchOptions(options)) return options.resume._tag === 'latest' @@ -91,7 +92,7 @@ const openSessionFor = ( : resumeSessionById(options.resume.sessionId, launchOptions(options)) } -const openSession = (options: CliSessionOptions): Effect.Effect => +const openSession = (options: CliSessionOptions): Effect.Effect => Effect.gen(function* () { const session = yield* openSessionFor(options) const logPath = sessionLogPathFor(session.sessionId, { @@ -121,10 +122,10 @@ const credentialSummary = (model: ActiveModel | null, options: CliSessionOptions if (model === null) return { _tag: 'unknown', detail: 'no active model row found in the session log' } if (model.providerKind === 'codex') { - const store = makeCodexAuthStore({ + const store = yield* makeCodexAuthStore({ providerId: model.providerId, ...(options.foldHome === undefined ? {} : { path: join(options.foldHome, 'auth.json') }), - }) + }).pipe(Effect.provide(NodeFileSystem.layer)) const token = yield* store.load if (Option.isNone(token)) return { _tag: 'missing', detail: `entry "${model.providerId}" in ${store.path}` } @@ -257,13 +258,13 @@ const withProcessSignals = ( * absent), and the regenerated `config.schema.json` + `FOLD_INFO.md`. Never fails a run - a broken * home surfaces as the launch's own config error moments later. */ -const bootstrapForRun = (options: CliSessionOptions): Effect.Effect => +const bootstrapForRun = (options: CliSessionOptions): Effect.Effect => bootstrapFoldHome(options.foldHome === undefined ? {} : { foldHome: options.foldHome }).pipe( Effect.asVoid, Effect.catchCause(() => Effect.void), ) -const forkStartupEnsures = (options: CliSessionOptions, renderer: OutputRenderer): Effect.Effect => +const forkStartupEnsures = (options: CliSessionOptions, renderer: OutputRenderer): Effect.Effect => Effect.forkDetach( Effect.gen(function* () { const statuses = yield* ensureManagedBinaries({ @@ -282,7 +283,7 @@ const forkStartupEnsures = (options: CliSessionOptions, renderer: OutputRenderer export const runPrompt = ( options: PromptRunOptions, renderer: OutputRenderer, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { yield* bootstrapForRun(options) const opened = yield* openSession(options) diff --git a/packages/fold-cli/src/tui/HostedTuiSession.ts b/packages/fold-cli/src/tui/HostedTuiSession.ts index 5886e9b..bb0296e 100644 --- a/packages/fold-cli/src/tui/HostedTuiSession.ts +++ b/packages/fold-cli/src/tui/HostedTuiSession.ts @@ -6,6 +6,7 @@ import { type FoldConfig, } from '@humanlayer/fold-agent' import { renderSkillContent, type ModelCatalogEntry, type SessionId, type FoldSession } from '@humanlayer/fold-core' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Cause, Duration, Effect, type Scope, Stream } from 'effect' import { batch, createSignal, type Accessor } from 'solid-js' import { createStore, reconcile } from 'solid-js/store' @@ -163,6 +164,7 @@ export const makeHostedTuiSession = ( else setTargetNotice({ agentId, text }) }), ), + Effect.provide(NodeFileSystem.layer), ), ) } @@ -200,6 +202,7 @@ export const makeHostedTuiSession = ( }), ), Effect.catchCause((cause) => Effect.sync(() => setNotice(Cause.pretty(cause)))), + Effect.provide(NodeFileSystem.layer), ), ) } diff --git a/packages/fold-cli/src/tui/Shell.tsx b/packages/fold-cli/src/tui/Shell.tsx index 4caa7cc..6fb0ca8 100644 --- a/packages/fold-cli/src/tui/Shell.tsx +++ b/packages/fold-cli/src/tui/Shell.tsx @@ -13,6 +13,7 @@ import { type ViewedPatchHashes, } from '@humanlayer/fold-agent' import { makeCodexAuth, makeCodexAuthStore } from '@humanlayer/fold-codex' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import type { SessionId } from '@humanlayer/fold-core' import { makeOpenCodeAuth, makeOpenCodeAuthStore } from '@humanlayer/fold-opencode' import { ALL_FX_ON, type FxToggles } from '@humanlayer/fold-tui-theme/postfx' @@ -20,7 +21,7 @@ import { nextThemeId, type ThemeId } from '@humanlayer/fold-tui-theme/themes' import { makeXaiAuth, makeXaiAuthStore } from '@humanlayer/fold-xai' import { createCliRenderer } from '@opentui/core' import { render } from '@opentui/solid' -import { Cause, Clock, Deferred, Effect, Option, Schema, type Scope } from 'effect' +import { Cause, Clock, Deferred, Effect, type FileSystem, Option, Schema, type Scope } from 'effect' import { FetchHttpClient } from 'effect/unstable/http' import { batch, createEffect, createSignal, Show, type Accessor } from 'solid-js' @@ -53,7 +54,7 @@ export type { TuiOptions } from './TuiSessionOptions' export const runTui = ( options: TuiOptions, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) return yield* new TuiRequiresTtyError() const quit = yield* Deferred.make() @@ -198,7 +199,7 @@ export const runTui = ( }) } if (providerKind === 'xai') { - const store = makeXaiAuthStore(xaiAuthStoreOptions(provider, options.foldHome)) + const store = yield* makeXaiAuthStore(xaiAuthStoreOptions(provider, options.foldHome)) if (action === 'status') { update({ _tag: 'working', message: 'Checking stored xAI credential...' }) const token = yield* store.load @@ -254,7 +255,7 @@ export const runTui = ( authStatus: 'logged-in', }) } - const store = makeCodexAuthStore(codexAuthStoreOptions(provider, options.foldHome)) + const store = yield* makeCodexAuthStore(codexAuthStoreOptions(provider, options.foldHome)) if (action === 'status') { update({ _tag: 'working', message: 'Checking stored credential...' }) const token = yield* store.load @@ -314,6 +315,7 @@ export const runTui = ( Effect.catchCause((cause) => Effect.sync(() => update({ _tag: 'failure', message: Cause.pretty(cause) })), ), + Effect.provide(NodeFileSystem.layer), ), ) } @@ -339,6 +341,7 @@ export const runTui = ( Effect.catchCause((cause) => Effect.sync(() => update({ _tag: 'failure', message: Cause.pretty(cause) })), ), + Effect.provide(NodeFileSystem.layer), ), ) } @@ -365,6 +368,7 @@ export const runTui = ( Effect.catchCause((cause) => Effect.sync(() => update({ _tag: 'failure', message: Cause.pretty(cause) })), ), + Effect.provide(NodeFileSystem.layer), ), ) } @@ -437,6 +441,7 @@ export const runTui = ( })), ), ), + Effect.provide(NodeFileSystem.layer), ), ) }) @@ -500,7 +505,7 @@ export const runTui = ( change.key, change.patchHash, layoutOptions, - ), + ).pipe(Effect.provide(NodeFileSystem.layer)), ) }} onRefreshGit={() => refreshGit(current().cwd)} diff --git a/packages/fold-cli/src/tui/TuiConfigBootstrap.ts b/packages/fold-cli/src/tui/TuiConfigBootstrap.ts index 023165f..e09c885 100644 --- a/packages/fold-cli/src/tui/TuiConfigBootstrap.ts +++ b/packages/fold-cli/src/tui/TuiConfigBootstrap.ts @@ -4,7 +4,7 @@ import { type ConfigInitOptions, type FoldConfig, } from '@humanlayer/fold-agent' -import { Cause, Effect, Exit } from 'effect' +import { Cause, Effect, Exit, type FileSystem } from 'effect' export type TuiConfigBootstrapResult = { readonly config: FoldConfig | null @@ -12,7 +12,7 @@ export type TuiConfigBootstrapResult = { } /** Bootstrap first, and only load a config after bootstrap has completed successfully. */ -export const bootstrapTuiConfig = (options: ConfigInitOptions): Effect.Effect => +export const bootstrapTuiConfig = (options: ConfigInitOptions): Effect.Effect => Effect.gen(function* () { const bootstrapExit = yield* Effect.exit(bootstrapFoldHome(options)) if (Exit.isFailure(bootstrapExit)) diff --git a/packages/fold-cli/src/tui/TuiSessionWorkspace.ts b/packages/fold-cli/src/tui/TuiSessionWorkspace.ts index 200d67b..b593a52 100644 --- a/packages/fold-cli/src/tui/TuiSessionWorkspace.ts +++ b/packages/fold-cli/src/tui/TuiSessionWorkspace.ts @@ -13,7 +13,8 @@ import { type FoldConfig, } from '@humanlayer/fold-agent' import { layerLiveIdFactory, lookupCatalogEntry, type SessionId, type FoldSession } from '@humanlayer/fold-core' -import { Cause, Duration, Effect, Match, Option, Scope } from 'effect' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' +import { Cause, Duration, Effect, type FileSystem, Match, Option, Scope } from 'effect' import { createSignal, type Accessor } from 'solid-js' import { makeHostedTuiSession, type HostedTuiSession, type HostedTuiSessionMetadata } from './HostedTuiSession' @@ -68,7 +69,7 @@ export const makeTuiSessionWorkspace = (options: { readonly config: Accessor | FoldConfig | null readonly configNotice: string | null readonly loadSummariesOnStart: boolean -}): Effect.Effect => +}): Effect.Effect => Effect.gen(function* () { const parentScope = yield* Scope.Scope const configOption = options.config @@ -132,6 +133,7 @@ export const makeTuiSessionWorkspace = (options: { Effect.tap((value) => Effect.sync(() => setSummaries(value))), Effect.catchCause((cause) => Effect.logWarning(Cause.pretty(cause))), Effect.ensuring(Effect.sync(() => (refreshScheduled = false))), + Effect.provide(NodeFileSystem.layer), ), ) } @@ -142,11 +144,12 @@ export const makeTuiSessionWorkspace = (options: { })) yield* Effect.addFinalizer(() => host.closeAll) const acquire = ( - session: Effect.Effect, + session: Effect.Effect, metadata: HostedTuiSessionMetadata, focused: boolean, ) => session.pipe( + Effect.provide(NodeFileSystem.layer), Effect.flatMap((value) => makeHostedTuiSession(value, { metadata, @@ -162,6 +165,7 @@ export const makeTuiSessionWorkspace = (options: { ) const finish = (hosted: HostedTuiSession) => loadSummaries.pipe( + Effect.provide(NodeFileSystem.layer), Effect.tap((value) => Effect.sync(() => setSummaries(value))), Effect.tap(() => Effect.sync(() => { @@ -274,7 +278,7 @@ export const makeTuiSessionWorkspace = (options: { ? 'SESSION AND STORED OUTPUT DELETED' : 'SESSION DELETED · STORED OUTPUT CLEANUP FAILED', ) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) return { sessions: () => projectSessionRows(summaries(), host.snapshots()), diff --git a/packages/fold-cli/test/tui/TuiConfigBootstrap.vi.test.ts b/packages/fold-cli/test/tui/TuiConfigBootstrap.vi.test.ts index 3926791..b61c63d 100644 --- a/packages/fold-cli/test/tui/TuiConfigBootstrap.vi.test.ts +++ b/packages/fold-cli/test/tui/TuiConfigBootstrap.vi.test.ts @@ -1,6 +1,6 @@ import { expect, it } from '@effect/vitest' import { describeModelConfiguration } from '@humanlayer/fold-agent' -import { Effect } from 'effect' +import { Effect, FileSystem, Layer } from 'effect' import { memoryFileFor, memoryFileSystem } from '../../../fold-agent/test/TestHelpers' import { providerManagementRows } from '../../src/tui/ProviderConfigState' @@ -14,7 +14,9 @@ const requireConfig = (config: A | null): A => { it.effect('bootstraps and loads a fresh fold home before deriving provider management rows', () => Effect.gen(function* () { const fs = memoryFileSystem({}) - const result = yield* bootstrapTuiConfig({ foldHome: '/fresh/.fold', fileSystem: fs }) + const result = yield* bootstrapTuiConfig({ foldHome: '/fresh/.fold' }).pipe( + Effect.provide(Layer.succeed(FileSystem.FileSystem, fs)), + ) expect(result.notice).toBeNull() expect(result.config).not.toBeNull() @@ -43,7 +45,9 @@ it.effect('does not rewrite an old commented config while virtual provider rows "roles": { "smart": { "provider": "openai", "model": "gpt-old" }, "fast": { "provider": "openai", "model": "gpt-old" } } }\n` const fs = memoryFileSystem({ '/old/.fold/config.jsonc': oldConfig }) - const result = yield* bootstrapTuiConfig({ foldHome: '/old/.fold', fileSystem: fs }) + const result = yield* bootstrapTuiConfig({ foldHome: '/old/.fold' }).pipe( + Effect.provide(Layer.succeed(FileSystem.FileSystem, fs)), + ) expect(result.notice).toBeNull() expect(yield* memoryFileFor(fs, '/old/.fold/config.jsonc')).toBe(oldConfig) @@ -66,7 +70,10 @@ it.effect('surfaces bootstrap failure while canonical virtual rows remain availa Effect.gen(function* () { const base = memoryFileSystem({}) const fs = { ...base, writeFileString: () => Effect.die(new Error('fixture write failure')) } - const result = yield* bootstrapTuiConfig({ foldHome: '/blocked', fileSystem: fs }) + const result = yield* bootstrapTuiConfig({ foldHome: '/blocked' }).pipe( + // oxlint-disable-next-line typescript/consistent-type-assertions + Effect.provide(Layer.succeed(FileSystem.FileSystem, fs as FileSystem.FileSystem)), + ) expect(result.config).toBeNull() expect(result.notice).toContain('CONFIGURATION BOOTSTRAP ERROR') diff --git a/packages/fold-cli/test/tui/TuiSessionWorkspace.vi.test.ts b/packages/fold-cli/test/tui/TuiSessionWorkspace.vi.test.ts index d8eb4b8..7dfd912 100644 --- a/packages/fold-cli/test/tui/TuiSessionWorkspace.vi.test.ts +++ b/packages/fold-cli/test/tui/TuiSessionWorkspace.vi.test.ts @@ -1,4 +1,5 @@ import { SessionId } from '@humanlayer/fold-core' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Effect, Option, Schema } from 'effect' import { describe, expect, it } from 'vitest' @@ -35,7 +36,7 @@ describe('TuiSessionWorkspace', () => { expect(workspace.opening()).toBe(false) expect(router.route()).toEqual({ _tag: 'picker' }) }), - ), + ).pipe(Effect.provide(NodeFileSystem.layer)), ) }) }) diff --git a/packages/fold-codex/examples/CodexAgent.ts b/packages/fold-codex/examples/CodexAgent.ts index a4e5c37..7a8d448 100644 --- a/packages/fold-codex/examples/CodexAgent.ts +++ b/packages/fold-codex/examples/CodexAgent.ts @@ -13,6 +13,7 @@ import { join } from 'node:path' import { codingTools, jsonlEventLog } from '@humanlayer/fold-agent' import { defineAgent, startSession } from '@humanlayer/fold-core' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Console, Effect } from 'effect' import { codexModel } from '../src/index' @@ -47,7 +48,7 @@ const program = Effect.gen(function* () { yield* Console.log(`result: ${finished.resultText ?? '(no text)'}`) yield* Console.log(`log rows: ${entries.length} (persisted to ${logPath})`) yield* Console.log(`tools used: ${entries.filter((entry) => entry._tag === 'tool-result').length} tool results`) -}).pipe(Effect.scoped) +}).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)) Effect.runPromise(program).catch((error) => { console.error(`Set up codex credentials in ${join(homedir(), '.fold', 'auth.json')} before running.`) diff --git a/packages/fold-codex/src/AuthStore.ts b/packages/fold-codex/src/AuthStore.ts index c50f574..cdf7e96 100644 --- a/packages/fold-codex/src/AuthStore.ts +++ b/packages/fold-codex/src/AuthStore.ts @@ -10,8 +10,7 @@ import { homedir } from 'node:os' import { dirname, join } from 'node:path' -import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' -import { Context, Effect, FileSystem, Layer, Option, Schema } from 'effect' +import { Effect, FileSystem, Option, Schema } from 'effect' /** Milliseconds before nominal expiry a token is already treated as expired (clanka parity). */ export const TOKEN_EXPIRY_BUFFER_MS = 30_000 @@ -55,25 +54,6 @@ export type MakeCodexAuthStoreOptions = { readonly path?: string /** Key of this provider's entry in the document. Defaults to `codex`. */ readonly providerId?: string - /** FileSystem implementation override. Defaults to the Node platform filesystem. */ - readonly fileSystem?: FileSystem.FileSystem -} - -let nodeFileSystem: FileSystem.FileSystem | null = null - -/** The process-wide Node FileSystem service, built lazily once (layer construction is synchronous). */ -export const defaultNodeFileSystem = (): FileSystem.FileSystem => { - if (nodeFileSystem === null) { - nodeFileSystem = Effect.runSync( - Effect.scoped( - Layer.build(NodeFileSystem.layer).pipe( - Effect.map((context) => Context.get(context, FileSystem.FileSystem)), - ), - ), - ) - } - - return nodeFileSystem } /** The auth document is provider-keyed; entries other than ours are opaque and preserved verbatim. */ @@ -92,8 +72,8 @@ const encodeToken = (token: CodexTokenData): Record => ({ }) /** Build a file-backed Codex credential store. */ -export const makeCodexAuthStore = (options?: MakeCodexAuthStoreOptions): CodexAuthStore => { - const fs = options?.fileSystem ?? defaultNodeFileSystem() +export const makeCodexAuthStore = (options?: MakeCodexAuthStoreOptions): Effect.Effect => + Effect.map(FileSystem.FileSystem, (fs) => { const path = options?.path ?? defaultAuthStorePath() const providerId = options?.providerId ?? 'codex' @@ -155,4 +135,4 @@ export const makeCodexAuthStore = (options?: MakeCodexAuthStoreOptions): CodexAu }).pipe(Effect.withSpan('fold.codexAuthStore.clear')) return { path, load, save, clear } -} + }) diff --git a/packages/fold-codex/src/CodexAuth.ts b/packages/fold-codex/src/CodexAuth.ts index 6538496..03f32f6 100644 --- a/packages/fold-codex/src/CodexAuth.ts +++ b/packages/fold-codex/src/CodexAuth.ts @@ -70,7 +70,7 @@ const defaultOnBrowserUrl = (url: string): Effect.Effect => /** Build a CodexAuth service over the ambient HttpClient. */ export const makeCodexAuth = Effect.fnUntraced(function* (options?: MakeCodexAuthOptions) { - const store = options?.store ?? makeCodexAuthStore() + const store = options?.store ?? (yield* makeCodexAuthStore()) const issuerClient = makeIssuerHttpClient(yield* HttpClient.HttpClient) const semaphore = Semaphore.makeUnsafe(1) diff --git a/packages/fold-codex/src/CodexModel.ts b/packages/fold-codex/src/CodexModel.ts index e5b309c..93718f7 100644 --- a/packages/fold-codex/src/CodexModel.ts +++ b/packages/fold-codex/src/CodexModel.ts @@ -17,6 +17,7 @@ import { OpenAiClient, OpenAiLanguageModel } from '@effect/ai-openai' import type * as OpenAiSchema from '@effect/ai-openai/OpenAiSchema' import { customModel, resolveCodexReasoning } from '@humanlayer/fold-core' import type { ReasoningLevel, FoldModel } from '@humanlayer/fold-core' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Context, Duration, Effect, Layer, Option, Schedule, Schema, Stream } from 'effect' import type { Scope } from 'effect' import { AiError } from 'effect/unstable/ai' @@ -287,7 +288,7 @@ export const makeCodexLanguageModel = ( : { reasoning: { effort: reasoning.effort, summary: reasoning.summary } }), }, }).pipe(Effect.provideService(OpenAiClient.OpenAiClient, codexClient)) - }) + }).pipe(Effect.provide(NodeFileSystem.layer)) /** * Describe a model served by the ChatGPT Codex backend using stored Codex OAuth credentials. Plugs diff --git a/packages/fold-codex/test/AuthStore.vi.test.ts b/packages/fold-codex/test/AuthStore.vi.test.ts index 364360a..a39f037 100644 --- a/packages/fold-codex/test/AuthStore.vi.test.ts +++ b/packages/fold-codex/test/AuthStore.vi.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Effect, Option, Schema } from 'effect' import { CodexTokenData, makeCodexAuthStore, TOKEN_EXPIRY_BUFFER_MS } from '../src/index' @@ -28,16 +29,16 @@ const sampleToken = new CodexTokenData({ describe('CodexAuthStore', () => { it.effect('load returns none for a missing store', () => Effect.gen(function* () { - const store = makeCodexAuthStore({ path: tempStorePath() }) + const store = yield* makeCodexAuthStore({ path: tempStorePath() }) const loaded = yield* store.load expect(Option.isNone(loaded)).toBe(true) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('save/load round-trips and forces 0600 permissions', () => Effect.gen(function* () { const path = tempStorePath() - const store = makeCodexAuthStore({ path }) + const store = yield* makeCodexAuthStore({ path }) yield* store.save(sampleToken) const loaded = yield* store.load @@ -51,7 +52,7 @@ describe('CodexAuthStore', () => { } expect(statSync(path).mode & 0o777).toBe(0o600) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('save preserves other providers entries in the document', () => @@ -59,13 +60,13 @@ describe('CodexAuthStore', () => { const path = tempStorePath() writeFileSync(path, JSON.stringify({ anthropic: { type: 'api', key: 'sk-other' } })) - const store = makeCodexAuthStore({ path }) + const store = yield* makeCodexAuthStore({ path }) yield* store.save(sampleToken) const document = readDocument(path) expect(document['anthropic']).toEqual({ type: 'api', key: 'sk-other' }) expect(document['codex']).toMatchObject({ access: 'access-token-1' }) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('clear removes only the codex entry', () => @@ -73,7 +74,7 @@ describe('CodexAuthStore', () => { const path = tempStorePath() writeFileSync(path, JSON.stringify({ anthropic: { type: 'api', key: 'sk-other' } })) - const store = makeCodexAuthStore({ path }) + const store = yield* makeCodexAuthStore({ path }) yield* store.save(sampleToken) yield* store.clear @@ -83,7 +84,7 @@ describe('CodexAuthStore', () => { const loaded = yield* store.load expect(Option.isNone(loaded)).toBe(true) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('corrupt JSON degrades to no credentials without clobbering the file', () => @@ -91,12 +92,12 @@ describe('CodexAuthStore', () => { const path = tempStorePath() writeFileSync(path, 'not json at all {') - const store = makeCodexAuthStore({ path }) + const store = yield* makeCodexAuthStore({ path }) const loaded = yield* store.load expect(Option.isNone(loaded)).toBe(true) expect(readFileSync(path, 'utf8')).toBe('not json at all {') - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('an invalid codex entry is skipped, not decoded', () => @@ -104,10 +105,10 @@ describe('CodexAuthStore', () => { const path = tempStorePath() writeFileSync(path, JSON.stringify({ codex: { type: 'api', key: 'wrong-shape' } })) - const store = makeCodexAuthStore({ path }) + const store = yield* makeCodexAuthStore({ path }) const loaded = yield* store.load expect(Option.isNone(loaded)).toBe(true) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it('isExpired applies the 30s safety buffer', () => { diff --git a/packages/fold-codex/test/CodexAuth.vi.test.ts b/packages/fold-codex/test/CodexAuth.vi.test.ts index 2d1e177..abb46a4 100644 --- a/packages/fold-codex/test/CodexAuth.vi.test.ts +++ b/packages/fold-codex/test/CodexAuth.vi.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Effect, Layer, Option, Predicate } from 'effect' import { FetchHttpClient, type HttpClient } from 'effect/unstable/http' @@ -50,10 +51,10 @@ const jsonResponse = (body: unknown, status = 200): Response => const storeWith = (token?: CodexTokenData): Effect.Effect => Effect.gen(function* () { - const store = makeCodexAuthStore({ path: tempStorePath() }) + const store = yield* makeCodexAuthStore({ path: tempStorePath() }) if (token !== undefined) yield* Effect.orDie(store.save(token)) return store - }) + }).pipe(Effect.provide(NodeFileSystem.layer)) describe('JWT account id extraction', () => { it('reads the direct claim first', () => { @@ -105,7 +106,7 @@ describe('CodexAuth.get', () => { const token = yield* auth.get expect(token.access).toBe('valid-access') expect(network.requests).toHaveLength(0) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('fails NotAuthenticated when the store is empty', () => @@ -120,7 +121,7 @@ describe('CodexAuth.get', () => { expect(result._tag).toBe('CodexAuthError') expect(result.reason).toBe('NotAuthenticated') expect(result.message).toContain(store.path) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('refreshes an expired token, persists it, and preserves the account id', () => @@ -151,7 +152,7 @@ describe('CodexAuth.get', () => { const persisted = yield* store.load expect(Option.isSome(persisted)).toBe(true) if (Option.isSome(persisted)) expect(persisted.value.access).toBe('fresh-access') - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('extracts the account id from a refreshed id_token', () => @@ -169,7 +170,7 @@ describe('CodexAuth.get', () => { const token = yield* auth.get expect(token.accountId).toBe('acct_new') - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('single-flights concurrent refreshes', () => @@ -184,7 +185,7 @@ describe('CodexAuth.get', () => { expect(first.access).toBe('fresh-access') expect(second.access).toBe('fresh-access') expect(network.requests).toHaveLength(1) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('a failed refresh surfaces RefreshFailed and keeps the stored credential', () => @@ -200,7 +201,7 @@ describe('CodexAuth.get', () => { const persisted = yield* store.load expect(Option.isSome(persisted)).toBe(true) if (Option.isSome(persisted)) expect(persisted.value.refresh).toBe('stale-refresh') - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('logout clears the stored credential', () => @@ -217,6 +218,6 @@ describe('CodexAuth.get', () => { const result = yield* auth.get.pipe(Effect.flip) expect(result.reason).toBe('NotAuthenticated') - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) }) diff --git a/packages/fold-core/examples/AnthropicAgent.ts b/packages/fold-core/examples/AnthropicAgent.ts index 8ed7df3..a3e7d2b 100644 --- a/packages/fold-core/examples/AnthropicAgent.ts +++ b/packages/fold-core/examples/AnthropicAgent.ts @@ -6,6 +6,7 @@ * * Run: ANTHROPIC_API_KEY=... bun packages/fold-core/examples/AnthropicAgent.ts */ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Console, Effect, Schema } from 'effect' import { anthropicModel, defineAgent, defineTool, startSession } from '../src/index' @@ -41,7 +42,7 @@ const makeProgram = (apiKey: string) => yield* Console.log(`finished: ${finished.outcome}`) yield* Console.log(`result: ${finished.resultText ?? '(no text)'}`) yield* Console.log(`log: ${entries.map((entry) => entry._tag).join(' -> ')}`) - }).pipe(Effect.scoped) + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)) if (apiKey === undefined || apiKey === '') { console.error('Set ANTHROPIC_API_KEY to run this example.') diff --git a/packages/fold-core/examples/AutoCompactAgent.ts b/packages/fold-core/examples/AutoCompactAgent.ts index 8a0510c..88191f7 100644 --- a/packages/fold-core/examples/AutoCompactAgent.ts +++ b/packages/fold-core/examples/AutoCompactAgent.ts @@ -10,6 +10,7 @@ * * Run: OPENAI_API_KEY=... bun packages/fold-core/examples/AutoCompactAgent.ts */ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Console, Effect } from 'effect' import { defineAgent, openaiModel, startSession, type CompactionLogEntry } from '../src/index' @@ -75,7 +76,7 @@ const makeProgram = (key: string) => yield* Console.log('\nsend 3: the session keeps running on the compacted context...') const third = yield* session.send('And what were we told about the dedupe window?') yield* Console.log(` -> ${third.resultText ?? '(no text)'}`) - }).pipe(Effect.scoped) + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)) if (apiKey === undefined || apiKey === '') { console.error('Set OPENAI_API_KEY to run this example.') diff --git a/packages/fold-core/examples/ModelSwitch.ts b/packages/fold-core/examples/ModelSwitch.ts index 0379d26..ea89b40 100644 --- a/packages/fold-core/examples/ModelSwitch.ts +++ b/packages/fold-core/examples/ModelSwitch.ts @@ -10,6 +10,7 @@ * * Run: OPENAI_API_KEY=... ANTHROPIC_API_KEY=... bun packages/fold-core/examples/ModelSwitch.ts */ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Console, Effect, Schema } from 'effect' import { anthropicModel, defineAgent, defineTool, openaiModel, startSession } from '../src/index' @@ -60,7 +61,7 @@ const makeProgram = (openAiKey: string, anthropicKey: string) => const entries = yield* session.entries yield* Console.log(`log: ${entries.map((entry) => entry._tag).join(' -> ')}`) - }).pipe(Effect.scoped) + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)) if (openAiKey === undefined || openAiKey === '' || anthropicKey === undefined || anthropicKey === '') { console.error('Set OPENAI_API_KEY and ANTHROPIC_API_KEY to run this example.') diff --git a/packages/fold-core/examples/OpenaiAgent.ts b/packages/fold-core/examples/OpenaiAgent.ts index 76a04f1..bdfec3f 100644 --- a/packages/fold-core/examples/OpenaiAgent.ts +++ b/packages/fold-core/examples/OpenaiAgent.ts @@ -5,6 +5,7 @@ * * Run: OPENAI_API_KEY=... bun packages/fold-core/examples/OpenaiAgent.ts */ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Console, Effect, Schema } from 'effect' import { defineAgent, defineTool, openaiModel, startSession } from '../src/index' @@ -40,7 +41,7 @@ const makeProgram = (apiKey: string) => yield* Console.log(`finished: ${finished.outcome}`) yield* Console.log(`result: ${finished.resultText ?? '(no text)'}`) yield* Console.log(`log: ${entries.map((entry) => entry._tag).join(' -> ')}`) - }).pipe(Effect.scoped) + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)) if (apiKey === undefined || apiKey === '') { console.error('Set OPENAI_API_KEY to run this example.') diff --git a/packages/fold-core/examples/SkillsAgent.ts b/packages/fold-core/examples/SkillsAgent.ts index 01f13f4..704d1ef 100644 --- a/packages/fold-core/examples/SkillsAgent.ts +++ b/packages/fold-core/examples/SkillsAgent.ts @@ -7,6 +7,7 @@ * * Run: ANTHROPIC_API_KEY=... bun packages/fold-core/examples/SkillsAgent.ts */ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Console, Effect } from 'effect' import { anthropicModel, defineAgent, skillsFromData, skillTool, startSession } from '../src/index' @@ -50,7 +51,7 @@ const makeProgram = (apiKey: string) => yield* Console.log(`finished: ${finished.outcome}`) yield* Console.log(`result:\n${finished.resultText ?? '(no text)'}`) yield* Console.log(`log: ${entries.map((entry) => entry._tag).join(' -> ')}`) - }).pipe(Effect.scoped) + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)) if (apiKey === undefined || apiKey === '') { console.error('Set ANTHROPIC_API_KEY to run this example.') diff --git a/packages/fold-core/package.json b/packages/fold-core/package.json index d8887c3..2ade7a7 100644 --- a/packages/fold-core/package.json +++ b/packages/fold-core/package.json @@ -25,6 +25,7 @@ "devDependencies": { "@effect/ai-anthropic": "catalog:", "@effect/ai-openai": "catalog:", + "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", "@humanlayer/fold-vitest-config": "workspace:*", "effect": "catalog:", diff --git a/packages/fold-core/src/Api/EventLogDescriptor.ts b/packages/fold-core/src/Api/EventLogDescriptor.ts index b7c4fb3..96443b3 100644 --- a/packages/fold-core/src/Api/EventLogDescriptor.ts +++ b/packages/fold-core/src/Api/EventLogDescriptor.ts @@ -5,7 +5,7 @@ * SQLite/Durable Object backends) contribute an EventLog service implementation without any layer * appearing in a public signature. */ -import type { Effect, Scope } from 'effect' +import type { Effect, FileSystem, Scope } from 'effect' import type { EventLogService } from '../EventLog/EventLogService' @@ -14,7 +14,7 @@ export type FoldEventLog = | { readonly _tag: 'memory' } | { readonly _tag: 'source' - readonly make: Effect.Effect + readonly make: Effect.Effect } /** Keep the session log in memory: fast, isolated, and gone when the session scope closes. */ @@ -25,7 +25,9 @@ export const memoryEventLog = (): FoldEventLog => ({ _tag: 'memory' }) * the session scope; construction failures are treated as infrastructure defects. Resuming an existing * log is this seam too: an implementation that loads prior entries replays them into the session. */ -export const eventLogSource = (make: Effect.Effect): FoldEventLog => ({ +export const eventLogSource = ( + make: Effect.Effect, +): FoldEventLog => ({ _tag: 'source', make, }) diff --git a/packages/fold-core/src/Api/Provisioning.ts b/packages/fold-core/src/Api/Provisioning.ts index 52bccec..08e5404 100644 --- a/packages/fold-core/src/Api/Provisioning.ts +++ b/packages/fold-core/src/Api/Provisioning.ts @@ -18,8 +18,7 @@ */ import { AnthropicClient, AnthropicLanguageModel } from '@effect/ai-anthropic' import { OpenAiClient, OpenAiLanguageModel } from '@effect/ai-openai' -import { Context, Effect, Layer, Stream } from 'effect' -import type { Scope } from 'effect' +import { Context, Effect, type FileSystem, Layer, type Scope, Stream } from 'effect' import { LanguageModel, Toolkit } from 'effect/unstable/ai' import type { Tool } from 'effect/unstable/ai' import { FetchHttpClient, HttpClient } from 'effect/unstable/http' @@ -93,6 +92,7 @@ export type SessionProvisioningServices = | ToolEventSink | Subagents | SessionControls + | FileSystem.FileSystem /** Lower a model descriptor to the LanguageModel layer for its provider connection. */ export const languageModelLayerFor = (model: FoldModel): Layer.Layer => { diff --git a/packages/fold-core/src/Api/StartSession.ts b/packages/fold-core/src/Api/StartSession.ts index 45cbd32..1a7b037 100644 --- a/packages/fold-core/src/Api/StartSession.ts +++ b/packages/fold-core/src/Api/StartSession.ts @@ -27,7 +27,7 @@ * e.g. a changed skills roster - D20 rule), the facade writes one epoch transition before the first * send. */ -import { Cause, Context, Effect, Exit, Fiber, Layer, Ref, Schema, Scope, Semaphore, Stream } from 'effect' +import { Cause, Context, Effect, Exit, Fiber, FileSystem, Layer, Ref, Schema, Scope, Semaphore, Stream } from 'effect' import { Prompt } from 'effect/unstable/ai' import { toolEventSinkLayerFromAgentEvents, liveAgentEventsLayer } from '../AgentEvents/AgentEventsLayer' @@ -260,7 +260,9 @@ type SessionAgentConfig = { } /** Lower the event log descriptor to its EventLog layer. */ -const eventLogLayerFor = (log: FoldEventLog): Layer.Layer => +const eventLogLayerFor = ( + log: FoldEventLog, +): Layer.Layer => log._tag === 'memory' ? layerInMemoryEventLogWithIds : Layer.effect(EventLog, log.make) /** Fold a leading-prompt config value into an ordered block list. */ @@ -283,7 +285,7 @@ type SessionGraph = { profiles: SessionProfiles, ) => Effect.Effect readonly extendSubagentRegistry: (definitions: CollectedAgentDefinitions) => void - readonly ensureToolContributions: (tools: ReadonlyArray) => Effect.Effect + readonly ensureToolContributions: (tools: ReadonlyArray) => Effect.Effect readonly collectNewSubagentDefinitions: (tools: ReadonlyArray) => Effect.Effect readonly provisionRootRuntime: ( model: FoldModel, @@ -291,6 +293,7 @@ type SessionGraph = { ) => Effect.Effect readonly setProvisionedRuntime: (runtime: AgentRuntimeService) => Effect.Effect readonly currentProvisionedRuntime: Effect.Effect + readonly fileSystem: FileSystem.FileSystem readonly leadingPromptFor: ( systemPrompt: string | ReadonlyArray | null, tools: ReadonlyArray, @@ -309,7 +312,7 @@ const assembleSessionGraph = (options: { readonly profiles?: SessionProfiles readonly catalog?: ReadonlyArray readonly compactionArchiveAccess?: CompactionArchiveAccessService -}): Effect.Effect => +}): Effect.Effect => Effect.gen(function* () { const agent = options.agent const rootTools = agent.tools ?? [] @@ -354,7 +357,7 @@ const assembleSessionGraph = (options: { // leading-prompt block, skill source - is reused by every agent listing that value, across // epochs, and by every subagent dispatch (D20's one-snapshot law). const toolContributions = new Map() - const ensureToolContributions = (tools: ReadonlyArray): Effect.Effect => + const ensureToolContributions = (tools: ReadonlyArray): Effect.Effect => Effect.forEach( tools.filter((tool) => !toolContributions.has(tool)), (tool) => tool.init.pipe(Effect.map((contribution) => toolContributions.set(tool, contribution))), @@ -430,14 +433,19 @@ const assembleSessionGraph = (options: { // instances (one EventLog, one Ids source, one AgentEvents PubSub, one SessionControls, one // Subagents engine). HookRunner is deliberately NOT session-fixed: each provisioned runtime // carries its own agent's hook chains (D16/D21). + const fileSystem = yield* FileSystem.FileSystem const idsLayer = layerLiveIdFactory + const fsLayer = Layer.succeed(FileSystem.FileSystem, fileSystem) const infraLayer = Layer.mergeAll( - eventLogLayerFor(options.log ?? { _tag: 'memory' }).pipe(Layer.provide(idsLayer)), + eventLogLayerFor(options.log ?? { _tag: 'memory' }).pipe( + Layer.provide(Layer.mergeAll(idsLayer, fsLayer)), + ), idsLayer, liveAgentEventsLayer, ) const servicesLayer = Layer.mergeAll( infraLayer, + Layer.succeed(FileSystem.FileSystem, fileSystem), makeSystemPrompt(agent.basePrompts === undefined ? {} : { basePrompts: agent.basePrompts }), liveModelRequestSettingsLayer, toolEventSinkLayerFromAgentEvents.pipe(Layer.provide(infraLayer)), @@ -546,6 +554,7 @@ const assembleSessionGraph = (options: { extendSubagentRegistry: (definitions) => { registry.extend(definitions) }, + fileSystem, ensureToolContributions, collectNewSubagentDefinitions: (tools) => collectAgentDefinitions(tools), provisionRootRuntime, @@ -811,7 +820,7 @@ const makeSessionHandle = (graph: SessionGraph, identity: StartedSession): FoldS const switchModel = (model: FoldModel, switchOptions?: SwitchModelOptions): Effect.Effect => gate.withPermit( - Effect.gen(function* () { + Effect.provideService(FileSystem.FileSystem, graph.fileSystem)(Effect.gen(function* () { const current = yield* Ref.get(configRef) const currentProfiles = yield* profiles.snapshot const candidateProfiles = switchOptions?.profiles ?? currentProfiles @@ -867,7 +876,7 @@ const makeSessionHandle = (graph: SessionGraph, identity: StartedSession): FoldS graph.extendSubagentRegistry(introduced) yield* profiles.replace(candidateProfiles) yield* Ref.set(configRef, next) - }), + })), ) const compact = (): Effect.Effect => @@ -910,7 +919,7 @@ const makeSessionHandle = (graph: SessionGraph, identity: StartedSession): FoldS * the surrounding scope: closing the scope releases the log backend, event spine, and provisioned model * runtimes. */ -export const startSession = (options: StartSessionOptions): Effect.Effect => +export const startSession = (options: StartSessionOptions): Effect.Effect => Effect.gen(function* () { const graph = yield* assembleSessionGraph(options) const config = yield* Ref.get(graph.configRef) @@ -939,7 +948,7 @@ export const startSession = (options: StartSessionOptions): Effect.Effect => +export const resumeSession = (options: ResumeSessionOptions): Effect.Effect => Effect.gen(function* () { const graph = yield* assembleSessionGraph(options) const entries = yield* Stream.runCollect(graph.eventLog.entries()).pipe( diff --git a/packages/fold-core/src/Api/ToolDefinition.ts b/packages/fold-core/src/Api/ToolDefinition.ts index 67ecb9f..5deaa14 100644 --- a/packages/fold-core/src/Api/ToolDefinition.ts +++ b/packages/fold-core/src/Api/ToolDefinition.ts @@ -11,7 +11,7 @@ * scan into its description and contributes the skills prompt block - do real work in theirs. Sharing * the same value across several agents' `tools` arrays shares one init (one scan, one snapshot). */ -import { Effect, Schema } from 'effect' +import { Effect, FileSystem, Schema } from 'effect' import { Tool } from 'effect/unstable/ai' import type { SkillSourceService } from '../Skills/SkillSource' @@ -41,6 +41,7 @@ export type ToolHandlerServices = | CurrentToolCall | InterruptNote | Subagents + | FileSystem.FileSystem /** Handler stored on a tool descriptor, erased to the runtime dispatch shape. */ export type ErasedToolHandler = (params: unknown) => Effect.Effect @@ -70,7 +71,7 @@ export type SessionToolContribution = { export type FoldTool = { readonly name: string /** Run ONCE per distinct value per session by the composition root; contributions are reused. */ - readonly init: Effect.Effect + readonly init: Effect.Effect } /** One realized tool ready to install into a Toolset: the composition-internal, post-init stage. */ @@ -121,7 +122,16 @@ export const defineTool = < failureMode: 'return', // Every tool may use the ambient per-call services; declaring them here keeps handler `R` // honest while the runtime provides all of them around each execution. - dependencies: [ToolState, ToolEvents, StopController, CurrentAgent, CurrentToolCall, InterruptNote, Subagents], + dependencies: [ + ToolState, + ToolEvents, + StopController, + CurrentAgent, + CurrentToolCall, + InterruptNote, + Subagents, + FileSystem.FileSystem, + ], }).annotate(Tool.Strict, false) // asVoid yields the undefined value at runtime, which is exactly what Schema.Undefined encodes. diff --git a/packages/fold-core/src/Skills/SkillSource.ts b/packages/fold-core/src/Skills/SkillSource.ts index 2ccb75c..58baa2b 100644 --- a/packages/fold-core/src/Skills/SkillSource.ts +++ b/packages/fold-core/src/Skills/SkillSource.ts @@ -5,7 +5,7 @@ * disk loader. Public configuration goes through descriptors ({@link skillsFromData} / * {@link skillSource}) so no service or layer appears in caller signatures. */ -import { Context, Effect, Schema } from 'effect' +import { Context, Effect, type FileSystem, Schema } from 'effect' import { skillDescriptionProblem, skillNameProblem, type Skill, type SkillMeta } from './Schemas' @@ -81,7 +81,7 @@ export const skillSourceFromData = (skills: ReadonlyArray): Effect.Ef /** Skills configuration descriptor for {@link defineAgent}: data-backed or a custom source seam. */ export type FoldSkills = | { readonly _tag: 'fromData'; readonly skills: ReadonlyArray } - | { readonly _tag: 'source'; readonly make: Effect.Effect } + | { readonly _tag: 'source'; readonly make: Effect.Effect } /** Configure an agent's skills from in-memory data (isomorphic; browser/worker hosts). */ export const skillsFromData = (skills: ReadonlyArray): FoldSkills => ({ _tag: 'fromData', skills }) @@ -90,11 +90,13 @@ export const skillsFromData = (skills: ReadonlyArray): FoldSkills => * Configure an agent's skills from a custom source implementation (the extension seam, mirroring * `eventLogSource`): fold-agent exposes its disk loader through this. */ -export const skillSource = (make: Effect.Effect): FoldSkills => ({ +export const skillSource = (make: Effect.Effect): FoldSkills => ({ _tag: 'source', make, }) /** Lower a skills descriptor to its source implementation (composition-root internal). */ -export const skillSourceFor = (skills: FoldSkills): Effect.Effect => +export const skillSourceFor = ( + skills: FoldSkills, +): Effect.Effect => skills._tag === 'fromData' ? skillSourceFromData(skills.skills) : skills.make.pipe(Effect.orDie) diff --git a/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts b/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts index 2d92449..94bae67 100644 --- a/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts +++ b/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts @@ -4,7 +4,7 @@ * per-call ToolState, ToolEvents, and StopController services while handlers run, then persists one durable * tool-result entry per call, including synthetic interruption results when a tool fiber is interrupted. */ -import { Cause, Effect, Layer, Ref, Schema, Stream } from 'effect' +import { Cause, Effect, FileSystem, Layer, Ref, Schema, Stream } from 'effect' import { Prompt } from 'effect/unstable/ai' import { EventLog } from '../EventLog/EventLogService' @@ -306,6 +306,7 @@ const finalOutputFromToolHandler = (input: { | CurrentToolCall | InterruptNote | Subagents + | FileSystem.FileSystem > => Effect.gen(function* () { const toolset = yield* Toolset @@ -383,7 +384,7 @@ const settlePreparedToolCall = (input: { readonly prepared: PreparedToolCall readonly stopRef: Ref.Ref readonly stateSnapshot: ReadonlyArray -}): Effect.Effect => +}): Effect.Effect => Effect.gen(function* () { const toolCallId = yield* decodeToolCallId(input.prepared.original) const toolName = input.prepared.original.name @@ -436,6 +437,7 @@ const settlePreparedToolCall = (input: { | CurrentToolCall | InterruptNote | Subagents + | FileSystem.FileSystem > = Effect.gen(function* () { if (input.prepared._tag === 'replaceResult') { return { @@ -517,7 +519,7 @@ type SettleToolCallsInput = Parameters[0] /** Settle every tool call in one assistant message and report whether a stop was requested. */ const settleToolCalls = ( input: SettleToolCallsInput, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const stopRef = yield* Ref.make(null) const stopController = { @@ -577,7 +579,7 @@ const settleToolCalls = ( export const liveToolRuntimeLayer: Layer.Layer< ToolRuntime, never, - EventLog | Ids | HookRunner | Toolset | ToolEventSink | Subagents + EventLog | Ids | HookRunner | Toolset | ToolEventSink | Subagents | FileSystem.FileSystem > = Layer.effect( ToolRuntime, Effect.gen(function* () { @@ -587,6 +589,7 @@ export const liveToolRuntimeLayer: Layer.Layer< const toolset = yield* Toolset const sink = yield* ToolEventSink const subagents = yield* Subagents + const fs = yield* FileSystem.FileSystem const settle: ToolRuntimeService['settle'] = Effect.fn('fold.tool_runtime.settle')((input) => settleToolCalls(input).pipe( @@ -596,6 +599,7 @@ export const liveToolRuntimeLayer: Layer.Layer< Effect.provideService(Toolset, toolset), Effect.provideService(ToolEventSink, sink), Effect.provideService(Subagents, subagents), + Effect.provideService(FileSystem.FileSystem, fs), ), ) diff --git a/packages/fold-core/src/ToolRuntime/ToolsetService.ts b/packages/fold-core/src/ToolRuntime/ToolsetService.ts index f119846..3e8c62d 100644 --- a/packages/fold-core/src/ToolRuntime/ToolsetService.ts +++ b/packages/fold-core/src/ToolRuntime/ToolsetService.ts @@ -3,7 +3,7 @@ * ToolRuntime live layer to execute tool handlers. The service keeps Effect AI's dynamic Toolkit boundary * contained so callers do not pass tool handlers around as arguments. */ -import { Context, type Effect, type Stream } from 'effect' +import { Context, type Effect, type FileSystem, type Stream } from 'effect' import type { Tool, Toolkit } from 'effect/unstable/ai' import type { CurrentAgent, CurrentToolCall, InterruptNote, StopController, ToolEvents } from './ToolContextServices' @@ -41,7 +41,7 @@ export type ToolsetService = { Stream.Stream< ToolHandlerOutput, unknown, - ToolState | ToolEvents | StopController | CurrentAgent | CurrentToolCall | InterruptNote + ToolState | ToolEvents | StopController | CurrentAgent | CurrentToolCall | InterruptNote | FileSystem.FileSystem > > } diff --git a/packages/fold-core/test/AgentRuntime/AgentRuntimeModelSettings.vi.test.ts b/packages/fold-core/test/AgentRuntime/AgentRuntimeModelSettings.vi.test.ts index 8ddb95c..cac6e90 100644 --- a/packages/fold-core/test/AgentRuntime/AgentRuntimeModelSettings.vi.test.ts +++ b/packages/fold-core/test/AgentRuntime/AgentRuntimeModelSettings.vi.test.ts @@ -1,4 +1,5 @@ import { expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Effect, Layer, Schema } from 'effect' import { Tool, Toolkit } from 'effect/unstable/ai' import type { LanguageModel } from 'effect/unstable/ai' @@ -199,6 +200,7 @@ const familyAgentLayer = ( Layer.succeed(ToolEventSink, noopToolEventSink), Layer.succeed(Subagents, noSubagentsStub), Layer.effect(SessionControls, makeSessionControls()), + NodeFileSystem.layer, ) const toolRuntimeLayer = liveToolRuntimeLayer.pipe(Layer.provideMerge(sharedLayer)) diff --git a/packages/fold-core/test/AgentRuntime/AgentRuntimeTestHelpers.ts b/packages/fold-core/test/AgentRuntime/AgentRuntimeTestHelpers.ts index bab0631..7701e27 100644 --- a/packages/fold-core/test/AgentRuntime/AgentRuntimeTestHelpers.ts +++ b/packages/fold-core/test/AgentRuntime/AgentRuntimeTestHelpers.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Effect, Layer } from 'effect' import type { LanguageModel, Tool } from 'effect/unstable/ai' @@ -99,6 +100,7 @@ export const agentRuntimeBaseLayer = ( Layer.succeed(Subagents, noSubagentsStub), Layer.succeed(StopConditions, stopConditions), Layer.effect(SessionControls, makeSessionControls()), + NodeFileSystem.layer, ) const toolRuntimeLayer = liveToolRuntimeLayer.pipe(Layer.provideMerge(sharedLayer)) diff --git a/packages/fold-core/test/Api/ResumeSession.vi.test.ts b/packages/fold-core/test/Api/ResumeSession.vi.test.ts index d52f6d2..e769b1f 100644 --- a/packages/fold-core/test/Api/ResumeSession.vi.test.ts +++ b/packages/fold-core/test/Api/ResumeSession.vi.test.ts @@ -6,6 +6,7 @@ * roster changes the block). An unchanged configuration writes nothing. */ import { expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Cause, Context, Effect, Exit, Layer } from 'effect' import { @@ -71,7 +72,7 @@ it.effect('resume adopts the log: same ids, no new rows, full continuity - and n expect(prompt).toContain('go') expect(prompt).toContain('first answer') expect(prompt).toContain('continue where we left off') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('resume with a different model binding writes one epoch transition (D17 resume ruling)', () => @@ -97,7 +98,7 @@ it.effect('resume with a different model binding writes one epoch transition (D1 const finished = yield* session.send('continue') expect(finished.resultText).toBe('answered by the new model') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('resume with changed leading blocks transitions too (D20 resume rule)', () => @@ -123,7 +124,7 @@ it.effect('resume with changed leading blocks transitions too (D20 resume rule)' const prompt = JSON.stringify((yield* resumedScripted.scripted.prompts)[0]) expect(prompt).toContain('prompt v2') expect(prompt).not.toContain('prompt v1') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('resuming an empty log is a defect with instructive guidance', () => @@ -138,5 +139,5 @@ it.effect('resuming an empty log is a defect with instructive guidance', () => if (!Exit.isFailure(exit)) throw new Error('expected resume on an empty log to defect') expect(String(Cause.squash(exit.cause))).toContain('no session_started') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-core/test/Api/SessionInterrupt.vi.test.ts b/packages/fold-core/test/Api/SessionInterrupt.vi.test.ts index 86e1fde..3015a78 100644 --- a/packages/fold-core/test/Api/SessionInterrupt.vi.test.ts +++ b/packages/fold-core/test/Api/SessionInterrupt.vi.test.ts @@ -5,6 +5,7 @@ * tool result as an interrupted-outcome result while the dispatcher keeps running. */ import { expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Deferred, Effect, Fiber } from 'effect' import { defineAgent, defineSubagent, shortAgentId, startSession, subagentTool } from '../../src/index' @@ -48,7 +49,7 @@ it.effect('interrupt discards partial assistant text, writes the root marker, an const resumedPrompt = JSON.stringify(prompts[1]) expect(resumedPrompt).not.toContain('I was thinking about the answer') expect(resumedPrompt).toContain('pick it back up') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('a targeted subagent interrupt folds into the dispatcher, which keeps running', () => @@ -106,5 +107,5 @@ it.effect('a targeted subagent interrupt folds into the dispatcher, which keeps expect(rendered).toContain(`agent_id: ${shortAgentId(childStarted.agentId)}`) expect(rendered).toContain('This subagent was interrupted') expect(rendered).not.toContain('The user interrupted the execution of this tool call.') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-core/test/Api/SessionIsolation.vi.test.ts b/packages/fold-core/test/Api/SessionIsolation.vi.test.ts index 9fdd657..b69d35c 100644 --- a/packages/fold-core/test/Api/SessionIsolation.vi.test.ts +++ b/packages/fold-core/test/Api/SessionIsolation.vi.test.ts @@ -6,6 +6,7 @@ * both sessions one shared EventLog and one shared event spine. */ import { expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Effect } from 'effect' import { defineAgent, startSession, type SessionStartedLogEntry } from '../../src/index' @@ -56,5 +57,5 @@ it.effect('two sessions in one program share no log, ids, or model runtime', () // Sequence numbers restart per log - interleaving into one shared log would break this. expect(entriesA.map((entry) => entry.seq)).toEqual(entriesA.map((_, index) => index)) expect(entriesB.map((entry) => entry.seq)).toEqual(entriesB.map((_, index) => index)) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-core/test/Api/SessionProfiles.vi.test.ts b/packages/fold-core/test/Api/SessionProfiles.vi.test.ts index d553ea4..02c861b 100644 --- a/packages/fold-core/test/Api/SessionProfiles.vi.test.ts +++ b/packages/fold-core/test/Api/SessionProfiles.vi.test.ts @@ -5,6 +5,7 @@ * runs keep their durable rows on the model that actually served them. */ import { expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Effect } from 'effect' import { defineSubagent } from '../../src/index' @@ -40,7 +41,7 @@ it.effect('setProfile rebinds a role for the very next dispatch of the same type expect(yield* fastB.scripted.remainingTurns).toBe(0) expect(yield* fastA.scripted.requests).toHaveLength(1) expect(yield* fastB.scripted.requests).toHaveLength(1) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('a failed atomic model switch leaves the complete profiles map unchanged', () => @@ -70,5 +71,5 @@ it.effect('a failed atomic model switch leaves the complete profiles map unchang expect(started.at(-1)?.model.modelId).toBe('fast-before') expect(yield* fastA.scripted.requests).toHaveLength(1) expect(yield* fastB.scripted.requests).toHaveLength(0) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-core/test/Api/SessionSendTargets.vi.test.ts b/packages/fold-core/test/Api/SessionSendTargets.vi.test.ts index 7f81079..d0995e7 100644 --- a/packages/fold-core/test/Api/SessionSendTargets.vi.test.ts +++ b/packages/fold-core/test/Api/SessionSendTargets.vi.test.ts @@ -6,6 +6,7 @@ * agent_started, full prior context. Unknown ids fail typed. */ import { expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Context, Effect, Fiber, Layer } from 'effect' import { @@ -60,7 +61,7 @@ it.effect('send while running joins the run as a follow-up; both senders get the const followUpPrompt = JSON.stringify(prompts[2]) expect(followUpPrompt).toContain('first answer') expect(followUpPrompt).toContain('one more thing') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('a follow-up the stopped run never consumed starts its own fresh run', () => @@ -91,7 +92,7 @@ it.effect('a follow-up the stopped run never consumed starts its own fresh run', const entries = yield* session.entries expect(entries.filter((entry) => entry._tag === 'agent-finished')).toHaveLength(2) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('send targeting a finished subagent continues it directly under a null envelope', () => @@ -148,7 +149,7 @@ it.effect('send targeting a finished subagent continues it directly under a null expect(continuedPrompt).toContain('map the module') expect(continuedPrompt).toContain('first findings') expect(continuedPrompt).toContain('quote the title line') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('send to an unknown agent id fails typed', () => @@ -160,7 +161,7 @@ it.effect('send to an unknown agent id fails typed', () => .send('hello?', { agentId: AgentId.make('agent_aaaaaaaaaaaaaaaaaaaaaaaa') }) .pipe(Effect.flip) expect(failure._tag).toBe('SubagentNotFoundError') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('send targeting a finished subagent by its SHORT id continues it like the full id', () => @@ -201,7 +202,7 @@ it.effect('send targeting a finished subagent by its SHORT id continues it like expect(continued.agentId).toBe(started.agentId) expect(continued.resultText).toBe('continued findings') expect(continued.toolCallId).toBeNull() - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('send with an ambiguous short reference fails typed, naming the candidate short ids', () => @@ -241,5 +242,5 @@ it.effect('send with an ambiguous short reference fails typed, naming the candid expect(failure._tag).toBe('SubagentNotFoundError') expect(failure.requested).toBe('agent_abcdef') expect(failure.candidates).toEqual(['agent_abcdef11', 'agent_abcdef22']) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-core/test/Api/SessionSteer.vi.test.ts b/packages/fold-core/test/Api/SessionSteer.vi.test.ts index 279b256..f7951e8 100644 --- a/packages/fold-core/test/Api/SessionSteer.vi.test.ts +++ b/packages/fold-core/test/Api/SessionSteer.vi.test.ts @@ -6,6 +6,7 @@ * are steerable by agentId, draining between the CHILD's turns with the dispatch envelope. */ import { expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Effect, Fiber } from 'effect' import { defineAgent, defineSubagent, startSession, subagentTool, type UserMessageLogEntry } from '../../src/index' @@ -48,7 +49,7 @@ it.effect('steering a running root drains between turns, exactly where the model const prompts = yield* rootScripted.scripted.prompts expect(JSON.stringify(prompts[0])).not.toContain('change course') expect(JSON.stringify(prompts[1])).toContain('change course') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('one-at-a-time steering drains one message per turn boundary', () => @@ -78,7 +79,7 @@ it.effect('one-at-a-time steering drains one message per turn boundary', () => expect(JSON.stringify(prompts[1])).toContain('first steer') expect(JSON.stringify(prompts[1])).not.toContain('second steer') expect(JSON.stringify(prompts[2])).toContain('second steer') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect("steering mode 'all' drains the whole queue at one boundary", () => @@ -104,7 +105,7 @@ it.effect("steering mode 'all' drains the whole queue at one boundary", () => const nextPrompt = JSON.stringify((yield* rootScripted.scripted.prompts)[1]) expect(nextPrompt).toContain('first steer') expect(nextPrompt).toContain('second steer') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('steering an idle agent fails typed, pointing at send', () => @@ -115,7 +116,7 @@ it.effect('steering an idle agent fails typed, pointing at send', () => const failure = yield* session.steer('too late').pipe(Effect.flip) expect(failure._tag).toBe('AgentNotRunningError') expect(failure.message).toContain('send(message, { agentId') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect("steering a running subagent drains between the child's turns under the dispatch envelope", () => @@ -176,5 +177,5 @@ it.effect("steering a running subagent drains between the child's turns under th expect(JSON.stringify(childPrompts[1])).toContain('focus on the config file') const rootPrompts = yield* rootScripted.scripted.prompts expect(JSON.stringify(rootPrompts)).not.toContain('focus on the config file') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-core/test/Api/SessionStop.vi.test.ts b/packages/fold-core/test/Api/SessionStop.vi.test.ts index d49e4e7..d162fd9 100644 --- a/packages/fold-core/test/Api/SessionStop.vi.test.ts +++ b/packages/fold-core/test/Api/SessionStop.vi.test.ts @@ -6,6 +6,7 @@ * the next send begins. */ import { expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Effect, Fiber } from 'effect' import { defineAgent, defineSubagent, startSession, subagentTool } from '../../src/index' @@ -43,7 +44,7 @@ it.effect('stop lets the in-flight batch finish, then ends the run with no furth const next = yield* session.send('carry on') expect(next.outcome).toBe('completed') expect(next.resultText).toBe('never requested') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('stop reaches the whole tree: the running subagent stops, then its dispatcher stops', () => @@ -106,5 +107,5 @@ it.effect('stop reaches the whole tree: the running subagent stops, then its dis // Neither model consumed its post-stop turn. expect(yield* researcherScripted.scripted.remainingTurns).toBe(1) expect(yield* rootScripted.scripted.remainingTurns).toBe(1) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-core/test/Api/SkillsSession.vi.test.ts b/packages/fold-core/test/Api/SkillsSession.vi.test.ts index 3ba688a..4d85851 100644 --- a/packages/fold-core/test/Api/SkillsSession.vi.test.ts +++ b/packages/fold-core/test/Api/SkillsSession.vi.test.ts @@ -5,6 +5,7 @@ * switch carries the same session-start block into the new epoch's leading system message. */ import { expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Effect, Ref } from 'effect' import { @@ -71,7 +72,7 @@ it.effect('renders the skills block into the leading prompt and installs the ski if (part === undefined || part.type !== 'tool-result') throw new Error('expected a tool-result part') expect(JSON.stringify(part.result)).toContain(' @@ -124,7 +125,7 @@ it.effect('adding a skill mid-session never changes rendered prompt bytes; refre if (part === undefined || part.type !== 'tool-result') throw new Error('expected a tool-result part') expect(JSON.stringify(part.result)).toContain('Skills added since session start') expect(JSON.stringify(part.result)).toContain('late-arrival') - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('a model switch carries the session-start skills block and skill tool into the new epoch', () => @@ -156,5 +157,5 @@ it.effect('a model switch carries the session-start skills block and skill tool // The new epoch still advertises the skill tool. const secondRequests = yield* second.scripted.requests expect(secondRequests[0]?.toolNames).toContain('skill') - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-core/test/Api/StartSession.vi.test.ts b/packages/fold-core/test/Api/StartSession.vi.test.ts index 486191e..811823f 100644 --- a/packages/fold-core/test/Api/StartSession.vi.test.ts +++ b/packages/fold-core/test/Api/StartSession.vi.test.ts @@ -6,6 +6,7 @@ * SessionIsolation.vi.test.ts. */ import { expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Context, Effect, Fiber, Layer, Schema, Stream } from 'effect' import { @@ -90,7 +91,7 @@ it.effect('runs a tool-calling turn end to end from descriptors only', () => expect(resultPart.result).toEqual({ echoed: 'hello facade' }) expect(yield* scripted.remainingTurns).toBe(0) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('runs a tool-free agent with defaults (memory log, no tools, no failure schema)', () => @@ -106,7 +107,7 @@ it.effect('runs a tool-free agent with defaults (memory log, no tools, no failur expect(finished.resultText).toBe('Just text.') expect(requests).toHaveLength(1) expect(requests[0]?.toolNames).toEqual([]) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('injects a skill as a linked synthetic tool call and result without a user message', () => @@ -138,7 +139,7 @@ it.effect('injects a skill as a linked synthetic tool call and result without a expect(resultPart.id).toBe(injected.result.toolCallId) expect(injected.call.agentId).toBe(session.rootAgentId) expect(injected.result.agentId).toBe(session.rootAgentId) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) // ── Ambient tool services and the merged event stream ─────────────────────── @@ -218,7 +219,7 @@ it.effect('tool handlers reach ToolState and ToolEvents; session.events carries expect(stateEntry?.namespace).toBe('progress-echo') expect(stateEntry?.key).toBe('last') expect(stateEntry?.value).toBe('hi') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) // ── Hooks and typed tool failures ──────────────────────────────────────────── @@ -264,7 +265,7 @@ it.effect('agent hooks run in the facade: a preToolUse deny replaces the result const resultPart = firstToolResultPart(entries) expect(resultPart.isFailure).toBe(true) expect(resultPart.result).toEqual({ message: 'denied by policy hook' }) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('a typed handler failure returns to the model schema-encoded with isFailure', () => @@ -296,7 +297,7 @@ it.effect('a typed handler failure returns to the model schema-encoded with isFa const resultPart = firstToolResultPart(entries) expect(resultPart.isFailure).toBe(true) expect(resultPart.result).toEqual({ message: 'expected failure' }) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) // ── Log backends and descriptor validation ────────────────────────────────── @@ -324,7 +325,7 @@ it.effect('eventLogSource backs the session with a caller-supplied EventLog serv 'assistant-message', 'agent-finished', ]) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('rejects duplicate tool names as a defect', () => @@ -337,5 +338,5 @@ it.effect('rejects duplicate tool names as a defect', () => expect(exit._tag).toBe('Failure') expect(String(exit)).toContain('duplicate tool names: echo') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-core/test/Api/SwitchModel.vi.test.ts b/packages/fold-core/test/Api/SwitchModel.vi.test.ts index 980a518..73fdbe3 100644 --- a/packages/fold-core/test/Api/SwitchModel.vi.test.ts +++ b/packages/fold-core/test/Api/SwitchModel.vi.test.ts @@ -6,6 +6,7 @@ * log and the requests the scripted per-epoch models actually received. */ import { expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Effect, Schema } from 'effect' import { @@ -106,7 +107,7 @@ it.effect('switchModel continues the same log on a new provider and records the // The old epoch really advertised the gpt-family toolset. const gptRequest = (yield* first.scripted.requests)[0] expect(gptRequest?.toolNames).toEqual(['echo', 'apply_patch']) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('switchModel can replace the agent prompt blocks, and the replacement sticks for later switches', () => @@ -147,7 +148,7 @@ it.effect('switchModel can replace the agent prompt blocks, and the replacement expect(systemContents((yield* first.scripted.requests)[0])).toEqual(['GPT base.', 'Original block.']) expect(systemContents((yield* second.scripted.requests)[0])).toEqual(['Claude base.', 'Replacement block.']) expect(systemContents((yield* third.scripted.requests)[0])).toEqual(['GPT base.', 'Replacement block.']) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('switchModel can replace the installed tools; the new tool executes and the change lands durably', () => @@ -184,7 +185,7 @@ it.effect('switchModel can replace the installed tools; the new tool executes an // ...and each epoch's request advertised its own toolset. expect((yield* first.scripted.requests)[0]?.toolNames).toEqual(['echo']) expect((yield* second.scripted.requests)[0]?.toolNames).toEqual(['lookup']) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('switchModel records thinking-change when the reasoning level changes and binds it on the next request', () => @@ -237,7 +238,7 @@ it.effect('switchModel records thinking-change when the reasoning level changes model: 'gpt-scripted-high', reasoning: { effort: 'high' }, }) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('switchModel rejects duplicate tool names in the replacement toolset as a defect', () => @@ -252,7 +253,7 @@ it.effect('switchModel rejects duplicate tool names in the replacement toolset a expect(exit._tag).toBe('Failure') expect(String(exit)).toContain('duplicate tool names: echo') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('switchModel extends the subagent registry at the switch boundary', () => @@ -274,5 +275,5 @@ it.effect('switchModel extends the subagent registry at the switch boundary', () const entries = yield* session.entries expect(entries.find((entry) => entry._tag === 'tools-change')).toMatchObject({ tools: ['subagent'] }) expect(entries.find((entry) => entry._tag === 'model-change')).toMatchObject({ reason: 'new roster' }) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-core/test/Compaction/AutoCompactSession.vi.test.ts b/packages/fold-core/test/Compaction/AutoCompactSession.vi.test.ts index 3458b2c..0a365ce 100644 --- a/packages/fold-core/test/Compaction/AutoCompactSession.vi.test.ts +++ b/packages/fold-core/test/Compaction/AutoCompactSession.vi.test.ts @@ -12,6 +12,7 @@ * failures degrade to a durable error note, and a resumed log projects the compacted history. */ import { expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Context, Effect, Layer } from 'effect' import { @@ -135,7 +136,7 @@ it.effect('compacts mid-run at the threshold and keeps running; config from befo expect(runtime.activeTools).toEqual(['echo']) expect(yield* scripted.remainingTurns).toBe(0) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect( @@ -204,7 +205,7 @@ it.effect( expect(thirdSend).toContain('third topic') expect(yield* scripted.remainingTurns).toBe(0) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('stale pre-compaction usage never re-triggers: no second compaction without a fresh response', () => @@ -241,7 +242,7 @@ it.effect('stale pre-compaction usage never re-triggers: no second compaction wi expect(thirdSend).not.toContain('topic one anchor text') expect(yield* scripted.remainingTurns).toBe(0) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('compaction is off by default and with enabled: false, even under huge reported usage', () => @@ -273,7 +274,7 @@ it.effect('compaction is off by default and with enabled: false, even under huge yield* runWithout(undefined) yield* runWithout({ enabled: false }) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('manual facade compaction delegates through the provisioned root runtime', () => @@ -302,7 +303,7 @@ it.effect('manual facade compaction delegates through the provisioned root runti expect(requests).toHaveLength(2) expect(JSON.stringify(requests[1]?.prompt)).toContain('manual compact anchor') expect(yield* scripted.remainingTurns).toBe(0) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('split-turn compaction separately summarizes a coherent discarded prefix and keeps its suffix', () => @@ -336,7 +337,7 @@ it.effect('split-turn compaction separately summarizes a coherent discarded pref expect(projected.map((message) => message._tag)).toEqual(['compaction-summary', 'assistant-message']) expect(JSON.stringify(projected[1])).toContain('kept suffix') expect(yield* scripted.remainingTurns).toBe(0) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('a configured compactionPrompt replaces the default instruction template', () => @@ -367,7 +368,7 @@ it.effect('a configured compactionPrompt replaces the default instruction templa expect(summarizeRequest).not.toContain('structured context checkpoint summary') // The framing around the instruction is fixed: the transcript still rides in . expect(summarizeRequest).toContain('') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('a summarizer failure degrades to a durable error note; the run proceeds uncompacted', () => @@ -400,7 +401,7 @@ it.effect('a summarizer failure degrades to a durable error note; the run procee // Uncompacted means the full history reached the model. const finalRequest = JSON.stringify((yield* scripted.requests)[2]?.prompt) expect(finalRequest).toContain('anchor question one') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('reactive overflow: compact and retry the turn once, then the run completes cleanly', () => @@ -434,7 +435,7 @@ it.effect('reactive overflow: compact and retry the turn once, then the run comp expect(retriedRequest).toContain('now do the follow-up') expect(yield* scripted.remainingTurns).toBe(0) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('overflow recovery runs once per run: a second overflow becomes the durable error outcome', () => @@ -463,7 +464,7 @@ it.effect('overflow recovery runs once per run: a second overflow becomes the du expect(errors[0]?.message).toContain('context_length_exceeded again') expect(yield* scripted.remainingTurns).toBe(0) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) /** A catalog row for the scripted model with deterministic context and output limits. */ @@ -516,7 +517,7 @@ it.effect('a session-provided catalog supplies the compaction context window (no expect(secondSend).not.toContain('catalog topic one anchor') expect(yield* scripted.remainingTurns).toBe(0) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('an explicit autoCompact.contextWindow beats the catalog entry', () => @@ -544,7 +545,7 @@ it.effect('an explicit autoCompact.contextWindow beats the catalog entry', () => expect(finished.outcome).toBe('completed') expect(compactionEntries(entries)).toHaveLength(1) expect(yield* scripted.remainingTurns).toBe(0) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('a resumed log projects the compacted history: summary plus post-cut messages only', () => @@ -590,5 +591,5 @@ it.effect('a resumed log projects the compacted history: summary plus post-cut m expect(resumedRequest).toContain('answer two') expect(resumedRequest).toContain('continue') expect(resumedRequest).not.toContain('secret phrase is xyzzy') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-core/test/Compaction/SubagentAutoCompact.vi.test.ts b/packages/fold-core/test/Compaction/SubagentAutoCompact.vi.test.ts index 2fb63dd..4368c86 100644 --- a/packages/fold-core/test/Compaction/SubagentAutoCompact.vi.test.ts +++ b/packages/fold-core/test/Compaction/SubagentAutoCompact.vi.test.ts @@ -6,6 +6,7 @@ * the parent's own view (global seq keeps the cut coherent - the worked D21 claim). */ import { expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Effect } from 'effect' import { @@ -111,7 +112,7 @@ it.effect('a dispatched subagent compacts its own context; the parent projection expect(yield* researcherScripted.scripted.remainingTurns).toBe(0) expect(yield* rootScripted.scripted.remainingTurns).toBe(0) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('a fork compacts history including the parent folded range without touching the parent view', () => @@ -184,5 +185,5 @@ it.effect('a fork compacts history including the parent folded range without tou expect(messagesForAgent(entries, session.rootAgentId).some((m) => m._tag === 'compaction-summary')).toBe(false) expect(yield* scripted.remainingTurns).toBe(0) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-core/test/Session/SessionTestHelpers.ts b/packages/fold-core/test/Session/SessionTestHelpers.ts index e9adea9..b54db5c 100644 --- a/packages/fold-core/test/Session/SessionTestHelpers.ts +++ b/packages/fold-core/test/Session/SessionTestHelpers.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Layer } from 'effect' import type { LanguageModel, Tool } from 'effect/unstable/ai' @@ -67,6 +68,7 @@ export const sessionBaseLayer = ( toolEventSinkLayerFromAgentEvents.pipe(Layer.provide(agentEventsLayer)), Layer.succeed(Subagents, noSubagentsStub), Layer.effect(SessionControls, makeSessionControls()), + NodeFileSystem.layer, ) const toolRuntimeLayer = liveToolRuntimeLayer.pipe(Layer.provideMerge(sharedLayer)) diff --git a/packages/fold-core/test/Skills/SkillTool.vi.test.ts b/packages/fold-core/test/Skills/SkillTool.vi.test.ts index 2a3ee95..f0c42f5 100644 --- a/packages/fold-core/test/Skills/SkillTool.vi.test.ts +++ b/packages/fold-core/test/Skills/SkillTool.vi.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Effect, Layer, Ref } from 'effect' import { @@ -32,6 +33,7 @@ const ambientServices = Layer.mergeAll( resume: () => Effect.die(new Error('Subagents not available in this test')), continueSubagent: () => Effect.die(new Error('Subagents not available in this test')), }), + NodeFileSystem.layer, ) const skillContentOf = (result: unknown): string => { @@ -59,7 +61,7 @@ describe('makeSkillTool', () => { expect(tool.name).toBe('skill') expect(realized.tool.description).toContain('Available skills: commit-helper, reviewer.') - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('loads a skill and wraps its content', () => @@ -73,7 +75,7 @@ describe('makeSkillTool', () => { expect(result).toEqual({ content: '\nWrite conventional commits.\n', }) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('returns an instructive failure with the roster for unknown skills', () => @@ -88,7 +90,7 @@ describe('makeSkillTool', () => { message: 'Skill "missing" not found. Available skills: commit-helper, reviewer', availableSkills: ['commit-helper', 'reviewer'], }) - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('refresh reports skills added after the session-start snapshot', () => @@ -118,7 +120,7 @@ describe('makeSkillTool', () => { expect(content).toContain('') expect(content).toContain('') expect(content).toContain('Skills added since session start:\n- late-arrival: Added mid-session') - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('refresh reports an unchanged roster', () => @@ -131,7 +133,7 @@ describe('makeSkillTool', () => { const content = skillContentOf(result) expect(content).toContain('The skill list has not changed since this session started.') - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('an empty snapshot steers the model toward refresh', () => @@ -140,6 +142,6 @@ describe('makeSkillTool', () => { const realized = yield* makeSkillTool({ source, snapshot: [] }).init expect(realized.tool.description).toContain('No skills were available when this session started') - }), + }).pipe(Effect.provide(NodeFileSystem.layer)), ) }) diff --git a/packages/fold-core/test/Subagents/DriveHarness.ts b/packages/fold-core/test/Subagents/DriveHarness.ts index 5887f4f..b0aa7b5 100644 --- a/packages/fold-core/test/Subagents/DriveHarness.ts +++ b/packages/fold-core/test/Subagents/DriveHarness.ts @@ -6,6 +6,7 @@ * hang-once scripted model for interrupt scenarios: its first request signals a Deferred and never * produces output; later requests (the resume) serve scripted turns. */ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Deferred, Effect, Ref, Schema, Stream } from 'effect' import { AiError, LanguageModel } from 'effect/unstable/ai' @@ -121,7 +122,7 @@ export const makeDriveSession = (input: { queue(instruction).pipe(Effect.flatMap(() => session.send('next'))) return { session, drive, queue, rootScripted } - }) + }).pipe(Effect.provide(NodeFileSystem.layer)) /** The agent_started rows of dispatched subagents (parented rows), in log order. */ export const subagentStartedEntries = (entries: ReadonlyArray): ReadonlyArray => diff --git a/packages/fold-core/test/Subagents/SubagentDispatch.vi.test.ts b/packages/fold-core/test/Subagents/SubagentDispatch.vi.test.ts index 6f31fdc..5634953 100644 --- a/packages/fold-core/test/Subagents/SubagentDispatch.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentDispatch.vi.test.ts @@ -5,6 +5,7 @@ * durable tool result renders the agent_id + turns header and the body. */ import { expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Effect } from 'effect' import { @@ -125,5 +126,5 @@ it.effect('dispatches a fresh subagent on the shared log and renders its result' // Both scripts fully consumed: the subagent ran exactly one turn, the root exactly two. expect(yield* researcherScripted.scripted.remainingTurns).toBe(0) expect(yield* rootScripted.scripted.remainingTurns).toBe(0) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-core/test/Subagents/SubagentHooks.vi.test.ts b/packages/fold-core/test/Subagents/SubagentHooks.vi.test.ts index 1203fe5..5744e7a 100644 --- a/packages/fold-core/test/Subagents/SubagentHooks.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentHooks.vi.test.ts @@ -5,6 +5,7 @@ * invisible in another agent's fold of the same namespace. */ import { expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Effect, Ref, Schema } from 'effect' import { @@ -87,5 +88,5 @@ it.effect('root and subagent run their own hook chains, and hook state stays per expect(toolStateForAgent(entries, rootStarted.agentId, 'probe')).toEqual({ marker: 'from-root' }) expect(toolStateForAgent(entries, subagentStarted.agentId, 'probe')).toEqual({ marker: 'from-subagent' }) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-core/test/Subagents/SubagentLogReplay.vi.test.ts b/packages/fold-core/test/Subagents/SubagentLogReplay.vi.test.ts index 47a4c27..85d5501 100644 --- a/packages/fold-core/test/Subagents/SubagentLogReplay.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentLogReplay.vi.test.ts @@ -6,6 +6,7 @@ * process-restart story at the storage seam (fold-agent's JSONL backend persists the same seam to disk). */ import { expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Context, Effect, Layer } from 'effect' import { @@ -139,7 +140,7 @@ it.effect("a new session over the same log resumes a prior session's subagent pu expect(rendered).toContain(`agent_id: ${shortAgentId(dispatched.agentId)}`) expect(rendered).toContain('turns: 1 this run (2 total)') expect(rendered).toContain('resumed findings') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('replay restores a configured fork toolset before resuming the child', () => @@ -202,5 +203,5 @@ it.effect('replay restores a configured fork toolset before resuming the child', expect(started[1]?.fork?.definitionId).toBe('leaf-fork') expect(started[1]?.parentAgentId).toBe(dispatched) expect(started[1]?.tools).not.toContain('subagent') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-core/test/Subagents/SubagentProfiles.vi.test.ts b/packages/fold-core/test/Subagents/SubagentProfiles.vi.test.ts index c0ec3cc..92df66b 100644 --- a/packages/fold-core/test/Subagents/SubagentProfiles.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentProfiles.vi.test.ts @@ -7,6 +7,7 @@ * defects at session start, and concrete bindings keep working with no profiles passed at all. */ import { expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Cause, Effect, Exit } from 'effect' import { defineAgent, defineSubagent, startSession, subagentTool, type ModelChangeLogEntry } from '../../src/index' @@ -36,7 +37,7 @@ it.effect('a role-bound subagent dispatches on the profiles-bound model', () => expect(started?.agentType).toBe('researcher') expect(started?.model.modelId).toBe('fast-bound') expect(yield* fastScripted.scripted.remainingTurns).toBe(0) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('resuming a subagent dispatched before a setProfile swap writes the child model-change', () => @@ -73,7 +74,7 @@ it.effect('resuming a subagent dispatched before a setProfile swap writes the ch const resumedPrompt = JSON.stringify((yield* fastB.scripted.prompts)[0]) expect(resumedPrompt).toContain('first findings') expect(resumedPrompt).toContain('keep going') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('an orchestrator-bound subagent falls back to the smart profile when orchestrator is unbound', () => @@ -93,7 +94,7 @@ it.effect('an orchestrator-bound subagent falls back to the smart profile when o const entries = yield* session.entries expect(subagentStartedEntries(entries)[0]?.model.modelId).toBe('smart-bound') expect(yield* smartScripted.scripted.remainingTurns).toBe(0) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('a role-bound roster with no covering profile binding defects at session start', () => @@ -109,7 +110,7 @@ it.effect('a role-bound roster with no covering profile binding defects at sessi const rendered = String(Cause.squash(exit.cause)) expect(rendered).toContain('subagent type "researcher" binds model role "fast"') expect(rendered).toContain('profiles.fast') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('an orchestrator binding is only covered by orchestrator or smart profiles', () => @@ -124,7 +125,7 @@ it.effect('an orchestrator binding is only covered by orchestrator or smart prof if (!Exit.isFailure(exit)) throw new Error('expected session start to defect') expect(String(Cause.squash(exit.cause))).toContain('profiles.orchestrator (or profiles.smart)') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('concrete model bindings keep working with no profiles passed (regression)', () => @@ -142,5 +143,5 @@ it.effect('concrete model bindings keep working with no profiles passed (regress const entries = yield* session.entries expect(subagentStartedEntries(entries)[0]?.model.modelId).toBe('concrete') expect(yield* concreteScripted.scripted.remainingTurns).toBe(0) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-core/test/Subagents/SubagentResume.vi.test.ts b/packages/fold-core/test/Subagents/SubagentResume.vi.test.ts index 1290047..e0e79fa 100644 --- a/packages/fold-core/test/Subagents/SubagentResume.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentResume.vi.test.ts @@ -7,6 +7,7 @@ * the child loops - is real. */ import { expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Effect, Ref, Schema } from 'effect' import { @@ -163,7 +164,7 @@ it.effect('resumes a completed subagent: no new agent_started, rows under the re expect(rendered).toContain(`agent_id: ${shortAgentId(started.agentId)}`) expect(rendered).toContain('turns: 1 this run (2 total)') expect(rendered).toContain('resumed findings') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('a subagent that errored is a result and remains resumable (model failure path)', () => @@ -212,7 +213,7 @@ it.effect('a subagent that errored is a result and remains resumable (model fail const prompts = yield* flakyScripted.scripted.prompts expect(JSON.stringify(prompts[1])).toContain('try it') expect(JSON.stringify(prompts[1])).toContain('try again') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('a subagent that died from a defect is flattened into an error result and remains resumable', () => @@ -270,5 +271,5 @@ it.effect('a subagent that died from a defect is flattened into an error result const entries = yield* session.entries expect(renderedDriveResult(entries, 1)).toContain('recovered after defect') expect(yield* workerScripted.scripted.remainingTurns).toBe(0) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-core/test/Subagents/SubagentRoster.vi.test.ts b/packages/fold-core/test/Subagents/SubagentRoster.vi.test.ts index 000a173..d88d314 100644 --- a/packages/fold-core/test/Subagents/SubagentRoster.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentRoster.vi.test.ts @@ -6,6 +6,7 @@ * distinct definitions are a session-start defect. */ import { expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Cause, Effect, Exit, Schema } from 'effect' import { @@ -38,7 +39,7 @@ it.effect('each subagentTool value advertises exactly its own roster', () => expect(wide.tool.description).toContain('- beta: second specialist') expect(narrow.tool.description).not.toContain('alpha') expect(narrow.tool.description).toContain('- beta: second specialist') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('nested rosters give depth; out-of-roster dispatch fails instructively; envelopes chain', () => @@ -115,7 +116,7 @@ it.effect('nested rosters give depth; out-of-roster dispatch fails instructively const renderedFailure = JSON.stringify(selfDispatchResult.message.content[0]) expect(renderedFailure).toContain('Agent type \\"general-purpose\\" is not available to you') expect(renderedFailure).toContain('researcher') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('duplicate type names across distinct definitions defect at session start', () => @@ -130,7 +131,7 @@ it.effect('duplicate type names across distinct definitions defect at session st if (!Exit.isFailure(exit)) throw new Error('expected session start to defect') expect(String(Cause.squash(exit.cause))).toContain('duplicate subagent type name') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('the same definition shared by two rosters is one registry entry', () => @@ -159,7 +160,7 @@ it.effect('the same definition shared by two rosters is one registry entry', () const finished = yield* session.send('go') expect(finished.outcome).toBe('completed') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) const hostAgentTool = (forkAgent: ForkAgentDefinition) => @@ -209,7 +210,7 @@ it.effect('host agent tools configure two fork generations structurally', () => expect(started[1]?.fork?.definitionId).toBe('leaf-fork') expect(started[1]?.tools).not.toContain('agent') expect(started[1]?.parentAgentId).toBe(started[0]?.agentId) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('duplicate fork agent definition ids defect at session start', () => @@ -227,5 +228,5 @@ it.effect('duplicate fork agent definition ids defect at session start', () => if (!Exit.isFailure(exit)) throw new Error('expected session start to defect') expect(String(Cause.squash(exit.cause))).toContain('duplicate fork agent definition id') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-core/test/Subagents/SubagentSkills.vi.test.ts b/packages/fold-core/test/Subagents/SubagentSkills.vi.test.ts index 2d0cdad..6dfae2a 100644 --- a/packages/fold-core/test/Subagents/SubagentSkills.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentSkills.vi.test.ts @@ -6,6 +6,7 @@ * a typed failure before any subagent row is written. */ import { expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Effect, Ref } from 'effect' import { @@ -93,7 +94,7 @@ it.effect('a shared skillTool value scans once; the preload rides the dispatcher (entry) => entry._tag === 'system-message' && entry.agentId === started.agentId, ) expect(JSON.stringify(subagentSystem)).toContain('available_skills') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('a dispatcher with no skillTool cannot preload: typed failure before any subagent row', () => @@ -125,5 +126,5 @@ it.effect('a dispatcher with no skillTool cannot preload: typed failure before a const toolResult = entries.find((entry) => entry._tag === 'tool-result') expect(JSON.stringify(toolResult)).toContain('Skill \\"commit-helper\\" not found') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts b/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts index a36f7fc..f75a334 100644 --- a/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts @@ -7,6 +7,7 @@ * unknown agent_id) come back as instructive tool failures the model can correct from. */ import { expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Context, Effect, Layer } from 'effect' import { @@ -107,7 +108,7 @@ it.effect('the model resumes a subagent through the tool wire by its SHORT id: f expect(yield* rootScripted.scripted.remainingTurns).toBe(0) expect(yield* researcherScripted.scripted.remainingTurns).toBe(0) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('malformed wire commands come back as instructive tool failures the model can correct from', () => @@ -164,7 +165,7 @@ it.effect('malformed wire commands come back as instructive tool failures the mo expect(renderedDriveResult(entries, 2)).toContain('No subagent with agent_id') expect(yield* rootScripted.scripted.remainingTurns).toBe(0) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('an ambiguous short agent_id comes back as an instructive failure naming the candidate short ids', () => @@ -241,5 +242,5 @@ it.effect('an ambiguous short agent_id comes back as an instructive failure nami // Nothing resumed: the failure fired before any subagent run. expect(yield* researcherScripted.scripted.remainingTurns).toBe(0) expect(yield* rootScripted.scripted.remainingTurns).toBe(0) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-core/test/ToolRuntime/ToolRuntimeInterrupt.vi.test.ts b/packages/fold-core/test/ToolRuntime/ToolRuntimeInterrupt.vi.test.ts index 594b977..614ad33 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeInterrupt.vi.test.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeInterrupt.vi.test.ts @@ -1,4 +1,5 @@ import { expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Deferred, Effect, Fiber, Layer, Schema } from 'effect' import { Prompt, Tool, Toolkit } from 'effect/unstable/ai' @@ -61,6 +62,7 @@ it.effect('writes a synthetic interrupted tool-result when a running tool fiber hookRunnerNoop, layerNoopToolEvents, Layer.succeed(Subagents, noSubagentsStub), + NodeFileSystem.layer, ), ), ) diff --git a/packages/fold-core/test/ToolRuntime/ToolRuntimeStateSnapshot.vi.test.ts b/packages/fold-core/test/ToolRuntime/ToolRuntimeStateSnapshot.vi.test.ts index 1297c53..860db9d 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeStateSnapshot.vi.test.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeStateSnapshot.vi.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Deferred, Effect, Layer, Ref, Schema } from 'effect' import { Prompt, Tool, Toolkit } from 'effect/unstable/ai' @@ -62,6 +63,7 @@ const probeRuntimeLayer = ( hookLayer.pipe(Layer.provide(hookDeps)), layerNoopToolEvents, Layer.succeed(Subagents, noSubagentsStub), + NodeFileSystem.layer, ), ), ) diff --git a/packages/fold-core/test/ToolRuntime/ToolRuntimeTestHelpers.ts b/packages/fold-core/test/ToolRuntime/ToolRuntimeTestHelpers.ts index 681fcb7..995a58f 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeTestHelpers.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeTestHelpers.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Effect, Layer, Ref, Stream } from 'effect' import { Prompt } from 'effect/unstable/ai' import type { Tool } from 'effect/unstable/ai' @@ -74,6 +75,7 @@ export const toolRuntimeBaseLayer = ( hookLayer.pipe(Layer.provide(hookDeps)), eventLayer, Layer.succeed(Subagents, noSubagentsStub), + NodeFileSystem.layer, ), ), ) diff --git a/packages/fold-xai/src/AuthStore.ts b/packages/fold-xai/src/AuthStore.ts index 48108c6..a00fd09 100644 --- a/packages/fold-xai/src/AuthStore.ts +++ b/packages/fold-xai/src/AuthStore.ts @@ -10,8 +10,7 @@ import { homedir } from 'node:os' import { dirname, join } from 'node:path' -import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' -import { Context, Effect, FileSystem, Layer, Option, Schema } from 'effect' +import { Effect, FileSystem, Option, Schema } from 'effect' /** Milliseconds before nominal expiry a token is already treated as expired (clanka parity). */ export const TOKEN_EXPIRY_BUFFER_MS = 30_000 @@ -55,25 +54,6 @@ export type MakeXaiAuthStoreOptions = { readonly path?: string /** Key of this provider's entry in the document. Defaults to `xai`. */ readonly providerId?: string - /** FileSystem implementation override. Defaults to the Node platform filesystem. */ - readonly fileSystem?: FileSystem.FileSystem -} - -let nodeFileSystem: FileSystem.FileSystem | null = null - -/** The process-wide Node FileSystem service, built lazily once (layer construction is synchronous). */ -export const defaultNodeFileSystem = (): FileSystem.FileSystem => { - if (nodeFileSystem === null) { - nodeFileSystem = Effect.runSync( - Effect.scoped( - Layer.build(NodeFileSystem.layer).pipe( - Effect.map((context) => Context.get(context, FileSystem.FileSystem)), - ), - ), - ) - } - - return nodeFileSystem } /** The auth document is provider-keyed; entries other than ours are opaque and preserved verbatim. */ @@ -92,8 +72,8 @@ const encodeToken = (token: XaiTokenData): Record => ({ }) /** Build a file-backed Xai credential store. */ -export const makeXaiAuthStore = (options?: MakeXaiAuthStoreOptions): XaiAuthStore => { - const fs = options?.fileSystem ?? defaultNodeFileSystem() +export const makeXaiAuthStore = (options?: MakeXaiAuthStoreOptions): Effect.Effect => + Effect.map(FileSystem.FileSystem, (fs) => { const path = options?.path ?? defaultAuthStorePath() const providerId = options?.providerId ?? 'xai' @@ -155,4 +135,4 @@ export const makeXaiAuthStore = (options?: MakeXaiAuthStoreOptions): XaiAuthStor }).pipe(Effect.withSpan('fold.xaiAuthStore.clear')) return { path, load, save, clear } -} + }) diff --git a/packages/fold-xai/src/XaiAuth.ts b/packages/fold-xai/src/XaiAuth.ts index 45dda71..7a83e19 100644 --- a/packages/fold-xai/src/XaiAuth.ts +++ b/packages/fold-xai/src/XaiAuth.ts @@ -35,7 +35,7 @@ const browserPrompt = (url: string) => Effect.log(`Open this URL to authenticate /** Construct xAI auth over the ambient HttpClient. Interactive flows are explicit methods. */ export const makeXaiAuth = Effect.fnUntraced(function* (options?: MakeXaiAuthOptions) { - const store = options?.store ?? makeXaiAuthStore() + const store = options?.store ?? (yield* makeXaiAuthStore()) const client = makeXaiIssuerClient(yield* HttpClient.HttpClient) const semaphore = Semaphore.makeUnsafe(1) let current = yield* store.load diff --git a/packages/fold-xai/src/XaiModel.ts b/packages/fold-xai/src/XaiModel.ts index 3c80c65..66518e5 100644 --- a/packages/fold-xai/src/XaiModel.ts +++ b/packages/fold-xai/src/XaiModel.ts @@ -2,6 +2,7 @@ import { OpenAiClient, OpenAiLanguageModel } from '@effect/ai-openai-compat' import { customModel, resolveOpenAiReasoning } from '@humanlayer/fold-core' import type { FoldModel, ReasoningLevel } from '@humanlayer/fold-core' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Context, Effect, Layer } from 'effect' import type { Scope } from 'effect' import type { LanguageModel } from 'effect/unstable/ai' @@ -37,7 +38,7 @@ export const makeXaiLanguageModel = ( return yield* OpenAiLanguageModel.make({ model: options.model ?? DEFAULT_XAI_MODEL_ID }).pipe( Effect.provideService(OpenAiClient.OpenAiClient, Context.get(clientContext, OpenAiClient.OpenAiClient)), ) - }) + }).pipe(Effect.provide(NodeFileSystem.layer)) /** Describe an xAI OAuth-backed model compatible with Fold sessions and switching. */ export const xaiModel = (options: XaiModelOptions = {}): FoldModel => { diff --git a/packages/fold-xai/test/Xai.vi.test.ts b/packages/fold-xai/test/Xai.vi.test.ts index be97f6f..0146470 100644 --- a/packages/fold-xai/test/Xai.vi.test.ts +++ b/packages/fold-xai/test/Xai.vi.test.ts @@ -1,6 +1,7 @@ import { readdir, rm } from 'node:fs/promises' import { describe, expect, it } from '@effect/vitest' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Effect, Option } from 'effect' import { @@ -27,7 +28,7 @@ describe('xAI OAuth', () => { it.effect('persists xAI tokens under its provider key and clears without losing peers', () => Effect.gen(function* () { const path = `${process.cwd()}/.tmp-xai-auth-${crypto.randomUUID()}.json` - const store = makeXaiAuthStore({ path }) + const store = yield* makeXaiAuthStore({ path }) const token = new XaiTokenData({ type: 'oauth', access: 'access', refresh: 'refresh', expires: 42 }) yield* store.save(token) const loaded = yield* store.load @@ -41,6 +42,7 @@ describe('xAI OAuth', () => { await Promise.all(files.map((file) => rm(file, { force: true }))) }), ), + Effect.provide(NodeFileSystem.layer), ), ) })