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 b3b4d02..9d19c32 100644 --- a/packages/fold-agent/examples/ApplyPatchAgent.ts +++ b/packages/fold-agent/examples/ApplyPatchAgent.ts @@ -10,6 +10,7 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { defineAgent, openaiModel, startSession } from '@humanlayer/fold-core' import { Predicate, Console, Effect } from 'effect' @@ -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 040e70e..c334a1f 100644 --- a/packages/fold-agent/examples/CodingAgent.ts +++ b/packages/fold-agent/examples/CodingAgent.ts @@ -10,6 +10,7 @@ import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { anthropicModel, defineAgent, startSession } from '@humanlayer/fold-core' import { Predicate, Console, Effect } from 'effect' @@ -49,7 +50,7 @@ const makeProgram = (apiKey: string) => yield* Console.log( `tools used: ${entries.filter((entry) => Predicate.isTagged(entry, '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..b0f0583 100644 --- a/packages/fold-agent/examples/ConfigAgent.ts +++ b/packages/fold-agent/examples/ConfigAgent.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** * Config-driven coding agent (D25/D27): the batteries-included launch path the CLI/OpenTUI will use. * Loads `~/.fold/config.jsonc` (writing a commented starter on first run), resolves the mode's `smart` @@ -9,7 +10,7 @@ * Then: bun packages/fold-agent/examples/ConfigAgent.ts "your prompt" */ import { layerLiveIdFactory } from '@humanlayer/fold-core' -import { Console, Effect } from 'effect' +import { Console, Effect, Layer } 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(Layer.mergeAll(layerLiveIdFactory, 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..927b510 100644 --- a/packages/fold-agent/examples/SkillsFromDisk.ts +++ b/packages/fold-agent/examples/SkillsFromDisk.ts @@ -11,6 +11,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { anthropicModel, defineAgent, skillTool, startSession } from '@humanlayer/fold-core' import { Console, Effect } from 'effect' @@ -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 2c06cf2..437eabf 100644 --- a/packages/fold-agent/examples/SubagentsAgent.ts +++ b/packages/fold-agent/examples/SubagentsAgent.ts @@ -11,6 +11,7 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { anthropicModel, defineAgent, defineSubagent, startSession, subagentTool } from '@humanlayer/fold-core' import { Predicate, Console, Effect } from 'effect' @@ -91,7 +92,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..c842e54 100644 --- a/packages/fold-agent/src/Bin/ManagedBinaries.ts +++ b/packages/fold-agent/src/Bin/ManagedBinaries.ts @@ -24,9 +24,8 @@ 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 { Cause, Effect, FileSystem, Schema } from 'effect' -import { fileSystemFor, type FsToolOptions } from '../Fs/DefaultFileSystem' import { managedBinaryRegistry, type ManagedBinaryAsset, type ManagedBinaryDefinition } from './Registry' /** Environment variable that, when set (non-empty), disables managed-binary downloads entirely. */ @@ -88,8 +87,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 +437,17 @@ 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 +465,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 +474,22 @@ 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..5f0ed9c 100644 --- a/packages/fold-agent/src/Catalog/LoadCatalog.ts +++ b/packages/fold-agent/src/Catalog/LoadCatalog.ts @@ -14,9 +14,8 @@ import { dirname, join } from 'node:path' import { ModelCatalogEntry } from '@humanlayer/fold-core' -import { Clock, Effect, Predicate, Schema, type FileSystem } from 'effect' +import { Clock, Effect, FileSystem, Predicate, Schema } from 'effect' -import { fileSystemFor, type FsToolOptions } from '../Fs/DefaultFileSystem' import { bakedModelCatalog } from './BakedCatalog' import { decodeModelsDevModels, ModelsDevDecodeError } from './ModelsDevSchema' import { modelCatalogEntriesFromModelsDev } from './Normalize' @@ -58,8 +57,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 +151,11 @@ 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..672d1a8 100644 --- a/packages/fold-agent/src/Config/ConfigSchemaJson.ts +++ b/packages/fold-agent/src/Config/ConfigSchemaJson.ts @@ -10,9 +10,8 @@ */ import { join } from 'node:path' -import { JsonSchema, Effect, Schema } from 'effect' +import { JsonSchema, Effect, FileSystem, Schema } from 'effect' -import { fileSystemFor, type FsToolOptions } from '../Fs/DefaultFileSystem' import { FoldConfig } from './ConfigSchema' import { writeFoldInfo } from './FoldInfo' import { defaultConfigPath, defaultFoldHome } from './Load' @@ -120,14 +119,14 @@ 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 +158,11 @@ 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 +190,6 @@ 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..36356b3 100644 --- a/packages/fold-agent/src/Config/FoldInfo.ts +++ b/packages/fold-agent/src/Config/FoldInfo.ts @@ -9,9 +9,8 @@ */ import { join } from 'node:path' -import { Effect } from 'effect' +import { Effect, FileSystem } from 'effect' -import { fileSystemFor } from '../Fs/DefaultFileSystem' import type { ConfigInitOptions } from './ConfigSchemaJson' import { defaultFoldHome } from './Load' @@ -215,9 +214,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..7ac3361 100644 --- a/packages/fold-agent/src/Config/Load.ts +++ b/packages/fold-agent/src/Config/Load.ts @@ -12,9 +12,8 @@ import { homedir } from 'node:os' import { join } from 'node:path' -import { Effect, Predicate, Schema } from 'effect' +import { Effect, FileSystem, Predicate, Schema } from 'effect' -import { fileSystemFor, type FsToolOptions } from '../Fs/DefaultFileSystem' import { FoldConfig } from './ConfigSchema' /** The config file could not be found at the resolved path. */ @@ -40,8 +39,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 +146,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 +164,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..e4beed9 100644 --- a/packages/fold-agent/src/Config/ProviderConfig.ts +++ b/packages/fold-agent/src/Config/ProviderConfig.ts @@ -8,9 +8,8 @@ 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 { Clock, Effect, FileSystem, Match, Random, Schema } from 'effect' -import { fileSystemFor } from '../Fs/DefaultFileSystem' import type { FoldConfig, ProviderKind } from './ConfigSchema' import { configPathFor, @@ -88,9 +87,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 +121,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..20879ed 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,11 @@ 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 +134,7 @@ 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 fc4584c..19cc971 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 { Predicate, Effect, Match, Schema, Semaphore, type Scope } from 'effect' +import { Predicate, 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,55 +415,65 @@ const withGeneratedTitles = ( session: FoldSession, model: FoldModel, options: { readonly cwd: string; readonly foldHome?: string }, -): Effect.Effect => - Semaphore.make(1).pipe( - Effect.map((titleLock) => ({ - ...session, - send: (text, target) => - session.send(text, target).pipe( - Effect.tap(() => { - if (target?.agentId !== undefined && target.agentId !== session.rootAgentId) return Effect.void - return titleLock.withPermit( - Effect.exit( - session.entries.pipe( - Effect.flatMap((entries) => { - const rootUsers = entries.filter( - (entry) => - Predicate.isTagged(entry, 'user-message') && - entry.agentId === session.rootAgentId, - ) - const lastTitle = entries.findLast((entry) => - Predicate.isTagged(entry, 'session_title'), - ) - const generatedTurns = lastTitle?.rootUserTurns ?? 0 - if (rootUsers.length <= generatedTurns) return Effect.void - return generateSessionTitle(entries, session.rootAgentId, model).pipe( - Effect.flatMap((title) => { - const generatedThroughSeq = entries.at(-1)?.seq - return session - .setTitle(title, { - ...(generatedThroughSeq === undefined - ? {} - : { generatedThroughSeq }), - rootUserTurns: rootUsers.length, - }) - .pipe( - Effect.andThen( - refreshSessionSummaryIndex(session.sessionId, options), - ), - ) - }), - ) - }), - ), - ).pipe(Effect.asVoid), - ) - }), - ), - })), - ) +): 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: string, target?: Parameters[1]) => + session.send(text, target).pipe( + Effect.tap(() => { + if (target?.agentId !== undefined && target.agentId !== session.rootAgentId) + return Effect.void + return titleLock.withPermit( + Effect.exit( + session.entries.pipe( + Effect.flatMap((entries) => { + const rootUsers = entries.filter( + (entry) => + Predicate.isTagged(entry, 'user-message') && + entry.agentId === session.rootAgentId, + ) + const lastTitle = entries.findLast((entry) => + Predicate.isTagged(entry, 'session_title'), + ) + const generatedTurns = lastTitle?.rootUserTurns ?? 0 + if (rootUsers.length <= generatedTurns) return Effect.void + return generateSessionTitle(entries, session.rootAgentId, model).pipe( + Effect.flatMap((title) => { + const generatedThroughSeq = entries.at(-1)?.seq + return session + .setTitle(title, { + ...(generatedThroughSeq === undefined + ? {} + : { generatedThroughSeq }), + rootUserTurns: rootUsers.length, + }) + .pipe( + Effect.andThen( + refreshSessionSummaryIndex( + session.sessionId, + options, + ).pipe(Effect.provide(fsLayer)), + ), + ) + }), + ) + }), + ), + ).pipe(Effect.asVoid), + ) + }), + ), + })), + ) + }) -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) @@ -470,7 +481,9 @@ const runtimeConfigFor = (options: LaunchSessionOptions): Effect.Effect> => +const catalogFor = ( + options: LaunchSessionOptions, +): Effect.Effect, never, FileSystem.FileSystem> => options.catalog !== undefined ? Effect.succeed(options.catalog) : loadModelCatalog({ @@ -484,7 +497,7 @@ const catalogFor = (options: LaunchSessionOptions): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const { options: opts, profileMode } = yield* resolveProfileSelection(options ?? {}) const mode = modeFor(opts, profileMode) @@ -498,7 +511,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 }), }) @@ -531,13 +544,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 }), }) @@ -565,7 +578,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) @@ -588,7 +601,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..949d698 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,87 +109,90 @@ 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 }) - const foldHome = options.foldHome ?? defaultFoldHome() - const sessionId = options.sessionId - const directory = toolOutputSessionDirFor({ sessionId, foldHome }) - const retentionMs = options.retentionMs ?? 7 * dayMs - - const refFor = (toolCallId: ToolCallId): OutputStoreRef => - new OutputStoreRef({ - sessionId, - toolCallId, - path: toolOutputPathFor({ sessionId, toolCallId, foldHome }), - }) - - const prepare = (toolCallId: ToolCallId): Effect.Effect => { - const ref = refFor(toolCallId) - return fs.makeDirectory(directory, { recursive: true }).pipe( - Effect.andThen(fs.writeFileString(ref.path, '', { flag: 'a' })), - Effect.as(ref), - Effect.mapError((cause) => fileOperationError({ operation: 'prepare', path: ref.path, cause })), - Effect.tapError(logStoreError), - Effect.withSpan('output_store.prepare', { - attributes: { sessionId, toolCallId, path: ref.path }, - }), - ) - } - - const append = (toolCallId: ToolCallId, chunk: string): Effect.Effect => { - const ref = refFor(toolCallId) - return fs.makeDirectory(directory, { recursive: true }).pipe( - Effect.andThen(fs.writeFileString(ref.path, chunk, { flag: 'a' })), - Effect.as(ref), - Effect.mapError((cause) => fileOperationError({ operation: 'append', path: ref.path, cause })), - Effect.tapError(logStoreError), - Effect.withSpan('output_store.append', { - attributes: { sessionId, toolCallId, path: ref.path, bytes: chunk.length }, - }), - ) - } - - const read = (ref: OutputStoreRef, options?: OutputStoreReadOptions): Effect.Effect => - fs.readFileString(ref.path).pipe( - Effect.map((content) => lineSlice(content, options)), - Effect.mapError((cause) => fileOperationError({ operation: 'read', path: ref.path, cause })), - Effect.tapError(logStoreError), - Effect.withSpan('output_store.read', { - attributes: { sessionId: ref.sessionId, toolCallId: ref.toolCallId, path: ref.path }, - }), - ) +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 }) + const retentionMs = options.retentionMs ?? 7 * dayMs + + const refFor = (toolCallId: ToolCallId): OutputStoreRef => + new OutputStoreRef({ + sessionId, + toolCallId, + path: toolOutputPathFor({ sessionId, toolCallId, foldHome }), + }) + + const prepare = (toolCallId: ToolCallId): Effect.Effect => { + const ref = refFor(toolCallId) + return fs.makeDirectory(directory, { recursive: true }).pipe( + Effect.andThen(fs.writeFileString(ref.path, '', { flag: 'a' })), + Effect.as(ref), + Effect.mapError((cause) => fileOperationError({ operation: 'prepare', path: ref.path, cause })), + Effect.tapError(logStoreError), + Effect.withSpan('output_store.prepare', { + attributes: { sessionId, toolCallId, path: ref.path }, + }), + ) + } - const sweep = Effect.gen(function* () { - const root = toolOutputRootFor({ foldHome }) - const now = yield* Clock.currentTimeMillis - const sessions = yield* fs - .readDirectory(root) - .pipe(Effect.catch(() => Effect.succeed>([]))) - - for (const sessionName of sessions) { - const sessionDir = join(root, sessionName) - const files = yield* fs - .readDirectory(sessionDir) - .pipe(Effect.catch(() => Effect.succeed>([]))) + const append = (toolCallId: ToolCallId, chunk: string): Effect.Effect => { + const ref = refFor(toolCallId) + return fs.makeDirectory(directory, { recursive: true }).pipe( + Effect.andThen(fs.writeFileString(ref.path, chunk, { flag: 'a' })), + Effect.as(ref), + Effect.mapError((cause) => fileOperationError({ operation: 'append', path: ref.path, cause })), + Effect.tapError(logStoreError), + Effect.withSpan('output_store.append', { + attributes: { sessionId, toolCallId, path: ref.path, bytes: chunk.length }, + }), + ) + } - for (const file of files) { - if (!file.endsWith('.txt')) continue - const path = join(sessionDir, file) - const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.succeed(null))) - if (info === null || info.type !== 'File') continue + const read = (ref: OutputStoreRef, options?: OutputStoreReadOptions): Effect.Effect => + fs.readFileString(ref.path).pipe( + Effect.map((content) => lineSlice(content, options)), + Effect.mapError((cause) => fileOperationError({ operation: 'read', path: ref.path, cause })), + Effect.tapError(logStoreError), + Effect.withSpan('output_store.read', { + attributes: { sessionId: ref.sessionId, toolCallId: ref.toolCallId, path: ref.path }, + }), + ) + + const sweep = Effect.gen(function* () { + const root = toolOutputRootFor({ foldHome }) + const now = yield* Clock.currentTimeMillis + const sessions = yield* fs + .readDirectory(root) + .pipe(Effect.catch(() => Effect.succeed>([]))) - const mtime = Option.match(info.mtime, { onNone: () => 0, onSome: (date) => date.getTime() }) - if (now - mtime > retentionMs) yield* fs.remove(path).pipe(Effect.ignore) + for (const sessionName of sessions) { + const sessionDir = join(root, sessionName) + const files = yield* fs + .readDirectory(sessionDir) + .pipe(Effect.catch(() => Effect.succeed>([]))) + + for (const file of files) { + if (!file.endsWith('.txt')) continue + const path = join(sessionDir, file) + const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.succeed(null))) + if (info === null || info.type !== 'File') continue + + const mtime = Option.match(info.mtime, { onNone: () => 0, onSome: (date) => date.getTime() }) + if (now - mtime > retentionMs) yield* fs.remove(path).pipe(Effect.ignore) + } } - } - }).pipe( - Effect.catchCause((cause) => Effect.logWarning(`OutputStore sweep failed: ${Cause.pretty(cause)}`)), - Effect.withSpan('output_store.sweep', { attributes: { sessionId, directory } }), - ) + }).pipe( + Effect.catchCause((cause) => Effect.logWarning(`OutputStore sweep failed: ${Cause.pretty(cause)}`)), + Effect.withSpan('output_store.sweep', { attributes: { sessionId, directory } }), + ) - return { sessionId, directory, refFor, prepare, append, read, sweep } -} + 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 a961fbc..f3d576d 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 { Predicate, Clock, Effect, Exit, Match, Option, Schema, Stream } from 'effect' +import { Predicate, 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,24 @@ 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 +129,28 @@ 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 +159,13 @@ 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 +175,11 @@ 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 @@ -286,7 +296,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( @@ -312,13 +322,15 @@ 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. @@ -369,9 +381,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 } @@ -389,16 +401,18 @@ 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))) @@ -413,7 +427,10 @@ 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..12273eb 100644 --- a/packages/fold-agent/src/Session/ViewedChanges.ts +++ b/packages/fold-agent/src/Session/ViewedChanges.ts @@ -1,9 +1,8 @@ import { join } from 'node:path' import { SessionId } from '@humanlayer/fold-core' -import { Clock, Effect, Option, Schema } from 'effect' +import { Clock, Effect, FileSystem, Option, Schema } from 'effect' -import { fileSystemFor } from '../Fs/DefaultFileSystem' import { sessionsDirFor, type SessionLayoutOptions } from './SessionLayout' const ViewedChangeRecord = Schema.Struct({ @@ -23,37 +22,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 +63,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..744986b 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,10 @@ 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 17d7b58..500a67a 100644 --- a/packages/fold-agent/src/Tools/ApplyPatchTool.ts +++ b/packages/fold-agent/src/Tools/ApplyPatchTool.ts @@ -16,7 +16,6 @@ import { } from '@humanlayer/fold-core' import { Match, Predicate, Effect, FileSystem, Path } from 'effect' -import type { FsToolOptions } from '../Fs/DefaultFileSystem' import { withFileMutationLocks } from '../Fs/MutationQueue' import { resolveToCwd } from '../Fs/PathResolve' import { platformErrorMessage } from './ReadTool' @@ -29,14 +28,14 @@ const verificationFailed = (detail: string): { message: string } => ({ const opPaths = (op: PatchOp): ReadonlyArray => Predicate.isTagged(op, '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, dependencies: platformToolDependencies, handler: (params) => Effect.gen(function* () { - const fs = options?.fileSystem ?? (yield* FileSystem.FileSystem) + const fs = yield* FileSystem.FileSystem const pathService = yield* Path.Path const cwd = yield* resolveToCwd(options?.cwd ?? process.cwd(), process.cwd()) const ops = yield* parsePatch(params.patch_text).pipe( diff --git a/packages/fold-agent/src/Tools/BashTool.ts b/packages/fold-agent/src/Tools/BashTool.ts index bc6be24..5e9ed26 100644 --- a/packages/fold-agent/src/Tools/BashTool.ts +++ b/packages/fold-agent/src/Tools/BashTool.ts @@ -28,7 +28,6 @@ import { import { Duration, Effect, Fiber, FileSystem, Option, Path, Random, Ref, Schema, Semaphore, Stream } from 'effect' import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process' -import type { FsToolOptions } from '../Fs/DefaultFileSystem' import { resolveToCwd } from '../Fs/PathResolve' import type { OutputStoreService } from '../OutputStore/OutputStore' import { platformErrorMessage } from './ReadTool' @@ -80,7 +79,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. */ @@ -244,7 +245,7 @@ export const bashTool = (options?: BashToolOptions): FoldTool => dependencies: platformToolDependencies, handler: (params) => Effect.gen(function* () { - const fs = options?.fileSystem ?? (yield* FileSystem.FileSystem) + const fs = yield* FileSystem.FileSystem const pathService = yield* Path.Path const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const configuredCwd = yield* resolveToCwd(options?.cwd ?? process.cwd(), process.cwd()) diff --git a/packages/fold-agent/src/Tools/CodingTools.ts b/packages/fold-agent/src/Tools/CodingTools.ts index 8edbba1..20d295c 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,9 +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 process configuration. */ -export type CodingToolsOptions = FsToolOptions & - Pick & +/** Options for {@link codingTools}: the shared cwd plus bash output-spill configuration. */ +export type CodingToolsOptions = Pick & WebToolsOptions /** diff --git a/packages/fold-agent/src/Tools/EditTool.ts b/packages/fold-agent/src/Tools/EditTool.ts index 2485a4b..8c99b3c 100644 --- a/packages/fold-agent/src/Tools/EditTool.ts +++ b/packages/fold-agent/src/Tools/EditTool.ts @@ -14,19 +14,18 @@ import { } from '@humanlayer/fold-core' import { Effect, FileSystem } from 'effect' -import type { FsToolOptions } 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, dependencies: platformToolDependencies, handler: (params) => Effect.gen(function* () { - const fs = options?.fileSystem ?? (yield* FileSystem.FileSystem) + const fs = yield* FileSystem.FileSystem const cwd = yield* resolveToCwd(options?.cwd ?? process.cwd(), process.cwd()) const absolutePath = yield* resolveToCwd(params.path, cwd) const edits = yield* normalizeEditInput(params).pipe( diff --git a/packages/fold-agent/src/Tools/ReadTool.ts b/packages/fold-agent/src/Tools/ReadTool.ts index 7b1a438..5bbd1ab 100644 --- a/packages/fold-agent/src/Tools/ReadTool.ts +++ b/packages/fold-agent/src/Tools/ReadTool.ts @@ -17,7 +17,6 @@ import { } from '@humanlayer/fold-core' import { Effect, FileSystem, Match, type PlatformError } from 'effect' -import type { FsToolOptions } from '../Fs/DefaultFileSystem' import { resolveReadPath, resolveToCwd } from '../Fs/PathResolve' import { detectSupportedImageMimeType, imageSniffBytes } from './Image/Mime' import { processImage } from './Image/Process' @@ -47,14 +46,14 @@ 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, dependencies: platformToolDependencies, handler: (params) => Effect.gen(function* () { - const fs = options?.fileSystem ?? (yield* FileSystem.FileSystem) + const fs = yield* FileSystem.FileSystem const cwd = yield* resolveToCwd(options?.cwd ?? process.cwd(), process.cwd()) 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 61c88e9..4a54dd3 100644 --- a/packages/fold-agent/src/Tools/WriteTool.ts +++ b/packages/fold-agent/src/Tools/WriteTool.ts @@ -12,19 +12,18 @@ import { } from '@humanlayer/fold-core' import { Effect, FileSystem, Path } from 'effect' -import type { FsToolOptions } 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, dependencies: platformToolDependencies, handler: (params) => Effect.gen(function* () { - const fs = options?.fileSystem ?? (yield* FileSystem.FileSystem) + const fs = yield* FileSystem.FileSystem const pathService = yield* Path.Path const cwd = yield* resolveToCwd(options?.cwd ?? process.cwd(), process.cwd()) const absolutePath = yield* resolveToCwd(params.path, cwd) diff --git a/packages/fold-agent/test/Bin/ManagedBinaries.vi.test.ts b/packages/fold-agent/test/Bin/ManagedBinaries.vi.test.ts index 6ed61dd..8a59457 100644 --- a/packages/fold-agent/test/Bin/ManagedBinaries.vi.test.ts +++ b/packages/fold-agent/test/Bin/ManagedBinaries.vi.test.ts @@ -8,6 +8,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { expect, it } from '@effect/vitest' import { Effect } from 'effect' @@ -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..2295ce2 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,30 @@ 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 +104,74 @@ 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 +188,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 34768e8..e463af5 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 { Predicate, Effect } from 'effect' +import { Effect, FileSystem, Layer, Predicate } 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..545d37f 100644 --- a/packages/fold-agent/test/Config/ProviderConfig.vi.test.ts +++ b/packages/fold-agent/test/Config/ProviderConfig.vi.test.ts @@ -2,8 +2,9 @@ import { statSync } from 'node:fs' import { readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { expect, it } from '@effect/vitest' -import { Effect } from 'effect' +import { Effect, FileSystem, Layer } from 'effect' 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 1a77df7..8ae2b05 100644 --- a/packages/fold-agent/test/Mode/Launch.vi.test.ts +++ b/packages/fold-agent/test/Mode/Launch.vi.test.ts @@ -7,9 +7,10 @@ import { mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { expect, it } from '@effect/vitest' import { customModel, layerLiveIdFactory, type ActiveModel, type FoldModel } from '@humanlayer/fold-core' -import { Predicate, Effect, Stream } from 'effect' +import { Effect, Predicate, Stream } from 'effect' 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 (!Predicate.isTagged(error, '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..2e4ee6a 100644 --- a/packages/fold-agent/test/OutputStore/OutputStore.vi.test.ts +++ b/packages/fold-agent/test/OutputStore/OutputStore.vi.test.ts @@ -1,5 +1,6 @@ import { existsSync, utimesSync } from 'node:fs' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { expect, it } from '@effect/vitest' import { SessionId, ToolCallId } from '@humanlayer/fold-core' import { Effect } from 'effect' @@ -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 c3efc42..7db076f 100644 --- a/packages/fold-agent/test/Session/SessionLayout.vi.test.ts +++ b/packages/fold-agent/test/Session/SessionLayout.vi.test.ts @@ -8,9 +8,10 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, appendFileSync, utimesSync } from 'node:fs' import { join } from 'node:path' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { expect, it } from '@effect/vitest' import { customModel, defineAgent, layerLiveIdFactory, SessionId, startSession } from '@humanlayer/fold-core' -import { Predicate, Effect, Stream } from 'effect' +import { Effect, Predicate, Stream } from 'effect' 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 00b82cd..0b1913b 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 { Predicate, Effect } from 'effect' +import { Effect, FileSystem, Layer, Predicate } 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 (!Predicate.isTagged(failure, '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 ba9c8eb..9b6a8a4 100644 --- a/packages/fold-agent/test/TestHelpers.ts +++ b/packages/fold-agent/test/TestHelpers.ts @@ -8,6 +8,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, normalize } from 'node:path' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import * as NodeServices from '@effect/platform-node/NodeServices' import { AgentId, @@ -60,6 +61,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 b15ad2d..fbc0f60 100644 --- a/packages/fold-agent/test/Tools/BashTool.vi.test.ts +++ b/packages/fold-agent/test/Tools/BashTool.vi.test.ts @@ -7,6 +7,7 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { expect, it } from '@effect/vitest' import { SessionId, ToolCallId } from '@humanlayer/fold-core' import { Duration, Effect, Fiber } from 'effect' @@ -205,7 +206,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( @@ -216,7 +217,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 23f393b..4d1e62e 100644 --- a/packages/fold-cli/src/Commands.ts +++ b/packages/fold-cli/src/Commands.ts @@ -655,7 +655,9 @@ 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 +724,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 +740,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 +793,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 +842,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 +878,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 715dcee..be26771 100644 --- a/packages/fold-cli/src/Run.ts +++ b/packages/fold-cli/src/Run.ts @@ -1,5 +1,6 @@ import { join } from 'node:path' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { bootstrapFoldHome, defaultFoldHome, @@ -26,7 +27,20 @@ import type { SessionId, FoldSession, } from '@humanlayer/fold-core' -import { Data, Match, Predicate, Cause, Clock, Effect, Exit, Fiber, Option, Stream, type Scope } from 'effect' +import { + Data, + Match, + Predicate, + Cause, + Clock, + Effect, + Exit, + Fiber, + type FileSystem, + Option, + Stream, + type Scope, +} from 'effect' import { CredentialSummary, type OutputRenderer, type ResumeCommandFlag, type SessionHeader } from './Renderer' @@ -84,7 +98,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 Match.valueTags(options.resume, { @@ -93,7 +107,9 @@ const openSessionFor = ( }) } -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, { @@ -123,10 +139,10 @@ const credentialSummary = (model: ActiveModel | null, options: CliSessionOptions if (model === null) return CredentialSummary.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 CredentialSummary.missing({ detail: `entry "${model.providerId}" in ${store.path}` }) @@ -261,13 +277,16 @@ 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({ @@ -286,7 +305,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..5fafd39 100644 --- a/packages/fold-cli/src/tui/HostedTuiSession.ts +++ b/packages/fold-cli/src/tui/HostedTuiSession.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { makeDiskSkillSource, modeForName, @@ -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..79ce8dc 100644 --- a/packages/fold-cli/src/tui/Shell.tsx +++ b/packages/fold-cli/src/tui/Shell.tsx @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** @jsxImportSource @opentui/solid */ import { configureProvider, @@ -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,11 @@ export type { TuiOptions } from './TuiSessionOptions' export const runTui = ( options: TuiOptions, -): Effect.Effect => +): Effect.Effect< + void, + TuiRequiresTtyError | TuiRendererError | TuiInitialSessionError, + Scope.Scope | FileSystem.FileSystem +> => Effect.gen(function* () { if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) return yield* new TuiRequiresTtyError() const quit = yield* Deferred.make() @@ -198,7 +203,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 +259,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 +319,7 @@ export const runTui = ( Effect.catchCause((cause) => Effect.sync(() => update({ _tag: 'failure', message: Cause.pretty(cause) })), ), + Effect.provide(NodeFileSystem.layer), ), ) } @@ -339,6 +345,7 @@ export const runTui = ( Effect.catchCause((cause) => Effect.sync(() => update({ _tag: 'failure', message: Cause.pretty(cause) })), ), + Effect.provide(NodeFileSystem.layer), ), ) } @@ -365,6 +372,7 @@ export const runTui = ( Effect.catchCause((cause) => Effect.sync(() => update({ _tag: 'failure', message: Cause.pretty(cause) })), ), + Effect.provide(NodeFileSystem.layer), ), ) } @@ -437,6 +445,7 @@ export const runTui = ( })), ), ), + Effect.provide(NodeFileSystem.layer), ), ) }) @@ -500,7 +509,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..937a6dd 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,9 @@ 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..495381d 100644 --- a/packages/fold-cli/src/tui/TuiSessionWorkspace.ts +++ b/packages/fold-cli/src/tui/TuiSessionWorkspace.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { deleteSession, launchSession, @@ -13,7 +14,7 @@ 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 { 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..12d6cbe 100644 --- a/packages/fold-cli/test/tui/TuiSessionWorkspace.vi.test.ts +++ b/packages/fold-cli/test/tui/TuiSessionWorkspace.vi.test.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { SessionId } from '@humanlayer/fold-core' 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 176984c..5ef8079 100644 --- a/packages/fold-codex/examples/CodexAgent.ts +++ b/packages/fold-codex/examples/CodexAgent.ts @@ -11,6 +11,7 @@ import { mkdtempSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { codingTools, jsonlEventLog } from '@humanlayer/fold-agent' import { defineAgent, startSession } from '@humanlayer/fold-core' import { Predicate, Console, Effect } from 'effect' @@ -49,7 +50,7 @@ const program = Effect.gen(function* () { yield* Console.log( `tools used: ${entries.filter((entry) => Predicate.isTagged(entry, '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..2c05cf2 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,67 +72,69 @@ const encodeToken = (token: CodexTokenData): Record => ({ }) /** Build a file-backed Codex credential store. */ -export const makeCodexAuthStore = (options?: MakeCodexAuthStoreOptions): CodexAuthStore => { - const fs = options?.fileSystem ?? defaultNodeFileSystem() - const path = options?.path ?? defaultAuthStorePath() - const providerId = options?.providerId ?? 'codex' - - const readDocument: Effect.Effect> = fs.readFileString(path).pipe( - Effect.flatMap((content) => { - const document = decodeDocument(content) - return Option.isSome(document) - ? Effect.succeed(document.value) - : Effect.logWarning(`Auth store ${path} is not a JSON object; treating it as empty`).pipe( - Effect.as>({}), - ) - }), - // A missing (or unreadable) document is simply "no credentials stored yet". - Effect.catch(() => Effect.succeed>({})), - ) - - const writeDocument = (document: Record): Effect.Effect => - Effect.gen(function* () { - yield* fs.makeDirectory(dirname(path), { recursive: true }) - yield* fs.writeFileString(path, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600 }) - // writeFileString's mode only applies on creation; force 0600 on pre-existing documents too. - yield* fs.chmod(path, 0o600) - }).pipe( - Effect.mapError( - (cause) => - new CodexAuthStoreError({ - reason: 'WriteFailed', - message: `Failed to write the auth store at ${path}`, - cause, - }), - ), +export const makeCodexAuthStore = ( + options?: MakeCodexAuthStoreOptions, +): Effect.Effect => + Effect.map(FileSystem.FileSystem, (fs) => { + const path = options?.path ?? defaultAuthStorePath() + const providerId = options?.providerId ?? 'codex' + + const readDocument: Effect.Effect> = fs.readFileString(path).pipe( + Effect.flatMap((content) => { + const document = decodeDocument(content) + return Option.isSome(document) + ? Effect.succeed(document.value) + : Effect.logWarning(`Auth store ${path} is not a JSON object; treating it as empty`).pipe( + Effect.as>({}), + ) + }), + // A missing (or unreadable) document is simply "no credentials stored yet". + Effect.catch(() => Effect.succeed>({})), ) - const load = Effect.gen(function* () { - const document = yield* readDocument - const entry = document[providerId] - if (entry === undefined) return Option.none() + const writeDocument = (document: Record): Effect.Effect => + Effect.gen(function* () { + yield* fs.makeDirectory(dirname(path), { recursive: true }) + yield* fs.writeFileString(path, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600 }) + // writeFileString's mode only applies on creation; force 0600 on pre-existing documents too. + yield* fs.chmod(path, 0o600) + }).pipe( + Effect.mapError( + (cause) => + new CodexAuthStoreError({ + reason: 'WriteFailed', + message: `Failed to write the auth store at ${path}`, + cause, + }), + ), + ) - const token = decodeToken(entry) - if (Option.isNone(token)) { - yield* Effect.logWarning(`Ignoring invalid "${providerId}" entry in ${path}`) - } + const load = Effect.gen(function* () { + const document = yield* readDocument + const entry = document[providerId] + if (entry === undefined) return Option.none() - return token - }).pipe(Effect.withSpan('fold.codexAuthStore.load')) + const token = decodeToken(entry) + if (Option.isNone(token)) { + yield* Effect.logWarning(`Ignoring invalid "${providerId}" entry in ${path}`) + } - const save = (token: CodexTokenData) => - Effect.gen(function* () { - const document = yield* readDocument - yield* writeDocument({ ...document, [providerId]: encodeToken(token) }) return token - }).pipe(Effect.withSpan('fold.codexAuthStore.save')) + }).pipe(Effect.withSpan('fold.codexAuthStore.load')) - const clear = Effect.gen(function* () { - const document = yield* readDocument - if (document[providerId] === undefined) return - const { [providerId]: _removed, ...rest } = document - yield* writeDocument(rest) - }).pipe(Effect.withSpan('fold.codexAuthStore.clear')) + const save = (token: CodexTokenData) => + Effect.gen(function* () { + const document = yield* readDocument + yield* writeDocument({ ...document, [providerId]: encodeToken(token) }) + return token + }).pipe(Effect.withSpan('fold.codexAuthStore.save')) - return { path, load, save, clear } -} + const clear = Effect.gen(function* () { + const document = yield* readDocument + if (document[providerId] === undefined) return + const { [providerId]: _removed, ...rest } = document + yield* writeDocument(rest) + }).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 495766c..4c3f3dc 100644 --- a/packages/fold-codex/src/CodexModel.ts +++ b/packages/fold-codex/src/CodexModel.ts @@ -15,6 +15,7 @@ */ import { OpenAiClient, OpenAiLanguageModel } from '@effect/ai-openai' import type * as OpenAiSchema from '@effect/ai-openai/OpenAiSchema' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { customModel, resolveCodexReasoning } from '@humanlayer/fold-core' import type { ReasoningLevel, FoldModel } from '@humanlayer/fold-core' import { Match, Context, Duration, Effect, Layer, Option, Schedule, Schema, Stream } from 'effect' @@ -289,7 +290,7 @@ export const makeCodexLanguageModel = ( ...reasoningConfig, }, }).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..32de54c 100644 --- a/packages/fold-codex/test/AuthStore.vi.test.ts +++ b/packages/fold-codex/test/AuthStore.vi.test.ts @@ -2,6 +2,7 @@ import { mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { describe, expect, it } from '@effect/vitest' import { Effect, Option, Schema } from 'effect' @@ -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..6e1a1ff 100644 --- a/packages/fold-codex/test/CodexAuth.vi.test.ts +++ b/packages/fold-codex/test/CodexAuth.vi.test.ts @@ -2,6 +2,7 @@ import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { describe, expect, it } from '@effect/vitest' 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 d324f0a..0f1f401 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 { Predicate, Console, Effect } from 'effect' import { defineAgent, openaiModel, startSession, type CompactionLogEntry } from '../src/index' @@ -77,7 +78,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 506fadc..31a766f 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 { Data, type Effect, type Scope } from 'effect' +import { Data, type Effect, type FileSystem, type 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 } const FoldEventLog = Data.taggedEnum() @@ -27,5 +27,6 @@ export const memoryEventLog = (): FoldEventLog => FoldEventLog.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 => - FoldEventLog.source({ make }) +export const eventLogSource = ( + make: Effect.Effect, +): FoldEventLog => FoldEventLog.source({ make }) diff --git a/packages/fold-core/src/Api/Provisioning.ts b/packages/fold-core/src/Api/Provisioning.ts index 9c97281..aa4d326 100644 --- a/packages/fold-core/src/Api/Provisioning.ts +++ b/packages/fold-core/src/Api/Provisioning.ts @@ -19,7 +19,7 @@ import { AnthropicClient, AnthropicLanguageModel } from '@effect/ai-anthropic' import { OpenAiClient, OpenAiLanguageModel } from '@effect/ai-openai' import { Context, Effect, Layer, Match, Stream } from 'effect' -import type { Scope } from 'effect' +import type { FileSystem, Scope } 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 +93,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 9d3585a..9007ee9 100644 --- a/packages/fold-core/src/Api/StartSession.ts +++ b/packages/fold-core/src/Api/StartSession.ts @@ -34,6 +34,7 @@ import { Effect, Exit, Fiber, + FileSystem, Layer, Match, Ref, @@ -275,7 +276,7 @@ type SessionAgentConfig = { } /** Lower the event log descriptor to its EventLog layer. */ -const eventLogLayerFor = (log: FoldEventLog): Layer.Layer => +const eventLogLayerFor = (log: FoldEventLog): Layer.Layer => Match.valueTags(log, { memory: () => layerInMemoryEventLogWithIds, source: ({ make }) => Layer.effect(EventLog, make), @@ -301,7 +302,9 @@ 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, @@ -309,6 +312,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, @@ -327,7 +331,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 ?? [] @@ -372,7 +376,9 @@ 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))), @@ -448,14 +454,17 @@ 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 ?? memoryEventLog()).pipe(Layer.provide(idsLayer)), + eventLogLayerFor(options.log ?? memoryEventLog()).pipe(Layer.provide(Layer.mergeAll(idsLayer, fsLayer))), idsLayer, liveAgentEventsLayer, ) const servicesLayer = Layer.mergeAll( infraLayer, + fsLayer, makeSystemPrompt(agent.basePrompts === undefined ? {} : { basePrompts: agent.basePrompts }), liveModelRequestSettingsLayer, toolEventSinkLayerFromAgentEvents.pipe(Layer.provide(infraLayer)), @@ -569,6 +578,7 @@ const assembleSessionGraph = (options: { provisionRootRuntime, setProvisionedRuntime: (runtime) => Ref.set(runtimeRef, runtime), currentProvisionedRuntime: Ref.get(runtimeRef), + fileSystem, leadingPromptFor, } }) @@ -830,63 +840,72 @@ const makeSessionHandle = (graph: SessionGraph, identity: StartedSession): FoldS const switchModel = (model: FoldModel, switchOptions?: SwitchModelOptions): Effect.Effect => gate.withPermit( - Effect.gen(function* () { - const current = yield* Ref.get(configRef) - const currentProfiles = yield* profiles.snapshot - const candidateProfiles = switchOptions?.profiles ?? currentProfiles - const next: SessionAgentConfig = { - model, - promptCacheKey: current.promptCacheKey, - systemPrompt: switchOptions?.systemPrompt ?? current.systemPrompt, - tools: switchOptions?.tools ?? current.tools, - } - yield* validateToolNames(next.tools) - - // A switch may introduce new session-initialized tools and subagent types. The switch gate - // is the sole extension boundary, so dispatch can never observe a partially installed graph. - yield* graph.ensureToolContributions(next.tools) - const introduced = yield* graph.collectNewSubagentDefinitions(next.tools) - yield* Effect.forEach(introduced.subagents, (definition) => validateToolNames(definition.tools ?? []), { - discard: true, - }) - yield* Effect.forEach( - introduced.subagents, - (definition) => graph.ensureToolContributions(definition.tools ?? []), - { + 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 + const next: SessionAgentConfig = { + model, + promptCacheKey: current.promptCacheKey, + systemPrompt: switchOptions?.systemPrompt ?? current.systemPrompt, + tools: switchOptions?.tools ?? current.tools, + } + yield* validateToolNames(next.tools) + + // A switch may introduce new session-initialized tools and subagent types. The switch gate + // is the sole extension boundary, so dispatch can never observe a partially installed graph. + yield* graph.ensureToolContributions(next.tools) + const introduced = yield* graph.collectNewSubagentDefinitions(next.tools) + yield* Effect.forEach( + introduced.subagents, + (definition) => validateToolNames(definition.tools ?? []), + { + discard: true, + }, + ) + yield* Effect.forEach( + introduced.subagents, + (definition) => graph.ensureToolContributions(definition.tools ?? []), + { + discard: true, + }, + ) + yield* Effect.forEach(introduced.forkAgents, (definition) => validateToolNames(definition.tools), { discard: true, - }, - ) - yield* Effect.forEach(introduced.forkAgents, (definition) => validateToolNames(definition.tools), { - discard: true, - }) - yield* Effect.forEach( - introduced.forkAgents, - (definition) => graph.ensureToolContributions(definition.tools), - { discard: true }, - ) - yield* graph.validateSubagentRegistry(introduced, candidateProfiles) - - // Provision against the new toolset before writing the transition, so the durable - // tools-change below resolves over the newly installed tools. Nothing can run in - // between: root runs wait on the same gate. - const nextRuntime = yield* graph.provisionRootRuntime(model, next.tools) - const previousRuntime = yield* graph.currentProvisionedRuntime - yield* graph.setProvisionedRuntime(nextRuntime) - const transition = yield* session - .switchModel({ - model: model.activeModel, - systemPrompt: graph.leadingPromptFor(next.systemPrompt, next.tools), - reason: switchOptions?.reason ?? null, }) - .pipe(Effect.orDie, Effect.exit) - if (Exit.isFailure(transition)) { - yield* graph.setProvisionedRuntime(previousRuntime) - return yield* Effect.failCause(transition.cause) - } - graph.extendSubagentRegistry(introduced) - yield* profiles.replace(candidateProfiles) - yield* Ref.set(configRef, next) - }), + yield* Effect.forEach( + introduced.forkAgents, + (definition) => graph.ensureToolContributions(definition.tools), + { discard: true }, + ) + yield* graph.validateSubagentRegistry(introduced, candidateProfiles) + + // Provision against the new toolset before writing the transition, so the durable + // tools-change below resolves over the newly installed tools. Nothing can run in + // between: root runs wait on the same gate. + const nextRuntime = yield* graph.provisionRootRuntime(model, next.tools) + const previousRuntime = yield* graph.currentProvisionedRuntime + yield* graph.setProvisionedRuntime(nextRuntime) + const transition = yield* session + .switchModel({ + model: model.activeModel, + systemPrompt: graph.leadingPromptFor(next.systemPrompt, next.tools), + reason: switchOptions?.reason ?? null, + }) + .pipe(Effect.orDie, Effect.exit) + if (Exit.isFailure(transition)) { + yield* graph.setProvisionedRuntime(previousRuntime) + return yield* Effect.failCause(transition.cause) + } + graph.extendSubagentRegistry(introduced) + yield* profiles.replace(candidateProfiles) + yield* Ref.set(configRef, next) + }), + ), ) const compact = (): Effect.Effect => @@ -930,7 +949,9 @@ 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) @@ -959,7 +980,9 @@ 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 8611b10..49a0ea3 100644 --- a/packages/fold-core/src/Api/ToolDefinition.ts +++ b/packages/fold-core/src/Api/ToolDefinition.ts @@ -42,6 +42,7 @@ export type ToolHandlerServices = | CurrentToolCall | InterruptNote | Subagents + | FileSystem.FileSystem type PlatformToolServices = FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner @@ -80,7 +81,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. */ @@ -142,6 +143,7 @@ export const defineTool = < CurrentToolCall, InterruptNote, Subagents, + FileSystem.FileSystem, ...(options.dependencies ?? []), ], }).annotate(Tool.Strict, false) diff --git a/packages/fold-core/src/Skills/SkillSource.ts b/packages/fold-core/src/Skills/SkillSource.ts index 56b08bc..3316800 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 { Data, Predicate, Context, Effect, Schema } from 'effect' +import { Data, Predicate, Context, Effect, Schema, type FileSystem } 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 } const FoldSkills = Data.taggedEnum() @@ -92,8 +92,9 @@ 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 => FoldSkills.source({ make }) +export const skillSource = (make: Effect.Effect): FoldSkills => + FoldSkills.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 => Predicate.isTagged(skills, '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 0537d1d..8142cbf 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 { Data, Match, Predicate, Cause, Effect, Layer, Ref, Schema, Stream } from 'effect' +import { Data, Match, Predicate, Cause, Effect, FileSystem, Layer, Ref, Schema, Stream } from 'effect' import { Prompt } from 'effect/unstable/ai' import { EventLog } from '../EventLog/EventLogService' @@ -307,6 +307,7 @@ const finalOutputFromToolHandler = (input: { | CurrentToolCall | InterruptNote | Subagents + | FileSystem.FileSystem > => Effect.gen(function* () { const toolset = yield* Toolset @@ -383,7 +384,11 @@ const settlePreparedToolCall = (input: { readonly prepared: PreparedToolCall readonly stopRef: Ref.Ref readonly stateSnapshot: ReadonlyArray -}): Effect.Effect => +}): Effect.Effect< + ToolResultLogEntry, + never, + EventLog | Ids | HookRunner | Toolset | ToolEventSink | Subagents | FileSystem.FileSystem +> => Effect.gen(function* () { const toolCallId = yield* decodeToolCallId(input.prepared.original) const toolName = input.prepared.original.name @@ -436,6 +441,7 @@ const settlePreparedToolCall = (input: { | CurrentToolCall | InterruptNote | Subagents + | FileSystem.FileSystem > = Effect.gen(function* () { if (Predicate.isTagged(input.prepared, 'replaceResult')) { return { @@ -519,7 +525,11 @@ 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< + ToolSettlement, + never, + EventLog | Ids | HookRunner | Toolset | ToolEventSink | Subagents | FileSystem.FileSystem +> => Effect.gen(function* () { const stopRef = yield* Ref.make(null) const stopController = { @@ -579,7 +589,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* () { @@ -589,6 +599,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( @@ -598,6 +609,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..c2c9e5b 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,13 @@ 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 786f30b..2ee3bf4 100644 --- a/packages/fold-core/test/AgentRuntime/AgentRuntimeModelSettings.vi.test.ts +++ b/packages/fold-core/test/AgentRuntime/AgentRuntimeModelSettings.vi.test.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { expect, it } from '@effect/vitest' import { Predicate, Effect, Layer, Schema } from 'effect' import { Tool, Toolkit } 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 7a5d566..9cd616d 100644 --- a/packages/fold-core/test/Api/ResumeSession.vi.test.ts +++ b/packages/fold-core/test/Api/ResumeSession.vi.test.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** * Slice-2 resume tests: `resumeSession` ADOPTS an existing log - identity recovered from the replayed * `session_started`, no new session/agent rows - and the facade writes ONE epoch transition exactly @@ -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 07fa787..d93a81b 100644 --- a/packages/fold-core/test/Api/SessionInterrupt.vi.test.ts +++ b/packages/fold-core/test/Api/SessionInterrupt.vi.test.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** * Slice-2 hard-interrupt tests (D10): `interrupt` cancels the live fiber tree, discards unfinished * assistant output, writes the root `agent-finished{interrupted}` marker, and resolves the awaiting @@ -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', () => @@ -108,5 +109,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 08568fa..658fa65 100644 --- a/packages/fold-core/test/Api/SessionIsolation.vi.test.ts +++ b/packages/fold-core/test/Api/SessionIsolation.vi.test.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** * Cross-session isolation: startSession builds with session-fresh memo maps, so two sessions started * inside one program never share module-level layers. This is the regression test for the v4 @@ -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..114c0eb 100644 --- a/packages/fold-core/test/Api/SessionProfiles.vi.test.ts +++ b/packages/fold-core/test/Api/SessionProfiles.vi.test.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** * Facade tests for session profiles: `startSession({ profiles })` seeds the session-wide role->model * map and `FoldSession.setProfile` rebinds one role mid-session - children provision per dispatch, so @@ -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 1aea735..5d19214 100644 --- a/packages/fold-core/test/Api/SessionSendTargets.vi.test.ts +++ b/packages/fold-core/test/Api/SessionSendTargets.vi.test.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** * Slice-2 send-targeting tests (D8): `send` on a RUNNING agent queues a follow-up that joins the run * at its natural completion boundary (both senders resolve with the same final entry); a follow-up the @@ -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) => Predicate.isTagged(entry, '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', () => @@ -149,7 +150,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', () => @@ -161,7 +162,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', () => @@ -202,7 +203,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', () => @@ -242,5 +243,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 96fdd8d..51c1341 100644 --- a/packages/fold-core/test/Api/SessionSteer.vi.test.ts +++ b/packages/fold-core/test/Api/SessionSteer.vi.test.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** * Slice-2 steering tests (D8): `steer` queues onto a RUNNING agent and drains between that agent's * turns - after the in-flight batch, before the next model call - landing as an ordinary user-message @@ -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", () => @@ -178,5 +179,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 8d88163..8c4e04f 100644 --- a/packages/fold-core/test/Api/SessionStop.vi.test.ts +++ b/packages/fold-core/test/Api/SessionStop.vi.test.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** * Slice-2 session-stop tests (D9): `stop` raises a session-wide graceful-stop signal every agent's * loop observes at its batch boundaries - the in-flight batch finishes and its results land, then the @@ -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', () => @@ -110,5 +111,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 f83e236..03851ea 100644 --- a/packages/fold-core/test/Api/SkillsSession.vi.test.ts +++ b/packages/fold-core/test/Api/SkillsSession.vi.test.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** * Facade tests for skills (D20/D24): the roster is read once at session start and rendered into the * leading system prompt plus the skill tool's description; the tool serves content on demand; adding a @@ -75,7 +76,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(' @@ -130,7 +131,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', () => @@ -162,5 +163,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 79c497e..5489799 100644 --- a/packages/fold-core/test/Api/StartSession.vi.test.ts +++ b/packages/fold-core/test/Api/StartSession.vi.test.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** * Facade tests: startSession lowers agent/log/model descriptors into the full runtime graph. Real * EventLog, projections, hook runner, tool runtime, and session facade run under scripted language @@ -92,7 +93,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)', () => @@ -108,7 +109,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', () => @@ -140,7 +141,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 ─────────────────────── @@ -220,7 +221,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 ──────────────────────────────────────────── @@ -266,7 +267,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', () => @@ -298,7 +299,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 ────────────────────────────────── @@ -326,7 +327,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', () => @@ -339,5 +340,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 f19ea9b..b73fcd4 100644 --- a/packages/fold-core/test/Api/SwitchModel.vi.test.ts +++ b/packages/fold-core/test/Api/SwitchModel.vi.test.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** * Facade switch tests: `FoldSession.switchModel` re-provisions the runtime for a new provider and * durably records the full configuration change - `model-change`, the recomposed leading @@ -114,7 +115,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', () => @@ -155,7 +156,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', () => @@ -194,7 +195,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', () => @@ -247,7 +248,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', () => @@ -262,7 +263,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', () => @@ -288,5 +289,5 @@ it.effect('switchModel extends the subagent registry at the switch boundary', () expect(entries.find((entry) => Predicate.isTagged(entry, '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 998b0fe..dd85f5e 100644 --- a/packages/fold-core/test/Compaction/AutoCompactSession.vi.test.ts +++ b/packages/fold-core/test/Compaction/AutoCompactSession.vi.test.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** * Facade-level auto-compaction tests (D11): sessions configured with `autoCompact` compact at the * top-of-turn threshold and on reactive provider overflow, write durable `compaction` entries, and @@ -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 406911d..77437ab 100644 --- a/packages/fold-core/test/Compaction/SubagentAutoCompact.vi.test.ts +++ b/packages/fold-core/test/Compaction/SubagentAutoCompact.vi.test.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** * Subagent auto-compaction tests (D11 x D21): the session-wide compaction policy applies to every * agent, but each agent compacts against its OWN projection with its own model - a dispatched @@ -116,7 +117,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', () => @@ -191,5 +192,5 @@ it.effect('a fork compacts history including the parent folded range without tou ).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..688b0fb 100644 --- a/packages/fold-core/test/Skills/SkillTool.vi.test.ts +++ b/packages/fold-core/test/Skills/SkillTool.vi.test.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { describe, expect, it } from '@effect/vitest' import { Effect, Layer, Ref } from 'effect' @@ -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 e4ea0aa..da585b4 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 { Predicate, 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 3a27ea1..b47b179 100644 --- a/packages/fold-core/test/Subagents/SubagentDispatch.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentDispatch.vi.test.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** * Engine tests for fresh subagent dispatch (D21) through the public facade: the subagent runs its own * scripted model on the SAME session log, every one of its rows carries the dispatching parent id and @@ -129,5 +130,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 320fdf9..d2d59fe 100644 --- a/packages/fold-core/test/Subagents/SubagentHooks.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentHooks.vi.test.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** * Engine tests for per-subagent hooks (round-four ruling 6) and per-agent state isolation (D4): each * agent runs its OWN hook chains (the root's hooks never fire for a subagent's tool calls and vice @@ -89,5 +90,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 48a6ae9..02a0f49 100644 --- a/packages/fold-core/test/Subagents/SubagentLogReplay.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentLogReplay.vi.test.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** * Engine test for resume-across-restart by log replay (D21): session A dispatches a subagent over a * shared EventLog and CLOSES; session B - a fresh session instance with no in-memory state from A - @@ -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 b3387c3..8678681 100644 --- a/packages/fold-core/test/Subagents/SubagentProfiles.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentProfiles.vi.test.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** * Engine tests for role-bound subagent models (profiles slice): a registry entry may bind `model` to a * profile role name instead of a concrete descriptor, resolved through the session's mutable profiles @@ -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', () => @@ -74,7 +75,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', () => @@ -94,7 +95,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', () => @@ -110,7 +111,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', () => @@ -125,7 +126,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)', () => @@ -143,5 +144,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 91a65bf..365d079 100644 --- a/packages/fold-core/test/Subagents/SubagentResume.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentResume.vi.test.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** * Engine tests for subagent resume (D21): a previously dispatched subagent - completed, errored, or * dead from a defect - is resumable by agent_id with its full prior context, new rows grouping under @@ -166,7 +167,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)', () => @@ -216,7 +217,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', () => @@ -275,5 +276,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 a67ccd4..6e89e19 100644 --- a/packages/fold-core/test/Subagents/SubagentRoster.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentRoster.vi.test.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** * Engine tests for per-agent rosters (D21, round-five shape): the roster is the subagentTool value's * argument - each value advertises exactly its own roster and its closure is the dispatch authority - @@ -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', () => @@ -117,7 +118,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', () => @@ -132,7 +133,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', () => @@ -161,7 +162,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) => @@ -211,7 +212,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', () => @@ -229,5 +230,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 583018c..91b4e54 100644 --- a/packages/fold-core/test/Subagents/SubagentSkills.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentSkills.vi.test.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** * Engine tests for skills in the subagent slice (D20/D21, round-five shape): a skillTool VALUE shared * by reference between agents is initialized once (one scan, one snapshot) and gives both the same @@ -94,7 +95,7 @@ it.effect('a shared skillTool value scans once; the preload rides the dispatcher (entry) => Predicate.isTagged(entry, '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', () => @@ -126,5 +127,5 @@ it.effect('a dispatcher with no skillTool cannot preload: typed failure before a const toolResult = entries.find((entry) => Predicate.isTagged(entry, '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 e3ca047..86e3e42 100644 --- a/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** * Engine tests for the REAL subagent tool wire (D21): the model dispatches and resumes subagents by * calling the `subagent` tool with its flat wire parameters - no test-only drive tool in the loop - so @@ -108,7 +109,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', () => @@ -165,7 +166,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', () => @@ -242,5 +243,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 c450f49..ded1717 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeInterrupt.vi.test.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeInterrupt.vi.test.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { expect, it } from '@effect/vitest' import { Predicate, 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 f6c5e54..c25e145 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeStateSnapshot.vi.test.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeStateSnapshot.vi.test.ts @@ -1,3 +1,4 @@ +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { describe, expect, it } from '@effect/vitest' import { Predicate, 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..3f8f64d 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,67 +72,69 @@ const encodeToken = (token: XaiTokenData): Record => ({ }) /** Build a file-backed Xai credential store. */ -export const makeXaiAuthStore = (options?: MakeXaiAuthStoreOptions): XaiAuthStore => { - const fs = options?.fileSystem ?? defaultNodeFileSystem() - const path = options?.path ?? defaultAuthStorePath() - const providerId = options?.providerId ?? 'xai' - - const readDocument: Effect.Effect> = fs.readFileString(path).pipe( - Effect.flatMap((content) => { - const document = decodeDocument(content) - return Option.isSome(document) - ? Effect.succeed(document.value) - : Effect.logWarning(`Auth store ${path} is not a JSON object; treating it as empty`).pipe( - Effect.as>({}), - ) - }), - // A missing (or unreadable) document is simply "no credentials stored yet". - Effect.catch(() => Effect.succeed>({})), - ) - - const writeDocument = (document: Record): Effect.Effect => - Effect.gen(function* () { - yield* fs.makeDirectory(dirname(path), { recursive: true }) - yield* fs.writeFileString(path, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600 }) - // writeFileString's mode only applies on creation; force 0600 on pre-existing documents too. - yield* fs.chmod(path, 0o600) - }).pipe( - Effect.mapError( - (cause) => - new XaiAuthStoreError({ - reason: 'WriteFailed', - message: `Failed to write the auth store at ${path}`, - cause, - }), - ), +export const makeXaiAuthStore = ( + options?: MakeXaiAuthStoreOptions, +): Effect.Effect => + Effect.map(FileSystem.FileSystem, (fs) => { + const path = options?.path ?? defaultAuthStorePath() + const providerId = options?.providerId ?? 'xai' + + const readDocument: Effect.Effect> = fs.readFileString(path).pipe( + Effect.flatMap((content) => { + const document = decodeDocument(content) + return Option.isSome(document) + ? Effect.succeed(document.value) + : Effect.logWarning(`Auth store ${path} is not a JSON object; treating it as empty`).pipe( + Effect.as>({}), + ) + }), + // A missing (or unreadable) document is simply "no credentials stored yet". + Effect.catch(() => Effect.succeed>({})), ) - const load = Effect.gen(function* () { - const document = yield* readDocument - const entry = document[providerId] - if (entry === undefined) return Option.none() + const writeDocument = (document: Record): Effect.Effect => + Effect.gen(function* () { + yield* fs.makeDirectory(dirname(path), { recursive: true }) + yield* fs.writeFileString(path, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600 }) + // writeFileString's mode only applies on creation; force 0600 on pre-existing documents too. + yield* fs.chmod(path, 0o600) + }).pipe( + Effect.mapError( + (cause) => + new XaiAuthStoreError({ + reason: 'WriteFailed', + message: `Failed to write the auth store at ${path}`, + cause, + }), + ), + ) - const token = decodeToken(entry) - if (Option.isNone(token)) { - yield* Effect.logWarning(`Ignoring invalid "${providerId}" entry in ${path}`) - } + const load = Effect.gen(function* () { + const document = yield* readDocument + const entry = document[providerId] + if (entry === undefined) return Option.none() - return token - }).pipe(Effect.withSpan('fold.xaiAuthStore.load')) + const token = decodeToken(entry) + if (Option.isNone(token)) { + yield* Effect.logWarning(`Ignoring invalid "${providerId}" entry in ${path}`) + } - const save = (token: XaiTokenData) => - Effect.gen(function* () { - const document = yield* readDocument - yield* writeDocument({ ...document, [providerId]: encodeToken(token) }) return token - }).pipe(Effect.withSpan('fold.xaiAuthStore.save')) + }).pipe(Effect.withSpan('fold.xaiAuthStore.load')) - const clear = Effect.gen(function* () { - const document = yield* readDocument - if (document[providerId] === undefined) return - const { [providerId]: _removed, ...rest } = document - yield* writeDocument(rest) - }).pipe(Effect.withSpan('fold.xaiAuthStore.clear')) + const save = (token: XaiTokenData) => + Effect.gen(function* () { + const document = yield* readDocument + yield* writeDocument({ ...document, [providerId]: encodeToken(token) }) + return token + }).pipe(Effect.withSpan('fold.xaiAuthStore.save')) - return { path, load, save, clear } -} + const clear = Effect.gen(function* () { + const document = yield* readDocument + if (document[providerId] === undefined) return + const { [providerId]: _removed, ...rest } = document + yield* writeDocument(rest) + }).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..3465ac5 100644 --- a/packages/fold-xai/src/XaiModel.ts +++ b/packages/fold-xai/src/XaiModel.ts @@ -1,5 +1,6 @@ /** FoldModel factory for xAI's OpenAI-compatible inference API authenticated with OAuth. */ import { OpenAiClient, OpenAiLanguageModel } from '@effect/ai-openai-compat' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { customModel, resolveOpenAiReasoning } from '@humanlayer/fold-core' import type { FoldModel, ReasoningLevel } from '@humanlayer/fold-core' import { Context, Effect, Layer } from 'effect' @@ -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..d26c954 100644 --- a/packages/fold-xai/test/Xai.vi.test.ts +++ b/packages/fold-xai/test/Xai.vi.test.ts @@ -1,5 +1,6 @@ import { readdir, rm } from 'node:fs/promises' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { describe, expect, it } from '@effect/vitest' import { Effect, Option } from 'effect' @@ -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), ), ) })