diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index 464777f..9024842 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -14,7 +14,10 @@ "automation/no-shadowed-standard-array-static": "error", "automation/no-disable-validation": "error", "automation/no-silent-error-swallow": "error", + "automation/no-manual-tag-comparison": "error", + "automation/no-manual-tagged-construction": "error", "automation/prefer-effect-match": "error", + "automation/prefer-tagged-error-handling": "error", "automation/no-ambient-nondeterminism": "error", "anti-slop/no-reflect-apply": "error", "typescript/consistent-type-imports": ["error", { "fixStyle": "inline-type-imports" }], @@ -73,10 +76,29 @@ "automation/no-disable-validation": "off", "automation/no-shadowed-standard-array-static": "off", "automation/no-silent-error-swallow": "off", + "automation/no-manual-tag-comparison": "off", + "automation/no-manual-tagged-construction": "off", "automation/prefer-effect-match": "off", + "automation/prefer-tagged-error-handling": "off", "automation/no-ambient-nondeterminism": "off", }, }, + { + // Runtime tagged values in tests, fixtures, and examples are often deliberate raw inputs. + "files": [ + "**/test/**", + "**/tests/**", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.vi.test.ts", + "**/*.vi.test.tsx", + "**/fixtures/**", + "**/examples/**", + ], + "rules": { + "automation/no-manual-tagged-construction": "off", + }, + }, { // no-ambient-nondeterminism targets production seams. Tests deliberately use fixed or real // time and node crypto, driven through explicit layers, so the rule is off for test files. diff --git a/packages/fold-agent/examples/ApplyPatchAgent.ts b/packages/fold-agent/examples/ApplyPatchAgent.ts index 2971fd3..b3b4d02 100644 --- a/packages/fold-agent/examples/ApplyPatchAgent.ts +++ b/packages/fold-agent/examples/ApplyPatchAgent.ts @@ -11,7 +11,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { defineAgent, openaiModel, startSession } from '@humanlayer/fold-core' -import { Console, Effect } from 'effect' +import { Predicate, Console, Effect } from 'effect' import { codingTools } from '../src/index' @@ -41,7 +41,7 @@ const makeProgram = (apiKey: string) => ) const entries = yield* session.entries const toolNames = entries.flatMap((entry) => - entry._tag === 'tool-result' + Predicate.isTagged(entry, 'tool-result') ? [entry.message.content[0]?.type === 'tool-result' ? entry.message.content[0].name : ''] : [], ) diff --git a/packages/fold-agent/examples/CodingAgent.ts b/packages/fold-agent/examples/CodingAgent.ts index 2dbefe6..040e70e 100644 --- a/packages/fold-agent/examples/CodingAgent.ts +++ b/packages/fold-agent/examples/CodingAgent.ts @@ -11,7 +11,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { anthropicModel, defineAgent, startSession } from '@humanlayer/fold-core' -import { Console, Effect } from 'effect' +import { Predicate, Console, Effect } from 'effect' import { codingTools, jsonlEventLog } from '../src/index' @@ -46,7 +46,9 @@ const makeProgram = (apiKey: string) => yield* Console.log(`finished: ${finished.outcome}`) yield* Console.log(`result: ${finished.resultText ?? '(no text)'}`) yield* Console.log(`log rows: ${entries.length} (persisted to ${logPath})`) - yield* Console.log(`tools used: ${entries.filter((entry) => entry._tag === 'tool-result').length} tool results`) + yield* Console.log( + `tools used: ${entries.filter((entry) => Predicate.isTagged(entry, 'tool-result')).length} tool results`, + ) }).pipe(Effect.scoped) if (apiKey === undefined || apiKey === '') { diff --git a/packages/fold-agent/examples/SubagentsAgent.ts b/packages/fold-agent/examples/SubagentsAgent.ts index 9cad37a..2c06cf2 100644 --- a/packages/fold-agent/examples/SubagentsAgent.ts +++ b/packages/fold-agent/examples/SubagentsAgent.ts @@ -12,7 +12,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { anthropicModel, defineAgent, defineSubagent, startSession, subagentTool } from '@humanlayer/fold-core' -import { Console, Effect } from 'effect' +import { Predicate, Console, Effect } from 'effect' import { bashTool, jsonlEventLog, readTool } from '../src/index' @@ -75,14 +75,16 @@ const makeProgram = (apiKey: string) => // Read the story back off the durable log: one subagent, resumed under a second tool call. const entries = yield* session.entries - const subagentStarts = entries.filter((entry) => entry._tag === 'agent_started' && entry.parentAgentId !== null) - const researcherId = subagentStarts[0]?._tag === 'agent_started' ? subagentStarts[0].agentId : null + const subagentStarts = entries.filter( + (entry) => Predicate.isTagged(entry, 'agent_started') && entry.parentAgentId !== null, + ) + const researcherId = Predicate.isTagged(subagentStarts[0], 'agent_started') ? subagentStarts[0].agentId : null const researcherTurns = entries.filter( - (entry) => entry._tag === 'assistant-message' && entry.agentId === researcherId, + (entry) => Predicate.isTagged(entry, 'assistant-message') && entry.agentId === researcherId, ).length const researcherCalls = new Set( entries - .filter((entry) => entry._tag === 'user-message' && entry.agentId === researcherId) + .filter((entry) => Predicate.isTagged(entry, 'user-message') && entry.agentId === researcherId) .map((entry) => entry.toolCallId), ).size diff --git a/packages/fold-agent/src/Config/ModelSelections.ts b/packages/fold-agent/src/Config/ModelSelections.ts index e86750a..f3067c0 100644 --- a/packages/fold-agent/src/Config/ModelSelections.ts +++ b/packages/fold-agent/src/Config/ModelSelections.ts @@ -2,7 +2,7 @@ import { DEFAULT_CODEX_MODEL_ID } from '@humanlayer/fold-codex' import { DEFAULT_ANTHROPIC_MODEL_ID, type ModelCatalogEntry, type FoldModel } from '@humanlayer/fold-core' import { DEFAULT_OPENCODE_MODEL_ID, GROK_BUILD_MODEL_ID } from '@humanlayer/fold-opencode' import { DEFAULT_XAI_MODEL_ID } from '@humanlayer/fold-xai' -import { Effect, Match } from 'effect' +import { Predicate, Effect, Match } from 'effect' import { agentModelsFromConfig, type AgentModelsOptions, RoleResolutionError } from './AgentModels' import type { ConfigRole, ProfileConfig, ProfileModeName, RoleBinding, FoldConfig } from './ConfigSchema' @@ -169,7 +169,7 @@ export const resolveConfiguredModelSelection = ( const rootRole = roleForMode(mode) return Effect.gen(function* () { let roles = config.roles - if (selection._tag === 'profile') { + if (Predicate.isTagged(selection, 'profile')) { const profileRoles = rolesForProfile(config, selection.profile) if (profileRoles === null) { return yield* new RoleResolutionError({ diff --git a/packages/fold-agent/src/Fs/MutationQueue.ts b/packages/fold-agent/src/Fs/MutationQueue.ts index 99c5329..87c0e0a 100644 --- a/packages/fold-agent/src/Fs/MutationQueue.ts +++ b/packages/fold-agent/src/Fs/MutationQueue.ts @@ -26,10 +26,10 @@ const queueKey = (fs: FileSystem.FileSystem, path: string): Effect.Effect error.reason._tag === 'NotFound' || error.reason._tag === 'BadResource', - () => Effect.succeed(resolved), - ), + Effect.catchReasons('PlatformError', { + NotFound: () => Effect.succeed(resolved), + BadResource: () => Effect.succeed(resolved), + }), ) } diff --git a/packages/fold-agent/src/Mode/Launch.ts b/packages/fold-agent/src/Mode/Launch.ts index bf9ff93..fc4584c 100644 --- a/packages/fold-agent/src/Mode/Launch.ts +++ b/packages/fold-agent/src/Mode/Launch.ts @@ -35,7 +35,7 @@ import { type FoldTool, type Ids, } from '@humanlayer/fold-core' -import { Effect, Match, Schema, Semaphore, type Scope } from 'effect' +import { Predicate, Effect, Match, Schema, Semaphore, type Scope } from 'effect' import { loadModelCatalog } from '../Catalog/LoadCatalog' import { agentModelsFromConfig, type EnvLookup, type RoleResolutionError } from '../Config/AgentModels' @@ -428,9 +428,12 @@ const withGeneratedTitles = ( Effect.flatMap((entries) => { const rootUsers = entries.filter( (entry) => - entry._tag === 'user-message' && entry.agentId === session.rootAgentId, + Predicate.isTagged(entry, 'user-message') && + entry.agentId === session.rootAgentId, + ) + const lastTitle = entries.findLast((entry) => + Predicate.isTagged(entry, 'session_title'), ) - const lastTitle = entries.findLast((entry) => entry._tag === 'session_title') const generatedTurns = lastTitle?.rootUserTurns ?? 0 if (rootUsers.length <= generatedTurns) return Effect.void return generateSessionTitle(entries, session.rootAgentId, model).pipe( diff --git a/packages/fold-agent/src/Session/SessionLayout.ts b/packages/fold-agent/src/Session/SessionLayout.ts index ac7695d..a961fbc 100644 --- a/packages/fold-agent/src/Session/SessionLayout.ts +++ b/packages/fold-agent/src/Session/SessionLayout.ts @@ -12,7 +12,7 @@ import { join } from 'node:path' import { SessionId, makeSessionId, usageInputTotal } from '@humanlayer/fold-core' import type { ActiveModel, LogEntry, FoldEventLog, Ids } from '@humanlayer/fold-core' -import { Clock, Effect, Exit, Match, Option, Schema, Stream } from 'effect' +import { Predicate, Clock, Effect, Exit, Match, Option, Schema, Stream } from 'effect' import { jsonlEventLog } from '../EventLog/JsonlDescriptor' import { fileSystemFor, type FsToolOptions } from '../Fs/DefaultFileSystem' @@ -199,26 +199,26 @@ export const listSessionLogs = (options?: SessionLayoutOptions): Effect.Effect => - entry._tag === 'session_started' + Predicate.isTagged(entry, 'session_started') const isSessionTitle = (entry: LogEntry): entry is Extract => - entry._tag === 'session_title' + Predicate.isTagged(entry, 'session_title') const isUserMessage = (entry: LogEntry): entry is Extract => - entry._tag === 'user-message' + Predicate.isTagged(entry, 'user-message') const isAgentFinished = (entry: LogEntry): entry is Extract => - entry._tag === 'agent-finished' + Predicate.isTagged(entry, 'agent-finished') type ModelCarrier = Extract const carriesModel = (entry: LogEntry): entry is ModelCarrier => - entry._tag === 'agent_started' || entry._tag === 'model-change' + Predicate.isTagged(entry, 'agent_started') || Predicate.isTagged(entry, 'model-change') type FinishedAssistantMessage = Extract & { readonly finish: NonNullable['finish']> } const isFinishedAssistantMessage = (entry: LogEntry): entry is FinishedAssistantMessage => - entry._tag === 'assistant-message' && entry.finish !== null + Predicate.isTagged(entry, 'assistant-message') && entry.finish !== null const userMessageText = (entry: Extract): string => { const content = entry.message.content @@ -233,7 +233,8 @@ const computeStatus = ( ): SessionSummary['status'] => { // No finish yet, or activity after the last finish → derive from latest activity if (lastFinished === undefined || (latestRootEntry !== undefined && latestRootEntry.seq > lastFinished.seq)) { - return latestRootEntry?._tag === 'system-message' || latestRootEntry?._tag === 'agent_started' + return Predicate.isTagged(latestRootEntry, 'system-message') || + Predicate.isTagged(latestRootEntry, 'agent_started') ? 'ready' : 'running' } @@ -306,7 +307,7 @@ const isCacheHit = ( ref: SessionLogRef, ): cached is typeof SummaryIndexRecord.Type => cached !== undefined && - cached._tag === 'summary' && + Predicate.isTagged(cached, 'summary') && cached.sourceMtimeMs === ref.mtimeMs && cached.sourceSize === (ref.size ?? 0) @@ -344,7 +345,11 @@ export const listSessionSummaries = (options?: SessionLayoutOptions): Effect.Eff summary === null ? Effect.void : appendSessionIndexRecord( - { _tag: 'summary', sourceMtimeMs: ref.mtimeMs, sourceSize: ref.size ?? 0, summary }, + SummaryIndexRecord.make({ + sourceMtimeMs: ref.mtimeMs, + sourceSize: ref.size ?? 0, + summary, + }), options, ), ), @@ -376,7 +381,7 @@ export const deleteSession = ( const outputExists = yield* fs.exists(outputDirectory).pipe(Effect.orDie) yield* fs.remove(logPath).pipe(Effect.orDie) const ts = yield* Clock.currentTimeMillis - yield* appendSessionIndexRecord({ _tag: 'deleted', sessionId, ts }, options) + yield* appendSessionIndexRecord(DeletedIndexRecord.make({ sessionId, ts }), options) if (!outputExists) return { deleted: true, outputRemoved: true } const outputRemoval = yield* Effect.exit(fs.remove(outputDirectory, { recursive: true })) @@ -417,12 +422,11 @@ export const refreshSessionSummaryIndex = (sessionId: SessionId, options?: Sessi summary === null ? Effect.void : appendSessionIndexRecord( - { - _tag: 'summary', + SummaryIndexRecord.make({ sourceMtimeMs: ref.mtimeMs, sourceSize: ref.size ?? 0, summary, - }, + }), options, ), ), diff --git a/packages/fold-agent/src/Session/TitleGenerator.ts b/packages/fold-agent/src/Session/TitleGenerator.ts index ea2da81..3ee5bf0 100644 --- a/packages/fold-agent/src/Session/TitleGenerator.ts +++ b/packages/fold-agent/src/Session/TitleGenerator.ts @@ -1,6 +1,6 @@ import type { LogEntry, FoldModel } from '@humanlayer/fold-core' import { languageModelLayerFor } from '@humanlayer/fold-core' -import { Effect, Schema } from 'effect' +import { Predicate, Effect, Schema } from 'effect' import { LanguageModel } from 'effect/unstable/ai' const TitleResult = Schema.Struct({ title: Schema.String }) @@ -9,7 +9,7 @@ const MAX_TRANSCRIPT_CHARS = 12_000 type MessageEntry = Extract const isMessageEntry = (entry: LogEntry): entry is MessageEntry => - entry._tag === 'user-message' || entry._tag === 'assistant-message' + Predicate.isTagged(entry, 'user-message') || Predicate.isTagged(entry, 'assistant-message') const extractMessageText = (entry: MessageEntry): string => typeof entry.message.content === 'string' @@ -30,7 +30,7 @@ export const normalizeSessionTitle = (title: string): string => .join(' ') export const fallbackSessionTitle = (entries: ReadonlyArray, rootAgentId: string): string => { - const first = entries.find((entry) => entry._tag === 'user-message' && entry.agentId === rootAgentId) + const first = entries.find((entry) => Predicate.isTagged(entry, 'user-message') && entry.agentId === rootAgentId) return normalizeSessionTitle(first === undefined ? '' : messageText(first)) || 'Untitled session' } @@ -39,9 +39,10 @@ export const titleTranscript = (entries: ReadonlyArray, rootAgentId: s entries .filter( (entry) => - entry.agentId === rootAgentId && (entry._tag === 'user-message' || entry._tag === 'assistant-message'), + entry.agentId === rootAgentId && + (Predicate.isTagged(entry, 'user-message') || Predicate.isTagged(entry, 'assistant-message')), ) - .map((entry) => `${entry._tag === 'user-message' ? 'User' : 'Assistant'}: ${messageText(entry)}`) + .map((entry) => `${Predicate.isTagged(entry, 'user-message') ? 'User' : 'Assistant'}: ${messageText(entry)}`) .join('\n') .slice(0, MAX_TRANSCRIPT_CHARS) diff --git a/packages/fold-agent/src/Tools/ApplyPatchTool.ts b/packages/fold-agent/src/Tools/ApplyPatchTool.ts index c45a915..17d7b58 100644 --- a/packages/fold-agent/src/Tools/ApplyPatchTool.ts +++ b/packages/fold-agent/src/Tools/ApplyPatchTool.ts @@ -14,7 +14,7 @@ import { type PatchOp, type FoldTool, } from '@humanlayer/fold-core' -import { Effect, FileSystem, Path } from 'effect' +import { Match, Predicate, Effect, FileSystem, Path } from 'effect' import type { FsToolOptions } from '../Fs/DefaultFileSystem' import { withFileMutationLocks } from '../Fs/MutationQueue' @@ -27,7 +27,7 @@ const verificationFailed = (detail: string): { message: string } => ({ /** Every path one op touches (move ops touch source and destination). */ const opPaths = (op: PatchOp): ReadonlyArray => - op._tag === 'update' && op.movePath !== null ? [op.path, op.movePath] : [op.path] + 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 => @@ -55,7 +55,7 @@ export const applyPatchTool = (options?: FsToolOptions): FoldTool => // Read every referenced file (null = does not exist) for the in-memory dry run. const files = new Map() for (const op of ops) { - if (op._tag === 'add') continue + if (Predicate.isTagged(op, 'add')) continue if (!files.has(op.path)) { const source = yield* resolvePath(op.path) const content = yield* fs @@ -71,50 +71,51 @@ export const applyPatchTool = (options?: FsToolOptions): FoldTool => // Dry run passed: perform the steps. Writes create parent directories. for (const step of computed.steps) { - switch (step._tag) { - case 'write': { - const target = yield* resolvePath(step.path) - yield* fs.makeDirectory(pathService.dirname(target), { recursive: true }).pipe( - Effect.mapError((error) => ({ - message: platformErrorMessage('apply_patch', step.path, error), - })), - ) - yield* fs.writeFileString(target, step.content).pipe( - Effect.mapError((error) => ({ - message: platformErrorMessage('apply_patch', step.path, error), - })), - ) - break - } - - case 'delete': - yield* fs.remove(yield* resolvePath(step.path)).pipe( - Effect.mapError((error) => ({ - message: platformErrorMessage('apply_patch', step.path, error), - })), - ) - break - - case 'move': { - const target = yield* resolvePath(step.toPath) - yield* fs.makeDirectory(pathService.dirname(target), { recursive: true }).pipe( - Effect.mapError((error) => ({ - message: platformErrorMessage('apply_patch', step.toPath, error), - })), - ) - yield* fs.writeFileString(target, step.content).pipe( - Effect.mapError((error) => ({ - message: platformErrorMessage('apply_patch', step.toPath, error), - })), - ) - yield* fs.remove(yield* resolvePath(step.fromPath)).pipe( - Effect.mapError((error) => ({ - message: platformErrorMessage('apply_patch', step.fromPath, error), - })), - ) - break - } - } + yield* Match.valueTags(step, { + write: (write) => + Effect.gen(function* () { + const target = yield* resolvePath(write.path) + yield* fs.makeDirectory(pathService.dirname(target), { recursive: true }).pipe( + Effect.mapError((error) => ({ + message: platformErrorMessage('apply_patch', write.path, error), + })), + ) + yield* fs.writeFileString(target, write.content).pipe( + Effect.mapError((error) => ({ + message: platformErrorMessage('apply_patch', write.path, error), + })), + ) + }), + delete: (deletion) => + resolvePath(deletion.path).pipe( + Effect.flatMap((path) => + fs.remove(path).pipe( + Effect.mapError((error) => ({ + message: platformErrorMessage('apply_patch', deletion.path, error), + })), + ), + ), + ), + move: (move) => + Effect.gen(function* () { + const target = yield* resolvePath(move.toPath) + yield* fs.makeDirectory(pathService.dirname(target), { recursive: true }).pipe( + Effect.mapError((error) => ({ + message: platformErrorMessage('apply_patch', move.toPath, error), + })), + ) + yield* fs.writeFileString(target, move.content).pipe( + Effect.mapError((error) => ({ + message: platformErrorMessage('apply_patch', move.toPath, error), + })), + ) + yield* fs.remove(yield* resolvePath(move.fromPath)).pipe( + Effect.mapError((error) => ({ + message: platformErrorMessage('apply_patch', move.fromPath, error), + })), + ) + }), + }) } return { message: `Applied patch.\n${computed.summary.join('\n')}` } diff --git a/packages/fold-agent/src/Tools/ReadTool.ts b/packages/fold-agent/src/Tools/ReadTool.ts index 43908f1..7b1a438 100644 --- a/packages/fold-agent/src/Tools/ReadTool.ts +++ b/packages/fold-agent/src/Tools/ReadTool.ts @@ -15,7 +15,7 @@ import { type FoldTool, type ToolResultBlock, } from '@humanlayer/fold-core' -import { Effect, FileSystem, type PlatformError } from 'effect' +import { Effect, FileSystem, Match, type PlatformError } from 'effect' import type { FsToolOptions } from '../Fs/DefaultFileSystem' import { resolveReadPath, resolveToCwd } from '../Fs/PathResolve' @@ -24,16 +24,14 @@ import { processImage } from './Image/Process' /** Render one platform error as a short, model-actionable failure message. */ export const platformErrorMessage = (action: string, path: string, error: PlatformError.PlatformError): string => { - switch (error.reason._tag) { - case 'NotFound': - return `${action} failed: file not found: ${path}` - case 'PermissionDenied': - return `${action} failed: permission denied: ${path}` - case 'BadResource': - return `${action} failed: not a readable file (is it a directory?): ${path}` - default: - return `${action} failed (${error.reason._tag}): ${path}` - } + return Match.value(error.reason).pipe( + Match.tags({ + NotFound: () => `${action} failed: file not found: ${path}`, + PermissionDenied: () => `${action} failed: permission denied: ${path}`, + BadResource: () => `${action} failed: not a readable file (is it a directory?): ${path}`, + }), + Match.orElse((reason) => `${action} failed (${reason._tag}): ${path}`), + ) } /** Extract the POSIX errno code (ENOENT, EACCES, ...) from a platform error, pi's error vocabulary. */ @@ -43,14 +41,10 @@ export const errnoCode = (error: PlatformError.PlatformError): string => { return cause.code } - switch (error.reason._tag) { - case 'NotFound': - return 'ENOENT' - case 'PermissionDenied': - return 'EACCES' - default: - return error.reason._tag - } + return Match.value(error.reason).pipe( + Match.tags({ NotFound: () => 'ENOENT', PermissionDenied: () => 'EACCES' }), + Match.orElse((reason) => reason._tag), + ) } /** Build the read tool over the default or provided filesystem. */ diff --git a/packages/fold-agent/test/Config/AgentModels.vi.test.ts b/packages/fold-agent/test/Config/AgentModels.vi.test.ts index 40975b6..1aac0f0 100644 --- a/packages/fold-agent/test/Config/AgentModels.vi.test.ts +++ b/packages/fold-agent/test/Config/AgentModels.vi.test.ts @@ -10,7 +10,7 @@ */ import { expect, it } from '@effect/vitest' import type { ModelCatalogEntry } from '@humanlayer/fold-core' -import { Effect } from 'effect' +import { Predicate, Effect } from 'effect' import { agentModelsFromConfig, bakedModelCatalog, parseFoldConfig } from '../../src/index' @@ -81,7 +81,7 @@ it.effect('resolves an explicit orchestrator (openai-compat) with inline key + b expect(model.activeModel.providerKind).toBe('openai-compatible') expect(model.activeModel.role).toBe('orchestrator') expect(model.provider._tag).toBe('openai-compatible') - if (model.provider._tag === 'openai-compatible') { + if (Predicate.isTagged(model.provider, 'openai-compatible')) { expect(model.provider.baseUrl).toBe('https://proxy.example/v1') } }), diff --git a/packages/fold-agent/test/Config/ConfigLoad.vi.test.ts b/packages/fold-agent/test/Config/ConfigLoad.vi.test.ts index 5385bab..34768e8 100644 --- a/packages/fold-agent/test/Config/ConfigLoad.vi.test.ts +++ b/packages/fold-agent/test/Config/ConfigLoad.vi.test.ts @@ -4,7 +4,7 @@ * FileSystem (never touches the real disk). */ import { expect, it } from '@effect/vitest' -import { Effect } from 'effect' +import { Predicate, Effect } from 'effect' import { loadFoldConfig, loadFoldConfigOrNull, parseFoldConfig, stripJsonc } from '../../src/index' import { memoryFileSystem } from '../TestHelpers' @@ -68,7 +68,7 @@ it.effect('rejects a role that references an undeclared provider (cross-referenc const error = yield* parseFoldConfig(text).pipe(Effect.flip) expect(error._tag).toBe('ConfigDecodeError') - if (error._tag === 'ConfigDecodeError') expect(error.message).toContain('missing') + if (Predicate.isTagged(error, 'ConfigDecodeError')) expect(error.message).toContain('missing') }), ) diff --git a/packages/fold-agent/test/EventLog/EventLogJsonl.vi.test.ts b/packages/fold-agent/test/EventLog/EventLogJsonl.vi.test.ts index b905978..0b80a59 100644 --- a/packages/fold-agent/test/EventLog/EventLogJsonl.vi.test.ts +++ b/packages/fold-agent/test/EventLog/EventLogJsonl.vi.test.ts @@ -170,7 +170,7 @@ it.effect('jsonl layer replays assistant usage when cache fields are absent', () const entry = entries[0] expect(entry?._tag).toBe('assistant-message') - if (entry?._tag !== 'assistant-message') return + if (!Predicate.isTagged(entry, 'assistant-message')) return expect(entry.finish?.usage.inputTokens?.cacheWrite).toBeUndefined() expect(entry.finish?.usage.inputTokens?.cacheRead).toBe(0) expect(entry.finish?.usage.outputTokens?.total).toBe(2) diff --git a/packages/fold-agent/test/Mode/Launch.vi.test.ts b/packages/fold-agent/test/Mode/Launch.vi.test.ts index 820b4ef..1a77df7 100644 --- a/packages/fold-agent/test/Mode/Launch.vi.test.ts +++ b/packages/fold-agent/test/Mode/Launch.vi.test.ts @@ -9,7 +9,7 @@ import { join } from 'node:path' import { expect, it } from '@effect/vitest' import { customModel, layerLiveIdFactory, type ActiveModel, type FoldModel } from '@humanlayer/fold-core' -import { Effect, Stream } from 'effect' +import { Predicate, Effect, Stream } from 'effect' import { LanguageModel, type Response } from 'effect/unstable/ai' import { @@ -96,16 +96,16 @@ it.effect('launchSession composes the model, agentfiles, and mode tools over sta const entries = yield* session.entries - const started = entries.find((entry) => entry._tag === 'session_started') + const started = entries.find((entry) => Predicate.isTagged(entry, 'session_started')) expect(started?._tag).toBe('session_started') - if (started?._tag === 'session_started') { + if (Predicate.isTagged(started, 'session_started')) { expect(started.cwd).toBe(workspace) expect(started.meta).toMatchObject({ mode: 'coding', rpi: false, profile: 'default' }) } // The leading system message carries the mode prompt AND the agentfile project_context. const leading = entries.find( - (entry) => entry._tag === 'system-message' && entry.placement === 'leading', + (entry) => Predicate.isTagged(entry, 'system-message') && entry.placement === 'leading', ) const leadingJson = JSON.stringify(leading) expect(leadingJson).toContain(DEFAULT_CODING_PROMPT) @@ -116,8 +116,8 @@ it.effect('launchSession composes the model, agentfiles, and mode tools over sta expect(leadingJson).not.toContain(RPI_HINT_PROMPT) // The mode's tool roster reached the agent (family-neutral + skill are always present). - const agentStarted = entries.find((entry) => entry._tag === 'agent_started') - const tools = agentStarted?._tag === 'agent_started' ? agentStarted.tools : [] + const agentStarted = entries.find((entry) => Predicate.isTagged(entry, 'agent_started')) + const tools = Predicate.isTagged(agentStarted, 'agent_started') ? agentStarted.tools : [] expect(tools).toContain('read') expect(tools).toContain('bash') expect(tools).toContain('skill') @@ -142,14 +142,14 @@ it.effect('launchSession with rpi appends the hint block after the mode prompt', }) const leading = (yield* session.entries).find( - (entry) => entry._tag === 'system-message' && entry.placement === 'leading', + (entry) => Predicate.isTagged(entry, 'system-message') && entry.placement === 'leading', ) const leadingJson = JSON.stringify(leading) - const started = (yield* session.entries).find((entry) => entry._tag === 'session_started') + const started = (yield* session.entries).find((entry) => Predicate.isTagged(entry, 'session_started')) expect(leadingJson).toContain(DEFAULT_CODING_PROMPT) expect(leadingJson).toContain(RPI_HINT_PROMPT) - if (started?._tag === 'session_started') expect(started.meta.rpi).toBe(true) + if (Predicate.isTagged(started, 'session_started')) expect(started.meta.rpi).toBe(true) // The hint composes AFTER the mode's own system prompt. expect(leadingJson.indexOf(RPI_HINT_PROMPT)).toBeGreaterThan(leadingJson.indexOf(DEFAULT_CODING_PROMPT)) }), @@ -178,11 +178,11 @@ it.effect('switchSessionMode preserves identity and writes one recomposed mode e expect(session.sessionId).toBe(sessionId) const entries = yield* session.entries - expect(entries.filter((entry) => entry._tag === 'session_started')).toHaveLength(1) - expect(entries.findLast((entry) => entry._tag === 'model-change')).toMatchObject({ + expect(entries.filter((entry) => Predicate.isTagged(entry, 'session_started'))).toHaveLength(1) + expect(entries.findLast((entry) => Predicate.isTagged(entry, 'model-change'))).toMatchObject({ reason: 'select rlm', }) - const switchedPrompt = entries.findLast((entry) => entry._tag === 'system-message') + const switchedPrompt = entries.findLast((entry) => Predicate.isTagged(entry, 'system-message')) expect(JSON.stringify(switchedPrompt)).toContain(RPI_HINT_PROMPT) }), ) @@ -301,10 +301,10 @@ it.effect('launchSession resolves CLI-style model selection overrides through fo modelSelection: { role: 'fast', model: 'gpt-override', reasoning: 'medium' }, }) const entries = yield* session.entries - const agentStarted = entries.find((entry) => entry._tag === 'agent_started') + const agentStarted = entries.find((entry) => Predicate.isTagged(entry, 'agent_started')) expect(agentStarted?._tag).toBe('agent_started') - if (agentStarted?._tag === 'agent_started') { + if (Predicate.isTagged(agentStarted, 'agent_started')) { expect(agentStarted.model.providerKind).toBe('codex') expect(agentStarted.model.modelId).toBe('gpt-override') expect(agentStarted.model.role).toBe('fast') @@ -341,10 +341,10 @@ it.effect('a direct Codex launch replaces the complete mixed-provider role map', env: () => undefined, modelSelection: { provider: 'codex', model: 'gpt-5.6-sol' }, }) - const started = (yield* session.entries).find((entry) => entry._tag === 'agent_started') + const started = (yield* session.entries).find((entry) => Predicate.isTagged(entry, 'agent_started')) expect(started?._tag).toBe('agent_started') - if (started?._tag === 'agent_started') { + if (Predicate.isTagged(started, 'agent_started')) { expect(started.model).toMatchObject({ providerId: 'codex', modelId: 'gpt-5.6-sol', @@ -375,7 +375,7 @@ it.effect('launchSession wires session profiles end to end: role-bound roster st // The default roster is role-bound ('smart'/'fast'), so the session starting AT ALL proves // launchSession passed a covering profiles map through startSession's validation. const session = yield* launchSession({ config, cwd: workspace, foldHome }) - const started = (yield* session.entries).find((entry) => entry._tag === 'agent_started') + const started = (yield* session.entries).find((entry) => Predicate.isTagged(entry, 'agent_started')) expect(started?._tag).toBe('agent_started') // The facade's profile rebinding is reachable and typed on the launched session. @@ -418,10 +418,10 @@ it.effect('--profile substitutes the profile roles and applies its pinned rlm mo foldHome, catalog: [], }) - const started = (yield* session.entries).find((entry) => entry._tag === 'agent_started') + const started = (yield* session.entries).find((entry) => Predicate.isTagged(entry, 'agent_started')) expect(started?._tag).toBe('agent_started') - if (started?._tag !== 'agent_started') return + if (!Predicate.isTagged(started, 'agent_started')) return // The rlm mode pinned by the profile runs the primary on the ORCHESTRATOR role, and its // toolset carries no bash - both prove the profile's roles AND mode were applied. expect(started.model.modelId).toBe('gpt-ultra-orchestrator') @@ -431,7 +431,7 @@ it.effect('--profile substitutes the profile roles and applies its pinned rlm mo // RLM carries the RPI specialists BY DEFAULT: the hint block lands without any --rpi flag. const entries = yield* session.entries const leading = entries.find( - (entry) => entry._tag === 'system-message' && entry.placement === 'leading', + (entry) => Predicate.isTagged(entry, 'system-message') && entry.placement === 'leading', ) expect(JSON.stringify(leading)).toContain(RPI_HINT_PROMPT) }), @@ -455,10 +455,10 @@ it.effect('an explicit mode option beats the profile pinned mode', () => foldHome, catalog: [], }) - const started = (yield* session.entries).find((entry) => entry._tag === 'agent_started') + const started = (yield* session.entries).find((entry) => Predicate.isTagged(entry, 'agent_started')) expect(started?._tag).toBe('agent_started') - if (started?._tag !== 'agent_started') return + if (!Predicate.isTagged(started, 'agent_started')) return // Default mode wins: primary on the profile's SMART binding, bash back in the toolset. expect(started.model.modelId).toBe('gpt-ultra-smart') expect(started.tools).toContain('bash') @@ -479,7 +479,7 @@ it.effect('an unknown --profile fails with UnknownProfileError naming what exist ) expect(error._tag).toBe('UnknownProfileError') - if (error._tag !== 'UnknownProfileError') return + if (!Predicate.isTagged(error, 'UnknownProfileError')) return expect(error.profile).toBe('nope') expect(error.available).toEqual(['ultratest']) }), diff --git a/packages/fold-agent/test/Session/SessionLayout.vi.test.ts b/packages/fold-agent/test/Session/SessionLayout.vi.test.ts index bb4e20a..c3efc42 100644 --- a/packages/fold-agent/test/Session/SessionLayout.vi.test.ts +++ b/packages/fold-agent/test/Session/SessionLayout.vi.test.ts @@ -10,7 +10,7 @@ import { join } from 'node:path' import { expect, it } from '@effect/vitest' import { customModel, defineAgent, layerLiveIdFactory, SessionId, startSession } from '@humanlayer/fold-core' -import { Effect, Stream } from 'effect' +import { Predicate, Effect, Stream } from 'effect' import { LanguageModel } from 'effect/unstable/ai' import { @@ -86,8 +86,8 @@ it.effect('a prepared log round-trips a session: the filename and session_starte expect(session.sessionId).toBe(prepared.sessionId) const entries = yield* session.entries - const sessionStarted = entries.find((entry) => entry._tag === 'session_started') - if (sessionStarted === undefined || sessionStarted._tag !== 'session_started') { + const sessionStarted = entries.find((entry) => Predicate.isTagged(entry, 'session_started')) + if (sessionStarted === undefined || !Predicate.isTagged(sessionStarted, 'session_started')) { throw new Error('expected session_started') } expect(sessionStarted.sessionId).toBe(prepared.sessionId) diff --git a/packages/fold-agent/test/Skills/DiskSkills.vi.test.ts b/packages/fold-agent/test/Skills/DiskSkills.vi.test.ts index 3520490..00b82cd 100644 --- a/packages/fold-agent/test/Skills/DiskSkills.vi.test.ts +++ b/packages/fold-agent/test/Skills/DiskSkills.vi.test.ts @@ -5,7 +5,7 @@ * handling, and baseDir wiring. */ import { expect, it } from '@effect/vitest' -import { Effect } from 'effect' +import { Predicate, Effect } from 'effect' import { makeDiskSkillSource } from '../../src/index' import { memoryFileSystem } from '../TestHelpers' @@ -133,7 +133,7 @@ it.effect('load fails with the roster for unknown names', () => const failure = yield* source.load('absent').pipe(Effect.flip) expect(failure._tag).toBe('SkillNotFoundError') - if (failure._tag !== 'SkillNotFoundError') throw new Error('expected SkillNotFoundError') + if (!Predicate.isTagged(failure, 'SkillNotFoundError')) throw new Error('expected SkillNotFoundError') expect(failure.availableSkills).toEqual(['present']) }), ) diff --git a/packages/fold-cli/src/Commands.ts b/packages/fold-cli/src/Commands.ts index dd28a6f..23f393b 100644 --- a/packages/fold-cli/src/Commands.ts +++ b/packages/fold-cli/src/Commands.ts @@ -34,7 +34,7 @@ import { type CliError, Command, Flag } from 'effect/unstable/cli' import { FetchHttpClient } from 'effect/unstable/http' import { makeJsonOutputRenderer, makePromptOutputRenderer, type JsonOutputMode } from './Renderer' -import { runPrompt, type CliSessionOptions, type ResumeTarget } from './Run' +import { ResumeTarget, runPrompt, type CliSessionOptions } from './Run' declare const FOLD_VERSION: string const version = typeof FOLD_VERSION === 'string' ? FOLD_VERSION : '0.0.0' @@ -88,11 +88,11 @@ export const shouldOpenBrowserForCodexLogin = (input: { */ export const parseResumeFlag = (raw: string): Effect.Effect => { const value = raw.trim() - if (value === RESUME_LATEST) return Effect.succeed({ _tag: 'latest' }) + if (value === RESUME_LATEST) return Effect.succeed(ResumeTarget.latest()) const decoded = decodeSessionId(value) return Option.isSome(decoded) - ? Effect.succeed({ _tag: 'id', sessionId: decoded.value }) + ? Effect.succeed(ResumeTarget.id({ sessionId: decoded.value })) : Effect.fail(new InvalidSessionIdError({ value: raw })) } diff --git a/packages/fold-cli/src/Renderer.ts b/packages/fold-cli/src/Renderer.ts index 26ef5b7..e1c694c 100644 --- a/packages/fold-cli/src/Renderer.ts +++ b/packages/fold-cli/src/Renderer.ts @@ -18,7 +18,7 @@ import { type UsageEncoded, type FoldEvent, } from '@humanlayer/fold-core' -import { Effect, Match } from 'effect' +import { Data, Effect, Match } from 'effect' import { makeAnsiPalette, type AnsiPalette } from './Ansi' @@ -82,6 +82,8 @@ export type CredentialSummary = | { readonly _tag: 'missing'; readonly detail: string } | { readonly _tag: 'unknown'; readonly detail: string } +export const CredentialSummary = Data.taggedEnum() + /** Mutable renderer state hidden behind a small event-rendering surface. */ export type OutputRenderer = { readonly renderHeader: (header: SessionHeader) => Effect.Effect @@ -425,80 +427,68 @@ export const makeOutputRenderer = (options?: RendererOptions): OutputRenderer => const renderAssistantText = (agentId: string, text: string): Effect.Effect => text.length === 0 ? Effect.void : renderAgentLine(agentId, `${ansi.green('[assistant]')} ${text}`) - const renderLog = (entry: LogEntry): Effect.Effect => { - switch (entry._tag) { - case 'session_started': - case 'system-message': - case 'tool_state': - case 'session_title': - return Effect.void - - case 'agent_started': - agentModels.set(entry.agentId, entry.model) - if (entry.parentAgentId === null) rootAgentId = entry.agentId - registerAgentLabel(entry) + const renderLog = (entry: LogEntry): Effect.Effect => + Match.valueTags(entry, { + session_started: () => Effect.void, + 'system-message': () => Effect.void, + tool_state: () => Effect.void, + session_title: () => Effect.void, + agent_started: (started) => { + agentModels.set(started.agentId, started.model) + if (started.parentAgentId === null) rootAgentId = started.agentId + registerAgentLabel(started) // The id is the /steer//send target, so it is printed on every start line; subagents show // the short form because that is exactly what those commands accept. - return entry.parentAgentId === null - ? renderLine(`${label(ansi, 'agent')} ${entry.agentId} ${modelName(entry)}`) + return started.parentAgentId === null + ? renderLine(`${label(ansi, 'agent')} ${started.agentId} ${modelName(started)}`) : renderAgentLine( - entry.agentId, - `${label(ansi, 'subagent')} ${displayAgentId(entry.agentId)} ${modelName(entry)}`, + started.agentId, + `${label(ansi, 'subagent')} ${displayAgentId(started.agentId)} ${modelName(started)}`, ) - - case 'user-message': { - const text = textContent(entry.message.content) - return text.length === 0 ? Effect.void : renderAgentLine(entry.agentId, `${ansi.cyan('>')} ${text}`) - } - - case 'assistant-message': { - if (entry.finish !== null) { - latestUsage.set(entry.agentId, { - usage: entry.finish.usage, - model: agentModels.get(entry.agentId) ?? headerModel, + }, + 'user-message': (message) => { + const text = textContent(message.message.content) + return text.length === 0 ? Effect.void : renderAgentLine(message.agentId, `${ansi.cyan('>')} ${text}`) + }, + 'assistant-message': (message) => { + if (message.finish !== null) { + latestUsage.set(message.agentId, { + usage: message.finish.usage, + model: agentModels.get(message.agentId) ?? headerModel, }) } - const text = textContent(entry.message.content) - const textEffect = streamedAssistantText.has(entry.agentId) + const text = textContent(message.message.content) + const textEffect = streamedAssistantText.has(message.agentId) ? Effect.void - : renderAssistantText(entry.agentId, text) - if (text.length > 0) agentsWithText.add(entry.agentId) - streamedAssistantText.delete(entry.agentId) - assistantLabelOpen.delete(entry.agentId) - - return textEffect.pipe(Effect.andThen(renderToolCalls(entry))) - } - - case 'tool-result': - return renderToolResult(entry) - - case 'compaction': - return renderAgentLine( - entry.agentId, - `${label(ansi, 'compact')} summarized through seq ${entry.replacesThroughSeq} (${entry.tokensBefore} tokens)`, - ) - - case 'model-change': - agentModels.set(entry.agentId, entry.model) - return renderAgentLine(entry.agentId, `${label(ansi, 'model')} ${modelName(entry)}`) - - case 'thinking-change': - return renderAgentLine(entry.agentId, `${label(ansi, 'thinking')} ${entry.reasoningLevel}`) - - case 'tools-change': - return renderAgentLine(entry.agentId, `${label(ansi, 'tools')} ${entry.tools.join(', ')}`) - - case 'agent-finished': - return entry.parentAgentId === null ? Effect.void : renderFinish(entry) - - case 'error': - return renderAgentLine( - entry.agentId ?? '', - `${label(ansi, 'error')} ${ansi.red(entry.errorType)} ${entry.message}`, - ) - } - } + : renderAssistantText(message.agentId, text) + if (text.length > 0) agentsWithText.add(message.agentId) + streamedAssistantText.delete(message.agentId) + assistantLabelOpen.delete(message.agentId) + + return textEffect.pipe(Effect.andThen(renderToolCalls(message))) + }, + 'tool-result': renderToolResult, + compaction: (compaction) => + renderAgentLine( + compaction.agentId, + `${label(ansi, 'compact')} summarized through seq ${compaction.replacesThroughSeq} (${compaction.tokensBefore} tokens)`, + ), + 'model-change': (change) => { + agentModels.set(change.agentId, change.model) + return renderAgentLine(change.agentId, `${label(ansi, 'model')} ${modelName(change)}`) + }, + 'thinking-change': (change) => + renderAgentLine(change.agentId, `${label(ansi, 'thinking')} ${change.reasoningLevel}`), + 'tools-change': (change) => + renderAgentLine(change.agentId, `${label(ansi, 'tools')} ${change.tools.join(', ')}`), + 'agent-finished': (finished) => (finished.parentAgentId === null ? Effect.void : renderFinish(finished)), + error: (error) => + renderAgentLine( + error.agentId ?? '', + `${label(ansi, 'error')} ${ansi.red(error.errorType)} ${error.message}`, + ), + }) /** * Handle a stream-source change before writing a delta: close any open line and, when the incoming diff --git a/packages/fold-cli/src/Run.ts b/packages/fold-cli/src/Run.ts index c2738f5..715dcee 100644 --- a/packages/fold-cli/src/Run.ts +++ b/packages/fold-cli/src/Run.ts @@ -26,15 +26,16 @@ import type { SessionId, FoldSession, } from '@humanlayer/fold-core' -import { Cause, Clock, Effect, Exit, Fiber, Option, Stream, type Scope } from 'effect' +import { Data, Match, Predicate, Cause, Clock, Effect, Exit, Fiber, Option, Stream, type Scope } from 'effect' -import type { CredentialSummary, OutputRenderer, ResumeCommandFlag, SessionHeader } from './Renderer' +import { CredentialSummary, type OutputRenderer, type ResumeCommandFlag, type SessionHeader } from './Renderer' /** * What `--resume` selected: the newest session log for this project, or one exact id. Absent means a * fresh session. */ export type ResumeTarget = { readonly _tag: 'latest' } | { readonly _tag: 'id'; readonly sessionId: SessionId } +export const ResumeTarget = Data.taggedEnum() /** Shared options for opening a CLI-backed fold session. */ export type CliSessionOptions = { @@ -86,9 +87,10 @@ const openSessionFor = ( ): Effect.Effect => { if (options.resume === undefined) return launchSession(launchOptions(options)) - return options.resume._tag === 'latest' - ? resumeLatestSession(launchOptions(options)) - : resumeSessionById(options.resume.sessionId, launchOptions(options)) + return Match.valueTags(options.resume, { + latest: () => resumeLatestSession(launchOptions(options)), + id: ({ sessionId }) => resumeSessionById(sessionId, launchOptions(options)), + }) } const openSession = (options: CliSessionOptions): Effect.Effect => @@ -110,7 +112,7 @@ const activeModelFromEntries = (entries: ReadonlyArray, rootAgentId: s for (let index = entries.length - 1; index >= 0; index -= 1) { const entry = entries[index] if (entry === undefined || entry.agentId !== rootAgentId) continue - if (entry._tag === 'model-change' || entry._tag === 'agent_started') return entry.model + if (Predicate.isTagged(entry, 'model-change') || Predicate.isTagged(entry, 'agent_started')) return entry.model } return null @@ -118,7 +120,7 @@ const activeModelFromEntries = (entries: ReadonlyArray, rootAgentId: s const credentialSummary = (model: ActiveModel | null, options: CliSessionOptions): Effect.Effect => Effect.gen(function* () { - if (model === null) return { _tag: 'unknown', detail: 'no active model row found in the session log' } + if (model === null) return CredentialSummary.unknown({ detail: 'no active model row found in the session log' }) if (model.providerKind === 'codex') { const store = makeCodexAuthStore({ @@ -126,14 +128,16 @@ const credentialSummary = (model: ActiveModel | null, options: CliSessionOptions ...(options.foldHome === undefined ? {} : { path: join(options.foldHome, 'auth.json') }), }) const token = yield* store.load - if (Option.isNone(token)) return { _tag: 'missing', detail: `entry "${model.providerId}" in ${store.path}` } + if (Option.isNone(token)) { + return CredentialSummary.missing({ detail: `entry "${model.providerId}" in ${store.path}` }) + } const now = yield* Clock.currentTimeMillis const expiry = token.value.isExpired(now) ? 'expired; will refresh on first request' : 'valid' - return { _tag: 'found', detail: `${expiry} entry "${model.providerId}" in ${store.path}` } + return CredentialSummary.found({ detail: `${expiry} entry "${model.providerId}" in ${store.path}` }) } - return { _tag: 'found', detail: `API key resolved for provider "${model.providerId}"` } + return CredentialSummary.found({ detail: `API key resolved for provider "${model.providerId}"` }) }) /** diff --git a/packages/fold-cli/test/TuiGitChanges.vi.test.ts b/packages/fold-cli/test/TuiGitChanges.vi.test.ts index f140d76..e73210b 100644 --- a/packages/fold-cli/test/TuiGitChanges.vi.test.ts +++ b/packages/fold-cli/test/TuiGitChanges.vi.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Effect } from 'effect' +import { Predicate, Effect } from 'effect' import { describe, expect, it } from 'vitest' import { loadGitSnapshot, parsePorcelainV1Z, TREE_SITTER_GRAMMAR_CACHE_STATUS } from '../src/tui/GitChanges' @@ -28,7 +28,7 @@ describe('git changes snapshot', () => { const snapshot = await Effect.runPromise(loadGitSnapshot(root)) expect(snapshot._tag).toBe('ready') - if (snapshot._tag !== 'ready') return + if (!Predicate.isTagged(snapshot, 'ready')) return expect(snapshot.files.map(({ group, path }) => `${group}:${path}`)).toEqual([ 'staged:both.txt', 'unstaged:both.txt', @@ -47,7 +47,7 @@ describe('git changes snapshot', () => { await writeFile(`${root}/both.txt`, 'one\ntwo\nthree\n') const refreshed = await Effect.runPromise(loadGitSnapshot(root)) expect(refreshed._tag).toBe('ready') - if (refreshed._tag !== 'ready') return + if (!Predicate.isTagged(refreshed, 'ready')) return expect(refreshed.files.find((file) => file.key === 'unstaged:both.txt')?.patchHash).not.toBe(previousHash) }) diff --git a/packages/fold-cli/test/tui/ModelSelection.vi.test.ts b/packages/fold-cli/test/tui/ModelSelection.vi.test.ts index 17693e9..de068f1 100644 --- a/packages/fold-cli/test/tui/ModelSelection.vi.test.ts +++ b/packages/fold-cli/test/tui/ModelSelection.vi.test.ts @@ -1,3 +1,4 @@ +import { Predicate } from 'effect' import { describe, expect, it } from 'vitest' import { requestToLaunchOptions, sessionToLaunchOptions } from '../../src/tui/LaunchRequests' @@ -24,7 +25,7 @@ const configuration = { const requirePickerState = (state: ReturnType): ModelPickerState => { if (state === null) throw new Error('Expected model picker to advance') - if (state._tag === 'direct' || (state._tag === 'profile' && 'profile' in state)) + if (Predicate.isTagged(state, 'direct') || (Predicate.isTagged(state, 'profile') && 'profile' in state)) throw new Error('Expected an intermediate model picker state') return state } diff --git a/packages/fold-codex/examples/CodexAgent.ts b/packages/fold-codex/examples/CodexAgent.ts index a4e5c37..176984c 100644 --- a/packages/fold-codex/examples/CodexAgent.ts +++ b/packages/fold-codex/examples/CodexAgent.ts @@ -13,7 +13,7 @@ import { join } from 'node:path' import { codingTools, jsonlEventLog } from '@humanlayer/fold-agent' import { defineAgent, startSession } from '@humanlayer/fold-core' -import { Console, Effect } from 'effect' +import { Predicate, Console, Effect } from 'effect' import { codexModel } from '../src/index' @@ -46,7 +46,9 @@ const program = Effect.gen(function* () { yield* Console.log(`finished: ${finished.outcome}`) yield* Console.log(`result: ${finished.resultText ?? '(no text)'}`) yield* Console.log(`log rows: ${entries.length} (persisted to ${logPath})`) - yield* Console.log(`tools used: ${entries.filter((entry) => entry._tag === 'tool-result').length} tool results`) + yield* Console.log( + `tools used: ${entries.filter((entry) => Predicate.isTagged(entry, 'tool-result')).length} tool results`, + ) }).pipe(Effect.scoped) Effect.runPromise(program).catch((error) => { diff --git a/packages/fold-codex/src/CodexModel.ts b/packages/fold-codex/src/CodexModel.ts index e5b309c..495766c 100644 --- a/packages/fold-codex/src/CodexModel.ts +++ b/packages/fold-codex/src/CodexModel.ts @@ -17,7 +17,7 @@ import { OpenAiClient, OpenAiLanguageModel } from '@effect/ai-openai' import type * as OpenAiSchema from '@effect/ai-openai/OpenAiSchema' import { customModel, resolveCodexReasoning } from '@humanlayer/fold-core' import type { ReasoningLevel, FoldModel } from '@humanlayer/fold-core' -import { Context, Duration, Effect, Layer, Option, Schedule, Schema, Stream } from 'effect' +import { Match, Context, Duration, Effect, Layer, Option, Schedule, Schema, Stream } from 'effect' import type { Scope } from 'effect' import { AiError } from 'effect/unstable/ai' import type { LanguageModel } from 'effect/unstable/ai' @@ -276,15 +276,17 @@ export const makeCodexLanguageModel = ( }) const reasoning = resolveCodexReasoning(options.reasoning ?? 'off') + const reasoningConfig = Match.valueTags(reasoning, { + disabled: () => ({}), + effort: ({ effort, summary }) => ({ reasoning: { effort, summary } }), + }) return yield* OpenAiLanguageModel.make({ model: options.model ?? DEFAULT_CODEX_MODEL_ID, config: { // The ChatGPT backend does no server-side response storage (clanka parity). store: false, - ...(reasoning._tag === 'disabled' - ? {} - : { reasoning: { effort: reasoning.effort, summary: reasoning.summary } }), + ...reasoningConfig, }, }).pipe(Effect.provideService(OpenAiClient.OpenAiClient, codexClient)) }) diff --git a/packages/fold-core/examples/AutoCompactAgent.ts b/packages/fold-core/examples/AutoCompactAgent.ts index 8a0510c..d324f0a 100644 --- a/packages/fold-core/examples/AutoCompactAgent.ts +++ b/packages/fold-core/examples/AutoCompactAgent.ts @@ -10,7 +10,7 @@ * * Run: OPENAI_API_KEY=... bun packages/fold-core/examples/AutoCompactAgent.ts */ -import { Console, Effect } from 'effect' +import { Predicate, Console, Effect } from 'effect' import { defineAgent, openaiModel, startSession, type CompactionLogEntry } from '../src/index' @@ -57,7 +57,9 @@ const makeProgram = (key: string) => yield* Console.log(` -> ${second.resultText ?? '(no text)'}`) const entries = yield* session.entries - const compactions = entries.filter((entry): entry is CompactionLogEntry => entry._tag === 'compaction') + const compactions = entries.filter((entry): entry is CompactionLogEntry => + Predicate.isTagged(entry, 'compaction'), + ) yield* Console.log(`\nlog: ${entries.map((entry) => entry._tag).join(' -> ')}`) diff --git a/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts b/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts index 0a3e471..ba0fb27 100644 --- a/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts +++ b/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts @@ -10,7 +10,7 @@ * those deltas are ephemeral and never persisted. Model provider failures become durable error + * agent-finished entries, never service failures. */ -import { Array as Arr, Effect, Layer, Predicate, Ref, Result, Schema, Stream } from 'effect' +import { Array as Arr, Data, Effect, Layer, Predicate, Ref, Result, Schema, Stream } from 'effect' import { LanguageModel, Prompt, type Response, type Tool, type Toolkit } from 'effect/unstable/ai' import { AgentEvents } from '../AgentEvents/AgentEventsService' @@ -18,13 +18,14 @@ import { CompactionArchiveAccess } from '../Compaction/CompactionArchiveAccess' import { isContextOverflowError } from '../Compaction/CompactionEngine' import { Compaction, type CompactionService, type CompactionTrigger } from '../Compaction/CompactionService' import { EventLog } from '../EventLog/EventLogService' -import type { - ActiveModel, - AgentFinishedLogEntry, - AgentFinishedOutcome, - CompactionLogEntry, - LogEntry, - LogEntryInput, +import { + LogEntryInputs, + type ActiveModel, + type AgentFinishedLogEntry, + type AgentFinishedOutcome, + type CompactionLogEntry, + type LogEntry, + type LogEntryInput, } from '../EventLog/Schemas' import { usageFromResponseUsage } from '../EventLog/Usage' import { HookRunner } from '../HookRunner/HookRunnerService' @@ -74,6 +75,7 @@ const encodeAssistantMessage = Schema.encodeUnknownSync(Prompt.AssistantMessage) /** Result of one private model/tool turn. */ type TurnResult = { readonly _tag: 'finished'; readonly entry: AgentFinishedLogEntry } | { readonly _tag: 'continue' } +const TurnResult = Data.taggedEnum() type CompactionEnvelope = Pick @@ -152,14 +154,15 @@ export const liveAgentRuntimeLayer: Layer.Layer< const appendUserMessage = (input: RunAgentInput, text: string): Effect.Effect => ids.makeMessageId.pipe( Effect.flatMap((messageId) => - appendToEventLog({ - _tag: 'user-message', - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - messageId, - message: encodeUserMessage(Prompt.userMessage({ content: [Prompt.textPart({ text })] })), - }), + appendToEventLog( + LogEntryInputs['user-message']({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + messageId, + message: encodeUserMessage(Prompt.userMessage({ content: [Prompt.textPart({ text })] })), + }), + ), ), Effect.asVoid, ) @@ -171,17 +174,18 @@ export const liveAgentRuntimeLayer: Layer.Layer< reason: string | null, ): Effect.Effect => Effect.gen(function* () { - const entry = yield* appendToEventLog({ - _tag: 'agent-finished', - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - outcome, - resultText, - reason, - }) + const entry = yield* appendToEventLog( + LogEntryInputs['agent-finished']({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + outcome, + resultText, + reason, + }), + ) - if (entry._tag === 'agent-finished') return entry + if (Predicate.isTagged(entry, 'agent-finished')) return entry // Invariant! return yield* Effect.die(new Error(`EventLog returned ${entry._tag} while appending agent-finished`)) @@ -232,15 +236,16 @@ export const liveAgentRuntimeLayer: Layer.Layer< .pipe(Effect.provideService(LanguageModel.LanguageModel, languageModel), Effect.result) if (Result.isFailure(planned)) { - yield* appendToEventLog({ - _tag: 'error', - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - errorType: 'compaction', - message: planned.failure.message, - details: { trigger }, - }) + yield* appendToEventLog( + LogEntryInputs['error']({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + errorType: 'compaction', + message: planned.failure.message, + details: { trigger }, + }), + ) return null } @@ -251,20 +256,21 @@ export const liveAgentRuntimeLayer: Layer.Layer< trigger, }) - const entry = yield* appendToEventLog({ - _tag: 'compaction', - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - compactionId: yield* ids.makeCompactionId, - prompt: planned.success.prompt, - summary: planned.success.summary, - ...(postCompactionInstructions === null ? {} : { postCompactionInstructions }), - replacesThroughSeq: planned.success.replacesThroughSeq, - tokensBefore: planned.success.tokensBefore, - }) + const entry = yield* appendToEventLog( + LogEntryInputs['compaction']({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + compactionId: yield* ids.makeCompactionId, + prompt: planned.success.prompt, + summary: planned.success.summary, + ...(postCompactionInstructions === null ? {} : { postCompactionInstructions }), + replacesThroughSeq: planned.success.replacesThroughSeq, + tokensBefore: planned.success.tokensBefore, + }), + ) - if (entry._tag === 'compaction') return entry + if (Predicate.isTagged(entry, 'compaction')) return entry return yield* Effect.die(new Error(`EventLog returned ${entry._tag} while appending compaction`)) }) @@ -309,7 +315,9 @@ export const liveAgentRuntimeLayer: Layer.Layer< .preRequest({ agentId: input.agentId, parentAgentId: input.parentAgentId, prompt }) .pipe(Effect.provideService(StopController, stopController), Effect.orDie) - const requestPrompt = preRequestDecision._tag === 'changed' ? preRequestDecision.prompt : prompt + const requestPrompt = Predicate.isTagged(preRequestDecision, 'changed') + ? preRequestDecision.prompt + : prompt // Both stop tracks bind before the model call: this run's own StopController (a preRequest // hook requested it) and the session-wide signal (D9 - external Session.stop reaches every @@ -318,7 +326,7 @@ export const liveAgentRuntimeLayer: Layer.Layer< (yield* Ref.get(stopRef)) ?? (yield* sessionControls.sessionStopReason) if (stopReasonAfterPreRequest !== null) { const entry = yield* appendFinished(input, 'stopped', null, stopReasonAfterPreRequest) - return { _tag: 'finished', entry } as const + return TurnResult.finished({ entry }) } // Advertise only the epoch's active toolset (agent_started/tools-change fold): the installed @@ -383,21 +391,22 @@ export const liveAgentRuntimeLayer: Layer.Layer< runtimeState.activeModel, 'overflow', ) - if (recovered) return { _tag: 'continue' } as const + if (recovered) return TurnResult.continue() } - yield* appendToEventLog({ - _tag: 'error', - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - errorType: 'model', - message, - details: {}, - }) + yield* appendToEventLog( + LogEntryInputs['error']({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + errorType: 'model', + message, + details: {}, + }), + ) const entry = yield* appendFinished(input, 'error', null, message) - return { _tag: 'finished', entry } as const + return TurnResult.finished({ entry }) } const parts: ReadonlyArray = modelParts.success @@ -409,23 +418,24 @@ export const liveAgentRuntimeLayer: Layer.Layer< if (assistantMessage === undefined) { const entry = yield* appendFinished(input, 'completed', null, null) - return { _tag: 'finished', entry } as const + return TurnResult.finished({ entry }) } const persistedAssistant = yield* rewriteAssistantToolCallIds(assistantMessage) - yield* appendToEventLog({ - _tag: 'assistant-message', - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - messageId: yield* ids.makeMessageId, - message: encodeAssistantMessage(persistedAssistant), - finish: - finishPart === undefined - ? null - : { reason: finishPart.reason, usage: usageFromResponseUsage(finishPart.usage) }, - }) + yield* appendToEventLog( + LogEntryInputs['assistant-message']({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + messageId: yield* ids.makeMessageId, + message: encodeAssistantMessage(persistedAssistant), + finish: + finishPart === undefined + ? null + : { reason: finishPart.reason, usage: usageFromResponseUsage(finishPart.usage) }, + }), + ) const toolCalls = persistedAssistant.content.flatMap((part) => part.type === 'tool-call' ? [{ name: part.name, params: part.params }] : [], @@ -444,7 +454,7 @@ export const liveAgentRuntimeLayer: Layer.Layer< if (settlement.stopRequested) { const entry = yield* appendFinished(input, 'stopped', null, 'a tool or hook requested a stop') - return { _tag: 'finished', entry } as const + return TurnResult.finished({ entry }) } // D9: a session-wide stop lets the in-flight batch finish and its results land (above), @@ -452,15 +462,15 @@ export const liveAgentRuntimeLayer: Layer.Layer< const sessionStopAfterBatch = yield* sessionControls.sessionStopReason if (sessionStopAfterBatch !== null) { const entry = yield* appendFinished(input, 'stopped', null, sessionStopAfterBatch) - return { _tag: 'finished', entry } as const + return TurnResult.finished({ entry }) } if (doomLoop.reason !== null) { const entry = yield* appendFinished(input, 'stopped', null, doomLoop.reason) - return { _tag: 'finished', entry } as const + return TurnResult.finished({ entry }) } - return { _tag: 'continue' } as const + return TurnResult.continue() } yield* Ref.set(doomLoopRef, initialDoomLoopState) @@ -474,12 +484,12 @@ export const liveAgentRuntimeLayer: Layer.Layer< const stopReasonAfterComplete = yield* Ref.get(stopRef) if (stopReasonAfterComplete !== null) { const entry = yield* appendFinished(input, 'stopped', resultText, stopReasonAfterComplete) - return { _tag: 'finished', entry } as const + return TurnResult.finished({ entry }) } - if (onCompleteDecision._tag === 'continueWith') { + if (Predicate.isTagged(onCompleteDecision, 'continueWith')) { yield* appendUserMessage(input, onCompleteDecision.text) - return { _tag: 'continue' } as const + return TurnResult.continue() } // D8: follow-ups queued while this agent was running drain exactly where the run would @@ -489,11 +499,11 @@ export const liveAgentRuntimeLayer: Layer.Layer< for (const text of followUps) { yield* appendUserMessage(input, text) } - return { _tag: 'continue' } as const + return TurnResult.continue() } const entry = yield* appendFinished(input, 'completed', resultText, null) - return { _tag: 'finished', entry } as const + return TurnResult.finished({ entry }) }) /** Shared epoch fields: whose log rows these are, and which model's leading prompt to compose. */ @@ -518,17 +528,18 @@ export const liveAgentRuntimeLayer: Layer.Layer< const blocks = yield* systemPrompt.compose({ model: input.model, agentBlocks }) if (Arr.isReadonlyArrayNonEmpty(blocks)) { - yield* appendToEventLog({ - _tag: 'system-message', - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - messageId: yield* ids.makeMessageId, - messages: Arr.map(blocks, (content, index) => - encodeSystemMessage(leadingSystemMessageFor(content, index === blocks.length - 1)), - ), - placement: 'leading', - }) + yield* appendToEventLog( + LogEntryInputs['system-message']({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + messageId: yield* ids.makeMessageId, + messages: Arr.map(blocks, (content, index) => + encodeSystemMessage(leadingSystemMessageFor(content, index === blocks.length - 1)), + ), + placement: 'leading', + }), + ) } }) @@ -538,19 +549,20 @@ export const liveAgentRuntimeLayer: Layer.Layer< // prompt block set once for the starting model; both are recorded durably. const resolvedToolset = yield* toolsetResolver.resolve({ model: input.model }) - const entry = yield* appendToEventLog({ - _tag: 'agent_started', - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - mode: input.mode, - model: input.model, - ...(input.promptCacheKey == null ? {} : { promptCacheKey: input.promptCacheKey }), - tools: resolvedToolset.names, - skill: input.skill, - fork: input.fork, - agentType: input.agentType, - }) + const entry = yield* appendToEventLog( + LogEntryInputs['agent_started']({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + mode: input.mode, + model: input.model, + ...(input.promptCacheKey == null ? {} : { promptCacheKey: input.promptCacheKey }), + tools: resolvedToolset.names, + skill: input.skill, + fork: input.fork, + agentType: input.agentType, + }), + ) // A fork appends no leading system message: its projection folds the forked-from agent's // history, leading blocks included, keeping the fork's prompt prefix byte-identical for @@ -559,7 +571,7 @@ export const liveAgentRuntimeLayer: Layer.Layer< yield* appendLeadingSystemMessage(input) } - if (entry._tag === 'agent_started') return entry + if (Predicate.isTagged(entry, 'agent_started')) return entry // Invariant! return yield* Effect.die(new Error(`EventLog returned ${entry._tag} while appending agent_started`)) @@ -574,40 +586,43 @@ export const liveAgentRuntimeLayer: Layer.Layer< // The next run's projection binds them; the caller swaps the LanguageModel layer (D15). const previousLevel = runtimeForAgent(yield* collectEntries, input.agentId).reasoningLevel - yield* appendToEventLog({ - _tag: 'model-change', - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - model: input.model, - reason: input.reason, - }) + yield* appendToEventLog( + LogEntryInputs['model-change']({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + model: input.model, + reason: input.reason, + }), + ) yield* appendLeadingSystemMessage(input) const resolvedToolset = yield* toolsetResolver.resolve({ model: input.model }) - yield* appendToEventLog({ - _tag: 'tools-change', - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - tools: resolvedToolset.names, - reason: input.reason, - }) + yield* appendToEventLog( + LogEntryInputs['tools-change']({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + tools: resolvedToolset.names, + reason: input.reason, + }), + ) // Reasoning is part of the switched configuration: when the incoming model's requested // level differs from the projected level, the change lands as its own durable fact. The // model-change fold already rebinds the level (D23 - not an epoch boundary), so this entry // is written for log legibility and skipped when nothing changed. if (previousLevel !== input.model.requestedReasoningLevel) { - yield* appendToEventLog({ - _tag: 'thinking-change', - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - reasoningLevel: input.model.requestedReasoningLevel, - reason: input.reason, - }) + yield* appendToEventLog( + LogEntryInputs['thinking-change']({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + reasoningLevel: input.model.requestedReasoningLevel, + reason: input.reason, + }), + ) } }), ) @@ -629,7 +644,7 @@ export const liveAgentRuntimeLayer: Layer.Layer< while (true) { const turn = yield* runTurn(input, stopController, stopRef, overflowRecoveryRef, doomLoopRef) - if (turn._tag === 'finished') return turn.entry + if (Predicate.isTagged(turn, 'finished')) return turn.entry } }), ) diff --git a/packages/fold-core/src/Api/EventLogDescriptor.ts b/packages/fold-core/src/Api/EventLogDescriptor.ts index b7c4fb3..506fadc 100644 --- a/packages/fold-core/src/Api/EventLogDescriptor.ts +++ b/packages/fold-core/src/Api/EventLogDescriptor.ts @@ -5,7 +5,7 @@ * SQLite/Durable Object backends) contribute an EventLog service implementation without any layer * appearing in a public signature. */ -import type { Effect, Scope } from 'effect' +import { Data, type Effect, type Scope } from 'effect' import type { EventLogService } from '../EventLog/EventLogService' @@ -17,15 +17,15 @@ export type FoldEventLog = readonly make: Effect.Effect } +const FoldEventLog = Data.taggedEnum() + /** Keep the session log in memory: fast, isolated, and gone when the session scope closes. */ -export const memoryEventLog = (): FoldEventLog => ({ _tag: 'memory' }) +export const memoryEventLog = (): FoldEventLog => FoldEventLog.memory() /** * Back the session log with a caller-supplied EventLog service implementation. The effect runs once in * 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 => ({ - _tag: 'source', - make, -}) +export const eventLogSource = (make: Effect.Effect): FoldEventLog => + FoldEventLog.source({ make }) diff --git a/packages/fold-core/src/Api/ModelDescriptor.ts b/packages/fold-core/src/Api/ModelDescriptor.ts index 0852524..adeb2bb 100644 --- a/packages/fold-core/src/Api/ModelDescriptor.ts +++ b/packages/fold-core/src/Api/ModelDescriptor.ts @@ -4,7 +4,7 @@ * Session composition root lowers a descriptor to a LanguageModel context when it builds or switches a * runtime, so no client or provider layer wiring appears in caller code (D15's provisioning seam). */ -import { Redacted } from 'effect' +import { Data, Redacted } from 'effect' import type { Effect, Scope } from 'effect' import type { LanguageModel } from 'effect/unstable/ai' @@ -32,6 +32,8 @@ export type FoldModelProvider = readonly make: Effect.Effect } +const FoldModelProvider = Data.taggedEnum() + /** * One model an agent can run on: the resolved ActiveModel snapshot recorded in the durable log plus the * provider connection used to reach it. Built with {@link openaiModel}, {@link anthropicModel}, or @@ -84,7 +86,10 @@ export const openaiModel = (options: ProviderModelOptions): FoldModel => { requestedReasoningLevel: level, reasoning: resolveOpenAiReasoning(level), }, - provider: { _tag: 'openai-compatible', apiKey: redact(options.apiKey), baseUrl: options.baseUrl ?? null }, + provider: FoldModelProvider['openai-compatible']({ + apiKey: redact(options.apiKey), + baseUrl: options.baseUrl ?? null, + }), } } @@ -102,7 +107,7 @@ export const anthropicModel = (options: AnthropicModelOptions): FoldModel => { requestedReasoningLevel: level, thinking: resolveAnthropicThinking(level, model), }, - provider: { _tag: 'anthropic', apiKey: redact(options.apiKey), baseUrl: options.baseUrl ?? null }, + provider: FoldModelProvider.anthropic({ apiKey: redact(options.apiKey), baseUrl: options.baseUrl ?? null }), } } @@ -117,5 +122,5 @@ export type CustomModelOptions = { /** Describe a model backed by a caller-supplied LanguageModel implementation. */ export const customModel = (options: CustomModelOptions): FoldModel => ({ activeModel: options.activeModel, - provider: { _tag: 'custom', make: options.make }, + provider: FoldModelProvider.custom({ make: options.make }), }) diff --git a/packages/fold-core/src/Api/Provisioning.ts b/packages/fold-core/src/Api/Provisioning.ts index 52bccec..9c97281 100644 --- a/packages/fold-core/src/Api/Provisioning.ts +++ b/packages/fold-core/src/Api/Provisioning.ts @@ -18,7 +18,7 @@ */ import { AnthropicClient, AnthropicLanguageModel } from '@effect/ai-anthropic' import { OpenAiClient, OpenAiLanguageModel } from '@effect/ai-openai' -import { Context, Effect, Layer, Stream } from 'effect' +import { Context, Effect, Layer, Match, Stream } from 'effect' import type { Scope } from 'effect' import { LanguageModel, Toolkit } from 'effect/unstable/ai' import type { Tool } from 'effect/unstable/ai' @@ -98,29 +98,26 @@ export type SessionProvisioningServices = export const languageModelLayerFor = (model: FoldModel): Layer.Layer => { const provider = model.provider - switch (provider._tag) { - case 'openai-compatible': { + return Match.valueTags(provider, { + 'openai-compatible': (connection) => { const clientLayer = OpenAiClient.layer({ - apiKey: provider.apiKey, - ...(provider.baseUrl === null ? {} : { apiUrl: provider.baseUrl }), + apiKey: connection.apiKey, + ...(connection.baseUrl === null ? {} : { apiUrl: connection.baseUrl }), }).pipe(Layer.provide(FetchHttpClient.layer)) return OpenAiLanguageModel.layer({ model: model.activeModel.modelId }).pipe(Layer.provide(clientLayer)) - } - - case 'anthropic': { + }, + anthropic: (connection) => { const clientLayer = AnthropicClient.layer({ - apiKey: provider.apiKey, + apiKey: connection.apiKey, transformClient: relaxAnthropicResponseModel(model.activeModel.modelId), - ...(provider.baseUrl === null ? {} : { apiUrl: provider.baseUrl }), + ...(connection.baseUrl === null ? {} : { apiUrl: connection.baseUrl }), }).pipe(Layer.provide(FetchHttpClient.layer)) return AnthropicLanguageModel.layer({ model: model.activeModel.modelId }).pipe(Layer.provide(clientLayer)) - } - - case 'custom': - return Layer.effect(LanguageModel.LanguageModel, provider.make) - } + }, + custom: (connection) => Layer.effect(LanguageModel.LanguageModel, connection.make), + }) } /** Assemble realized tool descriptors into the installed Toolset layer for one provisioned runtime. */ diff --git a/packages/fold-core/src/Api/StartSession.ts b/packages/fold-core/src/Api/StartSession.ts index 45cbd32..9d3585a 100644 --- a/packages/fold-core/src/Api/StartSession.ts +++ b/packages/fold-core/src/Api/StartSession.ts @@ -27,7 +27,21 @@ * e.g. a changed skills roster - D20 rule), the facade writes one epoch transition before the first * send. */ -import { Cause, Context, Effect, Exit, Fiber, Layer, Ref, Schema, Scope, Semaphore, Stream } from 'effect' +import { + Predicate, + Cause, + Context, + Effect, + Exit, + Fiber, + Layer, + Match, + Ref, + Schema, + Scope, + Semaphore, + Stream, +} from 'effect' import { Prompt } from 'effect/unstable/ai' import { toolEventSinkLayerFromAgentEvents, liveAgentEventsLayer } from '../AgentEvents/AgentEventsLayer' @@ -42,13 +56,14 @@ import { compactionServiceFor } from '../Compaction/CompactionLayer' import { Compaction } from '../Compaction/CompactionService' import { layerInMemoryEventLogWithIds } from '../EventLog/EventLogLayerMemory' import { EventLog, type EventLogService } from '../EventLog/EventLogService' -import type { - AgentFinishedLogEntry, - AssistantMessageLogEntry, - CompactionLogEntry, - LogEntry, - LogSeq, - ToolResultLogEntry, +import { + LogEntryInputs, + type AgentFinishedLogEntry, + type AssistantMessageLogEntry, + type CompactionLogEntry, + type LogEntry, + type LogSeq, + type ToolResultLogEntry, } from '../EventLog/Schemas' import { Ids, layerLiveIdFactory, type AgentId, type IdsService, type SessionId } from '../Ids' import { ModelCatalog, modelCatalogFromEntries, type ModelCatalogEntry } from '../Model/ModelCatalog' @@ -85,7 +100,7 @@ import { Subagents, type SubagentsService } from '../Subagents/SubagentsService' import { makeSystemPrompt } from '../SystemPrompt/SystemPromptLayer' import { SystemPrompt, type SystemPromptService } from '../SystemPrompt/SystemPromptService' import type { AgentDefinition } from './AgentDefinition' -import type { FoldEventLog } from './EventLogDescriptor' +import { memoryEventLog, type FoldEventLog } from './EventLogDescriptor' import type { FoldModel } from './ModelDescriptor' import { AgentProvisioner, makeAgentProvisioner, validateToolNames } from './Provisioning' import type { RealizedFoldTool, SessionToolContribution, FoldTool } from './ToolDefinition' @@ -261,7 +276,10 @@ type SessionAgentConfig = { /** Lower the event log descriptor to its EventLog layer. */ const eventLogLayerFor = (log: FoldEventLog): Layer.Layer => - log._tag === 'memory' ? layerInMemoryEventLogWithIds : Layer.effect(EventLog, log.make) + Match.valueTags(log, { + memory: () => layerInMemoryEventLogWithIds, + source: ({ make }) => Layer.effect(EventLog, make), + }) /** Fold a leading-prompt config value into an ordered block list. */ const promptBlocksOf = (systemPrompt: string | ReadonlyArray | null): ReadonlyArray => @@ -432,7 +450,7 @@ const assembleSessionGraph = (options: { // carries its own agent's hook chains (D16/D21). const idsLayer = layerLiveIdFactory const infraLayer = Layer.mergeAll( - eventLogLayerFor(options.log ?? { _tag: 'memory' }).pipe(Layer.provide(idsLayer)), + eventLogLayerFor(options.log ?? memoryEventLog()).pipe(Layer.provide(idsLayer)), idsLayer, liveAgentEventsLayer, ) @@ -574,16 +592,12 @@ const makeSessionHandle = (graph: SessionGraph, identity: StartedSession): FoldS collectEntries.pipe( Effect.flatMap((entries) => { const resolution = resolveAgentIdRef(agentIdsFromEntries(entries), ref) - switch (resolution._tag) { - case 'resolved': - return Effect.succeed(resolution.agentId) - case 'not-found': - return Effect.fail(new SubagentNotFoundError({ requested: ref })) - case 'ambiguous': - return Effect.fail( - new SubagentNotFoundError({ requested: ref, candidates: resolution.candidates }), - ) - } + return Match.valueTags(resolution, { + resolved: ({ agentId }) => Effect.succeed(agentId), + 'not-found': () => Effect.fail(new SubagentNotFoundError({ requested: ref })), + ambiguous: ({ candidates }) => + Effect.fail(new SubagentNotFoundError({ requested: ref, candidates })), + }) }), ) @@ -593,7 +607,7 @@ const makeSessionHandle = (graph: SessionGraph, identity: StartedSession): FoldS Effect.flatMap((entries) => { const finished = entries.findLast( (entry): entry is AgentFinishedLogEntry => - entry._tag === 'agent-finished' && entry.agentId === agentId, + Predicate.isTagged(entry, 'agent-finished') && entry.agentId === agentId, ) return finished === undefined ? Effect.die(new Error(`agent ${agentId} has no terminal marker after its run ended`)) @@ -616,20 +630,23 @@ const makeSessionHandle = (graph: SessionGraph, identity: StartedSession): FoldS const entries = yield* collectEntries const finishedThisRun = entries.some( (entry) => - entry._tag === 'agent-finished' && entry.agentId === rootAgentId && entry.seq > baselineSeq, + Predicate.isTagged(entry, 'agent-finished') && + entry.agentId === rootAgentId && + entry.seq > baselineSeq, ) if (finishedThisRun) return yield* eventLog - .append({ - _tag: 'agent-finished', - agentId: rootAgentId, - parentAgentId: null, - toolCallId: null, - outcome: 'interrupted', - resultText: null, - reason: 'interrupted by the user', - }) + .append( + LogEntryInputs['agent-finished']({ + agentId: rootAgentId, + parentAgentId: null, + toolCallId: null, + outcome: 'interrupted', + resultText: null, + reason: 'interrupted by the user', + }), + ) .pipe(Effect.orDie, Effect.asVoid) }) @@ -742,55 +759,57 @@ const makeSessionHandle = (graph: SessionGraph, identity: StartedSession): FoldS const target = options?.agentId === undefined ? rootAgentId : yield* resolveTarget(options.agentId) const toolCallId = yield* ids.makeToolCallId const call = yield* eventLog - .append({ - _tag: 'assistant-message', - agentId: target, - parentAgentId: null, - toolCallId: null, - messageId: yield* ids.makeMessageId, - message: encodeAssistantMessage( - Prompt.assistantMessage({ - content: [ - Prompt.toolCallPart({ - id: toolCallId, - name: 'skill', - params: { name }, - providerExecuted: false, - }), - ], - }), - ), - finish: null, - }) + .append( + LogEntryInputs['assistant-message']({ + agentId: target, + parentAgentId: null, + toolCallId: null, + messageId: yield* ids.makeMessageId, + message: encodeAssistantMessage( + Prompt.assistantMessage({ + content: [ + Prompt.toolCallPart({ + id: toolCallId, + name: 'skill', + params: { name }, + providerExecuted: false, + }), + ], + }), + ), + finish: null, + }), + ) .pipe(Effect.orDie) - if (call._tag !== 'assistant-message') { + if (!Predicate.isTagged(call, 'assistant-message')) { return yield* Effect.die(new Error(`EventLog returned ${call._tag} while injecting skill call`)) } const result = yield* eventLog - .append({ - _tag: 'tool-result', - agentId: target, - parentAgentId: null, - toolCallId, - messageId: yield* ids.makeMessageId, - message: encodeToolMessage( - Prompt.toolMessage({ - content: [ - Prompt.toolResultPart({ - id: toolCallId, - name: 'skill', - result: { content }, - isFailure: false, - providerExecuted: false, - }), - ], - }), - ), - executedInput: { name }, - }) + .append( + LogEntryInputs['tool-result']({ + agentId: target, + parentAgentId: null, + toolCallId, + messageId: yield* ids.makeMessageId, + message: encodeToolMessage( + Prompt.toolMessage({ + content: [ + Prompt.toolResultPart({ + id: toolCallId, + name: 'skill', + result: { content }, + isFailure: false, + providerExecuted: false, + }), + ], + }), + ), + executedInput: { name }, + }), + ) .pipe(Effect.orDie) - if (result._tag !== 'tool-result') { + if (!Predicate.isTagged(result, 'tool-result')) { return yield* Effect.die(new Error(`EventLog returned ${result._tag} while injecting skill result`)) } return { call, result } @@ -878,14 +897,15 @@ const makeSessionHandle = (graph: SessionGraph, identity: StartedSession): FoldS const setProfile = (role: ProfileRole, model: FoldModel): Effect.Effect => profiles.set(role, model) const setTitle: FoldSession['setTitle'] = (title, provenance) => graph.eventLog - .append({ - _tag: 'session_title', - agentId: null, - parentAgentId: null, - toolCallId: null, - title, - ...provenance, - }) + .append( + LogEntryInputs['session_title']({ + agentId: null, + parentAgentId: null, + toolCallId: null, + title, + ...provenance, + }), + ) .pipe(Effect.asVoid, Effect.orDie) return { @@ -947,8 +967,8 @@ export const resumeSession = (options: ResumeSessionOptions): Effect.Effect => collected), ) - const sessionStarted = entries.find((entry) => entry._tag === 'session_started') - if (sessionStarted === undefined || sessionStarted._tag !== 'session_started') { + const sessionStarted = entries.find((entry) => Predicate.isTagged(entry, 'session_started')) + if (sessionStarted === undefined || !Predicate.isTagged(sessionStarted, 'session_started')) { return yield* Effect.die( new Error('cannot resume: the log has no session_started row (use startSession for a fresh log)'), ) @@ -972,12 +992,12 @@ export const resumeSession = (options: ResumeSessionOptions): Effect.Effect - entry._tag === 'system-message' && + Predicate.isTagged(entry, 'system-message') && entry.agentId === identity.rootAgentId && entry.placement === 'leading', ) const loggedBlocks = - loggedLeading !== undefined && loggedLeading._tag === 'system-message' + loggedLeading !== undefined && Predicate.isTagged(loggedLeading, 'system-message') ? loggedLeading.messages.map((message) => message.content) : [] diff --git a/packages/fold-core/src/Compaction/CompactionEngine.ts b/packages/fold-core/src/Compaction/CompactionEngine.ts index 88f204d..9b1e807 100644 --- a/packages/fold-core/src/Compaction/CompactionEngine.ts +++ b/packages/fold-core/src/Compaction/CompactionEngine.ts @@ -1,3 +1,5 @@ +import { Match, Predicate } from 'effect' + /** * This file is the pure auto-compaction engine (D11): the threshold arithmetic over API-reported * usage, the interim per-model context windows (until ModelCatalog owns limits - D15), the @@ -85,11 +87,12 @@ export const contextTokensFromUsage = (usage: UsageEncoded): number | null => { * one compaction per reported response, never two without a new response in between. */ export const latestReportedContextTokens = (visibleEntries: ReadonlyArray): number | null => { - const compactionSeq = visibleEntries.findLast((entry) => entry._tag === 'compaction')?.seq ?? -1 + const compactionSeq = visibleEntries.findLast((entry) => Predicate.isTagged(entry, 'compaction'))?.seq ?? -1 for (let index = visibleEntries.length - 1; index >= 0; index -= 1) { const entry = visibleEntries[index] - if (entry === undefined || entry._tag !== 'assistant-message' || entry.seq <= compactionSeq) continue + if (entry === undefined || !Predicate.isTagged(entry, 'assistant-message') || entry.seq <= compactionSeq) + continue if (entry.finish === null) continue return contextTokensFromUsage(entry.finish.usage) @@ -142,19 +145,18 @@ const estimatePartChars = (part: EncodedPart): number => { /** Estimate one projected message's token footprint (chars/4 heuristic; image parts weigh 4800 chars). */ export const estimateMessageTokens = (message: ProjectedMessage): number => { - if (message._tag === 'compaction-summary') return Math.max(1, Math.ceil(message.summary.length / 4)) + if (Predicate.isTagged(message, 'compaction-summary')) return Math.max(1, Math.ceil(message.summary.length / 4)) - const chars = - message._tag === 'system-message' - ? message.messages.reduce((total, systemMessage) => total + systemMessage.content.length, 0) - : contentParts(message.message.content).reduce((total, part) => total + estimatePartChars(part), 0) + const chars = Predicate.isTagged(message, 'system-message') + ? message.messages.reduce((total, systemMessage) => total + systemMessage.content.length, 0) + : contentParts(message.message.content).reduce((total, part) => total + estimatePartChars(part), 0) return Math.max(1, Math.ceil(chars / 4)) } /** A message where a compaction cut may land: the first KEPT message must open a coherent exchange. */ const isValidCutMessage = (message: ProjectedMessage): boolean => - message._tag === 'user-message' || message._tag === 'assistant-message' + Predicate.isTagged(message, 'user-message') || Predicate.isTagged(message, 'assistant-message') /** The selected compaction boundary, including split-turn context needed by the summarizer. */ export type CompactionCut = { @@ -168,7 +170,7 @@ export type CompactionCut = { const turnStartBefore = (messages: ReadonlyArray, index: number): number => { for (let candidate = index; candidate >= 0; candidate -= 1) { - if (messages[candidate]?._tag === 'user-message') return candidate + if (Predicate.isTagged(messages[candidate], 'user-message')) return candidate } return -1 @@ -208,7 +210,7 @@ export const findCompactionCutPlan = ( const message = messages[index] if (message === undefined || !isValidCutMessage(message)) continue - const isSplitTurn = message._tag !== 'user-message' + const isSplitTurn = !Predicate.isTagged(message, 'user-message') const turnStartIndex = isSplitTurn ? turnStartBefore(messages, index) : -1 return { firstKeptIndex: index, @@ -221,7 +223,7 @@ export const findCompactionCutPlan = ( const message = messages[index] if (message === undefined || !isValidCutMessage(message)) continue - const isSplitTurn = message._tag !== 'user-message' + const isSplitTurn = !Predicate.isTagged(message, 'user-message') const turnStartIndex = isSplitTurn ? turnStartBefore(messages, index) : -1 return { firstKeptIndex: index, @@ -266,13 +268,12 @@ export const serializeConversation = (messages: ReadonlyArray) const lines: Array = [] for (const message of messages) { - switch (message._tag) { - case 'user-message': - lines.push(`[User]: ${serializeUserContent(message.message.content)}`) - break - - case 'assistant-message': { - const parts = contentParts(message.message.content) + Match.valueTags(message, { + 'user-message': (entry) => { + lines.push(`[User]: ${serializeUserContent(entry.message.content)}`) + }, + 'assistant-message': (entry) => { + const parts = contentParts(entry.message.content) const reasoning = parts.filter((part) => part.type === 'reasoning') const text = parts.filter((part) => part.type === 'text') const toolCalls = parts.filter((part) => part.type === 'tool-call') @@ -290,28 +291,24 @@ export const serializeConversation = (messages: ReadonlyArray) .join('; ')}`, ) } - break - } - - case 'tool-result': - for (const part of contentParts(message.message.content)) { + }, + 'tool-result': (entry) => { + for (const part of contentParts(entry.message.content)) { if (part.type !== 'tool-result') continue lines.push(`[Tool result]: ${truncateToolResult(safeStringify(part.result))}`) } - break - - case 'system-message': + }, + 'system-message': (entry) => { // Only inline system notes reach serialization; the leading block set is config, not // conversation, and the caller excludes it. - for (const systemMessage of message.messages) { + for (const systemMessage of entry.messages) { lines.push(`[System note]: ${systemMessage.content}`) } - break - - case 'compaction-summary': + }, + 'compaction-summary': () => { // Excluded by the caller: the previous summary travels in instead. - break - } + }, + }) } return lines.join('\n') diff --git a/packages/fold-core/src/Compaction/CompactionLayer.ts b/packages/fold-core/src/Compaction/CompactionLayer.ts index 805ce21..664eafa 100644 --- a/packages/fold-core/src/Compaction/CompactionLayer.ts +++ b/packages/fold-core/src/Compaction/CompactionLayer.ts @@ -63,9 +63,9 @@ const conversationOf = ( for (const message of projected) { // The leading block set is configuration, not conversation: it survives compaction untouched // (projection re-inserts it above the summary), so it is neither summarized nor kept-counted. - if (message._tag === 'system-message' && message.placement === 'leading') continue + if (Predicate.isTagged(message, 'system-message') && message.placement === 'leading') continue - if (message._tag === 'compaction-summary') { + if (Predicate.isTagged(message, 'compaction-summary')) { previousSummary = message.summary continue } diff --git a/packages/fold-core/src/EventLog/Schemas.ts b/packages/fold-core/src/EventLog/Schemas.ts index 2c57c01..ab3e5b8 100644 --- a/packages/fold-core/src/EventLog/Schemas.ts +++ b/packages/fold-core/src/EventLog/Schemas.ts @@ -1,4 +1,4 @@ -import { Schema } from 'effect' +import { Data, Schema } from 'effect' import { Prompt, Response } from 'effect/unstable/ai' import { AgentId, CompactionId, EventId, MessageId, SessionId, StateId, ToolCallId } from '../Ids' @@ -643,6 +643,7 @@ export const LogEntryInput = Schema.Union([ ErrorLogEntryInput, ]).annotate({ identifier: 'LogEntryInput', discriminator: '_tag' }) export type LogEntryInput = typeof LogEntryInput.Type +export const LogEntryInputs = Data.taggedEnum() /** Frozen wire schema for persisted v1 entries. Add a new schema rather than changing incompatible v1 fields. */ export const LogEntryV1 = Schema.Union([ diff --git a/packages/fold-core/src/HookRunner/HookRunnerLayer.ts b/packages/fold-core/src/HookRunner/HookRunnerLayer.ts index 45ef575..4265fb0 100644 --- a/packages/fold-core/src/HookRunner/HookRunnerLayer.ts +++ b/packages/fold-core/src/HookRunner/HookRunnerLayer.ts @@ -4,7 +4,7 @@ * the namespace of each read or write comes from the hook's own defineToolState declaration, not its name. * The caller of each hook point provides StopController from the surrounding run or batch. */ -import { Cause, Effect, Layer } from 'effect' +import { Data, Predicate, Cause, Effect, Layer } from 'effect' import { EventLog, type EventLogService } from '../EventLog/EventLogService' import { Ids, type AgentId, type IdsService, type ToolCallId } from '../Ids' @@ -13,9 +13,21 @@ import { toolStateServiceForToolCall } from '../ToolRuntime/ToolStateFactory' import { ToolState } from '../ToolRuntime/ToolStateService' import { HookExecutionError, type HookPhase } from './Errors' import { HookRunner, type HookRunnerService } from './HookRunnerService' -import type { OnCompleteHookInput, PostToolUseHookInput, PreRequestHookInput, PreToolUseHookInput } from './Schema' +import type { + OnCompleteHookDecision, + OnCompleteHookInput, + PostToolUseHookDecision, + PostToolUseHookInput, + PreRequestHookDecision, + PreRequestHookInput, + PreToolUseHookDecision, + PreToolUseHookInput, +} from './Schema' import type { HookScope, OnCompleteHook, PostToolUseHook, PreRequestHook, PreToolUseHook, HookConfig } from './Types.ts' +type HookDecision = PreRequestHookDecision | PreToolUseHookDecision | PostToolUseHookDecision | OnCompleteHookDecision +const HookDecision = Data.taggedEnum() + const isHookConfiguredForTool = (hook: { readonly tools?: ReadonlyArray }, toolName: string): boolean => hook.tools === undefined || hook.tools.includes(toolName) @@ -137,13 +149,13 @@ export const makeHookRunner = (hooks: HookConfig): Layer.Layer() +const CodexReasoning = Data.taggedEnum() +const AnthropicThinking = Data.taggedEnum() + /** * Map one reasoning level onto the OpenAI effort scale. `off` disables reasoning config entirely * (provider default applies); every other level passes straight through - the wire scale includes @@ -26,7 +30,7 @@ import type { * validation at config time (D23/D25). Direct-SDK callers bypass that validation and own the 400 risk. */ export const resolveOpenAiReasoning = (level: ReasoningLevel): OpenAiReasoningSetting => - level === 'off' ? { _tag: 'disabled' } : { _tag: 'effort', effort: level } + level === 'off' ? OpenAiReasoning.disabled() : OpenAiReasoning.effort({ effort: level }) /** * Map one reasoning level onto codex reasoning; codex always requests auto summaries (D23). Same @@ -34,7 +38,7 @@ export const resolveOpenAiReasoning = (level: ReasoningLevel): OpenAiReasoningSe * validated at config time, so no level is clamped here. */ export const resolveCodexReasoning = (level: ReasoningLevel): CodexReasoningSetting => - level === 'off' ? { _tag: 'disabled' } : { _tag: 'effort', effort: level, summary: 'auto' } + level === 'off' ? CodexReasoning.disabled() : CodexReasoning.effort({ effort: level, summary: 'auto' }) /** * Claude models that support adaptive thinking (`thinking: { type: "adaptive" }`): Opus 4.6+, @@ -79,10 +83,10 @@ export const defaultAnthropicThinkingBudgets: Record level === 'off' - ? { _tag: 'disabled' } + ? AnthropicThinking.disabled() : supportsAdaptiveThinking(modelId) - ? { _tag: 'adaptive' } - : { _tag: 'budget', budgetTokens: defaultAnthropicThinkingBudgets[level] } + ? AnthropicThinking.adaptive() + : AnthropicThinking.budget({ budgetTokens: defaultAnthropicThinkingBudgets[level] }) /** * Map one reasoning level onto the anthropic per-request effort knob used alongside adaptive @@ -146,25 +150,31 @@ export const liveModelRequestSettingsLayer: Layer.Layer = case 'openai-compatible': { const setting = level === model.requestedReasoningLevel ? model.reasoning : resolveOpenAiReasoning(level) + const reasoning = Match.valueTags(setting, { + disabled: () => ({}), + effort: ({ effort }) => ({ reasoning: { effort } }), + }) - return (self) => + return (self: Effect.Effect) => OpenAiLanguageModel.withConfigOverride(self, { model: model.modelId, ...(promptCacheKey === null ? {} : { prompt_cache_key: promptCacheKey }), - ...(setting._tag === 'disabled' ? {} : { reasoning: { effort: setting.effort } }), + ...reasoning, }) } case 'codex': { const setting = level === model.requestedReasoningLevel ? model.reasoning : resolveCodexReasoning(level) + const reasoning = Match.valueTags(setting, { + disabled: () => ({}), + effort: ({ effort, summary }) => ({ reasoning: { effort, summary } }), + }) - return (self) => + return (self: Effect.Effect) => OpenAiLanguageModel.withConfigOverride(self, { model: model.modelId, ...(promptCacheKey === null ? {} : { prompt_cache_key: promptCacheKey }), - ...(setting._tag === 'disabled' - ? {} - : { reasoning: { effort: setting.effort, summary: setting.summary } }), + ...reasoning, }) } @@ -179,21 +189,22 @@ export const liveModelRequestSettingsLayer: Layer.Layer = // layer construction until the AgentModels layer seam lands (D15). Tools opt out of strict // structured-output mode at definition time; do not pass the provider's `strictJsonSchema` // config helper here, because this beta provider accidentally forwards it into the API payload. - switch (setting._tag) { - case 'disabled': - return identity - case 'adaptive': - return (self) => + return Match.valueTags(setting, { + disabled: () => identity, + adaptive: + () => + (self: Effect.Effect) => AnthropicLanguageModel.withConfigOverride(self, { thinking: { type: 'adaptive' }, output_config: { effort: anthropicEffortForLevel(level) }, - }) - case 'budget': - return (self) => + }), + budget: + (budget) => + (self: Effect.Effect) => AnthropicLanguageModel.withConfigOverride(self, { - thinking: { type: 'enabled', budget_tokens: setting.budgetTokens }, - }) - } + thinking: { type: 'enabled', budget_tokens: budget.budgetTokens }, + }), + }) } } }, diff --git a/packages/fold-core/src/Model/RequestBuilder.ts b/packages/fold-core/src/Model/RequestBuilder.ts index d18032c..6699504 100644 --- a/packages/fold-core/src/Model/RequestBuilder.ts +++ b/packages/fold-core/src/Model/RequestBuilder.ts @@ -8,7 +8,7 @@ * metadata into history. The assistant tool-call params stay exactly as decoded from the persisted * assistant message, keeping already-sent prompt bytes stable across turns. */ -import { Effect, Option, Schema } from 'effect' +import { Effect, Match, Option, Schema } from 'effect' import { Prompt } from 'effect/unstable/ai' import type { ProjectedMessage } from '../Projection/Projection' @@ -223,47 +223,49 @@ export const buildPrompt = ( const promptMessages: Array = [] for (const projected of messages) { - switch (projected._tag) { - case 'system-message': - for (const encoded of projected.messages) { + yield* Match.valueTags(projected, { + 'system-message': (message) => + Effect.gen(function* () { + for (const encoded of message.messages) { + promptMessages.push( + yield* decodeSystemMessage(encoded).pipe(Effect.mapError(decodeErrorFor(message))), + ) + } + }), + 'user-message': (message) => + decodeUserMessage(message.message).pipe( + Effect.mapError(decodeErrorFor(message)), + Effect.tap((decoded) => Effect.sync(() => promptMessages.push(decoded))), + ), + 'assistant-message': (message) => + decodeAssistantMessage(message.message).pipe( + Effect.mapError(decodeErrorFor(message)), + Effect.tap((decoded) => + Effect.sync(() => + promptMessages.push(restoreAssistantToolCallIds(decoded, providerIdsByFoldId)), + ), + ), + ), + 'tool-result': (result) => + decodeToolMessage(result.message).pipe( + Effect.mapError(decodeErrorFor(result)), + Effect.tap((decoded) => + Effect.sync(() => { + const { message, followUp } = liftImagesFromToolMessage( + restoreToolResultIds(decoded, providerIdsByFoldId), + ) + promptMessages.push(message) + if (followUp !== null) promptMessages.push(followUp) + }), + ), + ), + 'compaction-summary': (summary) => + Effect.sync(() => { promptMessages.push( - yield* decodeSystemMessage(encoded).pipe(Effect.mapError(decodeErrorFor(projected))), + compactionSummaryMessage(summary.summary, summary.postCompactionInstructions), ) - } - break - - case 'user-message': - promptMessages.push( - yield* decodeUserMessage(projected.message).pipe(Effect.mapError(decodeErrorFor(projected))), - ) - break - - case 'assistant-message': { - const decoded = yield* decodeAssistantMessage(projected.message).pipe( - Effect.mapError(decodeErrorFor(projected)), - ) - promptMessages.push(restoreAssistantToolCallIds(decoded, providerIdsByFoldId)) - break - } - - case 'tool-result': { - const decoded = yield* decodeToolMessage(projected.message).pipe( - Effect.mapError(decodeErrorFor(projected)), - ) - const { message, followUp } = liftImagesFromToolMessage( - restoreToolResultIds(decoded, providerIdsByFoldId), - ) - promptMessages.push(message) - if (followUp !== null) promptMessages.push(followUp) - break - } - - case 'compaction-summary': - promptMessages.push( - compactionSummaryMessage(projected.summary, projected.postCompactionInstructions), - ) - break - } + }), + }) } return Prompt.fromMessages(markLatestUserSideCacheBreakpoint(promptMessages)) diff --git a/packages/fold-core/src/Projection/Projection.ts b/packages/fold-core/src/Projection/Projection.ts index 46191a7..515add4 100644 --- a/packages/fold-core/src/Projection/Projection.ts +++ b/packages/fold-core/src/Projection/Projection.ts @@ -1,3 +1,5 @@ +import { Data, Match, Predicate } from 'effect' + import type { ActiveModel, AgentFinishedLogEntry, @@ -69,6 +71,8 @@ export type ProjectedMessage = | ProjectedToolResult | ProjectedCompactionSummary +const ProjectedMessage = Data.taggedEnum() + /** Tool-owned key/value state for one agent namespace, built by folding tool_state entries in log order. */ export type ToolStateProjection = Readonly> @@ -77,12 +81,14 @@ const ownEntriesForAgent = (entries: ReadonlyArray, agentId: AgentId): const findAgentStarted = (entries: ReadonlyArray, agentId: AgentId): AgentStartedLogEntry | null => entries.find( - (entry): entry is AgentStartedLogEntry => entry._tag === 'agent_started' && entry.agentId === agentId, + (entry): entry is AgentStartedLogEntry => + Predicate.isTagged(entry, 'agent_started') && entry.agentId === agentId, ) ?? null const findAgentFinished = (entries: ReadonlyArray, agentId: AgentId): AgentFinishedLogEntry | null => entries.findLast( - (entry): entry is AgentFinishedLogEntry => entry._tag === 'agent-finished' && entry.agentId === agentId, + (entry): entry is AgentFinishedLogEntry => + Predicate.isTagged(entry, 'agent-finished') && entry.agentId === agentId, ) ?? null const compareSeq = (left: LogEntry, right: LogEntry) => left.seq - right.seq @@ -93,13 +99,13 @@ const userMessageText = (entry: UserMessageLogEntry): string => : entry.message.content.flatMap((part) => (part.type === 'text' ? [part.text] : [])).join('') const isInjectedSkillMessage = (entry: LogEntry): boolean => { - if (entry._tag !== 'user-message') return false + if (!Predicate.isTagged(entry, 'user-message')) return false const content = userMessageText(entry).trim() return /^)/.test(content) && content.endsWith('') } const isSettledAssistantText = (entry: LogEntry): boolean => { - if (entry._tag !== 'assistant-message') return false + if (!Predicate.isTagged(entry, 'assistant-message')) return false if (typeof entry.message.content === 'string') return entry.message.content.trim().length > 0 return entry.message.content.length > 0 && entry.message.content.every((part) => part.type === 'text') } @@ -108,13 +114,15 @@ const eligibleForkHistory = ( entries: ReadonlyArray, history: 'all' | 'none' | number, ): ReadonlyArray => { - const leadingSystem = entries.filter((entry) => entry._tag === 'system-message' && entry.placement === 'leading') + const leadingSystem = entries.filter( + (entry) => Predicate.isTagged(entry, 'system-message') && entry.placement === 'leading', + ) if (history === 'none') return leadingSystem let invokingTurnStart = entries.length for (let index = entries.length - 1; index >= 0; index -= 1) { const entry = entries[index] - if (entry?._tag === 'user-message' && !isInjectedSkillMessage(entry)) { + if (Predicate.isTagged(entry, 'user-message') && !isInjectedSkillMessage(entry)) { invokingTurnStart = index break } @@ -124,8 +132,8 @@ const eligibleForkHistory = ( .slice(0, invokingTurnStart) .filter( (entry) => - (entry._tag === 'system-message' && entry.placement === 'leading') || - entry._tag === 'user-message' || + (Predicate.isTagged(entry, 'system-message') && entry.placement === 'leading') || + Predicate.isTagged(entry, 'user-message') || isSettledAssistantText(entry), ) if (history === 'all') return eligible @@ -133,10 +141,13 @@ const eligibleForkHistory = ( let userTurns = 0 for (let index = eligible.length - 1; index >= 0; index -= 1) { const entry = eligible[index] - if (entry?._tag !== 'user-message' || isInjectedSkillMessage(entry)) continue + if (!Predicate.isTagged(entry, 'user-message') || isInjectedSkillMessage(entry)) continue userTurns += 1 if (userTurns === history) - return [...leadingSystem, ...eligible.slice(index).filter((item) => item._tag !== 'system-message')] + return [ + ...leadingSystem, + ...eligible.slice(index).filter((item) => !Predicate.isTagged(item, 'system-message')), + ] } return eligible } @@ -197,24 +208,27 @@ export const runtimeForAgent = (entries: ReadonlyArray, agentId: Agent let promptCacheKey: string | null = null for (const entry of visibleEntries) { - switch (entry._tag) { - case 'agent_started': - activeModel = entry.model - activeTools = entry.tools - reasoningLevel = entry.model.requestedReasoningLevel - promptCacheKey = entry.promptCacheKey ?? null - break - case 'model-change': - activeModel = entry.model - reasoningLevel = entry.model.requestedReasoningLevel - break - case 'thinking-change': - reasoningLevel = entry.reasoningLevel - break - case 'tools-change': - activeTools = entry.tools - break - } + Match.value(entry).pipe( + Match.tags({ + agent_started: (started) => { + activeModel = started.model + activeTools = started.tools + reasoningLevel = started.model.requestedReasoningLevel + promptCacheKey = started.promptCacheKey ?? null + }, + 'model-change': (change) => { + activeModel = change.model + reasoningLevel = change.model.requestedReasoningLevel + }, + 'thinking-change': (change) => { + reasoningLevel = change.reasoningLevel + }, + 'tools-change': (change) => { + activeTools = change.tools + }, + }), + Match.orElse(() => undefined), + ) } return { @@ -235,7 +249,7 @@ export const toolStateForAgent = ( const state: Record = {} for (const entry of ownEntriesForAgent(entries, agentId)) { - if (entry._tag !== 'tool_state' || entry.namespace !== namespace) continue + if (!Predicate.isTagged(entry, 'tool_state') || entry.namespace !== namespace) continue if (entry.value === null) { delete state[entry.key] @@ -249,11 +263,12 @@ export const toolStateForAgent = ( const latestLeadingSystemMessage = (entries: ReadonlyArray): SystemMessageLogEntry | null => entries.findLast( - (entry): entry is SystemMessageLogEntry => entry._tag === 'system-message' && entry.placement === 'leading', + (entry): entry is SystemMessageLogEntry => + Predicate.isTagged(entry, 'system-message') && entry.placement === 'leading', ) ?? null const latestCompaction = (entries: ReadonlyArray): CompactionLogEntry | null => - entries.findLast((entry): entry is CompactionLogEntry => entry._tag === 'compaction') ?? null + entries.findLast((entry): entry is CompactionLogEntry => Predicate.isTagged(entry, 'compaction')) ?? null const toolCallIdsForAssistantMessage = (message: AssistantMessageEncoded): ReadonlyArray => { if (typeof message.content === 'string') return [] @@ -276,7 +291,7 @@ const orderProjectedToolResults = (messages: ReadonlyArray): R ordered.push(message) index += 1 - if (message._tag !== 'assistant-message') continue + if (!Predicate.isTagged(message, 'assistant-message')) continue const toolCallIds = toolCallIdsForAssistantMessage(message.message) if (toolCallIds.length === 0) continue @@ -284,7 +299,7 @@ const orderProjectedToolResults = (messages: ReadonlyArray): R const toolResults: Array = [] while (true) { const toolResult = messages[index] - if (toolResult?._tag !== 'tool-result') break + if (!Predicate.isTagged(toolResult, 'tool-result')) break toolResults.push(toolResult) index += 1 @@ -310,36 +325,38 @@ const orderProjectedToolResults = (messages: ReadonlyArray): R /** Translate one durable message entry into the projection shape used by prompt construction. */ const projectMessageEntry = (entry: LogEntry): ProjectedMessage | null => { - switch (entry._tag) { - case 'system-message': - return { - _tag: 'system-message', - sourceSeq: entry.seq, - messageId: entry.messageId, - placement: entry.placement, - messages: entry.messages, - } - case 'user-message': - return { _tag: 'user-message', sourceSeq: entry.seq, messageId: entry.messageId, message: entry.message } - case 'assistant-message': - return { - _tag: 'assistant-message', - sourceSeq: entry.seq, - messageId: entry.messageId, - message: entry.message, - finish: entry.finish, - } - case 'tool-result': - return { - _tag: 'tool-result', - sourceSeq: entry.seq, - toolCallId: entry.toolCallId, - messageId: entry.messageId, - message: entry.message, - } - } - - return null + return Match.value(entry).pipe( + Match.tags({ + 'system-message': (message) => + ProjectedMessage['system-message']({ + sourceSeq: message.seq, + messageId: message.messageId, + placement: message.placement, + messages: message.messages, + }), + 'user-message': (message) => + ProjectedMessage['user-message']({ + sourceSeq: message.seq, + messageId: message.messageId, + message: message.message, + }), + 'assistant-message': (message) => + ProjectedMessage['assistant-message']({ + sourceSeq: message.seq, + messageId: message.messageId, + message: message.message, + finish: message.finish, + }), + 'tool-result': (message) => + ProjectedMessage['tool-result']({ + sourceSeq: message.seq, + toolCallId: message.toolCallId, + messageId: message.messageId, + message: message.message, + }), + }), + Match.orElse(() => null), + ) } /** @@ -360,32 +377,34 @@ export const messagesForAgent = ( const projected: Array = [] if (leading !== null) { - projected.push({ - _tag: 'system-message', - sourceSeq: leading.seq, - messageId: leading.messageId, - placement: leading.placement, - messages: leading.messages, - }) + projected.push( + ProjectedMessage['system-message']({ + sourceSeq: leading.seq, + messageId: leading.messageId, + placement: leading.placement, + messages: leading.messages, + }), + ) } if (compaction !== null) { - projected.push({ - _tag: 'compaction-summary', - sourceSeq: compaction.seq, - compactionId: compaction.compactionId, - replacesThroughSeq: compaction.replacesThroughSeq, - summary: compaction.summary, - ...(compaction.postCompactionInstructions === undefined - ? {} - : { postCompactionInstructions: compaction.postCompactionInstructions }), - tokensBefore: compaction.tokensBefore, - }) + projected.push( + ProjectedMessage['compaction-summary']({ + sourceSeq: compaction.seq, + compactionId: compaction.compactionId, + replacesThroughSeq: compaction.replacesThroughSeq, + summary: compaction.summary, + ...(compaction.postCompactionInstructions === undefined + ? {} + : { postCompactionInstructions: compaction.postCompactionInstructions }), + tokensBefore: compaction.tokensBefore, + }), + ) } for (const entry of visibleEntries) { if (entry.seq <= cutSeq) continue - if (entry._tag === 'system-message' && entry.placement === 'leading') continue + if (Predicate.isTagged(entry, 'system-message') && entry.placement === 'leading') continue const message = projectMessageEntry(entry) if (message !== null) projected.push(message) diff --git a/packages/fold-core/src/Session/SessionLayer.ts b/packages/fold-core/src/Session/SessionLayer.ts index c0bc741..8223bad 100644 --- a/packages/fold-core/src/Session/SessionLayer.ts +++ b/packages/fold-core/src/Session/SessionLayer.ts @@ -10,7 +10,7 @@ import { AgentEvents } from '../AgentEvents/AgentEventsService' import type { FoldEvent } from '../AgentEvents/AgentEventsService' import { AgentRuntime } from '../AgentRuntime/AgentRuntimeService' import { EventLog } from '../EventLog/EventLogService' -import type { LogSeq } from '../EventLog/Schemas' +import { LogEntryInputs, type LogSeq } from '../EventLog/Schemas' import { Ids } from '../Ids' import { SessionAlreadyStartedError, SessionNotStartedError } from './Errors' import { @@ -57,16 +57,17 @@ export const liveSessionLayer: Layer.Layer } | { readonly _tag: 'source'; readonly make: Effect.Effect } +const FoldSkills = Data.taggedEnum() + /** Configure an agent's skills from in-memory data (isomorphic; browser/worker hosts). */ -export const skillsFromData = (skills: ReadonlyArray): FoldSkills => ({ _tag: 'fromData', skills }) +export const skillsFromData = (skills: ReadonlyArray): FoldSkills => FoldSkills.fromData({ skills }) /** * 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 => ({ - _tag: '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 => - skills._tag === 'fromData' ? skillSourceFromData(skills.skills) : skills.make.pipe(Effect.orDie) + Predicate.isTagged(skills, 'fromData') ? skillSourceFromData(skills.skills) : skills.make.pipe(Effect.orDie) diff --git a/packages/fold-core/src/Subagents/AgentIdRef.ts b/packages/fold-core/src/Subagents/AgentIdRef.ts index cdcecfe..2586487 100644 --- a/packages/fold-core/src/Subagents/AgentIdRef.ts +++ b/packages/fold-core/src/Subagents/AgentIdRef.ts @@ -9,7 +9,7 @@ * cannot themselves be full ids (4-20 characters - a full cuid segment is 21-32). The 4-char floor * matches the CLI renderer's tag suffix, so an id read off a tag is always a valid reference. */ -import { Schema } from 'effect' +import { Data, Predicate, Schema } from 'effect' import type { LogEntry } from '../EventLog/Schemas' import type { AgentId } from '../Ids' @@ -56,6 +56,8 @@ export type AgentIdRefResolution = /** Two or more known ids share the referenced prefix; `candidates` carries their SHORT ids. */ | { readonly _tag: 'ambiguous'; readonly candidates: ReadonlyArray } +const AgentIdRefResolution = Data.taggedEnum() + /** * Resolve one inbound reference against the known agent ids (the log's `agent_started` rows). An exact * full-id match wins immediately; otherwise a reference of 4-20 characters prefix-matches the cuid @@ -66,19 +68,19 @@ export const resolveAgentIdRef = (knownIds: Iterable, ref: string): Age const ids = [...knownIds] const exact = ids.find((id) => id === ref) - if (exact !== undefined) return { _tag: 'resolved', agentId: exact } + if (exact !== undefined) return AgentIdRefResolution.resolved({ agentId: exact }) - if (!prefixRefPattern.test(ref)) return { _tag: 'not-found' } + if (!prefixRefPattern.test(ref)) return AgentIdRefResolution['not-found']() const wanted = cuidSegmentOf(ref) const matches = ids.filter((id) => cuidSegmentOf(id).startsWith(wanted)) const [single] = matches - if (matches.length === 1 && single !== undefined) return { _tag: 'resolved', agentId: single } - if (matches.length === 0) return { _tag: 'not-found' } + if (matches.length === 1 && single !== undefined) return AgentIdRefResolution.resolved({ agentId: single }) + if (matches.length === 0) return AgentIdRefResolution['not-found']() - return { _tag: 'ambiguous', candidates: matches.map(shortAgentId) } + return AgentIdRefResolution.ambiguous({ candidates: matches.map(shortAgentId) }) } /** The known agent ids of a session log: every `agent_started` row's id, in log order. */ export const agentIdsFromEntries = (entries: ReadonlyArray): ReadonlyArray => - entries.flatMap((entry) => (entry._tag === 'agent_started' ? [entry.agentId] : [])) + entries.flatMap((entry) => (Predicate.isTagged(entry, 'agent_started') ? [entry.agentId] : [])) diff --git a/packages/fold-core/src/Subagents/Schemas.ts b/packages/fold-core/src/Subagents/Schemas.ts index 61f950c..ac7f8bb 100644 --- a/packages/fold-core/src/Subagents/Schemas.ts +++ b/packages/fold-core/src/Subagents/Schemas.ts @@ -125,21 +125,19 @@ export const parseSubagentCommand = ( } if (params.agent !== undefined) { - return { - _tag: 'dispatch', + return DispatchSubagentCommand.make({ agent: params.agent, ...(params.description === undefined ? {} : { description: params.description }), prompt: params.prompt, skill, - } as const + }) } if (params.fork === true) { - return { - _tag: 'fork', + return ForkSubagentCommand.make({ ...(params.description === undefined ? {} : { description: params.description }), prompt: params.prompt, skill, - } as const + }) } const agentId = yield* decodeAgentIdRef(params.agent_id).pipe( @@ -153,5 +151,5 @@ export const parseSubagentCommand = ( ), ) - return { _tag: 'resume', agentId, prompt: params.prompt, skill } as const + return ResumeSubagentCommand.make({ agentId, prompt: params.prompt, skill }) }) diff --git a/packages/fold-core/src/Subagents/SubagentsLayer.ts b/packages/fold-core/src/Subagents/SubagentsLayer.ts index 2eda7d9..2f83cf2 100644 --- a/packages/fold-core/src/Subagents/SubagentsLayer.ts +++ b/packages/fold-core/src/Subagents/SubagentsLayer.ts @@ -14,7 +14,7 @@ * `setProfile` swap binds on the very next run and the existing transition diff sees only concrete * models. */ -import { Cause, Effect, Exit, Fiber, Ref, Schema, Stream } from 'effect' +import { Data, Match, Predicate, Cause, Effect, Exit, Fiber, Ref, Schema, Stream } from 'effect' import type { Array as Arr } from 'effect' import { Prompt } from 'effect/unstable/ai' @@ -22,15 +22,16 @@ import type { FoldModel } from '../Api/ModelDescriptor' import { AgentProvisioner } from '../Api/Provisioning' import type { RealizedFoldTool, FoldTool } from '../Api/ToolDefinition' import { EventLog } from '../EventLog/EventLogService' -import type { - AgentFinishedLogEntry, - AgentFork, - AgentLaunchMode, - AgentStartedLogEntry, - AssistantMessageLogEntry, - LogEntry, - LogEntryInput, - LogSeq, +import { + LogEntryInputs, + type AgentFinishedLogEntry, + type AgentFork, + type AgentLaunchMode, + type AgentStartedLogEntry, + type AssistantMessageLogEntry, + type LogEntry, + type LogEntryInput, + type LogSeq, } from '../EventLog/Schemas' import type { HookConfig } from '../HookRunner/Types' import { Ids, type AgentId, type ToolCallId } from '../Ids' @@ -90,6 +91,7 @@ export type SubagentsConfig = { /** Where an agent's configuration comes from: a registered type, or the root agent. */ type OriginatingConfig = { readonly _tag: 'entry'; readonly entry: RegisteredAgentType } | { readonly _tag: 'root' } +const OriginatingConfig = Data.taggedEnum() type AgentConfigurationSnapshot = { readonly model: FoldModel @@ -129,6 +131,8 @@ type LaunchSubagentParams = { readonly interruptNote: InterruptNoteService } +const SubagentLaunch = Data.taggedEnum() + /** Fold a leading-prompt config value into an ordered block list. */ const promptBlocksOf = (systemPrompt: string | ReadonlyArray | null): ReadonlyArray => systemPrompt === null ? [] : typeof systemPrompt === 'string' ? [systemPrompt] : systemPrompt @@ -155,7 +159,8 @@ const interruptedSubagentNote = (agentLabel: string, subagentId: AgentId, turnsT const findAgentStarted = (entries: ReadonlyArray, agentId: AgentId): AgentStartedLogEntry | null => entries.find( - (entry): entry is AgentStartedLogEntry => entry._tag === 'agent_started' && entry.agentId === agentId, + (entry): entry is AgentStartedLogEntry => + Predicate.isTagged(entry, 'agent_started') && entry.agentId === agentId, ) ?? null /** Count assistant turns for one subagent: this dispatch/resume (by toolCallId) and lifetime total. */ @@ -165,7 +170,8 @@ const countAssistantTurns = ( toolCallId: ToolCallId, ): { readonly thisRun: TurnCount; readonly total: TurnCount } => { const own = entries.filter( - (entry): entry is AssistantMessageLogEntry => entry._tag === 'assistant-message' && entry.agentId === agentId, + (entry): entry is AssistantMessageLogEntry => + Predicate.isTagged(entry, 'assistant-message') && entry.agentId === agentId, ) return { @@ -182,7 +188,9 @@ const lastAssistantTextForRun = ( ): string | null => { const lastAssistant = entries.findLast( (entry): entry is AssistantMessageLogEntry => - entry._tag === 'assistant-message' && entry.agentId === agentId && entry.toolCallId === toolCallId, + Predicate.isTagged(entry, 'assistant-message') && + entry.agentId === agentId && + entry.toolCallId === toolCallId, ) if (lastAssistant === undefined) return null @@ -262,12 +270,12 @@ export const makeSubagents = ( if (started.agentType !== null) { const entry = config.registry.resolveAgentType(started.agentType) - return entry === null ? null : { _tag: 'entry', entry } + return entry === null ? null : OriginatingConfig.entry({ entry }) } if (started.mode === 'fork' && started.fork !== null) { return originatingConfigForAgent(entries, started.fork.fromAgentId, new Set([...seen, agentId])) } - if (started.parentAgentId === null) return { _tag: 'root' } + if (started.parentAgentId === null) return OriginatingConfig.root() return null } @@ -278,17 +286,19 @@ export const makeSubagents = ( * role-bound type all see the live binding (a fork clones the caller's binding by definition). */ const agentSnapshotForOrigin = (origin: OriginatingConfig): Effect.Effect => - origin._tag === 'root' - ? config.currentRootAgent - : resolveModelBinding(origin.entry.model).pipe( + Match.valueTags(origin, { + root: () => config.currentRootAgent, + entry: ({ entry }) => + resolveModelBinding(entry.model).pipe( Effect.map((model) => ({ model, promptCacheKey: null, - tools: origin.entry.tools, - hooks: origin.entry.hooks, - systemPrompt: origin.entry.systemPrompt, + tools: entry.tools, + hooks: entry.hooks, + systemPrompt: entry.systemPrompt, })), - ) + ), + }) /** Reconstruct one agent's effective configuration, including a persisted fork tool override. */ const agentSnapshotForAgent = ( @@ -364,48 +374,51 @@ export const makeSubagents = ( const entries = yield* collectEntries const finishedThisRun = entries.some( (entry) => - entry._tag === 'agent-finished' && + Predicate.isTagged(entry, 'agent-finished') && entry.agentId === params.subagentId && entry.toolCallId === params.toolCallId, ) if (!finishedThisRun) { if (Cause.hasInterrupts(cause)) { - yield* appendToEventLog({ - _tag: 'user-message', - agentId: params.subagentId, - parentAgentId: params.parentAgentId, - toolCallId: params.toolCallId, - messageId: yield* ids.makeMessageId, - message: encodeUserMessage( - Prompt.userMessage({ - content: [ - Prompt.textPart({ - text: 'You were interrupted by the user before completing this work.', - }), - ], - }), - ), - }) - yield* appendToEventLog({ - _tag: 'agent-finished', - agentId: params.subagentId, - parentAgentId: params.parentAgentId, - toolCallId: params.toolCallId, - outcome: 'interrupted', - resultText: null, - reason: 'interrupted by the user', - }) + yield* appendToEventLog( + LogEntryInputs['user-message']({ + agentId: params.subagentId, + parentAgentId: params.parentAgentId, + toolCallId: params.toolCallId, + messageId: yield* ids.makeMessageId, + message: encodeUserMessage( + Prompt.userMessage({ + content: [ + Prompt.textPart({ + text: 'You were interrupted by the user before completing this work.', + }), + ], + }), + ), + }), + ) + yield* appendToEventLog( + LogEntryInputs['agent-finished']({ + agentId: params.subagentId, + parentAgentId: params.parentAgentId, + toolCallId: params.toolCallId, + outcome: 'interrupted', + resultText: null, + reason: 'interrupted by the user', + }), + ) } else { - yield* appendToEventLog({ - _tag: 'agent-finished', - agentId: params.subagentId, - parentAgentId: params.parentAgentId, - toolCallId: params.toolCallId, - outcome: 'error', - resultText: null, - reason: modelVisibleErrorDetailsFromCause(cause), - }) + yield* appendToEventLog( + LogEntryInputs['agent-finished']({ + agentId: params.subagentId, + parentAgentId: params.parentAgentId, + toolCallId: params.toolCallId, + outcome: 'error', + resultText: null, + reason: modelVisibleErrorDetailsFromCause(cause), + }), + ) } } }) @@ -460,7 +473,7 @@ export const makeSubagents = ( hooks: params.hooks, }) - if (params.launch._tag === 'start') { + if (SubagentLaunch.$is('start')(params.launch)) { yield* agentRuntimeForSubagent.start({ agentId: params.subagentId, parentAgentId: params.parentAgentId, @@ -508,7 +521,7 @@ export const makeSubagents = ( yield* eventLog.subscribe().pipe( Stream.filter( (entry) => - entry._tag === 'assistant-message' && + Predicate.isTagged(entry, 'assistant-message') && entry.agentId === params.subagentId && entry.toolCallId === params.toolCallId, ), @@ -554,46 +567,51 @@ export const makeSubagents = ( const entries = yield* collectEntries const finishedThisRun = entries.some( (entry) => - entry._tag === 'agent-finished' && entry.agentId === agentId && entry.seq > baselineSeq, + Predicate.isTagged(entry, 'agent-finished') && + entry.agentId === agentId && + entry.seq > baselineSeq, ) if (finishedThisRun) return if (Cause.hasInterrupts(exit.cause)) { - yield* appendToEventLog({ - _tag: 'user-message', - agentId, - parentAgentId: null, - toolCallId: null, - messageId: yield* ids.makeMessageId, - message: encodeUserMessage( - Prompt.userMessage({ - content: [ - Prompt.textPart({ - text: 'You were interrupted by the user before completing this work.', - }), - ], - }), - ), - }) - yield* appendToEventLog({ - _tag: 'agent-finished', - agentId, - parentAgentId: null, - toolCallId: null, - outcome: 'interrupted', - resultText: null, - reason: 'interrupted by the user', - }) + yield* appendToEventLog( + LogEntryInputs['user-message']({ + agentId, + parentAgentId: null, + toolCallId: null, + messageId: yield* ids.makeMessageId, + message: encodeUserMessage( + Prompt.userMessage({ + content: [ + Prompt.textPart({ + text: 'You were interrupted by the user before completing this work.', + }), + ], + }), + ), + }), + ) + yield* appendToEventLog( + LogEntryInputs['agent-finished']({ + agentId, + parentAgentId: null, + toolCallId: null, + outcome: 'interrupted', + resultText: null, + reason: 'interrupted by the user', + }), + ) } else { - yield* appendToEventLog({ - _tag: 'agent-finished', - agentId, - parentAgentId: null, - toolCallId: null, - outcome: 'error', - resultText: null, - reason: modelVisibleErrorDetailsFromCause(exit.cause), - }) + yield* appendToEventLog( + LogEntryInputs['agent-finished']({ + agentId, + parentAgentId: null, + toolCallId: null, + outcome: 'error', + resultText: null, + reason: modelVisibleErrorDetailsFromCause(exit.cause), + }), + ) } }) @@ -668,7 +686,7 @@ export const makeSubagents = ( const after = yield* collectEntries const finished = after.findLast( (entry): entry is AgentFinishedLogEntry => - entry._tag === 'agent-finished' && entry.agentId === input.agentId, + Predicate.isTagged(entry, 'agent-finished') && entry.agentId === input.agentId, ) if (finished === undefined) { return yield* Effect.die( @@ -741,7 +759,7 @@ export const makeSubagents = ( systemPrompt: leadingBlocksFor(entry.systemPrompt, realized), skillParam: input.skill, messages: preloaded === null ? [input.prompt] : [input.prompt, preloaded], - launch: { _tag: 'start' }, + launch: SubagentLaunch.start(), interruptNote, }).pipe(Effect.catchTag('SubagentBusyError', dieOnBusy)) }), @@ -812,7 +830,7 @@ export const makeSubagents = ( systemPrompt: null, skillParam: input.skill, messages: preloaded === null ? [input.prompt] : [input.prompt, preloaded], - launch: { _tag: 'start' }, + launch: SubagentLaunch.start(), interruptNote, }).pipe(Effect.catchTag('SubagentBusyError', dieOnBusy)) }), @@ -828,16 +846,12 @@ export const makeSubagents = ( // The wire carries a reference (full id or unique short prefix); resolve it against every // started agent before anything else. Ambiguity is a not-found carrying the candidates. const resolution = resolveAgentIdRef(agentIdsFromEntries(entries), input.agentId) - if (resolution._tag === 'not-found') { - return yield* new SubagentNotFoundError({ requested: input.agentId }) - } - if (resolution._tag === 'ambiguous') { - return yield* new SubagentNotFoundError({ - requested: input.agentId, - candidates: resolution.candidates, - }) - } - const agentId = resolution.agentId + const agentId = yield* Match.valueTags(resolution, { + resolved: ({ agentId }) => Effect.succeed(agentId), + 'not-found': () => Effect.fail(new SubagentNotFoundError({ requested: input.agentId })), + ambiguous: ({ candidates }) => + Effect.fail(new SubagentNotFoundError({ requested: input.agentId, candidates })), + }) const started = findAgentStarted(entries, agentId) if (started === null) { @@ -888,7 +902,7 @@ export const makeSubagents = ( systemPrompt: null, skillParam: input.skill, messages: preloaded === null ? [input.prompt] : [input.prompt, preloaded], - launch: { _tag: 'resume', modelTransition }, + launch: SubagentLaunch.resume({ modelTransition }), interruptNote, }) }), diff --git a/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts b/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts index 13eecd4..0537d1d 100644 --- a/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts +++ b/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts @@ -4,11 +4,11 @@ * per-call ToolState, ToolEvents, and StopController services while handlers run, then persists one durable * tool-result entry per call, including synthetic interruption results when a tool fiber is interrupted. */ -import { Cause, Effect, Layer, Ref, Schema, Stream } from 'effect' +import { Data, Match, Predicate, Cause, Effect, Layer, Ref, Schema, Stream } from 'effect' import { Prompt } from 'effect/unstable/ai' import { EventLog } from '../EventLog/EventLogService' -import type { LogEntry, ToolResultLogEntry } from '../EventLog/Schemas' +import { LogEntryInputs, type LogEntry, type ToolResultLogEntry } from '../EventLog/Schemas' import { isHookExecutionError, type HookExecutionError } from '../HookRunner/Errors' import { HookRunner } from '../HookRunner/HookRunnerService' import { Ids, ToolCallId, type AgentId } from '../Ids' @@ -42,6 +42,8 @@ type PreparedToolCall = readonly isFailure: boolean } +const PreparedToolCall = Data.taggedEnum() + type FinalToolOutput = { readonly result: unknown readonly isFailure: boolean @@ -118,18 +120,19 @@ const appendToolResultToEventLog = (input: { const message = yield* encodedToolResultMessage(input) const entry = yield* eventLog - .append({ - _tag: 'tool-result', - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - messageId: yield* ids.makeMessageId, - message, - ...(input.executedInput === undefined ? {} : { executedInput: input.executedInput }), - }) + .append( + LogEntryInputs['tool-result']({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + messageId: yield* ids.makeMessageId, + message, + ...(input.executedInput === undefined ? {} : { executedInput: input.executedInput }), + }), + ) .pipe(Effect.orDie) - if (entry._tag === 'tool-result') return entry + if (Predicate.isTagged(entry, 'tool-result')) return entry // Invariant! return yield* Effect.die(new Error(`EventLog returned ${entry._tag} while appending tool-result`)) @@ -211,32 +214,30 @@ const toolCallPreparedByPreToolHooks = (input: { params: input.toolCall.params, }) - switch (decision._tag) { - case 'replaceResult': - return { - _tag: 'replaceResult' as const, + return Match.valueTags(decision, { + replaceResult: (replacement) => + PreparedToolCall.replaceResult({ original: input.toolCall, - result: decision.result, - isFailure: decision.isFailure, - } - - case 'continue': - return { - _tag: 'execute' as const, + result: replacement.result, + isFailure: replacement.isFailure, + }), + continue: (continuation) => + PreparedToolCall.execute({ original: input.toolCall, - params: decision.params, - } - } + params: continuation.params, + }), + }) }).pipe( Effect.catchCause((cause) => - Effect.succeed({ - _tag: 'replaceResult' as const, - original: input.toolCall, - result: Cause.hasInterrupts(cause) - ? interruptedToolResult - : failureResultFromCause(input.toolCall.name, cause), - isFailure: true, - }), + Effect.succeed( + PreparedToolCall.replaceResult({ + original: input.toolCall, + result: Cause.hasInterrupts(cause) + ? interruptedToolResult + : failureResultFromCause(input.toolCall.name, cause), + isFailure: true, + }), + ), ), ) @@ -369,16 +370,10 @@ const finalOutputAfterPostToolHooks = (input: { isFailure: input.output.isFailure, }) - switch (decision._tag) { - case 'keep': - return input.output - - case 'replace': - return { - result: decision.result, - isFailure: decision.isFailure, - } - } + return Match.valueTags(decision, { + keep: () => input.output, + replace: ({ isFailure, result }) => ({ result, isFailure }), + }) }) /** Execute or replace one prepared tool call and persist exactly one tool-result entry. */ @@ -442,7 +437,7 @@ const settlePreparedToolCall = (input: { | InterruptNote | Subagents > = Effect.gen(function* () { - if (input.prepared._tag === 'replaceResult') { + if (Predicate.isTagged(input.prepared, 'replaceResult')) { return { result: input.prepared.result, isFailure: input.prepared.isFailure, diff --git a/packages/fold-core/src/ToolRuntime/ToolStateFactory.ts b/packages/fold-core/src/ToolRuntime/ToolStateFactory.ts index 92d4201..e32943d 100644 --- a/packages/fold-core/src/ToolRuntime/ToolStateFactory.ts +++ b/packages/fold-core/src/ToolRuntime/ToolStateFactory.ts @@ -8,7 +8,7 @@ import { Effect, Ref, Stream } from 'effect' import { EventLog, type EventLogService } from '../EventLog/EventLogService' -import type { LogEntry } from '../EventLog/Schemas' +import { LogEntryInputs, type LogEntry } from '../EventLog/Schemas' import { Ids, type AgentId, type IdsService, type ToolCallId } from '../Ids' import { toolStateForAgent } from '../Projection/Projection' import type { ToolStateService } from './ToolStateService' @@ -37,16 +37,17 @@ const appendToolStateEntry = ( Effect.fn('fold.tool_state.set')((namespace, key, value) => ids.makeStateId.pipe( Effect.flatMap((stateId) => - eventLog.append({ - _tag: 'tool_state', - agentId: scope.agentId, - parentAgentId: scope.parentAgentId, - toolCallId: scope.toolCallId, - namespace, - stateId, - key, - value, - }), + eventLog.append( + LogEntryInputs['tool_state']({ + agentId: scope.agentId, + parentAgentId: scope.parentAgentId, + toolCallId: scope.toolCallId, + namespace, + stateId, + key, + value, + }), + ), ), Effect.orDie, Effect.asVoid, diff --git a/packages/fold-core/src/Tools/PatchEngine.ts b/packages/fold-core/src/Tools/PatchEngine.ts index e8f2ad8..39f52f4 100644 --- a/packages/fold-core/src/Tools/PatchEngine.ts +++ b/packages/fold-core/src/Tools/PatchEngine.ts @@ -6,7 +6,7 @@ * 4-pass line matcher (exact, rstrip, trim, unicode-fold) - plus clanka's strict superset of accepting * raw git/unified diffs. Failures are typed tagged errors, not defects (contrast clanka's orDie). */ -import { Effect, Schema } from 'effect' +import { Data, Effect, Match, Schema } from 'effect' /** The patch text could not be parsed into file operations. */ export class PatchParseError extends Schema.TaggedError()('PatchParseError', { @@ -47,6 +47,8 @@ export type PatchOp = readonly chunks: ReadonlyArray } +const PatchOp = Data.taggedEnum() + const beginMarker = '*** Begin Patch' const endMarker = '*** End Patch' const addMarker = '*** Add File:' @@ -194,12 +196,12 @@ const parseV4A = (lines: ReadonlyArray): Effect.Effect): Effect.Effect): Effect.Effect chunk.newLines) - ops.push({ _tag: 'add', path: toPath, content: content.join('\n') }) + ops.push(PatchOp.add({ path: toPath, content: content.join('\n') })) return } if (toPath === null && fromPath !== null) { - ops.push({ _tag: 'delete', path: fromPath }) + ops.push(PatchOp.delete({ path: fromPath })) return } if (fromPath === null || toPath === null) return @@ -324,7 +326,7 @@ const parseGitDiff = (lines: ReadonlyArray): Effect.Effect() + /** Result of computing a whole patch: the steps to perform and a human summary per op. */ export type ComputedPatch = { readonly steps: ReadonlyArray @@ -544,42 +548,47 @@ export const computePatch = (input: { } for (const op of input.ops) { - switch (op._tag) { - case 'add': { - // Ensure exactly one trailing newline; content ending in a bare `+` line already has one. - const content = - op.content.length === 0 || op.content.endsWith('\n') ? op.content : `${op.content}\n` - state.set(op.path, content) - steps.push({ _tag: 'write', path: op.path, content }) - summary.push(`Added: ${op.path}`) - break - } - - case 'delete': { - yield* readFor(op.path, 'delete') - state.set(op.path, null) - steps.push({ _tag: 'delete', path: op.path }) - summary.push(`Deleted: ${op.path}`) - break - } - - case 'update': { - const current = yield* readFor(op.path, 'update') - const next = yield* applyChunks({ content: current, chunks: op.chunks, path: op.path }) - - if (op.movePath === null) { - state.set(op.path, next) - steps.push({ _tag: 'write', path: op.path, content: next }) - summary.push(`Updated: ${op.path}`) - } else { - state.set(op.path, null) - state.set(op.movePath, next) - steps.push({ _tag: 'move', fromPath: op.path, toPath: op.movePath, content: next }) - summary.push(`Updated: ${op.path} (moved to ${op.movePath})`) - } - break - } - } + yield* Match.valueTags(op, { + add: (addition) => + Effect.sync(() => { + // Ensure exactly one trailing newline; content ending in a bare `+` line already has one. + const content = + addition.content.length === 0 || addition.content.endsWith('\n') + ? addition.content + : `${addition.content}\n` + state.set(addition.path, content) + steps.push(PatchStep.write({ path: addition.path, content })) + summary.push(`Added: ${addition.path}`) + }), + delete: (deletion) => + readFor(deletion.path, 'delete').pipe( + Effect.tap(() => + Effect.sync(() => { + state.set(deletion.path, null) + steps.push(PatchStep.delete({ path: deletion.path })) + summary.push(`Deleted: ${deletion.path}`) + }), + ), + ), + update: (update) => + Effect.gen(function* () { + const current = yield* readFor(update.path, 'update') + const next = yield* applyChunks({ content: current, chunks: update.chunks, path: update.path }) + + if (update.movePath === null) { + state.set(update.path, next) + steps.push(PatchStep.write({ path: update.path, content: next })) + summary.push(`Updated: ${update.path}`) + } else { + state.set(update.path, null) + state.set(update.movePath, next) + steps.push( + PatchStep.move({ fromPath: update.path, toPath: update.movePath, content: next }), + ) + summary.push(`Updated: ${update.path} (moved to ${update.movePath})`) + } + }), + }) } return { steps, summary } diff --git a/packages/fold-core/test/AgentRuntime/AgentRuntimeModelError.vi.test.ts b/packages/fold-core/test/AgentRuntime/AgentRuntimeModelError.vi.test.ts index c564488..c731b71 100644 --- a/packages/fold-core/test/AgentRuntime/AgentRuntimeModelError.vi.test.ts +++ b/packages/fold-core/test/AgentRuntime/AgentRuntimeModelError.vi.test.ts @@ -1,5 +1,5 @@ import { expect, it } from '@effect/vitest' -import { Effect } from 'effect' +import { Predicate, Effect } from 'effect' import { AgentRuntime, type ErrorLogEntry } from '../../src/index' import { failureTurn, makeScriptedLanguageModel } from '../TestLayers/ScriptedLanguageModel' @@ -33,7 +33,7 @@ it.effect('records a model provider failure as durable facts and resolves the ru 'agent-finished', ]) - const error = result.entries.find((entry): entry is ErrorLogEntry => entry._tag === 'error') + const error = result.entries.find((entry): entry is ErrorLogEntry => Predicate.isTagged(entry, 'error')) expect(error?.errorType).toBe('model') expect(error?.message).toContain('boom: provider unavailable') }), diff --git a/packages/fold-core/test/AgentRuntime/AgentRuntimeModelSettings.vi.test.ts b/packages/fold-core/test/AgentRuntime/AgentRuntimeModelSettings.vi.test.ts index 8ddb95c..786f30b 100644 --- a/packages/fold-core/test/AgentRuntime/AgentRuntimeModelSettings.vi.test.ts +++ b/packages/fold-core/test/AgentRuntime/AgentRuntimeModelSettings.vi.test.ts @@ -1,5 +1,5 @@ import { expect, it } from '@effect/vitest' -import { Effect, Layer, Schema } from 'effect' +import { Predicate, Effect, Layer, Schema } from 'effect' import { Tool, Toolkit } from 'effect/unstable/ai' import type { LanguageModel } from 'effect/unstable/ai' @@ -256,7 +256,9 @@ it.effect('openai agents start with the gpt base prompt, apply_patch toolset, an expect(result.started.tools).toEqual(['apply_patch']) - const leading = result.entries.find((entry): entry is SystemMessageLogEntry => entry._tag === 'system-message') + const leading = result.entries.find((entry): entry is SystemMessageLogEntry => + Predicate.isTagged(entry, 'system-message'), + ) expect(leading?.messages.map((message) => message.content)).toEqual(['GPT BASE PROMPT', 'agent rules']) const requests = yield* scripted.requests @@ -286,7 +288,9 @@ it.effect('anthropic agents start with the claude base prompt, write/edit toolse expect(result.started.tools).toEqual(['write', 'edit']) - const leading = result.entries.find((entry): entry is SystemMessageLogEntry => entry._tag === 'system-message') + const leading = result.entries.find((entry): entry is SystemMessageLogEntry => + Predicate.isTagged(entry, 'system-message'), + ) expect(leading?.messages.map((message) => message.content)).toEqual(['CLAUDE BASE PROMPT', 'agent rules']) const requests = yield* scripted.requests @@ -390,7 +394,7 @@ it.effect('switchModel appends thinking-change when the requested level changes 'thinking-change', ]) - const thinkingChange = entries.find((entry) => entry._tag === 'thinking-change') + const thinkingChange = entries.find((entry) => Predicate.isTagged(entry, 'thinking-change')) expect(thinkingChange).toMatchObject({ reasoningLevel: 'high', reason: 'test raises reasoning' }) // The projected level binds the very next request through the per-request provider config. diff --git a/packages/fold-core/test/AgentRuntime/AgentRuntimeOnComplete.vi.test.ts b/packages/fold-core/test/AgentRuntime/AgentRuntimeOnComplete.vi.test.ts index 81be082..988e86c 100644 --- a/packages/fold-core/test/AgentRuntime/AgentRuntimeOnComplete.vi.test.ts +++ b/packages/fold-core/test/AgentRuntime/AgentRuntimeOnComplete.vi.test.ts @@ -1,5 +1,5 @@ import { expect, it } from '@effect/vitest' -import { Effect, Ref } from 'effect' +import { Predicate, Effect, Ref } from 'effect' import { AgentRuntime, type HookConfig, type UserMessageLogEntry } from '../../src/index' import { makeScriptedLanguageModel, textTurn } from '../TestLayers/ScriptedLanguageModel' @@ -54,8 +54,8 @@ it.effect('onComplete continueWith appends a continuation user message and loops 'agent-finished', ]) - const userMessages = result.entries.filter( - (entry): entry is UserMessageLogEntry => entry._tag === 'user-message', + const userMessages = result.entries.filter((entry): entry is UserMessageLogEntry => + Predicate.isTagged(entry, 'user-message'), ) const continuation = userMessages[1] expect(JSON.stringify(continuation?.message.content)).toContain('keep going') diff --git a/packages/fold-core/test/AgentRuntime/AgentRuntimeTextRun.vi.test.ts b/packages/fold-core/test/AgentRuntime/AgentRuntimeTextRun.vi.test.ts index df224a3..7182afd 100644 --- a/packages/fold-core/test/AgentRuntime/AgentRuntimeTextRun.vi.test.ts +++ b/packages/fold-core/test/AgentRuntime/AgentRuntimeTextRun.vi.test.ts @@ -1,5 +1,5 @@ import { expect, it } from '@effect/vitest' -import { Effect } from 'effect' +import { Predicate, Effect } from 'effect' import { AgentRuntime, type AssistantMessageLogEntry, type SystemMessageLogEntry } from '../../src/index' import { makeScriptedLanguageModel, textTurn } from '../TestLayers/ScriptedLanguageModel' @@ -37,8 +37,8 @@ it.effect('completes a text-only run with the full log shape', () => 'agent-finished', ]) - const assistant = result.entries.find( - (entry): entry is AssistantMessageLogEntry => entry._tag === 'assistant-message', + const assistant = result.entries.find((entry): entry is AssistantMessageLogEntry => + Predicate.isTagged(entry, 'assistant-message'), ) expect(assistant?.finish?.reason).toBe('stop') expect(assistant?.finish?.usage.inputTokens?.total).toBe(10) @@ -63,7 +63,9 @@ it.effect('persists a multi-block system prompt as one leading entry with one me return yield* collectEntries }).pipe(Effect.provide(layer)) - const systemEntries = entries.filter((entry): entry is SystemMessageLogEntry => entry._tag === 'system-message') + const systemEntries = entries.filter((entry): entry is SystemMessageLogEntry => + Predicate.isTagged(entry, 'system-message'), + ) expect(systemEntries).toHaveLength(1) const systemEntry = systemEntries[0] expect(systemEntry).toBeDefined() diff --git a/packages/fold-core/test/AgentRuntime/AgentRuntimeToolRun.vi.test.ts b/packages/fold-core/test/AgentRuntime/AgentRuntimeToolRun.vi.test.ts index 9eb2416..b271439 100644 --- a/packages/fold-core/test/AgentRuntime/AgentRuntimeToolRun.vi.test.ts +++ b/packages/fold-core/test/AgentRuntime/AgentRuntimeToolRun.vi.test.ts @@ -1,5 +1,5 @@ import { expect, it } from '@effect/vitest' -import { Effect, Ref } from 'effect' +import { Predicate, Effect, Ref } from 'effect' import type { Prompt } from 'effect/unstable/ai' import { AgentRuntime, type AssistantMessageLogEntry, type ToolResultLogEntry } from '../../src/index' @@ -43,8 +43,8 @@ it.effect('runs a tool turn end to end, rewriting and restoring provider tool-ca ]) // The persisted assistant tool-call has a minted fold id; the provider id is stashed in options. - const assistant = result.entries.find( - (entry): entry is AssistantMessageLogEntry => entry._tag === 'assistant-message', + const assistant = result.entries.find((entry): entry is AssistantMessageLogEntry => + Predicate.isTagged(entry, 'assistant-message'), ) const assistantContent = assistant?.message.content if (typeof assistantContent === 'string' || assistantContent === undefined) { @@ -58,7 +58,9 @@ it.effect('runs a tool turn end to end, rewriting and restoring provider tool-ca expect(persistedToolCall.options).toMatchObject({ fold: { providerToolCallId: 'provider-call-1' } }) // The durable tool result is grouped under the minted fold id. - const toolResult = result.entries.find((entry): entry is ToolResultLogEntry => entry._tag === 'tool-result') + const toolResult = result.entries.find((entry): entry is ToolResultLogEntry => + Predicate.isTagged(entry, 'tool-result'), + ) expect(toolResult?.toolCallId).toBe(persistedToolCall.id) // The continuation request restores the provider's original id on both sides of the exchange. diff --git a/packages/fold-core/test/Api/ResumeSession.vi.test.ts b/packages/fold-core/test/Api/ResumeSession.vi.test.ts index d52f6d2..7a5d566 100644 --- a/packages/fold-core/test/Api/ResumeSession.vi.test.ts +++ b/packages/fold-core/test/Api/ResumeSession.vi.test.ts @@ -6,7 +6,7 @@ * roster changes the block). An unchanged configuration writes nothing. */ import { expect, it } from '@effect/vitest' -import { Cause, Context, Effect, Exit, Layer } from 'effect' +import { Predicate, Cause, Context, Effect, Exit, Layer } from 'effect' import { defineAgent, @@ -57,9 +57,9 @@ it.effect('resume adopts the log: same ids, no new rows, full continuity - and n expect(session.rootAgentId).toBe(first.rootAgentId) const beforeSend = yield* session.entries - expect(beforeSend.filter((entry) => entry._tag === 'session_started')).toHaveLength(1) - expect(beforeSend.filter((entry) => entry._tag === 'agent_started')).toHaveLength(1) - expect(beforeSend.some((entry) => entry._tag === 'model-change')).toBe(false) + expect(beforeSend.filter((entry) => Predicate.isTagged(entry, 'session_started'))).toHaveLength(1) + expect(beforeSend.filter((entry) => Predicate.isTagged(entry, 'agent_started'))).toHaveLength(1) + expect(beforeSend.some((entry) => Predicate.isTagged(entry, 'model-change'))).toBe(false) // The next send continues the SAME agent over the replayed history. const finished = yield* session.send('continue where we left off') @@ -87,13 +87,13 @@ it.effect('resume with a different model binding writes one epoch transition (D1 }) const beforeSend = yield* session.entries - const modelChange = beforeSend.findLast((entry) => entry._tag === 'model-change') - if (modelChange === undefined || modelChange._tag !== 'model-change') { + const modelChange = beforeSend.findLast((entry) => Predicate.isTagged(entry, 'model-change')) + if (modelChange === undefined || !Predicate.isTagged(modelChange, 'model-change')) { throw new Error('expected the resume model-change entry') } expect(modelChange.model.modelId).toBe('gpt-scripted') expect(modelChange.reason).toContain('resume') - expect(beforeSend.some((entry) => entry._tag === 'tools-change')).toBe(true) + expect(beforeSend.some((entry) => Predicate.isTagged(entry, 'tools-change'))).toBe(true) const finished = yield* session.send('continue') expect(finished.resultText).toBe('answered by the new model') @@ -114,8 +114,8 @@ it.effect('resume with changed leading blocks transitions too (D20 resume rule)' }) const beforeSend = yield* session.entries - expect(beforeSend.some((entry) => entry._tag === 'model-change')).toBe(true) - const newLeading = beforeSend.findLast((entry) => entry._tag === 'system-message') + expect(beforeSend.some((entry) => Predicate.isTagged(entry, 'model-change'))).toBe(true) + const newLeading = beforeSend.findLast((entry) => Predicate.isTagged(entry, 'system-message')) expect(JSON.stringify(newLeading)).toContain('prompt v2') // The new epoch's leading blocks bind on the resumed send. diff --git a/packages/fold-core/test/Api/SessionControlHarness.ts b/packages/fold-core/test/Api/SessionControlHarness.ts index 33170b7..5add1a0 100644 --- a/packages/fold-core/test/Api/SessionControlHarness.ts +++ b/packages/fold-core/test/Api/SessionControlHarness.ts @@ -5,7 +5,7 @@ * hangs forever (the interruption target for the partial-assistant-flush assertions), while later * requests serve ordinary scripted turns. */ -import { Deferred, Effect, Ref, Schema, Stream } from 'effect' +import { Predicate, Deferred, Effect, Ref, Schema, Stream } from 'effect' import { AiError, LanguageModel } from 'effect/unstable/ai' import type { Response } from 'effect/unstable/ai' @@ -106,7 +106,7 @@ export const makePartialHangModel = ( } yield* Ref.set(turnsRef, remaining.slice(1)) - return turn._tag === 'failure' + return Predicate.isTagged(turn, 'failure') ? Stream.fail( AiError.make({ module: 'PartialHangModel', diff --git a/packages/fold-core/test/Api/SessionInterrupt.vi.test.ts b/packages/fold-core/test/Api/SessionInterrupt.vi.test.ts index 86e1fde..07fa787 100644 --- a/packages/fold-core/test/Api/SessionInterrupt.vi.test.ts +++ b/packages/fold-core/test/Api/SessionInterrupt.vi.test.ts @@ -5,7 +5,7 @@ * tool result as an interrupted-outcome result while the dispatcher keeps running. */ import { expect, it } from '@effect/vitest' -import { Deferred, Effect, Fiber } from 'effect' +import { Predicate, Deferred, Effect, Fiber } from 'effect' import { defineAgent, defineSubagent, shortAgentId, startSession, subagentTool } from '../../src/index' import { makeHangOnceModel } from '../Subagents/DriveHarness' @@ -33,8 +33,8 @@ it.effect('interrupt discards partial assistant text, writes the root marker, an expect(JSON.stringify(entries)).not.toContain('I was thinking about the answer') - const rootFinished = entries.findLast((entry) => entry._tag === 'agent-finished') - if (rootFinished === undefined || rootFinished._tag !== 'agent-finished') { + const rootFinished = entries.findLast((entry) => Predicate.isTagged(entry, 'agent-finished')) + if (rootFinished === undefined || !Predicate.isTagged(rootFinished, 'agent-finished')) { throw new Error('expected the root terminal marker') } expect(rootFinished.outcome).toBe('interrupted') @@ -79,8 +79,10 @@ it.effect('a targeted subagent interrupt folds into the dispatcher, which keeps yield* Deferred.await(hangOnce.firstRequestStarted) const midRun = yield* session.entries - const childStarted = midRun.find((entry) => entry._tag === 'agent_started' && entry.parentAgentId !== null) - if (childStarted === undefined || childStarted._tag !== 'agent_started') { + const childStarted = midRun.find( + (entry) => Predicate.isTagged(entry, 'agent_started') && entry.parentAgentId !== null, + ) + if (childStarted === undefined || !Predicate.isTagged(childStarted, 'agent_started')) { throw new Error('expected the dispatched subagent to have started') } @@ -94,14 +96,14 @@ it.effect('a targeted subagent interrupt folds into the dispatcher, which keeps const entries = yield* session.entries const childFinished = entries.findLast( - (entry) => entry._tag === 'agent-finished' && entry.agentId === childStarted.agentId, + (entry) => Predicate.isTagged(entry, 'agent-finished') && entry.agentId === childStarted.agentId, ) - if (childFinished === undefined || childFinished._tag !== 'agent-finished') { + if (childFinished === undefined || !Predicate.isTagged(childFinished, 'agent-finished')) { throw new Error('expected the subagent terminal marker') } expect(childFinished.outcome).toBe('interrupted') - const toolResult = entries.find((entry) => entry._tag === 'tool-result') + const toolResult = entries.find((entry) => Predicate.isTagged(entry, 'tool-result')) const rendered = JSON.stringify(toolResult) expect(rendered).toContain(`agent_id: ${shortAgentId(childStarted.agentId)}`) expect(rendered).toContain('This subagent was interrupted') diff --git a/packages/fold-core/test/Api/SessionIsolation.vi.test.ts b/packages/fold-core/test/Api/SessionIsolation.vi.test.ts index 9fdd657..08568fa 100644 --- a/packages/fold-core/test/Api/SessionIsolation.vi.test.ts +++ b/packages/fold-core/test/Api/SessionIsolation.vi.test.ts @@ -6,7 +6,7 @@ * both sessions one shared EventLog and one shared event spine. */ import { expect, it } from '@effect/vitest' -import { Effect } from 'effect' +import { Predicate, Effect } from 'effect' import { defineAgent, startSession, type SessionStartedLogEntry } from '../../src/index' import { textTurn } from '../TestLayers/ScriptedLanguageModel' @@ -41,11 +41,11 @@ it.effect('two sessions in one program share no log, ids, or model runtime', () const entriesA = yield* sessionA.entries const entriesB = yield* sessionB.entries - const sessionStartsA = entriesA.filter( - (entry): entry is SessionStartedLogEntry => entry._tag === 'session_started', + const sessionStartsA = entriesA.filter((entry): entry is SessionStartedLogEntry => + Predicate.isTagged(entry, 'session_started'), ) - const sessionStartsB = entriesB.filter( - (entry): entry is SessionStartedLogEntry => entry._tag === 'session_started', + const sessionStartsB = entriesB.filter((entry): entry is SessionStartedLogEntry => + Predicate.isTagged(entry, 'session_started'), ) expect(sessionStartsA.map((entry) => entry.sessionId)).toEqual([sessionA.sessionId]) expect(sessionStartsB.map((entry) => entry.sessionId)).toEqual([sessionB.sessionId]) diff --git a/packages/fold-core/test/Api/SessionSendTargets.vi.test.ts b/packages/fold-core/test/Api/SessionSendTargets.vi.test.ts index 7f81079..1aea735 100644 --- a/packages/fold-core/test/Api/SessionSendTargets.vi.test.ts +++ b/packages/fold-core/test/Api/SessionSendTargets.vi.test.ts @@ -6,7 +6,7 @@ * agent_started, full prior context. Unknown ids fail typed. */ import { expect, it } from '@effect/vitest' -import { Context, Effect, Fiber, Layer } from 'effect' +import { Predicate, Context, Effect, Fiber, Layer } from 'effect' import { AgentId, @@ -53,7 +53,7 @@ it.effect('send while running joins the run as a follow-up; both senders get the expect(secondFinished.seq).toBe(firstFinished.seq) const entries = yield* session.entries - expect(entries.filter((entry) => entry._tag === 'agent-finished')).toHaveLength(1) + expect(entries.filter((entry) => Predicate.isTagged(entry, 'agent-finished'))).toHaveLength(1) // The follow-up's model call saw the whole run including the first answer. const prompts = yield* rootScripted.scripted.prompts @@ -90,7 +90,7 @@ it.effect('a follow-up the stopped run never consumed starts its own fresh run', expect(secondFinished.resultText).toBe('fresh run answer') const entries = yield* session.entries - expect(entries.filter((entry) => entry._tag === 'agent-finished')).toHaveLength(2) + expect(entries.filter((entry) => Predicate.isTagged(entry, 'agent-finished'))).toHaveLength(2) }).pipe(Effect.scoped), ) @@ -138,7 +138,8 @@ it.effect('send targeting a finished subagent continues it directly under a null const entries = yield* session.entries expect(subagentStartedEntries(entries)).toHaveLength(1) const continuationMessage = entries.findLast( - (entry): entry is UserMessageLogEntry => entry._tag === 'user-message' && entry.agentId === started.agentId, + (entry): entry is UserMessageLogEntry => + Predicate.isTagged(entry, 'user-message') && entry.agentId === started.agentId, ) expect(continuationMessage?.toolCallId).toBeNull() diff --git a/packages/fold-core/test/Api/SessionSteer.vi.test.ts b/packages/fold-core/test/Api/SessionSteer.vi.test.ts index 279b256..96fdd8d 100644 --- a/packages/fold-core/test/Api/SessionSteer.vi.test.ts +++ b/packages/fold-core/test/Api/SessionSteer.vi.test.ts @@ -6,7 +6,7 @@ * are steerable by agentId, draining between the CHILD's turns with the dispatch envelope. */ import { expect, it } from '@effect/vitest' -import { Effect, Fiber } from 'effect' +import { Predicate, Effect, Fiber } from 'effect' import { defineAgent, defineSubagent, startSession, subagentTool, type UserMessageLogEntry } from '../../src/index' import { textTurn, toolCallTurn } from '../TestLayers/ScriptedLanguageModel' @@ -38,7 +38,7 @@ it.effect('steering a running root drains between turns, exactly where the model const tags = entries.map((entry) => entry._tag) const toolResultIndex = tags.indexOf('tool-result') const steeredIndex = entries.findIndex( - (entry) => entry._tag === 'user-message' && JSON.stringify(entry).includes('change course'), + (entry) => Predicate.isTagged(entry, 'user-message') && JSON.stringify(entry).includes('change course'), ) const finalAssistantIndex = tags.lastIndexOf('assistant-message') expect(steeredIndex).toBeGreaterThan(toolResultIndex) @@ -151,8 +151,10 @@ it.effect("steering a running subagent drains between the child's turns under th yield* gate.invoked const midRun = yield* session.entries - const childStarted = midRun.find((entry) => entry._tag === 'agent_started' && entry.parentAgentId !== null) - if (childStarted === undefined || childStarted._tag !== 'agent_started') { + const childStarted = midRun.find( + (entry) => Predicate.isTagged(entry, 'agent_started') && entry.parentAgentId !== null, + ) + if (childStarted === undefined || !Predicate.isTagged(childStarted, 'agent_started')) { throw new Error('expected the dispatched subagent to have started') } @@ -165,7 +167,7 @@ it.effect("steering a running subagent drains between the child's turns under th const entries = yield* session.entries const steered = entries.find( (entry): entry is UserMessageLogEntry => - entry._tag === 'user-message' && JSON.stringify(entry).includes('focus on the config file'), + Predicate.isTagged(entry, 'user-message') && JSON.stringify(entry).includes('focus on the config file'), ) if (steered === undefined) throw new Error('expected the steered user-message') expect(steered.agentId).toBe(childStarted.agentId) diff --git a/packages/fold-core/test/Api/SessionStop.vi.test.ts b/packages/fold-core/test/Api/SessionStop.vi.test.ts index d49e4e7..8d88163 100644 --- a/packages/fold-core/test/Api/SessionStop.vi.test.ts +++ b/packages/fold-core/test/Api/SessionStop.vi.test.ts @@ -6,7 +6,7 @@ * the next send begins. */ import { expect, it } from '@effect/vitest' -import { Effect, Fiber } from 'effect' +import { Predicate, Effect, Fiber } from 'effect' import { defineAgent, defineSubagent, startSession, subagentTool } from '../../src/index' import { textTurn, toolCallTurn } from '../TestLayers/ScriptedLanguageModel' @@ -36,7 +36,7 @@ it.effect('stop lets the in-flight batch finish, then ends the run with no furth // The batch's results are facts in the log; the second scripted turn was never consumed. const entries = yield* session.entries - expect(entries.some((entry) => entry._tag === 'tool-result')).toBe(true) + expect(entries.some((entry) => Predicate.isTagged(entry, 'tool-result'))).toBe(true) expect(yield* rootScripted.scripted.remainingTurns).toBe(1) // The signal clears on the next send: the remaining turn now runs to completion. @@ -86,21 +86,25 @@ it.effect('stop reaches the whole tree: the running subagent stops, then its dis // The child wrote its own stopped marker at ITS batch boundary... const entries = yield* session.entries - const childStarted = entries.find((entry) => entry._tag === 'agent_started' && entry.parentAgentId !== null) - if (childStarted === undefined || childStarted._tag !== 'agent_started') { + const childStarted = entries.find( + (entry) => Predicate.isTagged(entry, 'agent_started') && entry.parentAgentId !== null, + ) + if (childStarted === undefined || !Predicate.isTagged(childStarted, 'agent_started')) { throw new Error('expected the dispatched subagent to have started') } const childFinished = entries.findLast( - (entry) => entry._tag === 'agent-finished' && entry.agentId === childStarted.agentId, + (entry) => Predicate.isTagged(entry, 'agent-finished') && entry.agentId === childStarted.agentId, ) - if (childFinished === undefined || childFinished._tag !== 'agent-finished') { + if (childFinished === undefined || !Predicate.isTagged(childFinished, 'agent-finished')) { throw new Error('expected the subagent terminal marker') } expect(childFinished.outcome).toBe('stopped') // ...and the dispatcher's rendered result surfaces the stopped outcome honestly. (The child's // own gate tool-result is also in the log; the dispatcher's is the root-owned one.) - const dispatchResult = entries.find((entry) => entry._tag === 'tool-result' && entry.parentAgentId === null) + const dispatchResult = entries.find( + (entry) => Predicate.isTagged(entry, 'tool-result') && entry.parentAgentId === null, + ) expect(JSON.stringify(dispatchResult)).toContain('stopped early') // Neither model consumed its post-stop turn. diff --git a/packages/fold-core/test/Api/SkillsSession.vi.test.ts b/packages/fold-core/test/Api/SkillsSession.vi.test.ts index 3ba688a..f83e236 100644 --- a/packages/fold-core/test/Api/SkillsSession.vi.test.ts +++ b/packages/fold-core/test/Api/SkillsSession.vi.test.ts @@ -5,7 +5,7 @@ * switch carries the same session-start block into the new epoch's leading system message. */ import { expect, it } from '@effect/vitest' -import { Effect, Ref } from 'effect' +import { Predicate, Effect, Ref } from 'effect' import { defineAgent, @@ -28,7 +28,7 @@ const demoSkills = [ const leadingSystemBlocks = (entries: ReadonlyArray<{ readonly _tag: string }>): ReadonlyArray => entries - .filter((entry): entry is SystemMessageLogEntry => entry._tag === 'system-message') + .filter((entry): entry is SystemMessageLogEntry => Predicate.isTagged(entry, 'system-message')) .filter((entry) => entry.placement === 'leading') .map((entry) => entry.messages.map((message) => message.content).join('\n---\n')) @@ -60,13 +60,17 @@ it.effect('renders the skills block into the leading prompt and installs the ski expect(blocks[0]).toContain('commit-helper') // The skill tool is installed and advertised to the model. - const agentStarted = entries.find((entry): entry is AgentStartedLogEntry => entry._tag === 'agent_started') + const agentStarted = entries.find((entry): entry is AgentStartedLogEntry => + Predicate.isTagged(entry, 'agent_started'), + ) expect(agentStarted?.tools).toContain('skill') const requests = yield* scripted.requests expect(requests[0]?.toolNames).toContain('skill') // The tool served the wrapped skill content. - const toolResult = entries.find((entry): entry is ToolResultLogEntry => entry._tag === 'tool-result') + const toolResult = entries.find((entry): entry is ToolResultLogEntry => + Predicate.isTagged(entry, 'tool-result'), + ) const part = toolResult?.message.content[0] if (part === undefined || part.type !== 'tool-result') throw new Error('expected a tool-result part') expect(JSON.stringify(part.result)).toContain(' entry._tag === 'tool-result') + const toolResult = entries.find((entry): entry is ToolResultLogEntry => + Predicate.isTagged(entry, 'tool-result'), + ) const part = toolResult?.message.content[0] 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') diff --git a/packages/fold-core/test/Api/StartSession.vi.test.ts b/packages/fold-core/test/Api/StartSession.vi.test.ts index 486191e..79c497e 100644 --- a/packages/fold-core/test/Api/StartSession.vi.test.ts +++ b/packages/fold-core/test/Api/StartSession.vi.test.ts @@ -6,7 +6,7 @@ * SessionIsolation.vi.test.ts. */ import { expect, it } from '@effect/vitest' -import { Context, Effect, Fiber, Layer, Schema, Stream } from 'effect' +import { Predicate, Context, Effect, Fiber, Layer, Schema, Stream } from 'effect' import { defineAgent, @@ -29,7 +29,7 @@ import { echoTool, gptActiveModel, makeRecordedTool, scriptedModel } from './Api /** The encoded tool-result content part of the first durable tool-result entry. */ const firstToolResultPart = (entries: ReadonlyArray<{ readonly _tag: string }>) => { - const toolResult = entries.find((entry): entry is ToolResultLogEntry => entry._tag === 'tool-result') + const toolResult = entries.find((entry): entry is ToolResultLogEntry => Predicate.isTagged(entry, 'tool-result')) if (toolResult === undefined) throw new Error('expected a tool-result entry') const part = toolResult.message.content[0] @@ -73,15 +73,17 @@ it.effect('runs a tool-calling turn end to end from descriptors only', () => 'agent-finished', ]) - const sessionStarted = entries.find( - (entry): entry is SessionStartedLogEntry => entry._tag === 'session_started', + const sessionStarted = entries.find((entry): entry is SessionStartedLogEntry => + Predicate.isTagged(entry, 'session_started'), ) expect(sessionStarted?.sessionId).toBe(session.sessionId) expect(sessionStarted?.cwd).toBe('/tmp/facade-demo') expect(sessionStarted?.meta['suite']).toBe('facade') expect(sessionStarted?.meta['agentName']).toBe('facade-demo') - const agentStarted = entries.find((entry): entry is AgentStartedLogEntry => entry._tag === 'agent_started') + const agentStarted = entries.find((entry): entry is AgentStartedLogEntry => + Predicate.isTagged(entry, 'agent_started'), + ) expect(agentStarted?.agentId).toBe(session.rootAgentId) expect(agentStarted?.tools).toEqual(['echo']) @@ -122,7 +124,7 @@ it.effect('injects a skill as a linked synthetic tool call and result without a if (callPart?.type !== 'tool-call') throw new Error('expected injected skill tool call') if (resultPart?.type !== 'tool-result') throw new Error('expected injected skill tool result') - expect(entries.some((entry) => entry._tag === 'user-message')).toBe(false) + expect(entries.some((entry) => Predicate.isTagged(entry, 'user-message'))).toBe(false) expect(callPart).toMatchObject({ type: 'tool-call', name: 'skill', @@ -177,7 +179,7 @@ it.effect('tool handlers reach ToolState and ToolEvents; session.events carries // Subscribe before sending: deltas are live-only (durable rows replay from seq 0 regardless). // `startImmediately` + one yield lets the merge's subscription fibers register first. const collector = yield* session.events().pipe( - Stream.takeUntil((event) => event.kind === 'log' && event.entry._tag === 'agent-finished'), + Stream.takeUntil((event) => event.kind === 'log' && Predicate.isTagged(event.entry, 'agent-finished')), Stream.runCollect, Effect.forkChild({ startImmediately: true }), ) @@ -214,7 +216,7 @@ it.effect('tool handlers reach ToolState and ToolEvents; session.events carries // The handler's ToolState write landed as a durable, namespaced tool_state entry. const entries = yield* session.entries - const stateEntry = entries.find((entry): entry is ToolStateLogEntry => entry._tag === 'tool_state') + const stateEntry = entries.find((entry): entry is ToolStateLogEntry => Predicate.isTagged(entry, 'tool_state')) expect(stateEntry?.namespace).toBe('progress-echo') expect(stateEntry?.key).toBe('last') expect(stateEntry?.value).toBe('hi') diff --git a/packages/fold-core/test/Api/SwitchModel.vi.test.ts b/packages/fold-core/test/Api/SwitchModel.vi.test.ts index 980a518..f19ea9b 100644 --- a/packages/fold-core/test/Api/SwitchModel.vi.test.ts +++ b/packages/fold-core/test/Api/SwitchModel.vi.test.ts @@ -6,7 +6,7 @@ * log and the requests the scripted per-epoch models actually received. */ import { expect, it } from '@effect/vitest' -import { Effect, Schema } from 'effect' +import { Predicate, Effect, Schema } from 'effect' import { defineAgent, @@ -84,18 +84,26 @@ it.effect('switchModel continues the same log on a new provider and records the ]) // The durable transition binds the new model, the recomposed family prompt, and the re-resolved toolset. - const modelChange = entries.find((entry): entry is ModelChangeLogEntry => entry._tag === 'model-change') + const modelChange = entries.find((entry): entry is ModelChangeLogEntry => + Predicate.isTagged(entry, 'model-change'), + ) expect(modelChange?.model.modelId).toBe('claude-scripted') expect(modelChange?.reason).toBe('switch providers') - const systemEntries = entries.filter((entry): entry is SystemMessageLogEntry => entry._tag === 'system-message') + const systemEntries = entries.filter((entry): entry is SystemMessageLogEntry => + Predicate.isTagged(entry, 'system-message'), + ) expect(systemEntries[0]?.messages.map((message) => message.content)).toEqual(['GPT base.', 'Agent block.']) expect(systemEntries[1]?.messages.map((message) => message.content)).toEqual(['Claude base.', 'Agent block.']) - const agentStarted = entries.find((entry): entry is AgentStartedLogEntry => entry._tag === 'agent_started') + const agentStarted = entries.find((entry): entry is AgentStartedLogEntry => + Predicate.isTagged(entry, 'agent_started'), + ) expect(agentStarted?.tools).toEqual(['echo', 'apply_patch']) - const toolsChange = entries.find((entry): entry is ToolsChangeLogEntry => entry._tag === 'tools-change') + const toolsChange = entries.find((entry): entry is ToolsChangeLogEntry => + Predicate.isTagged(entry, 'tools-change'), + ) expect(toolsChange?.tools).toEqual(['echo']) // The new epoch's request advertises the re-resolved toolset and the recomposed leading prompt. @@ -135,7 +143,7 @@ it.effect('switchModel can replace the agent prompt blocks, and the replacement const entries = yield* session.entries const leadingBlocks = entries - .filter((entry): entry is SystemMessageLogEntry => entry._tag === 'system-message') + .filter((entry): entry is SystemMessageLogEntry => Predicate.isTagged(entry, 'system-message')) .map((entry) => entry.messages.map((message) => message.content)) expect(leadingBlocks).toEqual([ ['GPT base.', 'Original block.'], @@ -177,9 +185,11 @@ it.effect('switchModel can replace the installed tools; the new tool executes an // Durable facts: the epoch transition recorded the newly installed toolset... const entries = yield* session.entries - const toolsChange = entries.find((entry): entry is ToolsChangeLogEntry => entry._tag === 'tools-change') + const toolsChange = entries.find((entry): entry is ToolsChangeLogEntry => + Predicate.isTagged(entry, 'tools-change'), + ) expect(toolsChange?.tools).toEqual(['lookup']) - expect(entries.some((entry) => entry._tag === 'tool-result')).toBe(true) + expect(entries.some((entry) => Predicate.isTagged(entry, 'tool-result'))).toBe(true) // ...and each epoch's request advertised its own toolset. expect((yield* first.scripted.requests)[0]?.toolNames).toEqual(['echo']) @@ -225,8 +235,8 @@ it.effect('switchModel records thinking-change when the reasoning level changes 'agent-finished', ]) - const thinkingChange = entries.find( - (entry): entry is ThinkingChangeLogEntry => entry._tag === 'thinking-change', + const thinkingChange = entries.find((entry): entry is ThinkingChangeLogEntry => + Predicate.isTagged(entry, 'thinking-change'), ) expect(thinkingChange?.reasoningLevel).toBe('high') expect(thinkingChange?.reason).toBe('raise reasoning') @@ -272,7 +282,11 @@ it.effect('switchModel extends the subagent registry at the switch boundary', () yield* session.send('turn two') const entries = yield* session.entries - expect(entries.find((entry) => entry._tag === 'tools-change')).toMatchObject({ tools: ['subagent'] }) - expect(entries.find((entry) => entry._tag === 'model-change')).toMatchObject({ reason: 'new roster' }) + expect(entries.find((entry) => Predicate.isTagged(entry, 'tools-change'))).toMatchObject({ + tools: ['subagent'], + }) + expect(entries.find((entry) => Predicate.isTagged(entry, 'model-change'))).toMatchObject({ + reason: 'new roster', + }) }).pipe(Effect.scoped), ) diff --git a/packages/fold-core/test/Compaction/AutoCompactSession.vi.test.ts b/packages/fold-core/test/Compaction/AutoCompactSession.vi.test.ts index 3458b2c..998b0fe 100644 --- a/packages/fold-core/test/Compaction/AutoCompactSession.vi.test.ts +++ b/packages/fold-core/test/Compaction/AutoCompactSession.vi.test.ts @@ -12,7 +12,7 @@ * failures degrade to a durable error note, and a resumed log projects the compacted history. */ import { expect, it } from '@effect/vitest' -import { Context, Effect, Layer } from 'effect' +import { Predicate, Context, Effect, Layer } from 'effect' import { defineAgent, @@ -46,13 +46,13 @@ const compactConfig: AutoCompactConfig = { enabled: true, contextWindow: 10_000, const hugeUsage = { inputTokens: 7_000 } const compactionEntries = (entries: ReadonlyArray): ReadonlyArray => - entries.filter((entry): entry is CompactionLogEntry => entry._tag === 'compaction') + entries.filter((entry): entry is CompactionLogEntry => Predicate.isTagged(entry, 'compaction')) const errorEntries = (entries: ReadonlyArray): ReadonlyArray => - entries.filter((entry): entry is ErrorLogEntry => entry._tag === 'error') + entries.filter((entry): entry is ErrorLogEntry => Predicate.isTagged(entry, 'error')) const userEntries = (entries: ReadonlyArray): ReadonlyArray => - entries.filter((entry): entry is UserMessageLogEntry => entry._tag === 'user-message') + entries.filter((entry): entry is UserMessageLogEntry => Predicate.isTagged(entry, 'user-message')) it.effect('compacts mid-run at the threshold and keeps running; config from before the cut survives', () => Effect.gen(function* () { diff --git a/packages/fold-core/test/Compaction/SubagentAutoCompact.vi.test.ts b/packages/fold-core/test/Compaction/SubagentAutoCompact.vi.test.ts index 2fb63dd..406911d 100644 --- a/packages/fold-core/test/Compaction/SubagentAutoCompact.vi.test.ts +++ b/packages/fold-core/test/Compaction/SubagentAutoCompact.vi.test.ts @@ -6,7 +6,7 @@ * the parent's own view (global seq keeps the cut coherent - the worked D21 claim). */ import { expect, it } from '@effect/vitest' -import { Effect } from 'effect' +import { Predicate, Effect } from 'effect' import { defineAgent, @@ -27,11 +27,12 @@ const compactConfig: AutoCompactConfig = { enabled: true, contextWindow: 10_000, const hugeUsage = { inputTokens: 7_000 } const compactionEntries = (entries: ReadonlyArray): ReadonlyArray => - entries.filter((entry): entry is CompactionLogEntry => entry._tag === 'compaction') + entries.filter((entry): entry is CompactionLogEntry => Predicate.isTagged(entry, 'compaction')) const subagentStarted = (entries: ReadonlyArray): AgentStartedLogEntry => { const started = entries.find( - (entry): entry is AgentStartedLogEntry => entry._tag === 'agent_started' && entry.parentAgentId !== null, + (entry): entry is AgentStartedLogEntry => + Predicate.isTagged(entry, 'agent_started') && entry.parentAgentId !== null, ) if (started === undefined) throw new Error('expected a subagent agent_started entry') return started @@ -106,8 +107,12 @@ it.effect('a dispatched subagent compacts its own context; the parent projection expect(rootFinal).not.toContain('') // Projection read models agree per agent. - expect(messagesForAgent(entries, session.rootAgentId).some((m) => m._tag === 'compaction-summary')).toBe(false) - expect(messagesForAgent(entries, child.agentId).some((m) => m._tag === 'compaction-summary')).toBe(true) + expect( + messagesForAgent(entries, session.rootAgentId).some((m) => Predicate.isTagged(m, 'compaction-summary')), + ).toBe(false) + expect(messagesForAgent(entries, child.agentId).some((m) => Predicate.isTagged(m, 'compaction-summary'))).toBe( + true, + ) expect(yield* researcherScripted.scripted.remainingTurns).toBe(0) expect(yield* rootScripted.scripted.remainingTurns).toBe(0) @@ -181,7 +186,9 @@ it.effect('a fork compacts history including the parent folded range without tou const rootClosing = JSON.stringify(prompts[6]) expect(rootClosing).toContain('launch code is 4242') expect(rootClosing).not.toContain('') - expect(messagesForAgent(entries, session.rootAgentId).some((m) => m._tag === 'compaction-summary')).toBe(false) + expect( + messagesForAgent(entries, session.rootAgentId).some((m) => Predicate.isTagged(m, 'compaction-summary')), + ).toBe(false) expect(yield* scripted.remainingTurns).toBe(0) }).pipe(Effect.scoped), diff --git a/packages/fold-core/test/HookRunner/HookScope.vi.test.ts b/packages/fold-core/test/HookRunner/HookScope.vi.test.ts index 96281f0..5de1b97 100644 --- a/packages/fold-core/test/HookRunner/HookScope.vi.test.ts +++ b/packages/fold-core/test/HookRunner/HookScope.vi.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from '@effect/vitest' -import { Effect, Layer, Ref, Schema } from 'effect' +import { Predicate, Effect, Layer, Ref, Schema } from 'effect' import { AgentId, @@ -82,7 +82,7 @@ describe('HookRunner hook scope services', () => { expect(result.first).toEqual({ _tag: 'continue', params: { text: 'one' } }) expect(result.second).toEqual({ _tag: 'continue', params: { text: 'two' } }) - const stateEntries = result.entries.filter((entry) => entry._tag === 'tool_state') + const stateEntries = result.entries.filter((entry) => Predicate.isTagged(entry, 'tool_state')) expect(stateEntries).toHaveLength(2) expect(stateEntries[0]).toMatchObject({ namespace: 'guard', @@ -136,7 +136,7 @@ describe('HookRunner hook scope services', () => { expect(result.decision).toEqual({ _tag: 'complete' }) - const stateEntries = result.entries.filter((entry) => entry._tag === 'tool_state') + const stateEntries = result.entries.filter((entry) => Predicate.isTagged(entry, 'tool_state')) expect(stateEntries).toHaveLength(1) expect(stateEntries[0]).toMatchObject({ namespace: 'judge', diff --git a/packages/fold-core/test/HookRunner/HookStateResume.vi.test.ts b/packages/fold-core/test/HookRunner/HookStateResume.vi.test.ts index f38a009..7b28187 100644 --- a/packages/fold-core/test/HookRunner/HookStateResume.vi.test.ts +++ b/packages/fold-core/test/HookRunner/HookStateResume.vi.test.ts @@ -1,5 +1,5 @@ import { expect, it } from '@effect/vitest' -import { Effect, Layer, Schema } from 'effect' +import { Predicate, Effect, Layer, Schema } from 'effect' import { AgentId, @@ -80,7 +80,7 @@ it.effect('a hook reproduces its state from tool_state entries already persisted expect(result.decision).toEqual({ _tag: 'continue', params: { count: 42 } }) - const stateEntries = result.entries.filter((entry) => entry._tag === 'tool_state') + const stateEntries = result.entries.filter((entry) => Predicate.isTagged(entry, 'tool_state')) expect(stateEntries.map((entry) => entry.value)).toEqual([41, 42]) expect(stateEntries[1]).toMatchObject({ namespace: 'guard', key: 'count', toolCallId }) }), diff --git a/packages/fold-core/test/Model/ModelRequestSettings.vi.test.ts b/packages/fold-core/test/Model/ModelRequestSettings.vi.test.ts index 0627264..8234641 100644 --- a/packages/fold-core/test/Model/ModelRequestSettings.vi.test.ts +++ b/packages/fold-core/test/Model/ModelRequestSettings.vi.test.ts @@ -1,7 +1,7 @@ import { AnthropicLanguageModel } from '@effect/ai-anthropic' import { OpenAiLanguageModel } from '@effect/ai-openai' import { expect, it } from '@effect/vitest' -import { Effect } from 'effect' +import { Predicate, Effect } from 'effect' import { defaultAnthropicThinkingBudgets, @@ -111,8 +111,8 @@ const observedConfigs = (input: WrapModelRequestInput) => const anthropic = yield* settings.wrap(input)(Effect.serviceOption(AnthropicLanguageModel.Config)) return { - openai: openai._tag === 'Some' ? openai.value : null, - anthropic: anthropic._tag === 'Some' ? anthropic.value : null, + openai: Predicate.isTagged(openai, 'Some') ? openai.value : null, + anthropic: Predicate.isTagged(anthropic, 'Some') ? anthropic.value : null, } }).pipe(Effect.provide(liveModelRequestSettingsLayer)) diff --git a/packages/fold-core/test/Session/SessionEvents.vi.test.ts b/packages/fold-core/test/Session/SessionEvents.vi.test.ts index a3aa336..f80b158 100644 --- a/packages/fold-core/test/Session/SessionEvents.vi.test.ts +++ b/packages/fold-core/test/Session/SessionEvents.vi.test.ts @@ -1,5 +1,5 @@ import { expect, it } from '@effect/vitest' -import { Effect, Fiber, Stream } from 'effect' +import { Predicate, Effect, Fiber, Stream } from 'effect' import { Session } from '../../src/index' import { makeScriptedLanguageModel, textTurn, toolCallTurn } from '../TestLayers/ScriptedLanguageModel' @@ -39,7 +39,7 @@ it.effect('surfaces durable rows and one ephemeral tool-progress delta on Sessio // runs. Scheduling is cooperative and deterministic, so no wall-clock sleep is needed. Durable log // rows still replay from seq 0, so pre-subscription rows are not missed either. const collector = yield* session.events().pipe( - Stream.takeUntil((event) => event.kind === 'log' && event.entry._tag === 'agent-finished'), + Stream.takeUntil((event) => event.kind === 'log' && Predicate.isTagged(event.entry, 'agent-finished')), Stream.runCollect, Effect.forkChild({ startImmediately: true }), ) diff --git a/packages/fold-core/test/Session/SessionRuntime.vi.test.ts b/packages/fold-core/test/Session/SessionRuntime.vi.test.ts index 08e81b6..7c7e3b2 100644 --- a/packages/fold-core/test/Session/SessionRuntime.vi.test.ts +++ b/packages/fold-core/test/Session/SessionRuntime.vi.test.ts @@ -1,5 +1,5 @@ import { expect, it } from '@effect/vitest' -import { Effect } from 'effect' +import { Predicate, Effect } from 'effect' import { Session, @@ -44,8 +44,8 @@ it.effect('starts a session and completes a text-only send with the full log sha 'agent-finished', ]) - const sessionStarted = result.entries.find( - (entry): entry is SessionStartedLogEntry => entry._tag === 'session_started', + const sessionStarted = result.entries.find((entry): entry is SessionStartedLogEntry => + Predicate.isTagged(entry, 'session_started'), ) expect(sessionStarted?.seq).toBe(0) expect(sessionStarted?.sessionId).toBe(result.started.sessionId) @@ -53,8 +53,8 @@ it.effect('starts a session and completes a text-only send with the full log sha expect(sessionStarted?.cwd).toBe('/test') expect(sessionStarted?.version).toBe(1) - const agentStarted = result.entries.find( - (entry): entry is AgentStartedLogEntry => entry._tag === 'agent_started', + const agentStarted = result.entries.find((entry): entry is AgentStartedLogEntry => + Predicate.isTagged(entry, 'agent_started'), ) expect(agentStarted?.agentId).toBe(result.started.rootAgentId) }), @@ -73,8 +73,8 @@ it.effect('records a null cwd when the host has none', () => return yield* collectEntries }).pipe(Effect.provide(layer)) - const sessionStarted = entries.find( - (entry): entry is SessionStartedLogEntry => entry._tag === 'session_started', + const sessionStarted = entries.find((entry): entry is SessionStartedLogEntry => + Predicate.isTagged(entry, 'session_started'), ) expect(sessionStarted?.cwd).toBeNull() }), @@ -113,6 +113,6 @@ it.effect('fails a second start with SessionAlreadyStartedError and appends no s }).pipe(Effect.provide(layer)) expect(result.error).toBeInstanceOf(SessionAlreadyStartedError) - expect(result.entries.filter((entry) => entry._tag === 'session_started')).toHaveLength(1) + expect(result.entries.filter((entry) => Predicate.isTagged(entry, 'session_started'))).toHaveLength(1) }), ) diff --git a/packages/fold-core/test/Skills/SkillSource.vi.test.ts b/packages/fold-core/test/Skills/SkillSource.vi.test.ts index a46a4bc..626707f 100644 --- a/packages/fold-core/test/Skills/SkillSource.vi.test.ts +++ b/packages/fold-core/test/Skills/SkillSource.vi.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from '@effect/vitest' -import { Cause, Effect, Exit, Result } from 'effect' +import { Predicate, Cause, Effect, Exit, Result } from 'effect' import { renderSkillContent, @@ -52,7 +52,8 @@ describe('skillSourceFromData', () => { if (!Result.isFailure(result)) throw new Error('expected load to fail') expect(result.failure).toBeInstanceOf(SkillNotFoundError) - if (result.failure._tag !== 'SkillNotFoundError') throw new Error('expected SkillNotFoundError') + if (!Predicate.isTagged(result.failure, 'SkillNotFoundError')) + throw new Error('expected SkillNotFoundError') expect(result.failure.availableSkills).toEqual(['commit-helper', 'pdf-report']) }), ) diff --git a/packages/fold-core/test/Subagents/AgentIdRef.vi.test.ts b/packages/fold-core/test/Subagents/AgentIdRef.vi.test.ts index 9cf0ddc..8a987da 100644 --- a/packages/fold-core/test/Subagents/AgentIdRef.vi.test.ts +++ b/packages/fold-core/test/Subagents/AgentIdRef.vi.test.ts @@ -6,6 +6,7 @@ * edge) and with a shorter shared prefix whose candidates stay distinguishable. */ import { expect, it } from '@effect/vitest' +import { Predicate } from 'effect' import { AgentId, isAgentIdRef, resolveAgentIdRef, shortAgentId } from '../../src/index' @@ -53,7 +54,7 @@ it('an unknown reference and a full-length non-member are both not-found (no pre it('two agents sharing the referenced prefix are ambiguous, carrying the candidate short ids', () => { const resolution = resolveAgentIdRef([twinOne, twinTwo, beta], 'agent_abcdef') expect(resolution._tag).toBe('ambiguous') - if (resolution._tag !== 'ambiguous') return + if (!Predicate.isTagged(resolution, 'ambiguous')) return // Both twins share the full 8-char short id - the candidates report one entry per match. expect(resolution.candidates).toEqual(['agent_abcdefgh', 'agent_abcdefgh']) }) @@ -63,6 +64,6 @@ it('candidates stay distinguishable when the shared prefix is shorter than the s const nearTwo = idWithCuid('abcdef22') const resolution = resolveAgentIdRef([nearOne, nearTwo], 'agent_abcdef') expect(resolution._tag).toBe('ambiguous') - if (resolution._tag !== 'ambiguous') return + if (!Predicate.isTagged(resolution, 'ambiguous')) return expect(resolution.candidates).toEqual(['agent_abcdef11', 'agent_abcdef22']) }) diff --git a/packages/fold-core/test/Subagents/DriveHarness.ts b/packages/fold-core/test/Subagents/DriveHarness.ts index 5887f4f..e4ea0aa 100644 --- a/packages/fold-core/test/Subagents/DriveHarness.ts +++ b/packages/fold-core/test/Subagents/DriveHarness.ts @@ -6,7 +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 { Deferred, Effect, Ref, Schema, Stream } from 'effect' +import { Predicate, Deferred, Effect, Ref, Schema, Stream } from 'effect' import { AiError, LanguageModel } from 'effect/unstable/ai' import { @@ -126,14 +126,16 @@ export const makeDriveSession = (input: { /** The agent_started rows of dispatched subagents (parented rows), in log order. */ export const subagentStartedEntries = (entries: ReadonlyArray): ReadonlyArray => entries.filter( - (entry): entry is AgentStartedLogEntry => entry._tag === 'agent_started' && entry.parentAgentId !== null, + (entry): entry is AgentStartedLogEntry => + Predicate.isTagged(entry, 'agent_started') && entry.parentAgentId !== null, ) /** The nth durable tool-result's rendered content, JSON-stringified for substring assertions. */ export const renderedDriveResult = (entries: ReadonlyArray, occurrence: number): string => { - const results = entries.filter((entry) => entry._tag === 'tool-result') + const results = entries.filter((entry) => Predicate.isTagged(entry, 'tool-result')) const entry = results[occurrence] - if (entry === undefined || entry._tag !== 'tool-result') throw new Error('expected a tool-result entry') + if (entry === undefined || !Predicate.isTagged(entry, 'tool-result')) + throw new Error('expected a tool-result entry') return JSON.stringify(entry.message.content[0]) } @@ -181,7 +183,7 @@ export const makeHangOnceModel = ( } yield* Ref.set(turnsRef, remaining.slice(1)) - return turn._tag === 'failure' + return Predicate.isTagged(turn, 'failure') ? Stream.fail( AiError.make({ module: 'HangOnceModel', diff --git a/packages/fold-core/test/Subagents/SubagentDispatch.vi.test.ts b/packages/fold-core/test/Subagents/SubagentDispatch.vi.test.ts index 6f31fdc..3a27ea1 100644 --- a/packages/fold-core/test/Subagents/SubagentDispatch.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentDispatch.vi.test.ts @@ -5,7 +5,7 @@ * durable tool result renders the agent_id + turns header and the body. */ import { expect, it } from '@effect/vitest' -import { Effect } from 'effect' +import { Predicate, Effect } from 'effect' import { defineAgent, @@ -79,7 +79,9 @@ it.effect('dispatches a fresh subagent on the shared log and renders its result' ]) // The subagent's rows carry the dispatching parent and the dispatching tool call (D2 envelope). - const startedEntries = entries.filter((entry): entry is AgentStartedLogEntry => entry._tag === 'agent_started') + const startedEntries = entries.filter((entry): entry is AgentStartedLogEntry => + Predicate.isTagged(entry, 'agent_started'), + ) const rootStarted = startedEntries[0] const subagentStarted = startedEntries[1] if (rootStarted === undefined || subagentStarted === undefined) throw new Error('expected two agent_started') @@ -113,7 +115,9 @@ it.effect('dispatches a fresh subagent on the shared log and renders its result' expect(JSON.stringify(userContents)).not.toContain('go') // The dispatcher's durable tool result carries the id + turns header and the result body. - const toolResult = entries.find((entry): entry is ToolResultLogEntry => entry._tag === 'tool-result') + const toolResult = entries.find((entry): entry is ToolResultLogEntry => + Predicate.isTagged(entry, 'tool-result'), + ) if (toolResult === undefined) throw new Error('expected a tool-result entry') const rendered = toolResultText(toolResult) expect(rendered).toContain(`agent_id: ${shortAgentId(subagentStarted.agentId)}`) diff --git a/packages/fold-core/test/Subagents/SubagentFork.vi.test.ts b/packages/fold-core/test/Subagents/SubagentFork.vi.test.ts index 582169f..d7a8e5d 100644 --- a/packages/fold-core/test/Subagents/SubagentFork.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentFork.vi.test.ts @@ -5,7 +5,7 @@ * asserted for real against the scripted model's recorded prompts). */ import { expect, it } from '@effect/vitest' -import { Effect } from 'effect' +import { Predicate, Effect } from 'effect' import { shortAgentId, type AgentStartedLogEntry, type AssistantMessageLogEntry } from '../../src/index' import { textTurn, toolCallTurn } from '../TestLayers/ScriptedLanguageModel' @@ -60,19 +60,20 @@ it.effect('a fork clones the caller: shared history prefix, no new leading promp expect(forkStarted.mode).toBe('fork') expect(forkStarted.agentType).toBeNull() const rootStarted = entries.find( - (entry): entry is AgentStartedLogEntry => entry._tag === 'agent_started' && entry.parentAgentId === null, + (entry): entry is AgentStartedLogEntry => + Predicate.isTagged(entry, 'agent_started') && entry.parentAgentId === null, ) if (rootStarted === undefined) throw new Error('expected the root agent_started') expect(forkStarted.fork?.fromAgentId).toBe(rootStarted.agentId) const dispatchingAssistantRow = entries.find( (entry): entry is AssistantMessageLogEntry => - entry._tag === 'assistant-message' && entry.agentId === rootStarted.agentId, + Predicate.isTagged(entry, 'assistant-message') && entry.agentId === rootStarted.agentId, ) expect(forkStarted.fork?.atSeq).toBe(dispatchingAssistantRow?.seq) // No new leading system message for the fork: the fold carries the caller's blocks. const forkSystemMessages = entries.filter( - (entry) => entry._tag === 'system-message' && entry.agentId === forkStarted.agentId, + (entry) => Predicate.isTagged(entry, 'system-message') && entry.agentId === forkStarted.agentId, ) expect(forkSystemMessages).toHaveLength(0) diff --git a/packages/fold-core/test/Subagents/SubagentHooks.vi.test.ts b/packages/fold-core/test/Subagents/SubagentHooks.vi.test.ts index 1203fe5..320fdf9 100644 --- a/packages/fold-core/test/Subagents/SubagentHooks.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentHooks.vi.test.ts @@ -5,7 +5,7 @@ * invisible in another agent's fold of the same namespace. */ import { expect, it } from '@effect/vitest' -import { Effect, Ref, Schema } from 'effect' +import { Predicate, Effect, Ref, Schema } from 'effect' import { defineAgent, @@ -79,9 +79,11 @@ it.effect('root and subagent run their own hook chains, and hook state stays per // Per-agent KV isolation (D4): each agent's probe namespace holds only its own marker. const entries = yield* session.entries - const rootStarted = entries.find((entry) => entry._tag === 'agent_started' && entry.parentAgentId === null) + const rootStarted = entries.find( + (entry) => Predicate.isTagged(entry, 'agent_started') && entry.parentAgentId === null, + ) const subagentStarted = subagentStartedEntries(entries)[0] - if (rootStarted?._tag !== 'agent_started' || subagentStarted === undefined) { + if (!Predicate.isTagged(rootStarted, 'agent_started') || subagentStarted === undefined) { throw new Error('expected both agents to have started') } diff --git a/packages/fold-core/test/Subagents/SubagentInterrupt.vi.test.ts b/packages/fold-core/test/Subagents/SubagentInterrupt.vi.test.ts index 14d88e8..a2a6ab7 100644 --- a/packages/fold-core/test/Subagents/SubagentInterrupt.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentInterrupt.vi.test.ts @@ -7,7 +7,7 @@ * resumable over the same log and completes. */ import { expect, it } from '@effect/vitest' -import { Deferred, Effect, Fiber } from 'effect' +import { Predicate, Deferred, Effect, Fiber } from 'effect' import { defineSubagent, shortAgentId, type LogEntry, type UserMessageLogEntry } from '../../src/index' import { claudeActiveModel } from '../Api/ApiTestHelpers' @@ -60,15 +60,17 @@ it.effect('an interrupted subagent leaves honest durable markers and is resumabl // The child-side markers: the interrupt note user-message and the interrupted terminal marker. const markerMessage = entries.filter( - (entry): entry is UserMessageLogEntry => entry._tag === 'user-message' && entry.agentId === started.agentId, + (entry): entry is UserMessageLogEntry => + Predicate.isTagged(entry, 'user-message') && entry.agentId === started.agentId, )[1] expect(JSON.stringify(markerMessage)).toContain('You were interrupted by the user') const finished = entries.findLast( (entry): entry is LogEntry & { readonly outcome: string } => - entry._tag === 'agent-finished' && entry.agentId === started.agentId, + Predicate.isTagged(entry, 'agent-finished') && entry.agentId === started.agentId, ) - if (finished === undefined || finished._tag !== 'agent-finished') throw new Error('expected agent-finished') + if (finished === undefined || !Predicate.isTagged(finished, 'agent-finished')) + throw new Error('expected agent-finished') expect(finished.outcome).toBe('interrupted') // The dispatcher's synthetic tool result carries the enriched InterruptNote: id + turn count. @@ -80,9 +82,9 @@ it.effect('an interrupted subagent leaves honest durable markers and is resumabl // Slice 2 (D10): the interrupted root run has its own durable terminal marker, written by the // facade's uninterruptible exit finalizer after the child's markers landed. const rootFinished = entries.findLast( - (entry) => entry._tag === 'agent-finished' && entry.agentId !== started.agentId, + (entry) => Predicate.isTagged(entry, 'agent-finished') && entry.agentId !== started.agentId, ) - if (rootFinished === undefined || rootFinished._tag !== 'agent-finished') { + if (rootFinished === undefined || !Predicate.isTagged(rootFinished, 'agent-finished')) { throw new Error('expected the root interrupt marker') } expect(rootFinished.outcome).toBe('interrupted') diff --git a/packages/fold-core/test/Subagents/SubagentLogReplay.vi.test.ts b/packages/fold-core/test/Subagents/SubagentLogReplay.vi.test.ts index 47a4c27..48a6ae9 100644 --- a/packages/fold-core/test/Subagents/SubagentLogReplay.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentLogReplay.vi.test.ts @@ -6,7 +6,7 @@ * process-restart story at the storage seam (fold-agent's JSONL backend persists the same seam to disk). */ import { expect, it } from '@effect/vitest' -import { Context, Effect, Layer } from 'effect' +import { Predicate, Context, Effect, Layer } from 'effect' import { defineAgent, @@ -109,7 +109,7 @@ it.effect("a new session over the same log resumes a prior session's subagent pu const entries = yield* sessionB.entries // Two session_started rows (an honest restart marker), but still exactly ONE subagent start. - expect(entries.filter((entry) => entry._tag === 'session_started')).toHaveLength(2) + expect(entries.filter((entry) => Predicate.isTagged(entry, 'session_started'))).toHaveLength(2) expect(subagentStartedEntries(entries)).toHaveLength(1) // The resumed model call reconstructed A's context purely from the log rows. @@ -122,13 +122,13 @@ it.effect("a new session over the same log resumes a prior session's subagent pu // The resumed rows carry session B's dispatcher as parent, under the RESUMING tool call. const rootStartedRows = entries.filter( - (entry) => entry._tag === 'agent_started' && entry.parentAgentId === null, + (entry) => Predicate.isTagged(entry, 'agent_started') && entry.parentAgentId === null, ) const rootB = rootStartedRows[1] - if (rootB?._tag !== 'agent_started') throw new Error("expected session B's root agent_started") + if (!Predicate.isTagged(rootB, 'agent_started')) throw new Error("expected session B's root agent_started") const researcherUserMessages = entries.filter( (entry): entry is UserMessageLogEntry => - entry._tag === 'user-message' && entry.agentId === (dispatched.agentId satisfies AgentId), + Predicate.isTagged(entry, 'user-message') && entry.agentId === (dispatched.agentId satisfies AgentId), ) expect(researcherUserMessages).toHaveLength(2) expect(researcherUserMessages[1]?.parentAgentId).toBe(rootB.agentId) diff --git a/packages/fold-core/test/Subagents/SubagentProfiles.vi.test.ts b/packages/fold-core/test/Subagents/SubagentProfiles.vi.test.ts index c0ec3cc..b3387c3 100644 --- a/packages/fold-core/test/Subagents/SubagentProfiles.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentProfiles.vi.test.ts @@ -7,7 +7,7 @@ * defects at session start, and concrete bindings keep working with no profiles passed at all. */ import { expect, it } from '@effect/vitest' -import { Cause, Effect, Exit } from 'effect' +import { Predicate, Cause, Effect, Exit } from 'effect' import { defineAgent, defineSubagent, startSession, subagentTool, type ModelChangeLogEntry } from '../../src/index' import { claudeActiveModel, gptActiveModel, scriptedModel } from '../Api/ApiTestHelpers' @@ -64,7 +64,8 @@ it.effect('resuming a subagent dispatched before a setProfile swap writes the ch // the durable D17 transition for the CHILD agent before re-entering its loop. const entries = yield* session.entries const childModelChange = entries.find( - (entry): entry is ModelChangeLogEntry => entry._tag === 'model-change' && entry.agentId === started.agentId, + (entry): entry is ModelChangeLogEntry => + Predicate.isTagged(entry, 'model-change') && entry.agentId === started.agentId, ) expect(childModelChange?.model.modelId).toBe('fast-b') diff --git a/packages/fold-core/test/Subagents/SubagentResume.vi.test.ts b/packages/fold-core/test/Subagents/SubagentResume.vi.test.ts index 1290047..91a65bf 100644 --- a/packages/fold-core/test/Subagents/SubagentResume.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentResume.vi.test.ts @@ -7,7 +7,7 @@ * the child loops - is real. */ import { expect, it } from '@effect/vitest' -import { Effect, Ref, Schema } from 'effect' +import { Predicate, Effect, Ref, Schema } from 'effect' import { defineAgent, @@ -104,13 +104,15 @@ const makeDriveSession = (input: { const subagentStartedEntries = (entries: ReadonlyArray): ReadonlyArray => entries.filter( - (entry): entry is AgentStartedLogEntry => entry._tag === 'agent_started' && entry.parentAgentId !== null, + (entry): entry is AgentStartedLogEntry => + Predicate.isTagged(entry, 'agent_started') && entry.parentAgentId !== null, ) const renderedDriveResult = (entries: ReadonlyArray, occurrence: number): string => { - const results = entries.filter((entry) => entry._tag === 'tool-result') + const results = entries.filter((entry) => Predicate.isTagged(entry, 'tool-result')) const entry = results[occurrence] - if (entry === undefined || entry._tag !== 'tool-result') throw new Error('expected a tool-result entry') + if (entry === undefined || !Predicate.isTagged(entry, 'tool-result')) + throw new Error('expected a tool-result entry') return JSON.stringify(entry.message.content[0]) } @@ -142,7 +144,8 @@ it.effect('resumes a completed subagent: no new agent_started, rows under the re // The resumed run's rows group under the RESUMING tool call (per-dispatch envelope, D2). const subagentUserMessages = entries.filter( - (entry): entry is UserMessageLogEntry => entry._tag === 'user-message' && entry.agentId === started.agentId, + (entry): entry is UserMessageLogEntry => + Predicate.isTagged(entry, 'user-message') && entry.agentId === started.agentId, ) expect(subagentUserMessages).toHaveLength(2) const dispatchCall = subagentUserMessages[0]?.toolCallId @@ -189,9 +192,10 @@ it.effect('a subagent that errored is a result and remains resumable (model fail // The subagent's own log carries the error facts. const subagentFinished = afterDispatch.findLast( - (entry) => entry._tag === 'agent-finished' && entry.agentId === started.agentId, + (entry) => Predicate.isTagged(entry, 'agent-finished') && entry.agentId === started.agentId, ) - if (subagentFinished?._tag !== 'agent-finished') throw new Error('expected the subagent to have finished') + if (!Predicate.isTagged(subagentFinished, 'agent-finished')) + throw new Error('expected the subagent to have finished') expect(subagentFinished.outcome).toBe('error') // The dispatcher's rendered result names the id, the error, and the resume guidance. @@ -251,9 +255,10 @@ it.effect('a subagent that died from a defect is flattened into an error result // The exit finalizer wrote the durable error marker for the dead subagent. const subagentFinished = afterDispatch.findLast( - (entry) => entry._tag === 'agent-finished' && entry.agentId === started.agentId, + (entry) => Predicate.isTagged(entry, 'agent-finished') && entry.agentId === started.agentId, ) - if (subagentFinished?._tag !== 'agent-finished') throw new Error('expected the subagent to have finished') + if (!Predicate.isTagged(subagentFinished, 'agent-finished')) + throw new Error('expected the subagent to have finished') expect(subagentFinished.outcome).toBe('error') expect(subagentFinished.reason).toContain('explode-once') diff --git a/packages/fold-core/test/Subagents/SubagentRoster.vi.test.ts b/packages/fold-core/test/Subagents/SubagentRoster.vi.test.ts index 000a173..a67ccd4 100644 --- a/packages/fold-core/test/Subagents/SubagentRoster.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentRoster.vi.test.ts @@ -6,7 +6,7 @@ * distinct definitions are a session-start defect. */ import { expect, it } from '@effect/vitest' -import { Cause, Effect, Exit, Schema } from 'effect' +import { Predicate, Cause, Effect, Exit, Schema } from 'effect' import { defineAgent, @@ -107,7 +107,9 @@ it.effect('nested rosters give depth; out-of-roster dispatch fails instructively expect(grandchildStarted?.parentAgentId).toBe(generalStarted?.agentId) // The out-of-roster attempt came back schema-encoded with the caller's available list. - const toolResults = entries.filter((entry): entry is ToolResultLogEntry => entry._tag === 'tool-result') + const toolResults = entries.filter((entry): entry is ToolResultLogEntry => + Predicate.isTagged(entry, 'tool-result'), + ) const selfDispatchResult = toolResults.find( (entry) => entry.agentId === generalStarted?.agentId && JSON.stringify(entry).includes('not available'), ) diff --git a/packages/fold-core/test/Subagents/SubagentSkills.vi.test.ts b/packages/fold-core/test/Subagents/SubagentSkills.vi.test.ts index 2d0cdad..583018c 100644 --- a/packages/fold-core/test/Subagents/SubagentSkills.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentSkills.vi.test.ts @@ -6,7 +6,7 @@ * a typed failure before any subagent row is written. */ import { expect, it } from '@effect/vitest' -import { Effect, Ref } from 'effect' +import { Predicate, Effect, Ref } from 'effect' import { defineAgent, @@ -81,7 +81,8 @@ it.effect('a shared skillTool value scans once; the preload rides the dispatcher // D21 message order: dispatch prompt first, skill invocation second. const subagentUserMessages = entries.filter( - (entry): entry is UserMessageLogEntry => entry._tag === 'user-message' && entry.agentId === started.agentId, + (entry): entry is UserMessageLogEntry => + Predicate.isTagged(entry, 'user-message') && entry.agentId === started.agentId, ) expect(subagentUserMessages).toHaveLength(2) expect(JSON.stringify(subagentUserMessages[0])).toContain('research it') @@ -90,7 +91,7 @@ it.effect('a shared skillTool value scans once; the preload rides the dispatcher // The subagent's own leading prompt carries the shared skills block. const subagentSystem = entries.find( - (entry) => entry._tag === 'system-message' && entry.agentId === started.agentId, + (entry) => Predicate.isTagged(entry, 'system-message') && entry.agentId === started.agentId, ) expect(JSON.stringify(subagentSystem)).toContain('available_skills') }).pipe(Effect.scoped), @@ -123,7 +124,7 @@ it.effect('a dispatcher with no skillTool cannot preload: typed failure before a // No subagent was started: the preload failed before any durable subagent row. expect(subagentStartedEntries(entries)).toHaveLength(0) - const toolResult = entries.find((entry) => entry._tag === 'tool-result') + const toolResult = entries.find((entry) => Predicate.isTagged(entry, 'tool-result')) expect(JSON.stringify(toolResult)).toContain('Skill \\"commit-helper\\" not found') }).pipe(Effect.scoped), ) diff --git a/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts b/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts index a36f7fc..e3ca047 100644 --- a/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts @@ -7,7 +7,7 @@ * unknown agent_id) come back as instructive tool failures the model can correct from. */ import { expect, it } from '@effect/vitest' -import { Context, Effect, Layer } from 'effect' +import { Predicate, Context, Effect, Layer } from 'effect' import { AgentId, @@ -86,7 +86,8 @@ it.effect('the model resumes a subagent through the tool wire by its SHORT id: f // Resume through the wire wrote no second agent_started and grouped rows under the resuming call. expect(subagentStartedEntries(entries)).toHaveLength(1) const researcherUserMessages = entries.filter( - (entry): entry is UserMessageLogEntry => entry._tag === 'user-message' && entry.agentId === started.agentId, + (entry): entry is UserMessageLogEntry => + Predicate.isTagged(entry, 'user-message') && entry.agentId === started.agentId, ) expect(researcherUserMessages).toHaveLength(2) expect(researcherUserMessages[1]?.toolCallId).not.toBeNull() @@ -199,9 +200,9 @@ it.effect('an ambiguous short agent_id comes back as an instructive failure nami }) const rootStarted = (yield* session.entries).find( - (entry) => entry._tag === 'agent_started' && entry.parentAgentId === null, + (entry) => Predicate.isTagged(entry, 'agent_started') && entry.parentAgentId === null, ) - if (rootStarted?._tag !== 'agent_started') throw new Error('expected the root agent_started row') + if (!Predicate.isTagged(rootStarted, 'agent_started')) throw new Error('expected the root agent_started row') const twinIds = [ AgentId.make(`agent_abcdef11${'0'.repeat(16)}`), diff --git a/packages/fold-core/test/TestLayers/ScriptedLanguageModel.ts b/packages/fold-core/test/TestLayers/ScriptedLanguageModel.ts index e539c64..822ceb7 100644 --- a/packages/fold-core/test/TestLayers/ScriptedLanguageModel.ts +++ b/packages/fold-core/test/TestLayers/ScriptedLanguageModel.ts @@ -11,7 +11,7 @@ */ import { AnthropicLanguageModel } from '@effect/ai-anthropic' import { OpenAiLanguageModel } from '@effect/ai-openai' -import { Effect, Layer, Ref, Stream } from 'effect' +import { Predicate, Effect, Layer, Ref, Stream } from 'effect' import { AiError, LanguageModel, type Prompt, type Response } from 'effect/unstable/ai' /** Optional shaping for a scripted turn's finish part. */ @@ -136,8 +136,8 @@ export const makeScriptedLanguageModel = (turns: ReadonlyArray): E { prompt: options.prompt, toolNames: options.tools.map((tool) => tool.name), - openAiConfig: openAiConfig._tag === 'Some' ? openAiConfig.value : null, - anthropicConfig: anthropicConfig._tag === 'Some' ? anthropicConfig.value : null, + openAiConfig: Predicate.isTagged(openAiConfig, 'Some') ? openAiConfig.value : null, + anthropicConfig: Predicate.isTagged(anthropicConfig, 'Some') ? anthropicConfig.value : null, }, ]) @@ -160,7 +160,7 @@ export const makeScriptedLanguageModel = (turns: ReadonlyArray): E Stream.unwrap( nextTurn(options).pipe( Effect.map((turn) => - turn._tag === 'failure' + Predicate.isTagged(turn, 'failure') ? Stream.fail(scriptedFailure(turn.message)) : Stream.fromIterable(turn.parts), ), diff --git a/packages/fold-core/test/ToolRuntime/ToolRuntimeDefect.vi.test.ts b/packages/fold-core/test/ToolRuntime/ToolRuntimeDefect.vi.test.ts index 8d23aeb..7a46f5f 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeDefect.vi.test.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeDefect.vi.test.ts @@ -1,15 +1,15 @@ import { expect, it } from '@effect/vitest' -import { Effect, Ref } from 'effect' +import { Predicate, Effect, Ref } from 'effect' import { makeHookRunner, messagesForAgent, StopController, ToolRuntime } from '../../src/index' import { layerEchoTool, makeEchoRecorder, TestToolkit } from '../TestLayers/TestTools' import { agentId, collectEntries, makeAssistantToolCall, toolRuntimeBaseLayer } from './ToolRuntimeTestHelpers' const projectedToolResultPart = (projected: ReturnType) => { - const toolResult = projected.find((message) => message._tag === 'tool-result') + const toolResult = projected.find((message) => Predicate.isTagged(message, 'tool-result')) expect(toolResult?._tag).toBe('tool-result') - if (toolResult?._tag !== 'tool-result') throw new Error('Expected a projected tool-result') + if (!Predicate.isTagged(toolResult, 'tool-result')) throw new Error('Expected a projected tool-result') const part = toolResult.message.content[0] if (part === undefined || part.type !== 'tool-result') throw new Error('Expected a tool-result content part') diff --git a/packages/fold-core/test/ToolRuntime/ToolRuntimeHookState.vi.test.ts b/packages/fold-core/test/ToolRuntime/ToolRuntimeHookState.vi.test.ts index 3515f63..36f9b50 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeHookState.vi.test.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeHookState.vi.test.ts @@ -1,5 +1,5 @@ import { expect, it } from '@effect/vitest' -import { Effect, Schema } from 'effect' +import { Predicate, Effect, Schema } from 'effect' import { defineToolState, makeHookRunner, toolStateForAgent, ToolRuntime } from '../../src/index' import { layerStatefulEchoTool, makeEchoRecorder } from '../TestLayers/TestTools' @@ -59,7 +59,7 @@ it.effect('a preToolUse hook writes durable state in its declared namespace, sep } }).pipe(Effect.provide(layer)) - const stateEntries = result.entries.filter((entry) => entry._tag === 'tool_state') + const stateEntries = result.entries.filter((entry) => Predicate.isTagged(entry, 'tool_state')) expect(stateEntries.map((entry) => entry.namespace).sort()).toEqual(['audit', 'echo']) const auditEntry = stateEntries.find((entry) => entry.namespace === 'audit') diff --git a/packages/fold-core/test/ToolRuntime/ToolRuntimeInterrupt.vi.test.ts b/packages/fold-core/test/ToolRuntime/ToolRuntimeInterrupt.vi.test.ts index 594b977..c450f49 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeInterrupt.vi.test.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeInterrupt.vi.test.ts @@ -1,5 +1,5 @@ import { expect, it } from '@effect/vitest' -import { Deferred, Effect, Fiber, Layer, Schema } from 'effect' +import { Predicate, Deferred, Effect, Fiber, Layer, Schema } from 'effect' import { Prompt, Tool, Toolkit } from 'effect/unstable/ai' import { @@ -85,7 +85,7 @@ it.effect('writes a synthetic interrupted tool-result when a running tool fiber const toolResult = entries[0] expect(toolResult?._tag).toBe('tool-result') - if (toolResult?._tag !== 'tool-result') return + if (!Predicate.isTagged(toolResult, 'tool-result')) return expect(toolResult.message.content[0]).toMatchObject({ type: 'tool-result', @@ -95,7 +95,9 @@ it.effect('writes a synthetic interrupted tool-result when a running tool fiber result: 'The user interrupted the execution of this tool call.', }) - const projectedToolResult = messagesForAgent(entries, agentId).find((message) => message._tag === 'tool-result') + const projectedToolResult = messagesForAgent(entries, agentId).find((message) => + Predicate.isTagged(message, 'tool-result'), + ) expect(projectedToolResult?.message.content[0]).toMatchObject({ type: 'tool-result', id: 'tool_call_aaaaaaaaaaaaaaaaaaaaaaaa', diff --git a/packages/fold-core/test/ToolRuntime/ToolRuntimeLiveHooks.vi.test.ts b/packages/fold-core/test/ToolRuntime/ToolRuntimeLiveHooks.vi.test.ts index 6d54744..c50fcd2 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeLiveHooks.vi.test.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeLiveHooks.vi.test.ts @@ -1,5 +1,5 @@ import { expect, it } from '@effect/vitest' -import { Effect, Ref } from 'effect' +import { Predicate, Effect, Ref } from 'effect' import { makeHookRunner, messagesForAgent, ToolRuntime } from '../../src/index' import { layerEchoTool, makeEchoRecorder } from '../TestLayers/TestTools' @@ -99,7 +99,7 @@ it.effect('live preToolUse hook can update execution params without changing pro params: { text: 'original' }, }) - const projectedToolResult = result.projected.find((message) => message._tag === 'tool-result') + const projectedToolResult = result.projected.find((message) => Predicate.isTagged(message, 'tool-result')) expect(projectedToolResult).not.toHaveProperty('executedInput') }), ) diff --git a/packages/fold-core/test/ToolRuntime/ToolRuntimeStateSnapshot.vi.test.ts b/packages/fold-core/test/ToolRuntime/ToolRuntimeStateSnapshot.vi.test.ts index 1297c53..f6c5e54 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeStateSnapshot.vi.test.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeStateSnapshot.vi.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from '@effect/vitest' -import { Deferred, Effect, Layer, Ref, Schema } from 'effect' +import { Predicate, Deferred, Effect, Layer, Ref, Schema } from 'effect' import { Prompt, Tool, Toolkit } from 'effect/unstable/ai' import { @@ -149,7 +149,7 @@ describe('ToolRuntime handler state snapshots', () => { expect(observed.aAfterBWrote).toBe('a') // Both writes are durable facts in the log, and the next batch folds the last writer. - const stateEntries = result.entries.filter((entry) => entry._tag === 'tool_state') + const stateEntries = result.entries.filter((entry) => Predicate.isTagged(entry, 'tool_state')) expect(stateEntries.map((entry) => entry.value)).toEqual(['a', 'b']) expect(toolStateForAgent(result.entries, agentId, 'probe')).toEqual({ shared: 'b' }) expect(result.settlement.toolResults).toHaveLength(2) diff --git a/packages/fold-core/test/ToolState/ToolStateSnapshot.vi.test.ts b/packages/fold-core/test/ToolState/ToolStateSnapshot.vi.test.ts index 6273596..1e0b5a4 100644 --- a/packages/fold-core/test/ToolState/ToolStateSnapshot.vi.test.ts +++ b/packages/fold-core/test/ToolState/ToolStateSnapshot.vi.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from '@effect/vitest' -import { Effect, Layer, Stream } from 'effect' +import { Predicate, Effect, Layer, Stream } from 'effect' import { AgentId, @@ -72,7 +72,9 @@ describe('handler ToolState snapshot semantics', () => { const afterOwnClear = yield* state.get('probe', 'shared') const entries = yield* collectLogEntries - const stateValues = entries.filter((entry) => entry._tag === 'tool_state').map((entry) => entry.value) + const stateValues = entries + .filter((entry) => Predicate.isTagged(entry, 'tool_state')) + .map((entry) => entry.value) expect(seeded).toBe('seeded') expect(afterExternal).toBe('seeded') diff --git a/packages/fold-core/test/Tools/PatchEngine.vi.test.ts b/packages/fold-core/test/Tools/PatchEngine.vi.test.ts index d5b6bd2..6353d44 100644 --- a/packages/fold-core/test/Tools/PatchEngine.vi.test.ts +++ b/packages/fold-core/test/Tools/PatchEngine.vi.test.ts @@ -40,12 +40,12 @@ describe('parsePatch (V4A)', () => { expect(ops.map((op) => op._tag)).toEqual(['add', 'update', 'delete']) const add = ops[0] - if (add?._tag !== 'add') throw new Error('expected add') + if (!Predicate.isTagged(add, 'add')) throw new Error('expected add') expect(add.path).toBe('new.txt') expect(add.content).toBe('hello\nworld') const update = ops[1] - if (update?._tag !== 'update') throw new Error('expected update') + if (!Predicate.isTagged(update, 'update')) throw new Error('expected update') expect(update.path).toBe('src/app.ts') expect(update.movePath).toBe('src/main.ts') expect(update.chunks).toHaveLength(1) @@ -110,7 +110,7 @@ describe('parsePatch (V4A)', () => { ) const update = ops[0] - if (update?._tag !== 'update') throw new Error('expected update') + if (!Predicate.isTagged(update, 'update')) throw new Error('expected update') expect(update.chunks[0]?.isEndOfFile).toBe(true) }), ) @@ -130,7 +130,7 @@ describe('parsePatch (V4A)', () => { ) const update = ops[0] - if (update?._tag !== 'update') throw new Error('expected update') + if (!Predicate.isTagged(update, 'update')) throw new Error('expected update') expect(update.chunks[0]?.oldLines).toEqual(['old']) expect(update.chunks[0]?.newLines).toEqual(['new']) }), @@ -154,7 +154,7 @@ describe('parsePatch (git/unified diffs - clanka superset)', () => { ) const update = ops[0] - if (update?._tag !== 'update') throw new Error('expected update') + if (!Predicate.isTagged(update, 'update')) throw new Error('expected update') expect(update.path).toBe('src/x.ts') expect(update.movePath).toBeNull() expect(update.chunks[0]?.oldLines).toEqual(['keep', 'remove']) @@ -184,7 +184,7 @@ describe('parsePatch (git/unified diffs - clanka superset)', () => { expect(ops.map((op) => op._tag)).toEqual(['add', 'delete']) const add = ops[0] - if (add?._tag !== 'add') throw new Error('expected add') + if (!Predicate.isTagged(add, 'add')) throw new Error('expected add') expect(add.content).toBe('first\nsecond') }), ) @@ -206,7 +206,7 @@ describe('parsePatch (git/unified diffs - clanka superset)', () => { ) const update = ops[0] - if (update?._tag !== 'update') throw new Error('expected update') + if (!Predicate.isTagged(update, 'update')) throw new Error('expected update') expect(update.path).toBe('old-name.ts') expect(update.movePath).toBe('new-name.ts') }), @@ -219,7 +219,7 @@ describe('parsePatch (git/unified diffs - clanka superset)', () => { ) const update = ops[0] - if (update?._tag !== 'update') throw new Error('expected update') + if (!Predicate.isTagged(update, 'update')) throw new Error('expected update') expect(update.path).toBe('f.txt') }), ) diff --git a/packages/fold-opencode/src/OpenCodeModel.ts b/packages/fold-opencode/src/OpenCodeModel.ts index 2ec7a85..a648a24 100644 --- a/packages/fold-opencode/src/OpenCodeModel.ts +++ b/packages/fold-opencode/src/OpenCodeModel.ts @@ -3,7 +3,7 @@ import { OpenAiClient as ResponsesClient, OpenAiLanguageModel as ResponsesLangua import { OpenAiClient as ChatClient, OpenAiLanguageModel as ChatLanguageModel } from '@effect/ai-openai-compat' import { customModel, resolveOpenAiReasoning } from '@humanlayer/fold-core' import type { FoldModel, ReasoningLevel } from '@humanlayer/fold-core' -import { Context, Effect, Layer, Option, Schema } from 'effect' +import { Match, Context, Effect, Layer, Option, Schema } from 'effect' import type { Scope } from 'effect' import type { LanguageModel } from 'effect/unstable/ai' import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from 'effect/unstable/http' @@ -124,6 +124,10 @@ export const makeOpenCodeLanguageModel = ( ) const resolved = resolveOpenCodeModelConfig(providers, requestedModel, options.apiUrl) const reasoning = resolveOpenAiReasoning(options.reasoning ?? 'off') + const config = Match.valueTags(reasoning, { + disabled: () => ({}), + effort: ({ effort }) => ({ reasoning: { effort } }), + }) if (resolved.protocol === 'chat-completions') { const clientContext = yield* Layer.build(ChatClient.layer({ apiUrl: resolved.apiUrl })).pipe( @@ -131,7 +135,7 @@ export const makeOpenCodeLanguageModel = ( ) return yield* ChatLanguageModel.make({ model: resolved.model, - config: reasoning._tag === 'disabled' ? {} : { reasoning: { effort: reasoning.effort } }, + config, }).pipe(Effect.provideService(ChatClient.OpenAiClient, Context.get(clientContext, ChatClient.OpenAiClient))) } @@ -140,7 +144,7 @@ export const makeOpenCodeLanguageModel = ( ) return yield* ResponsesLanguageModel.make({ model: resolved.model, - config: reasoning._tag === 'disabled' ? {} : { reasoning: { effort: reasoning.effort } }, + config, }).pipe( Effect.provideService( ResponsesClient.OpenAiClient, diff --git a/packages/fold-xai/src/OAuthFlows.ts b/packages/fold-xai/src/OAuthFlows.ts index b62fcaf..fc32202 100644 --- a/packages/fold-xai/src/OAuthFlows.ts +++ b/packages/fold-xai/src/OAuthFlows.ts @@ -2,7 +2,7 @@ import { createServer } from 'node:http' import type { Server } from 'node:http' -import { Clock, Crypto, Deferred, Duration, Effect, Schedule, Schema } from 'effect' +import { Clock, Crypto, Deferred, Duration, Effect, Result, Schedule, Schema } from 'effect' import { HttpClient, HttpClientRequest, HttpClientResponse } from 'effect/unstable/http' import { XaiTokenData } from './AuthStore' @@ -170,7 +170,7 @@ export const runXaiDeviceFlow = Effect.fn('fold.xaiAuth.deviceFlow')(function* ( Effect.result, ) const result = yield* poll - if (result._tag === 'Success') + if (Result.isSuccess(result)) return yield* decodeToken(result.success, 'DeviceFlowFailed', 'Failed to decode the xAI device token') const body: typeof DeviceError.Type = result.failure.response === undefined diff --git a/tools/oxlint/automation/index.ts b/tools/oxlint/automation/index.ts index f028c6e..032763e 100644 --- a/tools/oxlint/automation/index.ts +++ b/tools/oxlint/automation/index.ts @@ -2,17 +2,23 @@ import { eslintCompatPlugin } from '@oxlint/plugins' import noAmbientNondeterminism from './rules/no-ambient-nondeterminism.ts' import noDisableValidation from './rules/no-disable-validation.ts' +import noManualTagComparison from './rules/no-manual-tag-comparison.ts' +import noManualTaggedConstruction from './rules/no-manual-tagged-construction.ts' import noShadowedStandardArrayStatic from './rules/no-shadowed-standard-array-static.ts' import noSilentErrorSwallow from './rules/no-silent-error-swallow.ts' import preferEffectMatch from './rules/prefer-effect-match.ts' +import preferTaggedErrorHandling from './rules/prefer-tagged-error-handling.ts' export default eslintCompatPlugin({ meta: { name: 'automation' }, rules: { 'no-ambient-nondeterminism': noAmbientNondeterminism, 'no-disable-validation': noDisableValidation, + 'no-manual-tag-comparison': noManualTagComparison, + 'no-manual-tagged-construction': noManualTaggedConstruction, 'no-shadowed-standard-array-static': noShadowedStandardArrayStatic, 'no-silent-error-swallow': noSilentErrorSwallow, 'prefer-effect-match': preferEffectMatch, + 'prefer-tagged-error-handling': preferTaggedErrorHandling, }, }) diff --git a/tools/oxlint/automation/rules/no-manual-tag-comparison.ts b/tools/oxlint/automation/rules/no-manual-tag-comparison.ts new file mode 100644 index 0000000..31da4ea --- /dev/null +++ b/tools/oxlint/automation/rules/no-manual-tag-comparison.ts @@ -0,0 +1,74 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +const equalityOperators = new Set(['==', '===', '!=', '!==']) +const broadCatchMethods = new Set(['catch', 'catchIf']) + +const isTagMember = (node: ESTree.Node | null | undefined): boolean => + node?.type === 'MemberExpression' && + ((!node.computed && node.property.type === 'Identifier' && node.property.name === '_tag') || + (node.computed && node.property.type === 'Literal' && node.property.value === '_tag')) + +const isStringLiteral = (node: ESTree.Node | null | undefined): boolean => + node?.type === 'Literal' && typeof node.value === 'string' + +const isEffectBroadCatch = (node: ESTree.Node | null | undefined): boolean => + node?.type === 'CallExpression' && + node.callee.type === 'MemberExpression' && + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'Effect' && + node.callee.property.type === 'Identifier' && + broadCatchMethods.has(node.callee.property.name) + +const memberRoot = (member: ESTree.MemberExpression): string | undefined => { + if (member.object.type === 'Identifier') return member.object.name + if (member.object.type === 'MemberExpression' && member.object.object.type === 'Identifier') { + return member.object.object.name + } + return undefined +} + +const isBroadCatchTagUse = (node: ESTree.Node, member: ESTree.MemberExpression): boolean => { + let current = node.parent + while (current !== null && current !== undefined) { + if (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') { + const parameter = current.params[0] + return ( + parameter?.type === 'Identifier' && + memberRoot(member) === parameter.name && + isEffectBroadCatch(current.parent) + ) + } + current = current.parent + } + return false +} + +export default defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Use Effect Match or Predicate.isTagged instead of manually inspecting `_tag`.', + }, + messages: { + comparison: + 'Use Predicate.isTagged for a simple tag predicate, or Match.value(...).pipe(Match.tag/Match.tags) for branching.', + branching: 'Use Match.value(...).pipe(Match.tag/Match.tags) instead of switching on `_tag`.', + }, + }, + createOnce(context) { + return { + BinaryExpression(node) { + if (!equalityOperators.has(node.operator)) return + const member = isTagMember(node.left) && isStringLiteral(node.right) ? node.left : node.right + if (!isTagMember(member) || !isStringLiteral(member === node.left ? node.right : node.left)) return + if (isBroadCatchTagUse(node, member)) return + context.report({ node, messageId: 'comparison' }) + }, + SwitchStatement(node) { + if (!isTagMember(node.discriminant) || isBroadCatchTagUse(node, node.discriminant)) return + context.report({ node, messageId: 'branching' }) + }, + } + }, +}) diff --git a/tools/oxlint/automation/rules/no-manual-tagged-construction.ts b/tools/oxlint/automation/rules/no-manual-tagged-construction.ts new file mode 100644 index 0000000..dddc39f --- /dev/null +++ b/tools/oxlint/automation/rules/no-manual-tagged-construction.ts @@ -0,0 +1,30 @@ +import { defineRule } from '@oxlint/plugins' + +export default defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Use tagged constructors instead of manually defining `_tag` in object literals.', + }, + messages: { + manualConstruction: + 'Do not define a literal `_tag` manually. Use Schema tagged `.make`, a tagged class/error constructor, or a Data.taggedEnum constructor.', + }, + }, + createOnce(context) { + return { + ObjectExpression(node) { + for (const property of node.properties) { + if (property.type !== 'Property' || property.kind !== 'init') continue + const isTag = + (!property.computed && property.key.type === 'Identifier' && property.key.name === '_tag') || + (property.key.type === 'Literal' && property.key.value === '_tag') + if (!isTag) continue + const valueText = context.sourceCode.getText(property.value) + if (!/^(?:['"][^'"]+['"]|`[^`]+`)(?:\s+as\s+const)?$/.test(valueText)) continue + context.report({ node: property, messageId: 'manualConstruction' }) + } + }, + } + }, +}) diff --git a/tools/oxlint/automation/rules/prefer-tagged-error-handling.ts b/tools/oxlint/automation/rules/prefer-tagged-error-handling.ts new file mode 100644 index 0000000..af0c0e9 --- /dev/null +++ b/tools/oxlint/automation/rules/prefer-tagged-error-handling.ts @@ -0,0 +1,112 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +const equalityOperators = new Set(['==', '===', '!=', '!==']) +const broadCatchMethods = new Set(['catch', 'catchIf']) + +const isTagMember = (node: ESTree.Node | null | undefined): node is ESTree.MemberExpression => + node?.type === 'MemberExpression' && + ((!node.computed && node.property.type === 'Identifier' && node.property.name === '_tag') || + (node.computed && node.property.type === 'Literal' && node.property.value === '_tag')) + +const isStringLiteral = (node: ESTree.Node | null | undefined): boolean => + node?.type === 'Literal' && typeof node.value === 'string' + +const tagComparison = (node: ESTree.Node): ESTree.MemberExpression | undefined => { + if (node.type !== 'BinaryExpression' || !equalityOperators.has(node.operator)) return undefined + if (isTagMember(node.left) && isStringLiteral(node.right)) return node.left + if (isStringLiteral(node.left) && isTagMember(node.right)) return node.right + return undefined +} + +const containsTagComparison = (root: ESTree.Node, candidate: ESTree.Node): ESTree.MemberExpression | undefined => { + const comparison = tagComparison(candidate) + if (comparison === undefined) return undefined + let current: ESTree.Node | null | undefined = candidate + while (current !== null && current !== undefined && current !== root) current = current.parent + return current === root ? comparison : undefined +} + +const isEffectBroadCatch = (node: ESTree.CallExpression): boolean => + node.callee.type === 'MemberExpression' && + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'Effect' && + node.callee.property.type === 'Identifier' && + broadCatchMethods.has(node.callee.property.name) + +const isNestedReasonTag = (member: ESTree.MemberExpression): boolean => + member.object.type === 'MemberExpression' && + !member.object.computed && + member.object.property.type === 'Identifier' && + member.object.property.name === 'reason' + +const memberRoot = (member: ESTree.MemberExpression): string | undefined => { + if (member.object.type === 'Identifier') return member.object.name + if (member.object.type === 'MemberExpression' && member.object.object.type === 'Identifier') { + return member.object.object.name + } + return undefined +} + +export default defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Use Effect tagged error handlers instead of manually inspecting `_tag` in broad handlers.', + }, + messages: { + taggedError: 'Use Effect.catchTag or Effect.catchTags instead of manually checking an error `_tag`.', + taggedReason: + 'Use Effect.catchReason or Effect.catchReasons instead of manually checking a tagged error reason.', + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (!isEffectBroadCatch(node)) return + for (const argument of node.arguments) { + if (argument.type !== 'ArrowFunctionExpression' && argument.type !== 'FunctionExpression') continue + const parameter = argument.params[0] + if (parameter?.type !== 'Identifier') continue + const body = argument.body + const text = context.sourceCode.getText(body) + const predicatePrefix = `Predicate.isTagged(${parameter.name}` + if (text.includes(predicatePrefix)) { + context.report({ + node: body, + messageId: text.includes(`${predicatePrefix}.reason,`) ? 'taggedReason' : 'taggedError', + }) + continue + } + if (!text.includes('_tag')) continue + + let found: ESTree.MemberExpression | undefined + const stack: Array = [body] + while (stack.length > 0 && found === undefined) { + const candidate = stack.pop() + if (candidate === undefined) break + const comparison = containsTagComparison(body, candidate) + if (comparison !== undefined && memberRoot(comparison) === parameter.name) found = comparison + if (found !== undefined) break + for (const key of context.sourceCode.visitorKeys[candidate.type] ?? []) { + const child = candidate[key] + if (Array.isArray(child)) { + for (const item of child) + if (item !== null && typeof item === 'object' && 'type' in item) stack.push(item) + } else if (child !== null && typeof child === 'object' && 'type' in child) { + stack.push(child) + } + } + } + + if (found !== undefined) { + context.report({ + node: found, + messageId: isNestedReasonTag(found) ? 'taggedReason' : 'taggedError', + }) + } + } + }, + } + }, +})