From bf88869de005abec18a2c993d84a45eb3074f9e3 Mon Sep 17 00:00:00 2001 From: HumanLayer Agent Date: Fri, 9 Oct 2026 18:53:20 +0000 Subject: [PATCH] refactor: open sessions with ambient EventLog layers --- .../skills/effect-program-design/REFERENCE.md | 14 +- .claude/skills/effect-program-design/SKILL.md | 18 +- README.md | 46 +++- examples/alchemy-cloudflare/README.md | 8 +- .../alchemy-cloudflare/src/ChatSession.ts | 30 ++- .../src/DurableObjectEventLog.ts | 162 +++++++------- examples/alchemy-cloudflare/test/Api.test.ts | 45 ++-- .../fold-agent/examples/ApplyPatchAgent.ts | 4 +- packages/fold-agent/examples/CodexAgent.ts | 12 +- packages/fold-agent/examples/CodingAgent.ts | 12 +- .../fold-agent/examples/SkillsFromDisk.ts | 4 +- .../fold-agent/examples/SubagentsAgent.ts | 12 +- .../src/EventLog/JsonlDescriptor.ts | 13 -- packages/fold-agent/src/Mode/Launch.ts | 32 +-- packages/fold-agent/src/Mode/Mode.ts | 2 +- .../fold-agent/src/Session/SessionLayout.ts | 41 ++-- packages/fold-agent/src/index.ts | 1 - .../test/EventLog/SessionOpenJsonl.vi.test.ts | 68 ++++++ .../fold-agent/test/Mode/Launch.vi.test.ts | 6 +- .../test/Session/SessionLayout.vi.test.ts | 29 ++- packages/fold-codex/src/CodexModel.ts | 2 +- packages/fold-core/examples/AnthropicAgent.ts | 4 +- .../fold-core/examples/AutoCompactAgent.ts | 4 +- packages/fold-core/examples/ModelSwitch.ts | 4 +- packages/fold-core/examples/OpenaiAgent.ts | 6 +- packages/fold-core/examples/SkillsAgent.ts | 4 +- packages/fold-core/src/Api/AgentDefinition.ts | 2 +- .../fold-core/src/Api/EventLogDescriptor.ts | 37 ---- packages/fold-core/src/Api/ModelDescriptor.ts | 4 +- .../Api/{StartSession.ts => OpenSession.ts} | 204 ++++++++---------- packages/fold-core/src/Api/Provisioning.ts | 8 +- packages/fold-core/src/Api/ToolDefinition.ts | 6 +- packages/fold-core/src/Model/ModelCatalog.ts | 2 +- packages/fold-core/src/Session/Profiles.ts | 4 +- .../fold-core/src/Session/SessionService.ts | 8 +- packages/fold-core/src/Skills/SkillSource.ts | 4 +- .../fold-core/src/Subagents/SessionAgents.ts | 2 +- .../src/Subagents/SubagentDefinition.ts | 2 +- packages/fold-core/src/index.ts | 10 +- .../fold-core/test/Api/OpenSession.vi.test.ts | 123 +++++++++++ .../test/Api/ResumeSession.vi.test.ts | 59 +++-- .../test/Api/SessionInterrupt.vi.test.ts | 6 +- .../test/Api/SessionIsolation.vi.test.ts | 29 ++- .../test/Api/SessionProfiles.vi.test.ts | 2 +- .../test/Api/SessionSendTargets.vi.test.ts | 18 +- .../test/Api/SessionSteer.vi.test.ts | 12 +- .../fold-core/test/Api/SessionStop.vi.test.ts | 6 +- .../test/Api/SkillsSession.vi.test.ts | 8 +- .../test/Api/StartSession.vi.test.ts | 32 ++- .../fold-core/test/Api/SwitchModel.vi.test.ts | 18 +- .../Compaction/AutoCompactSession.vi.test.ts | 50 ++--- .../Compaction/SubagentAutoCompact.vi.test.ts | 6 +- .../fold-core/test/Subagents/DriveHarness.ts | 4 +- .../Subagents/SubagentDispatch.vi.test.ts | 4 +- .../test/Subagents/SubagentHooks.vi.test.ts | 4 +- .../Subagents/SubagentLogReplay.vi.test.ts | 36 ++-- .../Subagents/SubagentProfiles.vi.test.ts | 6 +- .../test/Subagents/SubagentResume.vi.test.ts | 4 +- .../test/Subagents/SubagentRoster.vi.test.ts | 12 +- .../test/Subagents/SubagentSkills.vi.test.ts | 6 +- .../Subagents/SubagentToolWire.vi.test.ts | 16 +- 61 files changed, 742 insertions(+), 595 deletions(-) delete mode 100644 packages/fold-agent/src/EventLog/JsonlDescriptor.ts create mode 100644 packages/fold-agent/test/EventLog/SessionOpenJsonl.vi.test.ts delete mode 100644 packages/fold-core/src/Api/EventLogDescriptor.ts rename packages/fold-core/src/Api/{StartSession.ts => OpenSession.ts} (84%) create mode 100644 packages/fold-core/test/Api/OpenSession.vi.test.ts diff --git a/.claude/skills/effect-program-design/REFERENCE.md b/.claude/skills/effect-program-design/REFERENCE.md index 8afbafb..f415354 100644 --- a/.claude/skills/effect-program-design/REFERENCE.md +++ b/.claude/skills/effect-program-design/REFERENCE.md @@ -17,20 +17,19 @@ conventions from another repository. Fold has two intentionally different interfaces: -1. A host-facing descriptor interface, where consumers describe an agent, model, toolset, hooks, and event-log - backend without learning Fold's internal runtime graph. +1. A host-facing descriptor interface for agents, models, tools, and hooks. Hosts select EventLog backends + through ordinary Layer composition; Session.open hides the internal runtime graph. 2. An internal Effect interface, where `Context.Service` tags and `Layer` implementations express capabilities, resource requirements, and replaceable test seams. Keep the conversion in one direction at the facade/provisioning seam: ```text -host descriptors -> provisioning -> Effect services/layers -> running session +host agent descriptors + ambient EventLog -> Session.open/provisioning -> running session ``` -Do not expose layer construction to a host just because the implementation needs a provider client, tool runtime, or -event log. Conversely, do not turn a capability that varies per runtime into a global singleton or a closure-captured -ambient dependency. +Do not expose internal provider-client or tool-runtime wiring. EventLog backend selection is deliberately +layer-native at the host boundary. Do not turn a runtime-varying capability into a global singleton. ## A small service seam @@ -100,3 +99,6 @@ it.effect('records an entry through the public service', () => No `vi.mock`, `vi.spyOn`, module patching, or sleep-based timing. If the behavior cannot be tested by providing a layer, move the seam rather than patching the module under test. + +EventLog resources belong to the host's acquisition scope, while internal session graphs use session-fresh memo +maps. Session.open adopts existing identity or initializes an empty log; it never rebuilds a supplied backend. diff --git a/.claude/skills/effect-program-design/SKILL.md b/.claude/skills/effect-program-design/SKILL.md index a7c0ecc..e259d20 100644 --- a/.claude/skills/effect-program-design/SKILL.md +++ b/.claude/skills/effect-program-design/SKILL.md @@ -17,11 +17,13 @@ API, read `~/projects/effect`, not Effect v3 documentation or examples. ## Fold's architecture -- **Public Fold APIs are descriptor-facing.** Hosts use `defineAgent`, model descriptors, tool descriptors, and - event-log descriptors without learning `Layer`, `Toolkit`, or runtime wiring. See `README.md` and - `packages/fold-core/src/Api/Provisioning.ts`. +- **Agent APIs are descriptor-facing; event logs are layer-native.** Hosts use `defineAgent`, model and tool + descriptors. `Session.open({ agent })` initializes or resumes the ambient `EventLog`; absence means a fresh + isolated memory log. Custom logs are selected at the host composition root with `Layer`, never a session option. + See `README.md` and `packages/fold-core/src/Api/OpenSession.ts`. - **Provisioning owns lowering.** The provisioner turns descriptors into the required services and layers once per - runtime/session. Do not make callers build or pass Fold's internal clients, layers, toolsets, or runtime services. + runtime/session. Do not make callers build Fold's internal clients, toolsets, or runtime services. Host-owned + EventLog layers are the deliberate exception: supply them through the Effect environment. - **Services are internal capabilities.** Use `Context.Service` and `Layer` where a capability varies by runtime, implementation, or test seam. One implementation is enough when the seam is valuable for real tests. - **Event schemas are durable contracts.** Model persisted and wire-visible log entries with `Schema.TaggedStruct` and @@ -62,8 +64,12 @@ API, read `~/projects/effect`, not Effect v3 documentation or examples. only when that separation improves locality. - Dependencies normally remain ambient in `R`. Yield them where the operation needs them rather than forwarding them through public signatures. -- Build runtime-specific layer graphs at the provisioner/facade seam. Fold's agent provisioner owns memo-map and - scope semantics; callers do not recreate that graph. +- Build internal runtime graphs at the provisioner/facade seam with fresh memo maps. Hosts own their EventLog + acquisition scope and cardinality: build once per session, share with that session's consumers, and keep it alive + for every handle/RPC use. Never put unrelated sessions under one shared EventLog. Session.open alone may use + Effect.serviceOption (documented lint exception); the internal graph requires EventLog directly. +- Persisted-log acquisition and operation failures are infrastructure defects by Fold policy. Use Layer.orDie + at host acquisition and Effect.orDie at session operations; do not silently fall back to an empty log. - Acquire resources in a scope and make background work supervised/owned. Bound concurrency for unbounded fan-out and keep external calls outside authoritative transactions. - Use `Effect.fn` or `Effect.withSpan` for meaningful public or I/O operation boundaries when observability is diff --git a/README.md b/README.md index 1a64dd5..c811468 100644 --- a/README.md +++ b/README.md @@ -25,38 +25,41 @@ foldcode auth --help # configure and manage authentication ## Isomorphic agents with `fold-core` -The ergonomic API is descriptor-based: define an agent, then run it against a session backend. No Effect `Layer`, `Toolkit`, or runtime wiring is exposed. +Define an agent with model and tool descriptors, then open a session. Custom event-log backends are supplied through normal Effect layer composition. Provider and tool-runtime wiring stays internal. ```ts -import { anthropicModel, defineAgent, startSession } from '@humanlayer/fold-core' +import { anthropicModel, defineAgent, Session } from '@humanlayer/fold-core' import { Config, Effect } from 'effect' export const ask = (prompt: string) => Effect.gen(function* () { - const apiKey = yield* Config.redacted('ANTHROPIC_API_KEY') + const apiKey = yield* Config.Redacted('ANTHROPIC_API_KEY') const agent = defineAgent({ name: 'assistant', model: anthropicModel({ model: 'claude-sonnet-4-6', apiKey }), systemPrompt: 'Be concise and helpful.', }) - const session = yield* startSession({ agent }) // in-memory by default + const session = yield* Session.open({ agent }) // in-memory by default return yield* session.send(prompt) }).pipe(Effect.scoped) ``` -`defineTool`, model descriptors, and event-log descriptors use the same data-first API, so hosts can supply browser, worker, or filesystem implementations without changing the agent definition. +`defineTool` and model descriptors keep agent configuration portable. `Session.open` initializes an empty log or resumes the session recorded in an existing one. Without a supplied `EventLog`, each call creates a fresh, isolated in-memory log. ## Custom coding agents with `fold-agent` Compose `fold-agent`'s platform tools and JSONL backend with the same `fold-core` API. `codingTools({ cwd })` installs the complete tool union; `fold-core` selects the advertised tools from the active model on every request: Claude gets `write`/`edit`, while GPT/Codex gets `apply_patch`. Switching models reselects the tools automatically. ```ts -import { codingTools, jsonlEventLog } from '@humanlayer/fold-agent' -import { anthropicModel, defineAgent, startSession } from '@humanlayer/fold-core' -import { Config, Effect } from 'effect' +import { codingTools, layerCodingToolServices, layerJsonl } from '@humanlayer/fold-agent' +import { anthropicModel, defineAgent, Session } from '@humanlayer/fold-core' +import { Config, Effect, Layer } from 'effect' + +const hostServices = layerCodingToolServices({ outputDirectory: '.fold/tool-output' }) +const eventLog = layerJsonl('.fold/review.jsonl').pipe(Layer.orDie) const program = Effect.gen(function* () { - const apiKey = yield* Config.redacted('ANTHROPIC_API_KEY') + const apiKey = yield* Config.Redacted('ANTHROPIC_API_KEY') const cwd = '.' const agent = defineAgent({ name: 'reviewer', @@ -65,7 +68,28 @@ const program = Effect.gen(function* () { tools: codingTools({ cwd }), autoCompact: { enabled: true }, }) - const session = yield* startSession({ agent, cwd, log: jsonlEventLog('.fold/review.jsonl') }) + const session = yield* Session.open({ agent, cwd }) return yield* session.send('Find the highest-risk code in this project.') -}).pipe(Effect.scoped) +}).pipe(Effect.provide(eventLog.pipe(Layer.provideMerge(hostServices))), Effect.scoped) ``` + +The supplied backend represents one session, with at most one active handle. Reuse its service within +that session, not as an application-wide default for unrelated sessions. Layer memoization shares +instances by reference; Fold creates default memory logs and internal session graphs with fresh memo +maps, but deliberately adopts the exact host-supplied log. A fresh layer pointing at the same file is +still the same persistent log, not an isolated session. + +Keep session construction **and use** within the owning scope. Long-lived hosts can build the backend +once into their session/object scope with `Layer.buildWithMemoMap` and provide the resulting context +for later calls. Storage acquisition errors are treated as defects using `Layer.orDie`. + +Existing log identity is authoritative. A conflicting `sessionId`, a nonempty log without valid session +history, or multiple session starts fails rather than silently initializing a new session. `cwd` and +`meta` apply only to initialization; resumed model/prompt changes are recorded as configuration epochs. + +### API migration + +This is a breaking API change: replace `startSession` / `resumeSession` with `Session.open` and remove +`log` from the options. `FoldEventLog`, `memoryEventLog`, `eventLogSource`, and `jsonlEventLog` have been +removed. Supply `layerInMemoryEventLog`, `layerJsonl`, `layerJsonlWithIds`, or `layerJsonlNode` through the +Effect environment instead. `prepareSessionLog` now returns only the session id and path. diff --git a/examples/alchemy-cloudflare/README.md b/examples/alchemy-cloudflare/README.md index 5c23453..3b194b4 100644 --- a/examples/alchemy-cloudflare/README.md +++ b/examples/alchemy-cloudflare/README.md @@ -3,7 +3,7 @@ A minimal fold chat host on Cloudflare, deployed with [Alchemy v2](https://alchemy.run/cloudflare/). Inference runs on OpenAI (`gpt-5.6-terra`, medium reasoning) through fold's `openaiModel`. Each fold session is one Durable Object, named by its `SessionId`, with a workspace of cloned repos in a second one of the same name. - `src/ChatSession.ts` — the Durable Object. It opens the fold log in its SQLite on activation; the first message clones its repos and starts the session, later activations resume it. -- `src/DurableObjectEventLog.ts` — the service that opens fold's `EventLogService` over the object's SQLite. +- `src/DurableObjectEventLog.ts` — an `EventLog`-producing layer over the object's SQLite. - `src/Keepalive.ts` — a 30s alarm heartbeat that keeps the object alive while a turn runs. - `src/SessionExpiry.ts` — deletes an idle session and its workspace; see below. - `src/ChatSessions.ts` — the service the routes require; its `layer` reaches each session's Durable Object. @@ -55,3 +55,9 @@ The agent gets fold-agent's file tools on the workspace: `read`, plus `write` an - Clones go over HTTPS, public repos only. They run in the Durable Object through `isomorphic-git`, which fetches every file at the tip, so large repos clone slowly. - Replies arrive whole. `FoldSession.events` can stream deltas once a route forwards them. + +`ChatSession` builds the SQLite layer once per object activation into the explicit object-lifetime scope, +inside Alchemy's returned runtime Effect (planning has no live SQLite handle). The same EventLog instance +serves emptiness checks, `Session.open({ agent })`, and the `entries()` RPC. Workspace preparation still +branches on an empty versus existing log; Fold itself automatically initializes or resumes it. The backend +is not rebuilt for each RPC and does not end with the constructor/request scope. diff --git a/examples/alchemy-cloudflare/src/ChatSession.ts b/examples/alchemy-cloudflare/src/ChatSession.ts index 96188e0..b8c8db2 100644 --- a/examples/alchemy-cloudflare/src/ChatSession.ts +++ b/examples/alchemy-cloudflare/src/ChatSession.ts @@ -19,17 +19,16 @@ import { skillsFromDisk } from '@humanlayer/fold-agent/skills' import { fileTools, Photon } from '@humanlayer/fold-agent/tools/files' import { + EventLog, SessionId, type AgentId, type FoldSession, type LogEntry, type UserMessageLogEntry, defineAgent, - eventLogSource, openaiModel, - resumeSession, + Session, skillTool, - startSession, } from '@humanlayer/fold-core' import * as Cloudflare from 'alchemy/Cloudflare' import { @@ -52,7 +51,7 @@ import { FetchHttpClient } from 'effect/http' import { bashTool } from './BashTool' import type { Message, WhenRunning } from './ChatSessions' import { WORKSPACE_ROOT } from './computer/Contract' -import { DurableObjectEventLog } from './DurableObjectEventLog' +import { layerDurableObjectEventLog } from './DurableObjectEventLog' import { Keepalive } from './Keepalive' import { SessionExpiry } from './SessionExpiry' import { type Repo, Workspace } from './Workspace' @@ -149,17 +148,20 @@ export default class ChatSession extends Cloudflare.DurableObject() const state = yield* Cloudflare.DurableObjectState // Read from the deploy environment and bound to the Worker as a secret. Missing, the deploy fails. const apiKey = yield* Config.Redacted('OPENAI_API_KEY').pipe(Effect.orDie) - const eventLogs = yield* DurableObjectEventLog const keepalive = yield* Keepalive const expiry = yield* SessionExpiry const workspace = yield* Workspace - // Alchemy runs this outer Effect once per class and the returned Effect once per object instance. + // Alchemy also evaluates the outer Effect during planning, without a live SQLite handle. + // Storage acquisition stays in the returned runtime-only Effect. // oxlint-disable-next-line effecttsgo/return-effect-in-gen return Effect.gen(function* () { const sessionId = yield* Schema.decodeUnknownEffect(SessionId)(state.id.name) - const eventLog = yield* eventLogs.open - const log = eventLogSource(Effect.succeed(eventLog)) + // This scope and backend outlive construction and every individual RPC call. + const scope = yield* Scope.make() + const memoMap = yield* Layer.makeMemoMap + const logContext = yield* Layer.buildWithMemoMap(layerDurableObjectEventLog, memoMap, scope) + const eventLog = yield* EventLog.pipe(Effect.provideContext(logContext)) const isEmpty = Option.isNone(yield* Stream.runHead(eventLog.entries())) const fileSystem = workspace.fileSystem(sessionId) @@ -176,19 +178,14 @@ export default class ChatSession extends Cloudflare.DurableObject() ], }) - // Never closed: the session lives as long as the object stays in memory, past every call's own - // scope. Opened on the first send, so reading an unknown id writes nothing. - const scope = yield* Scope.make() - // A new session clones its repos, starts its container in the background, then starts; a cut-off // start leaves the log empty, so the next send clones them again from scratch. const start = (repos: ReadonlyArray) => Effect.gen(function* () { const cloned = yield* workspace.prepare(sessionId, repos) yield* Effect.forkIn(workspace.startContainer(sessionId), scope) - return yield* startSession({ + return yield* Session.open({ agent: agentFor(cloned.map((repo) => repo.name)), - log, sessionId, cwd: WORKSPACE_ROOT, meta: { repos: cloned }, @@ -204,7 +201,7 @@ export default class ChatSession extends Cloudflare.DurableObject() Effect.orDie, ), ) - return yield* resumeSession({ agent: agentFor(repoNames), log }) + return yield* Session.open({ agent: agentFor(repoNames), sessionId }) }) // Opened once, by whichever send or restart nudge comes first; `repos` only matter to a new session. @@ -217,6 +214,7 @@ export default class ChatSession extends Cloudflare.DurableObject() (isEmpty ? start(repos) : resume).pipe( // fold hands these to the file tools, the skill loader, and the model. Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideContext(logContext), Effect.provide(Layer.mergeAll(Path.layer, FetchHttpClient.layer, Photon.layer)), Scope.provide(scope), Effect.map((session) => [session, Option.some(session)] as const), @@ -259,5 +257,5 @@ export default class ChatSession extends Cloudflare.DurableObject() ), } }).pipe(Effect.orDie) - }).pipe(Effect.provide([DurableObjectEventLog.layer, Keepalive.layer, SessionExpiry.layer, Workspace.layer])), + }).pipe(Effect.provide([Keepalive.layer, SessionExpiry.layer, Workspace.layer])), ) {} diff --git a/examples/alchemy-cloudflare/src/DurableObjectEventLog.ts b/examples/alchemy-cloudflare/src/DurableObjectEventLog.ts index e50bfb5..9238e55 100644 --- a/examples/alchemy-cloudflare/src/DurableObjectEventLog.ts +++ b/examples/alchemy-cloudflare/src/DurableObjectEventLog.ts @@ -1,12 +1,13 @@ /** * A fold event log in a Durable Object's SQLite: one object is one session's log. Opening the log loads - * every row, so `resumeSession` replays them; appends write through before they publish. This mirrors + * every row, so `Session.open` replays them; appends write through before they publish. This mirrors * fold-agent's JSONL log with a table in place of the file. * * Queries run on the raw SQLite handle, which is synchronous and needs nothing at call time, so the log * fold holds needs nothing either. */ import { + EventLog, EventLogCorruptEntryError, EventLogInvalidEntryError, EventLogUnavailableError, @@ -15,14 +16,13 @@ import { decodeStoredLogEntry, layerLiveIdFactory, storedLogEntry, - type EventLogError, type EventLogOperation, type EventLogService, type LogEntryInput, type LogSeq, } from '@humanlayer/fold-core' import * as Cloudflare from 'alchemy/Cloudflare' -import { Context, Effect, Layer, PubSub, Ref, Schema, Semaphore, Stream } from 'effect' +import { Effect, Layer, PubSub, Ref, Schema, Semaphore, Stream } from 'effect' type LogRow = { readonly seq: number; readonly entry: string } @@ -58,90 +58,76 @@ const encodeEntry = (entry: LogEntry) => ), ) -export class DurableObjectEventLog extends Context.Service< - DurableObjectEventLog, - { - /** Open this object's log. Run it once the object is live: it reads the object's SQLite. */ - readonly open: Effect.Effect - } ->()('alchemy-cloudflare/DurableObjectEventLog') { - static readonly layer = Layer.effect( - DurableObjectEventLog, - Effect.gen(function* () { - const { raw } = yield* Cloudflare.DurableObjectState - const ids = yield* Ids - - const query = = Record>( - operation: EventLogOperation, - sql: string, - ...bindings: Array - ) => - Effect.try({ - try: () => raw.storage.sql.exec(sql, ...bindings).toArray(), - catch: (cause) => - new EventLogUnavailableError({ - operation, - message: 'fold_log query failed', - retryable: false, - cause, - }), - }) - - const open = Effect.gen(function* () { - yield* query( - 'entries', - 'CREATE TABLE IF NOT EXISTS fold_log (seq INTEGER PRIMARY KEY, entry TEXT NOT NULL)', - ) - const rows = yield* query('entries', 'SELECT seq, entry FROM fold_log ORDER BY seq') - const initialEntries = yield* Effect.forEach(rows, decodeRow) - - const entriesRef = yield* Ref.make>(initialEntries) - const pubsub = yield* PubSub.unbounded() - const appendLock = yield* Semaphore.make(1) - - const append = Effect.fn('alchemy_cloudflare.event_log.append')((input: LogEntryInput) => - appendLock.withPermit( - Effect.gen(function* () { - const current = yield* Ref.get(entriesRef) - const stored = yield* storedLogEntry(input, current.length, yield* ids.makeEventId) - const entry = yield* encodeEntry(stored) - - yield* query('append', 'INSERT INTO fold_log (seq, entry) VALUES (?, ?)', stored.seq, entry) - yield* Ref.set(entriesRef, [...current, stored]) - yield* PubSub.publish(pubsub, stored) - - return stored - }), - ), - ) - - const entries: EventLogService['entries'] = (fromSeq = 0) => - Stream.fromIterableEffect( - Ref.get(entriesRef).pipe(Effect.map((snapshot) => entriesFrom(snapshot, fromSeq))), - ) - - const subscribe: EventLogService['subscribe'] = (fromSeq = 0) => - Stream.unwrap( - appendLock.withPermit( - Effect.gen(function* () { - const subscription = yield* PubSub.subscribe(pubsub) - const snapshot = yield* Ref.get(entriesRef) - - return Stream.fromIterable(entriesFrom(snapshot, fromSeq)).pipe( - Stream.concat( - Stream.fromSubscription(subscription).pipe( - Stream.filter((entry) => entry.seq >= fromSeq), - ), - ), - ) - }), - ), - ) - - return { append, entries, subscribe } +/** SQLite backend, built once in the Durable Object's runtime scope. */ +export const layerDurableObjectEventLog = Layer.effect( + EventLog, + Effect.gen(function* () { + const { raw } = yield* Cloudflare.DurableObjectState + const ids = yield* Ids + + const query = = Record>( + operation: EventLogOperation, + sql: string, + ...bindings: Array + ) => + Effect.try({ + try: () => raw.storage.sql.exec(sql, ...bindings).toArray(), + catch: (cause) => + new EventLogUnavailableError({ + operation, + message: 'fold_log query failed', + retryable: false, + cause, + }), }) - return DurableObjectEventLog.of({ open }) - }), - ).pipe(Layer.provide(layerLiveIdFactory)) -} + yield* query('entries', 'CREATE TABLE IF NOT EXISTS fold_log (seq INTEGER PRIMARY KEY, entry TEXT NOT NULL)') + const rows = yield* query('entries', 'SELECT seq, entry FROM fold_log ORDER BY seq') + const initialEntries = yield* Effect.forEach(rows, decodeRow) + + const entriesRef = yield* Ref.make>(initialEntries) + const pubsub = yield* PubSub.unbounded() + const appendLock = yield* Semaphore.make(1) + + const append = Effect.fn('alchemy_cloudflare.event_log.append')((input: LogEntryInput) => + appendLock.withPermit( + Effect.gen(function* () { + const current = yield* Ref.get(entriesRef) + const stored = yield* storedLogEntry(input, current.length, yield* ids.makeEventId) + const entry = yield* encodeEntry(stored) + + yield* query('append', 'INSERT INTO fold_log (seq, entry) VALUES (?, ?)', stored.seq, entry) + yield* Ref.set(entriesRef, [...current, stored]) + yield* PubSub.publish(pubsub, stored) + + return stored + }), + ), + ) + + const entries: EventLogService['entries'] = (fromSeq = 0) => + Stream.fromIterableEffect( + Ref.get(entriesRef).pipe(Effect.map((snapshot) => entriesFrom(snapshot, fromSeq))), + ) + + const subscribe: EventLogService['subscribe'] = (fromSeq = 0) => + Stream.unwrap( + appendLock.withPermit( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(pubsub) + const snapshot = yield* Ref.get(entriesRef) + + return Stream.fromIterable(entriesFrom(snapshot, fromSeq)).pipe( + Stream.concat( + Stream.fromSubscription(subscription).pipe( + Stream.filter((entry) => entry.seq >= fromSeq), + ), + ), + ) + }), + ), + ) + + return { append, entries, subscribe } + }), +).pipe(Layer.provide(layerLiveIdFactory), Layer.orDie) diff --git a/examples/alchemy-cloudflare/test/Api.test.ts b/examples/alchemy-cloudflare/test/Api.test.ts index dd132b5..9048ad4 100644 --- a/examples/alchemy-cloudflare/test/Api.test.ts +++ b/examples/alchemy-cloudflare/test/Api.test.ts @@ -6,7 +6,9 @@ */ import { it } from '@effect/vitest' import { - AgentId, + customModel, + defineAgent, + Session, EventLog, LogEntry, SessionId, @@ -15,6 +17,7 @@ import { type LogEntryInput, } from '@humanlayer/fold-core' import { Context, Effect, Layer, Predicate, Ref, Schema, Scope, Stream } from 'effect' +import { LanguageModel } from 'effect/ai' import { HttpRouter } from 'effect/http' import { expect } from 'vitest' @@ -22,7 +25,22 @@ import { ChatRoutes } from '../src/Api' import { ChatSessions } from '../src/ChatSessions' import { RepoCloneError, repoName } from '../src/Workspace' -const rootAgentId = AgentId.create() +const apiTestAgent = defineAgent({ + model: customModel({ + activeModel: { + providerId: 'test', + providerKind: 'openai-compatible', + modelId: 'test', + role: null, + requestedReasoningLevel: 'off', + reasoning: { _tag: 'disabled' }, + }, + make: LanguageModel.make({ + generateText: () => Effect.die('API test does not call a model'), + streamText: () => Stream.empty, + }), + }), +}) const layerInMemory = Layer.effect( ChatSessions, @@ -34,7 +52,7 @@ const layerInMemory = Layer.effect( Effect.gen(function* () { const existing = (yield* Ref.get(logs)).get(sessionId) if (existing !== undefined) return existing - const opened = yield* Layer.build(layerInMemoryEventLog).pipe( + const opened = yield* Layer.build(layerInMemoryEventLog.pipe(Layer.fresh)).pipe( Effect.map(Context.get(EventLog)), Scope.provide(scope), ) @@ -55,20 +73,20 @@ const layerInMemory = Layer.effect( if (missing !== undefined) { return yield* new RepoCloneError({ message: `Cloning ${repoName(missing)} failed` }) } - yield* appendOrDie(log, { - _tag: 'session_started', - agentId: null, - parentAgentId: null, - toolCallId: null, - cwd: '/workspace', + // Layer composition at the host boundary supplies the same log to Fold and RPC readers. + yield* Session.open({ + agent: apiTestAgent, sessionId, - rootAgentId, + cwd: '/workspace', meta: { repos: repos.map(repoName) }, - }) + }).pipe(Effect.provideService(EventLog, log), Scope.provide(scope)) } + const identity = (yield* entriesOf(log)).find(Predicate.isTagged('session_started')) + if (identity === undefined || !Predicate.isTagged(identity, 'session_started')) + return yield* Effect.die('missing session identity') const finished = yield* appendOrDie(log, { _tag: 'agent-finished', - agentId: rootAgentId, + agentId: identity.rootAgentId, parentAgentId: null, toolCallId: null, outcome: 'completed', @@ -115,7 +133,8 @@ it.effect('the first message to a new id starts its session; GET /log replays it }) const entries = yield* request('GET', `/sessions/${sessionId}/log`).pipe(Effect.flatMap(decodeEntries)) - expect(entries.map((entry) => entry._tag)).toEqual(['session_started', 'agent-finished']) + expect(entries.filter(Predicate.isTagged('session_started'))).toHaveLength(1) + expect(entries.at(-1)?._tag).toBe('agent-finished') }).pipe(Effect.scoped), ) diff --git a/packages/fold-agent/examples/ApplyPatchAgent.ts b/packages/fold-agent/examples/ApplyPatchAgent.ts index bfdb017..ce01bdf 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 * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' -import { defineAgent, openaiModel, startSession } from '@humanlayer/fold-core' +import { defineAgent, openaiModel, Session } from '@humanlayer/fold-core' import { Predicate, Console, Effect, Layer } from 'effect' import { codingTools, layerCodingToolServices } from '../src/index' @@ -29,7 +29,7 @@ const makeProgram = (apiKey: string) => const toolServices = yield* Layer.build( layerCodingToolServices({ outputDirectory: join(workspace, 'tool-output') }), ) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ name: 'apply-patch-demo', model: openaiModel({ model: modelId, apiKey, reasoning: 'medium' }), diff --git a/packages/fold-agent/examples/CodexAgent.ts b/packages/fold-agent/examples/CodexAgent.ts index c16d342..41ff297 100644 --- a/packages/fold-agent/examples/CodexAgent.ts +++ b/packages/fold-agent/examples/CodexAgent.ts @@ -13,10 +13,10 @@ import { join } from 'node:path' import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { codexModel } from '@humanlayer/fold-codex' -import { defineAgent, startSession } from '@humanlayer/fold-core' +import { defineAgent, Session } from '@humanlayer/fold-core' import { Predicate, Console, Effect, Layer } from 'effect' -import { codingTools, jsonlEventLog, layerCodingToolServices } from '../src/index' +import { codingTools, layerJsonl, layerCodingToolServices } from '../src/index' const modelId = process.env.FOLD_CODEX_MODEL ?? 'gpt-5.5' @@ -27,9 +27,12 @@ const program = Effect.gen(function* () { // The coding tools' services live as long as the session, in this program's scope. const toolServices = yield* Layer.build( - layerCodingToolServices({ outputDirectory: join(workspace, 'tool-output') }), + layerJsonl(logPath).pipe( + Layer.orDie, + Layer.provideMerge(layerCodingToolServices({ outputDirectory: join(workspace, 'tool-output') })), + ), ) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ name: 'codex-demo', model: codexModel({ model: modelId, reasoning: 'medium' }), @@ -38,7 +41,6 @@ const program = Effect.gen(function* () { 'Use your tools to inspect and change files; keep answers short.', tools: codingTools({ cwd: workspace }), }), - log: jsonlEventLog(logPath), cwd: workspace, }).pipe(Effect.provideContext(toolServices)) diff --git a/packages/fold-agent/examples/CodingAgent.ts b/packages/fold-agent/examples/CodingAgent.ts index 9e5c61c..d7da367 100644 --- a/packages/fold-agent/examples/CodingAgent.ts +++ b/packages/fold-agent/examples/CodingAgent.ts @@ -11,10 +11,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' -import { anthropicModel, defineAgent, startSession } from '@humanlayer/fold-core' +import { anthropicModel, defineAgent, Session } from '@humanlayer/fold-core' import { Predicate, Console, Effect, Layer } from 'effect' -import { codingTools, jsonlEventLog, layerCodingToolServices } from '../src/index' +import { codingTools, layerJsonl, layerCodingToolServices } from '../src/index' const modelId = process.env.ANTHROPIC_MODEL ?? 'claude-opus-4-8' const apiKey = process.env.ANTHROPIC_API_KEY @@ -27,9 +27,12 @@ const makeProgram = (apiKey: string) => // The coding tools' services live as long as the session, in this program's scope. const toolServices = yield* Layer.build( - layerCodingToolServices({ outputDirectory: join(workspace, 'tool-output') }), + layerJsonl(logPath).pipe( + Layer.orDie, + Layer.provideMerge(layerCodingToolServices({ outputDirectory: join(workspace, 'tool-output') })), + ), ) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ name: 'coding-demo', model: anthropicModel({ model: modelId, apiKey, reasoning: 'medium' }), @@ -38,7 +41,6 @@ const makeProgram = (apiKey: string) => 'Use your tools to inspect and change files; keep answers short.', tools: codingTools({ cwd: workspace }), }), - log: jsonlEventLog(logPath), cwd: workspace, }).pipe(Effect.provideContext(toolServices)) diff --git a/packages/fold-agent/examples/SkillsFromDisk.ts b/packages/fold-agent/examples/SkillsFromDisk.ts index 33461b3..2917808 100644 --- a/packages/fold-agent/examples/SkillsFromDisk.ts +++ b/packages/fold-agent/examples/SkillsFromDisk.ts @@ -12,7 +12,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' -import { anthropicModel, defineAgent, skillTool, startSession } from '@humanlayer/fold-core' +import { anthropicModel, defineAgent, skillTool, Session } from '@humanlayer/fold-core' import { Console, Effect, Layer } from 'effect' import { FetchHttpClient } from 'effect/http' @@ -60,7 +60,7 @@ const makeProgram = (apiKey: string) => yield* Console.log(`fake home: ${home}`) yield* Console.log(`fake cwd: ${cwd}`) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ name: 'disk-skills-demo', model: anthropicModel({ model: modelId, apiKey, reasoning: 'medium' }), diff --git a/packages/fold-agent/examples/SubagentsAgent.ts b/packages/fold-agent/examples/SubagentsAgent.ts index 5f141e7..98cb7b1 100644 --- a/packages/fold-agent/examples/SubagentsAgent.ts +++ b/packages/fold-agent/examples/SubagentsAgent.ts @@ -12,10 +12,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' -import { anthropicModel, defineAgent, defineSubagent, startSession, subagentTool } from '@humanlayer/fold-core' +import { anthropicModel, defineAgent, defineSubagent, Session, subagentTool } from '@humanlayer/fold-core' import { Predicate, Console, Effect, Layer } from 'effect' -import { bashTool, jsonlEventLog, layerCodingToolServices, readTool } from '../src/index' +import { bashTool, layerJsonl, layerCodingToolServices, readTool } from '../src/index' const modelId = process.env.ANTHROPIC_MODEL ?? 'claude-opus-4-8' const apiKey = process.env.ANTHROPIC_API_KEY @@ -50,9 +50,12 @@ const makeProgram = (apiKey: string) => // The root is a pure orchestrator: its ONLY tool is the subagent tool over its roster. // The coding tools' services live as long as the session, in this program's scope. const toolServices = yield* Layer.build( - layerCodingToolServices({ outputDirectory: join(workspace, 'tool-output') }), + layerJsonl(logPath).pipe( + Layer.orDie, + Layer.provideMerge(layerCodingToolServices({ outputDirectory: join(workspace, 'tool-output') })), + ), ) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ name: 'subagents-demo', model, @@ -61,7 +64,6 @@ const makeProgram = (apiKey: string) => 'to your subagents and synthesize short answers from their results.', tools: [subagentTool([researcher])], }), - log: jsonlEventLog(logPath), cwd: workspace, }).pipe(Effect.provideContext(toolServices)) diff --git a/packages/fold-agent/src/EventLog/JsonlDescriptor.ts b/packages/fold-agent/src/EventLog/JsonlDescriptor.ts deleted file mode 100644 index 1fd2243..0000000 --- a/packages/fold-agent/src/EventLog/JsonlDescriptor.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * This file exposes the JSONL event log through the public descriptor seam: `jsonlEventLog(path)` - * plugs straight into `startSession({ log })`, hiding the layer construction the same way the core's - * `memoryEventLog` does. One file is one session; pointing at an existing file resumes its entries. - */ -import { eventLogSource, EventLog, type FoldEventLog } from '@humanlayer/fold-core' -import { Context, Effect, type FileSystem, Layer } from 'effect' - -import { layerJsonl } from './JsonlLayer' - -/** Back a session's durable log with one JSONL file. Existing entries replay on start (resume). */ -export const jsonlEventLog = (filePath: string): FoldEventLog => - eventLogSource(Layer.build(layerJsonl(filePath)).pipe(Effect.map((context) => Context.get(context, EventLog)))) diff --git a/packages/fold-agent/src/Mode/Launch.ts b/packages/fold-agent/src/Mode/Launch.ts index 7bc3450..50f0f45 100644 --- a/packages/fold-agent/src/Mode/Launch.ts +++ b/packages/fold-agent/src/Mode/Launch.ts @@ -1,15 +1,15 @@ import { join } from 'node:path' /** - * This file is the fold-agent composition root over fold-core's `startSession`/`resumeSession` (D27): + * This file is the fold-agent composition root over fold-core's `Session.open` (D27): * it turns a mode + the loaded `FoldConfig` + agentfiles into a running coding session, so the CLI and * OpenTUI (and callers) never assemble providers/tools/prompts by hand. * * `launchSession`: resolve the primary model (explicit `model`, else the config role for the mode), * load agentfiles for the cwd into a leading prompt block, build the mode's tool roster, prepare a - * JSONL session log under the D5 layout, and `startSession`. + * JSONL session log under the D5 layout, and `Session.open`. * - * `resumeLatestSession`: discover the newest session log for the cwd (D5) and `resumeSession` with a + * `resumeLatestSession`: discover the newest session log for the cwd (D5) and `Session.open` with a * FRESHLY rebuilt agent - agentfiles and the skills roster are re-read, so fold-core's resume path * writes exactly one epoch transition when they changed since the log was written (D17/D20/D22), and * nothing when they did not. @@ -20,9 +20,8 @@ import { join } from 'node:path' */ import { defineAgent, - resumeSession, + Session, SessionId, - startSession, type AgentDefinition, type AutoCompactConfig, type ModelCatalogEntry, @@ -63,7 +62,7 @@ import { type ConfigParseError, } from '../Config/Load' import { rolesForDirectProviderSelection } from '../Config/ModelSelections' -import { jsonlEventLog } from '../EventLog/JsonlDescriptor' +import { layerJsonl } from '../EventLog/JsonlLayer' import { memoryPromptBlock } from '../Memory/AgentFiles' import { layerOutputStore, @@ -553,7 +552,7 @@ const catalogFor = ( /** * Start a fresh coding session: resolve the model, load agentfiles, build the mode's tools, and - * `startSession` over a JSONL log named by a freshly minted session id under the D5 layout. + * `Session.open` over a JSONL log named by a freshly minted session id under the D5 layout. */ export const launchSession = ( options?: LaunchSessionOptions, @@ -589,10 +588,11 @@ export const launchSession = ( const agent = yield* buildAgentDefinition(opts, mode, models, cwd, config) // The output store lives as long as the session, in the caller's scope. - const outputStore = yield* Layer.build(outputStoreFor(prepared.sessionId, opts)) - const session = yield* startSession({ + const sessionServices = yield* Layer.build( + Layer.mergeAll(outputStoreFor(prepared.sessionId, opts), layerJsonl(prepared.path).pipe(Layer.orDie)), + ) + const session = yield* Session.open({ agent, - log: prepared.log, cwd, sessionId: prepared.sessionId, meta: { @@ -604,7 +604,7 @@ export const launchSession = ( catalog, compactionArchiveAccess: compactionArchiveAccessFor({ logPath: prepared.path, modeName: mode.name }), ...Struct.pick(opts, ['steering']), - }).pipe(Effect.provideContext(outputStore)) + }).pipe(Effect.provideContext(sessionServices)) return yield* withGeneratedTitles(session, models.fast, { cwd, ...Struct.pick(opts, ['foldHome']) }) }) @@ -639,15 +639,17 @@ const resumeFromLog = ( const agent = yield* buildAgentDefinition(options, mode, models, cwd, config) // The output store lives as long as the session, in the caller's scope. - const outputStore = yield* Layer.build(outputStoreFor(log.sessionId, options)) - const session = yield* resumeSession({ + const sessionServices = yield* Layer.build( + Layer.mergeAll(outputStoreFor(log.sessionId, options), layerJsonl(log.path).pipe(Layer.orDie)), + ) + const session = yield* Session.open({ agent, - log: jsonlEventLog(log.path), + sessionId: log.sessionId, profiles: sessionProfilesFor(models), catalog, compactionArchiveAccess: compactionArchiveAccessFor({ logPath: log.path, modeName: mode.name }), ...Struct.pick(options, ['steering']), - }).pipe(Effect.provideContext(outputStore)) + }).pipe(Effect.provideContext(sessionServices)) return yield* withGeneratedTitles(session, models.fast, { cwd, ...Struct.pick(options, ['foldHome']) }) }) diff --git a/packages/fold-agent/src/Mode/Mode.ts b/packages/fold-agent/src/Mode/Mode.ts index 12b570b..ff96662 100644 --- a/packages/fold-agent/src/Mode/Mode.ts +++ b/packages/fold-agent/src/Mode/Mode.ts @@ -2,7 +2,7 @@ * This file defines the `FoldMode` primitive (D27): a mode is a pre-baked composition of a primary * model role, a mode system prompt, and a tool roster - the thing a CLI/OpenTUI selects ("coding", * later "rlm"/"rpi"). Launch (Mode/Launch) turns a mode plus the loaded config and agentfiles into a - * running `FoldSession` over fold-core's `startSession`/`resumeSession`. + * running `FoldSession` over fold-core's `Session.open`. * * `defaultCodingMode` is the batteries-included local coding agent: the full filesystem toolset (read, * write, edit, apply_patch, bash - the family policy advertises the right editing subset per model), diff --git a/packages/fold-agent/src/Session/SessionLayout.ts b/packages/fold-agent/src/Session/SessionLayout.ts index c1e2fd2..c54ce8b 100644 --- a/packages/fold-agent/src/Session/SessionLayout.ts +++ b/packages/fold-agent/src/Session/SessionLayout.ts @@ -2,31 +2,30 @@ * This file implements the D5 session layout and discovery: one JSONL log per session under * `~/.fold/sessions//.jsonl`, where the slug is the escaped working directory * (pi-style), so "resume the latest session in this project" is a directory listing. `prepareSessionLog` - * mints the session id UP FRONT (the file is named by it - `startSession({ sessionId })` records the - * same id durably) and returns the ready log descriptor; `listSessionLogs`/`latestSessionLog` discover + * mints the session id UP FRONT (the file is named by it - `Session.open({ sessionId })` records the + * same id durably) and returns the log location; `listSessionLogs`/`latestSessionLog` discover * existing logs newest-first for the resume-latest path, while `sessionLogById` resolves an exact * `sess_*` id inside the current project's slug directory. */ import { homedir } from 'node:os' import { join } from 'node:path' -import { SessionId, encodedContentText, makeSessionId, usageInputTotal } from '@humanlayer/fold-core' +import { EventLog, SessionId, encodedContentText, makeSessionId, usageInputTotal } from '@humanlayer/fold-core' import type { ActiveModel, AgentFinishedLogEntry, AgentStartedLogEntry, AssistantMessageLogEntry, LogEntry, - FoldEventLog, Ids, ModelChangeLogEntry, SessionStartedLogEntry, SessionTitleLogEntry, UserMessageLogEntry, } from '@humanlayer/fold-core' -import { Predicate, Clock, Effect, Exit, FileSystem, Match, Option, Schema, Stream, Struct } from 'effect' +import { Predicate, Clock, Effect, Exit, FileSystem, Layer, Match, Option, Schema, Stream, Struct } from 'effect' -import { jsonlEventLog } from '../EventLog/JsonlDescriptor' +import { layerJsonl } from '../EventLog/JsonlLayer' import { toolOutputSessionDirFor } from '../OutputStore/OutputStore' /** Options shared by the layout helpers. */ @@ -164,16 +163,12 @@ const loadSessionIndex = ( /** * Mint a session id and prepare its log location: the directory exists, the path is derived from the - * id, and the returned descriptor plugs straight into `startSession({ sessionId, log })` - so the + * id, and the host provides layerJsonl(path) around Session.open({ sessionId }), so the * durable `session_started.sessionId` and the filename agree (D5). */ export const prepareSessionLog = ( options?: SessionLayoutOptions, -): Effect.Effect< - { readonly sessionId: SessionId; readonly path: string; readonly log: FoldEventLog }, - never, - Ids | FileSystem.FileSystem -> => +): Effect.Effect<{ readonly sessionId: SessionId; readonly path: string }, never, Ids | FileSystem.FileSystem> => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const sessionId = yield* makeSessionId @@ -181,7 +176,7 @@ export const prepareSessionLog = ( yield* fs.makeDirectory(directory, { recursive: true }).pipe(Effect.orDie) const path = sessionLogPathFor(sessionId, options) - return { sessionId, path, log: jsonlEventLog(path) } + return { sessionId, path } }) /** Discover this project's session logs, newest first (by file mtime). */ @@ -310,18 +305,14 @@ const sessionSummary = (ref: SessionLogRef, entries: ReadonlyArray): S } const loadSessionSummary = (ref: SessionLogRef): Effect.Effect => - Match.value(jsonlEventLog(ref.path)).pipe( - Match.tag('source', (descriptor) => - Effect.exit( - Effect.scoped( - descriptor.make.pipe( - Effect.flatMap((eventLog) => Stream.runCollect(eventLog.entries())), - Effect.map((entries) => sessionSummary(ref, Array.from(entries))), - ), - ), - ).pipe(Effect.map((exit) => (Exit.isSuccess(exit) ? exit.value : null))), - ), - Match.orElse(() => Effect.succeed(null)), + Effect.gen(function* () { + const eventLog = yield* EventLog + const entries = yield* Stream.runCollect(eventLog.entries()) + return sessionSummary(ref, Array.from(entries)) + }).pipe( + Effect.provide(layerJsonl(ref.path).pipe(Layer.orDie)), + Effect.exit, + Effect.map((exit) => (Exit.isSuccess(exit) ? exit.value : null)), ) /** Check if a cached record is still valid for the given session log ref. */ diff --git a/packages/fold-agent/src/index.ts b/packages/fold-agent/src/index.ts index 1bdf244..bea523b 100644 --- a/packages/fold-agent/src/index.ts +++ b/packages/fold-agent/src/index.ts @@ -19,7 +19,6 @@ export * from './Compatibility/GrokCompatibility' export * from './Compatibility/GrokInstructions' export * from './Compatibility/GrokPlugins' export * from './Compatibility/GrokSkills' -export * from './EventLog/JsonlDescriptor' export * from './EventLog/JsonlLayer' export * from './Fs/DefaultFileSystem' export * from './Fs/MutationQueue' diff --git a/packages/fold-agent/test/EventLog/SessionOpenJsonl.vi.test.ts b/packages/fold-agent/test/EventLog/SessionOpenJsonl.vi.test.ts new file mode 100644 index 0000000..a6c7752 --- /dev/null +++ b/packages/fold-agent/test/EventLog/SessionOpenJsonl.vi.test.ts @@ -0,0 +1,68 @@ +import { join } from 'node:path' + +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' +import { expect, it } from '@effect/vitest' +import { customModel, defineAgent, Session } from '@humanlayer/fold-core' +import { Cause, Effect, Exit, FileSystem, Layer, Predicate, Stream } from 'effect' +import { LanguageModel } from 'effect/ai' + +import { layerJsonl } from '../../src/index' + +const agent = defineAgent({ + model: customModel({ + activeModel: { + providerId: 'test', + providerKind: 'openai-compatible', + modelId: 'test', + role: null, + requestedReasoningLevel: 'off', + reasoning: { _tag: 'disabled' }, + }, + make: LanguageModel.make({ + generateText: () => Effect.die('this test must not call a model'), + streamText: () => Stream.empty, + }), + }), +}) + +it.effect('Session.open reopens a JSONL backend without duplicating identity or initialization rows', () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const dir = yield* fs.makeTempDirectoryScoped({ prefix: 'fold-session-open-' }) + const path = join(dir, 'session.jsonl') + const first = yield* Effect.gen(function* () { + const session = yield* Session.open({ agent, cwd: '/original' }) + yield* session.setTitle('persisted') + return { sessionId: session.sessionId, rootAgentId: session.rootAgentId, entries: yield* session.entries } + }).pipe(Effect.provide(layerJsonl(path).pipe(Layer.orDie)), Effect.scoped) + + yield* Effect.gen(function* () { + const session = yield* Session.open({ agent, sessionId: first.sessionId, cwd: '/ignored' }) + expect(session.sessionId).toBe(first.sessionId) + expect(session.rootAgentId).toBe(first.rootAgentId) + expect(yield* session.entries).toEqual(first.entries) + yield* session.setTitle('after reopening') + const entries = yield* session.entries + expect(entries.filter(Predicate.isTagged('session_started'))).toHaveLength(1) + expect(entries.map((entry) => entry.seq)).toEqual(entries.map((_, index) => index)) + expect(entries.at(-1)).toMatchObject({ _tag: 'session_title', title: 'after reopening' }) + }).pipe(Effect.provide(layerJsonl(path).pipe(Layer.orDie)), Effect.scoped) + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), +) + +it.effect('a corrupt JSONL acquisition remains a defect and is not replaced by an empty memory log', () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const dir = yield* fs.makeTempDirectoryScoped({ prefix: 'fold-session-corrupt-' }) + const path = join(dir, 'session.jsonl') + yield* fs.writeFileString(path, 'not JSON\n') + const exit = yield* Session.open({ agent }).pipe( + Effect.provide(layerJsonl(path).pipe(Layer.orDie)), + Effect.exit, + ) + if (!Exit.isFailure(exit)) throw new Error('expected corrupt log acquisition to fail') + expect(Cause.hasDies(exit.cause)).toBe(true) + expect(Cause.hasFails(exit.cause)).toBe(false) + expect(yield* fs.readFileString(path)).toBe('not JSON\n') + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), +) diff --git a/packages/fold-agent/test/Mode/Launch.vi.test.ts b/packages/fold-agent/test/Mode/Launch.vi.test.ts index ea28a9c..9087e22 100644 --- a/packages/fold-agent/test/Mode/Launch.vi.test.ts +++ b/packages/fold-agent/test/Mode/Launch.vi.test.ts @@ -1,6 +1,6 @@ /** * Launch composition tests (D27): `launchSession` wires a resolved model + agentfiles + the mode's tool - * roster into a real `startSession` over a JSONL log (D5 layout), and `resumeLatestSession` adopts the + * roster into a real `Session.open` over a JSONL log (D5 layout), and `resumeLatestSession` adopts the * newest log for the cwd. Uses a scripted `customModel` (no network) and real temp directories for the * workspace and fold home, so the JSONL persistence + agentfile discovery run end to end. */ @@ -80,7 +80,7 @@ const workspaceAndHome = (root: string): { readonly workspace: string; readonly return { workspace, foldHome } } -it.effect('launchSession composes the model, agentfiles, and mode tools over startSession', () => +it.effect('launchSession composes the model, agentfiles, and mode tools over Session.open', () => Effect.gen(function* () { const root = yield* tempDir const { workspace, foldHome } = workspaceAndHome(root) @@ -378,7 +378,7 @@ it.effect('launchSession wires session profiles end to end: role-bound roster st yield* Effect.scoped( Effect.gen(function* () { // 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. + // launchSession passed a covering profiles map through Session.open's validation. const session = yield* launchSession({ config, cwd: workspace, foldHome }) const started = (yield* session.entries).find((entry) => Predicate.isTagged(entry, 'agent_started')) expect(started?._tag).toBe('agent_started') diff --git a/packages/fold-agent/test/Session/SessionLayout.vi.test.ts b/packages/fold-agent/test/Session/SessionLayout.vi.test.ts index ca87920..04f2e97 100644 --- a/packages/fold-agent/test/Session/SessionLayout.vi.test.ts +++ b/packages/fold-agent/test/Session/SessionLayout.vi.test.ts @@ -1,20 +1,21 @@ /** * D5 session layout/discovery tests: the project slug is deterministic and filesystem-safe, prepared * logs live at `/sessions//.jsonl` with the directory created, a prepared log - * round-trips a real session (`startSession({ sessionId, log })` records the SAME id the filename + * round-trips a real session (`Session.open({ sessionId })` with a provided JSONL layer records the SAME id the filename * carries), and discovery lists a project's logs newest-first with the latest ready for - * `resumeSession`. + * `Session.open`. */ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, utimesSync } from 'node:fs' import { join } from 'node:path' import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { expect, it } from '@effect/vitest' -import { customModel, defineAgent, layerLiveIdFactory, SessionId, startSession } from '@humanlayer/fold-core' -import { Effect, Predicate, Stream } from 'effect' +import { customModel, defineAgent, layerLiveIdFactory, SessionId, Session } from '@humanlayer/fold-core' +import { Layer, Effect, Predicate, Stream } from 'effect' import { LanguageModel } from 'effect/ai' import { + layerJsonl, latestSessionLog, deleteSession, listSessionLogs, @@ -78,12 +79,11 @@ it.effect('a prepared log round-trips a session: the filename and session_starte }), }) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model }), - log: prepared.log, sessionId: prepared.sessionId, cwd, - }) + }).pipe(Effect.provideContext(yield* Layer.build(layerJsonl(prepared.path).pipe(Layer.orDie)))) expect(session.sessionId).toBe(prepared.sessionId) const entries = yield* session.entries @@ -114,12 +114,11 @@ it.effect('session summaries expose first-message titles, turns, and the active streamText: () => Stream.empty, }), }) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model }), - log: prepared.log, sessionId: prepared.sessionId, cwd, - }) + }).pipe(Effect.provideContext(yield* Layer.build(layerJsonl(prepared.path).pipe(Layer.orDie)))) yield* session.send(' Fix the flaky picker test ') yield* session.setTitle('Repair Flaky Picker') @@ -153,12 +152,11 @@ it.effect('session summary index is a full fast path and latest valid record win }, make: LanguageModel.make({ generateText: () => Effect.die('unused'), streamText: () => Stream.empty }), }) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model }), - log: prepared.log, sessionId: prepared.sessionId, cwd, - }) + }).pipe(Effect.provideContext(yield* Layer.build(layerJsonl(prepared.path).pipe(Layer.orDie)))) yield* session.send('Cache picker summaries') const [built] = yield* listSessionSummaries({ cwd, foldHome }) if (built === undefined) throw new Error('expected summary') @@ -198,12 +196,11 @@ it.effect('missing, corrupt, and stale summary records rebuild only their source }, make: LanguageModel.make({ generateText: () => Effect.die('unused'), streamText: () => Stream.empty }), }) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model }), - log: prepared.log, sessionId: prepared.sessionId, cwd, - }) + }).pipe(Effect.provideContext(yield* Layer.build(layerJsonl(prepared.path).pipe(Layer.orDie)))) yield* session.send('Original title') const indexPath = join(sessionsDirFor({ cwd, foldHome }), 'index.jsonl') writeFileSync(indexPath, '{corrupt cache row\n') diff --git a/packages/fold-codex/src/CodexModel.ts b/packages/fold-codex/src/CodexModel.ts index fc31e0b..e5ce353 100644 --- a/packages/fold-codex/src/CodexModel.ts +++ b/packages/fold-codex/src/CodexModel.ts @@ -320,7 +320,7 @@ export const makeCodexLanguageModel = ( /** * Describe a model served by the ChatGPT Codex backend using stored Codex OAuth credentials. Plugs - * into `startSession`/`switchModel` like any other model descriptor; the loop's per-request reasoning + * into `Session.open`/`switchModel` like any other model descriptor; the loop's per-request reasoning * and model-id binding work unchanged because the provider on top is the stock OpenAI one. */ export const codexModel = (options: CodexModelOptions): FoldModel => { diff --git a/packages/fold-core/examples/AnthropicAgent.ts b/packages/fold-core/examples/AnthropicAgent.ts index 7732702..4151f98 100644 --- a/packages/fold-core/examples/AnthropicAgent.ts +++ b/packages/fold-core/examples/AnthropicAgent.ts @@ -9,7 +9,7 @@ import { Console, Effect, Schema } from 'effect' import { FetchHttpClient } from 'effect/http' -import { anthropicModel, defineAgent, defineTool, startSession } from '../src/index' +import { anthropicModel, defineAgent, defineTool, Session } from '../src/index' const modelId = process.env.ANTHROPIC_MODEL ?? 'claude-opus-4-8' const apiKey = process.env.ANTHROPIC_API_KEY @@ -24,7 +24,7 @@ const echo = defineTool({ const makeProgram = (apiKey: string) => Effect.gen(function* () { - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ name: 'anthropic-demo', model: anthropicModel({ model: modelId, apiKey, reasoning: 'medium' }), diff --git a/packages/fold-core/examples/AutoCompactAgent.ts b/packages/fold-core/examples/AutoCompactAgent.ts index 21726d8..4d0311e 100644 --- a/packages/fold-core/examples/AutoCompactAgent.ts +++ b/packages/fold-core/examples/AutoCompactAgent.ts @@ -13,7 +13,7 @@ import { Predicate, Console, Effect } from 'effect' import { FetchHttpClient } from 'effect/http' -import { defineAgent, openaiModel, startSession, type CompactionLogEntry } from '../src/index' +import { defineAgent, openaiModel, Session, type CompactionLogEntry } from '../src/index' const modelId = process.env.OPENAI_MODEL ?? 'gpt-5.5' const apiKey = process.env.OPENAI_API_KEY @@ -36,7 +36,7 @@ const briefing = [ const makeProgram = (key: string) => Effect.gen(function* () { - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ name: 'auto-compact-demo', model: openaiModel({ model: modelId, apiKey: key }), diff --git a/packages/fold-core/examples/ModelSwitch.ts b/packages/fold-core/examples/ModelSwitch.ts index ad9e793..5da675a 100644 --- a/packages/fold-core/examples/ModelSwitch.ts +++ b/packages/fold-core/examples/ModelSwitch.ts @@ -13,7 +13,7 @@ import { Console, Effect, Schema } from 'effect' import { FetchHttpClient } from 'effect/http' -import { anthropicModel, defineAgent, defineTool, openaiModel, startSession } from '../src/index' +import { anthropicModel, defineAgent, defineTool, openaiModel, Session } from '../src/index' const openAiModelId = process.env.OPENAI_MODEL ?? 'gpt-5.5' const anthropicModelId = process.env.ANTHROPIC_MODEL ?? 'claude-opus-4-8' @@ -30,7 +30,7 @@ const echo = defineTool({ const makeProgram = (openAiKey: string, anthropicKey: string) => Effect.gen(function* () { - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ name: 'model-switch-demo', model: openaiModel({ model: openAiModelId, apiKey: openAiKey, reasoning: 'medium' }), diff --git a/packages/fold-core/examples/OpenaiAgent.ts b/packages/fold-core/examples/OpenaiAgent.ts index 011a98e..a39e194 100644 --- a/packages/fold-core/examples/OpenaiAgent.ts +++ b/packages/fold-core/examples/OpenaiAgent.ts @@ -1,14 +1,14 @@ /** * Minimal real-model example against the OpenAI API using the high-level API: describe the agent * (model, prompt, an inline tool), start a session, send one turn. All service wiring - event log, - * toolset, hooks, system prompt, request settings, runtime layers - stays inside `startSession`. + * toolset, hooks, system prompt, request settings, runtime layers - stays inside `Session.open`. * * Run: OPENAI_API_KEY=... bun packages/fold-core/examples/OpenaiAgent.ts */ import { Console, Effect, Schema } from 'effect' import { FetchHttpClient } from 'effect/http' -import { defineAgent, defineTool, openaiModel, startSession } from '../src/index' +import { defineAgent, defineTool, openaiModel, Session } from '../src/index' const modelId = process.env.OPENAI_MODEL ?? 'gpt-5.5' const apiKey = process.env.OPENAI_API_KEY @@ -23,7 +23,7 @@ const echo = defineTool({ const makeProgram = (apiKey: string) => Effect.gen(function* () { - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ name: 'openai-demo', model: openaiModel({ model: modelId, apiKey, reasoning: 'medium' }), diff --git a/packages/fold-core/examples/SkillsAgent.ts b/packages/fold-core/examples/SkillsAgent.ts index e53e11c..950ef0a 100644 --- a/packages/fold-core/examples/SkillsAgent.ts +++ b/packages/fold-core/examples/SkillsAgent.ts @@ -10,7 +10,7 @@ import { Console, Effect } from 'effect' import { FetchHttpClient } from 'effect/http' -import { anthropicModel, defineAgent, skillsFromData, skillTool, startSession } from '../src/index' +import { anthropicModel, defineAgent, skillsFromData, skillTool, Session } from '../src/index' const modelId = process.env.ANTHROPIC_MODEL ?? 'claude-opus-4-8' const apiKey = process.env.ANTHROPIC_API_KEY @@ -34,7 +34,7 @@ const skills = skillsFromData([ const makeProgram = (apiKey: string) => Effect.gen(function* () { - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ name: 'skills-demo', model: anthropicModel({ model: modelId, apiKey, reasoning: 'medium' }), diff --git a/packages/fold-core/src/Api/AgentDefinition.ts b/packages/fold-core/src/Api/AgentDefinition.ts index 646ef12..886344e 100644 --- a/packages/fold-core/src/Api/AgentDefinition.ts +++ b/packages/fold-core/src/Api/AgentDefinition.ts @@ -29,7 +29,7 @@ export const systemPromptBlocks = (systemPrompt: SystemPromptInput | undefined): /** * Configuration for one agent, as plain data. Built with {@link defineAgent}. `R` is every host service - * its tools need; `startSession` requires them from its caller. + * its tools need; `Session.open` requires them from its caller. */ export type AgentDefinition = { /** Optional display name, recorded in `session_started` meta. */ diff --git a/packages/fold-core/src/Api/EventLogDescriptor.ts b/packages/fold-core/src/Api/EventLogDescriptor.ts deleted file mode 100644 index cc0dedb..0000000 --- a/packages/fold-core/src/Api/EventLogDescriptor.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * This file defines event log backend descriptors for the public API: where a session's durable log - * lives, described as data. `memoryEventLog` covers tests, browsers, and transient sessions; - * `eventLogSource` is the extension seam through which platform packages (fold-agent JSONL, future - * SQLite/Durable Object backends) contribute an EventLog service implementation without any layer - * appearing in a public signature. - */ -import { Data, Effect, type Scope } from 'effect' - -import type { EventLogService } from '../EventLog/EventLogService' - -/** - * Where one session's durable event log lives. Built with {@link memoryEventLog} or {@link eventLogSource}. - * `R` is the host services the backend needs (a filesystem for JSONL); `startSession` requires them. - */ -export type FoldEventLog = Data.TaggedEnum<{ - memory: {} - source: { readonly make: Effect.Effect } -}> - -interface FoldEventLogDefinition extends Data.TaggedEnum.WithGenerics<1> { - readonly taggedEnum: FoldEventLog -} - -const FoldEventLog = Data.taggedEnum() - -/** Keep the session log in memory: fast, isolated, and gone when the session scope closes. */ -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 => FoldEventLog.source({ make: Effect.orDie(make) }) diff --git a/packages/fold-core/src/Api/ModelDescriptor.ts b/packages/fold-core/src/Api/ModelDescriptor.ts index e999bf9..7e15e0a 100644 --- a/packages/fold-core/src/Api/ModelDescriptor.ts +++ b/packages/fold-core/src/Api/ModelDescriptor.ts @@ -36,14 +36,14 @@ 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 - * {@link customModel}; consumed by `startSession` and `FoldSession.switchModel`. + * {@link customModel}; consumed by `Session.open` and `FoldSession.switchModel`. */ export type FoldModel = { readonly activeModel: ActiveModel readonly provider: FoldModelProvider /** * Builds the model's LanguageModel service in the runtime's scope. `R` is the host services it needs - * (an HttpClient for the built-in providers); `startSession` requires them from its caller. + * (an HttpClient for the built-in providers); `Session.open` requires them from its caller. */ readonly make: Effect.Effect } diff --git a/packages/fold-core/src/Api/StartSession.ts b/packages/fold-core/src/Api/OpenSession.ts similarity index 84% rename from packages/fold-core/src/Api/StartSession.ts rename to packages/fold-core/src/Api/OpenSession.ts index b5a98e5..ccd21db 100644 --- a/packages/fold-core/src/Api/StartSession.ts +++ b/packages/fold-core/src/Api/OpenSession.ts @@ -1,31 +1,8 @@ /** - * This file implements `startSession` and `resumeSession` - the ergonomic composition roots of the - * public API. Callers describe an agent (model, prompt, tools, hooks) and optionally an event log - * backend; this file lowers those descriptors into the internal service graph (EventLog, Ids, - * AgentEvents, SystemPrompt, ModelRequestSettings, SessionControls, SessionAgents, and - * per-provision Toolset + resolver + HookRunner + ToolRuntime + AgentRuntime, plus the Session facade) - * and returns a running session handle. Per the composition-root ruling, this is the only place - * descriptors become layers; no public signature accepts or returns one. - * - * System tools are ordinary members of `tools` (round-five ruling): the composition root walks tools - * arrays from the root through every delegation tool's specialist and fork definitions, builds the - * flat agent-definition registry, and runs each distinct tool value's `init` exactly once (the - * skill tool's roster scan), collecting the realized tool and its leading-prompt block for every agent - * listing that value. - * - * Session control (slice 2, D8/D9/D10): every run executes on a fiber the facade forks and registers - * in SessionControls, so `interrupt` cancels the live fiber tree (uninterruptible finalizers write the - * durable markers - the root's `agent-finished{interrupted}` here, subagent markers in the Subagents - * engine, partial assistant text in the loop), `stop` raises the session-wide graceful-stop signal - * every agent's loop observes at its batch boundaries, `steer` queues onto a running agent's steering - * queue (drained between its turns), and `send` targets any agent - queueing a follow-up when the - * target is running, continuing a finished subagent directly otherwise. - * - * Resume (slice 2): `resumeSession` ADOPTS an existing log - no new `session_started`/`agent_started` - * rows; identity is recovered from the replayed `session_started`, and when the provided configuration - * differs from the log's projected state (model binding - D17 ruling - or the composed leading blocks, - * e.g. a changed skills roster - D20 rule), the facade writes one epoch transition before the first - * send. + * Session.open initializes an empty log or adopts an existing session. Agent descriptors are lowered + * into a session-local service graph; EventLog is supplied by the host through layer composition. + * Without an ambient EventLog, each open allocates its own isolated in-memory log. Session resources + * belong to the caller's scope; host-provided log resources belong to the host's composition scope. */ import { Array as Arr, @@ -37,6 +14,7 @@ import { Fiber, Layer, Match, + Option, Ref, Schema, Scope, @@ -102,20 +80,15 @@ import { continueSubagent, type ContinueSubagentInput } from '../Subagents/Subag import { layerSystemPrompt } from '../SystemPrompt/SystemPromptLayer' import { SystemPrompt, type SystemPromptService } from '../SystemPrompt/SystemPromptService' import { systemPromptBlocks, type AgentDefinition, type SystemPromptInput } from './AgentDefinition' -import { memoryEventLog, type FoldEventLog } from './EventLogDescriptor' import type { FoldModel } from './ModelDescriptor' import { provisionAgentRuntime, validateToolNames } from './Provisioning' import type { RealizedFoldTool, SessionToolContribution, FoldTool } from './ToolDefinition' /** - * Options for {@link startSession}. `RA` is the host services the agent (its model and tools) needs, `RL` - * the ones the log backend needs, and `RP` the ones the profile models need; the session requires all - * of them from its caller. + * Options for Session.open. RA and RP are the host services needed by the agent and profile models. */ -export type StartSessionOptions = { +export type OpenSessionOptions = { readonly agent: AgentDefinition - /** Event log backend for the session. Defaults to in-memory. */ - readonly log?: FoldEventLog /** Host working directory recorded on `session_started`; omit on hosts without a filesystem. */ readonly cwd?: string readonly meta?: Readonly> @@ -142,29 +115,6 @@ export type StartSessionOptions = { readonly compactionArchiveAccess?: CompactionArchiveAccessService } -/** Options for {@link resumeSession}: the same agent configuration, over an existing log. */ -export type ResumeSessionOptions = { - readonly agent: AgentDefinition - /** The existing event log to adopt; the session continues exactly where the log left off. */ - readonly log: FoldEventLog - /** How queued steering messages drain at a turn boundary (D8). Defaults to one-at-a-time. */ - readonly steering?: SteeringMode - /** - * Initial role->model bindings for role-bound subagent types (profiles slice). Must cover every - * role the roster names (`orchestrator` falls back to `smart`, D25); optional when every subagent - * binds a concrete model. Rebind mid-session with {@link FoldSession.setProfile}. - */ - readonly profiles?: SessionProfiles - /** - * Model catalog entries installed session-wide (D15): compaction resolves context windows through - * them, and future consumers (cost projection, pickers) share the same data. Omitted means the - * empty catalog - every consumer falls back to its interim defaults. - */ - readonly catalog?: ReadonlyArray - /** Optional host-provided archive/log access guidance appended after compaction summaries. */ - readonly compactionArchiveAccess?: CompactionArchiveAccessService -} - /** Options for {@link FoldSession.switchModel}. Omitted fields keep the session's current configuration. */ export type SwitchModelOptions = { readonly reason?: string @@ -286,13 +236,6 @@ type SessionAgentConfig = { readonly tools: ReadonlyArray> } -/** Lower the event log descriptor to its EventLog layer; the backend's own needs stay in the layer's `R`. */ -const eventLogLayerFor = (log: FoldEventLog): Layer.Layer => - Match.valueTags(log, { - memory: () => layerInMemoryEventLogWithIds, - source: ({ make }) => Layer.effect(EventLog, make), - }) - /** * Run a tool's handler with the session's host services over whatever is in scope where the turn runs. * Effect AI merges the turn's services over the toolkit's, so a host whose request handling carries its @@ -311,7 +254,7 @@ const withHostServices = ( const activeModelsEquivalent = Schema.toEquivalence(Schema.NullOr(ActiveModel)) const promptBlocksEquivalent = Schema.toEquivalence(Schema.Array(Schema.String)) -/** Everything one assembled session shares between `startSession` and `resumeSession`. */ +/** The assembled service graph shared by initialization and adoption. */ type SessionGraph = { readonly agent: AgentDefinition readonly session: SessionService @@ -348,19 +291,18 @@ type SessionGraph = { /** * Assemble one session's whole service graph - registry, tool contributions, shared services, * controls, and the delegating root runtime - without writing anything - * durable. `startSession` follows with `session.start`; `resumeSession` follows with adoption. + * durable. Session.open follows with initialization or adoption. */ const assembleSessionGraph = (options: { readonly agent: AgentDefinition - readonly log?: FoldEventLog readonly steering?: SteeringMode readonly profiles?: SessionProfiles readonly catalog?: ReadonlyArray readonly compactionArchiveAccess?: CompactionArchiveAccessService -}): Effect.Effect, never, Scope.Scope | R> => +}): Effect.Effect, never, Scope.Scope | EventLog | R> => Effect.gen(function* () { const agent = options.agent - // The host services every tool and the log backend need, taken once from the caller - the way Effect + // The host services every tool needs, taken once from the caller - the way Effect // AI's Toolkit hands its build context to handlers. Tools run later on the session's own fibers // (including tools installed by a later switch), so the session carries these for them. const hostServices = yield* Effect.context() @@ -386,7 +328,7 @@ const assembleSessionGraph = (options: { return yield* Effect.die( new Error( `subagent type "${entry.name}" binds model role "${entry.model}", but the session has no ` + - `covering binding: pass ${needed} to startSession/resumeSession`, + `covering binding: pass ${needed} to Session.open`, ), ) } @@ -486,11 +428,8 @@ const assembleSessionGraph = (options: { // carries its own agent's hook chains (D16/D21). Tool handlers get their declared platform // services from this graph too: Effect AI hands each handler the context its toolkit was built in. const idsLayer = layerLiveIdFactory - const infraLayer = Layer.mergeAll( - eventLogLayerFor(options.log ?? memoryEventLog()).pipe(Layer.provide(idsLayer)), - idsLayer, - liveAgentEventsLayer, - ) + const eventLog = yield* EventLog + const infraLayer = Layer.mergeAll(Layer.succeed(EventLog, eventLog), idsLayer, liveAgentEventsLayer) const servicesLayer = Layer.mergeAll( infraLayer, Layer.succeedContext(hostServices), @@ -518,7 +457,7 @@ const assembleSessionGraph = (options: { ) // Builds use session-fresh memo maps, never the ambient CurrentMemoMap: layers are memoized by // reference per memo map, and under `Effect.provide` (any app or test harness) the ambient map - // would share module-level layers - the event log, the event spine - across sessions, and hand + // would share module-level layers - the event spine - across sessions, and hand // every model switch the previous epoch's memoized runtime. const sessionMemoMap = yield* Layer.makeMemoMap const sessionServices = yield* Layer.buildWithMemoMap(servicesLayer, sessionMemoMap, sessionScope).pipe( @@ -953,56 +892,83 @@ const makeSessionHandle = (graph: SessionGraph, identity: StartedSession): } /** - * Start one session for the given agent definition and return its running handle. The session lives in - * the surrounding scope: closing the scope releases the log backend, event spine, and provisioned model - * runtimes. + * Open one session. An empty log is initialized; a nonempty log must contain exactly one session's + * start and root-agent identity. Existing identity/metadata are preserved; configuration differences + * record an epoch transition. One supplied log must have at most one active session handle. */ -export const startSession = ( - options: StartSessionOptions, -): Effect.Effect, never, Scope.Scope | RA | RL | RP> => +export const openSession = ( + options: OpenSessionOptions, +): Effect.Effect, never, Scope.Scope | RA | RP> => Effect.gen(function* () { - const graph = yield* assembleSessionGraph(options) - const config = yield* Ref.get(graph.configRef) - const meta = - options.agent.name === undefined ? { ...options.meta } : { ...options.meta, agentName: options.agent.name } - const started = yield* graph.session - .start({ - cwd: options.cwd ?? null, - model: options.agent.model.activeModel, - systemPrompt: graph.leadingPromptFor(config.systemPrompt, config.tools), - meta, - ...Struct.pick(options.agent, ['promptCacheKey']), - ...Struct.pick(options, ['sessionId']), - }) - .pipe(Effect.orDie) - - return makeSessionHandle(graph, started) + // This is the intentional optional dependency boundary, not an optional internal capability. + // oxlint-disable-next-line automation/no-service-option + const supplied = yield* Effect.serviceOption(EventLog) + if (Option.isSome(supplied)) { + return yield* openWithLog(options).pipe(Effect.provideService(EventLog, supplied.value)) + } + const scope = yield* Effect.scope + const memoMap = yield* Layer.makeMemoMap + const context = yield* Layer.buildWithMemoMap( + layerInMemoryEventLogWithIds.pipe(Layer.provide(layerLiveIdFactory)), + memoMap, + scope, + ) + return yield* openWithLog(options).pipe(Effect.provideContext(context)) }) -/** - * Resume an existing session log: ADOPT its identity (no new `session_started`/`agent_started` rows - - * the replayed log is the state) and continue with the given agent configuration. When the projected - * root state differs from the configuration - the model binding (D17 resume ruling) or the composed - * leading blocks, e.g. a freshly scanned skills roster (D20 resume rule) - one durable epoch - * transition is written before the first send. - */ -export const resumeSession = ( - options: ResumeSessionOptions, -): Effect.Effect, never, Scope.Scope | RA | RL | RP> => +const openWithLog = ( + options: OpenSessionOptions, +): Effect.Effect, never, Scope.Scope | EventLog | RA | RP> => Effect.gen(function* () { - const graph = yield* assembleSessionGraph(options) - const entries = yield* Stream.runCollect(graph.eventLog.entries()).pipe( - Effect.orDie, - Effect.map((collected): ReadonlyArray => collected), - ) - - 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)'), - ) + const eventLog = yield* EventLog + const entries = yield* Stream.runCollect(eventLog.entries()).pipe(Effect.orDie) + const sessionStarted = entries[0] + if (Arr.isReadonlyArrayNonEmpty(entries)) { + if ( + sessionStarted === undefined || + !Predicate.isTagged(sessionStarted, 'session_started') || + entries.filter(Predicate.isTagged('session_started')).length !== 1 || + entries.some((entry, index) => entry.seq !== index) || + !entries.some( + (entry) => + Predicate.isTagged(entry, 'agent_started') && + entry.agentId === sessionStarted.rootAgentId && + entry.parentAgentId === null, + ) + ) { + return yield* Effect.die( + new Error('cannot open: nonempty log must contain one session_started row and its root agent'), + ) + } + if (options.sessionId !== undefined && options.sessionId !== sessionStarted.sessionId) { + return yield* Effect.die( + new Error('cannot open: supplied sessionId conflicts with the existing log identity'), + ) + } } + const graph = yield* assembleSessionGraph(options) + if (sessionStarted === undefined) { + const config = yield* Ref.get(graph.configRef) + const meta = + options.agent.name === undefined + ? { ...options.meta } + : { ...options.meta, agentName: options.agent.name } + const started = yield* graph.session + .start({ + cwd: options.cwd ?? null, + model: options.agent.model.activeModel, + systemPrompt: graph.leadingPromptFor(config.systemPrompt, config.tools), + meta, + ...Struct.pick(options.agent, ['promptCacheKey']), + ...Struct.pick(options, ['sessionId']), + }) + .pipe(Effect.orDie) + return makeSessionHandle(graph, started) + } + if (!Predicate.isTagged(sessionStarted, 'session_started')) { + return yield* Effect.die(new Error('cannot open: invalid session history')) + } const identity: StartedSession = { sessionId: sessionStarted.sessionId, rootAgentId: sessionStarted.rootAgentId, diff --git a/packages/fold-core/src/Api/Provisioning.ts b/packages/fold-core/src/Api/Provisioning.ts index 080576d..7c4ef8a 100644 --- a/packages/fold-core/src/Api/Provisioning.ts +++ b/packages/fold-core/src/Api/Provisioning.ts @@ -1,6 +1,6 @@ /** * This file owns agent-runtime provisioning - the one place a (model, tools, hooks) configuration - * becomes a fully wired AgentRuntime. `startSession` provisions the root agent's runtime here (once at + * becomes a fully wired AgentRuntime. `Session.open` provisions the root agent's runtime here (once at * start, again on every model switch), and the subagent operations provision each subagent's runtime * here; both run inside the session's shared services, so every runtime shares the same EventLog, Ids, * AgentEvents spine, SystemPrompt, ModelRequestSettings, and ToolEventSink, while each provision gets its @@ -10,7 +10,7 @@ * - Every provision builds with a fresh `Layer.makeMemoMap`. v4 memoizes module-level layers by * reference per memo map, so reusing a map would silently hand a new provision a previous * provision's Toolset/ToolRuntime/AgentRuntime (the SessionIsolation regression). - * - Every provision builds into the caller's ambient Scope. `startSession` provides the session scope + * - Every provision builds into the caller's ambient Scope. `Session.open` provides the session scope * for root-agent provisions; a subagent provisions inside its dispatch call's scope, so its provider * HTTP client releases when its dispatch returns instead of leaking for the session's lifetime. */ @@ -74,7 +74,7 @@ export type ProvisionAgentRuntimeInput = { * Provision one agent runtime into the ambient Scope: installed Toolset + family resolver + this * agent's HookRunner + ToolRuntime + the model's provider LanguageModel layer, built with a fresh memo * map. Everything else - the session's shared services and the host's - comes from the surroundings, so - * this runs wherever those are present: `startSession` for the root agent, and the subagent operations, + * this runs wherever those are present: `Session.open` for the root agent, and the subagent operations, * which run inside a tool call. */ export const provisionAgentRuntime = Effect.fnUntraced(function* (input: ProvisionAgentRuntimeInput) { @@ -84,7 +84,7 @@ export const provisionAgentRuntime = Effect.fnUntraced(function* (input: Provisi // Tool handlers run with the context their toolkit was built in (Effect AI merges it under each call's // per-call services), so building here gives every handler the session's and the host's services. const toolsetLayer = toolsetLayerFor(input.tools) - // SAFETY: every model reaching a session came through startSession, resumeSession, switchModel, or + // SAFETY: every model reaching a session came through Session.open, switchModel, or // setProfile, whose types require the model's services to be among the session's host services - and // the host services are part of the surroundings this runs in. // oxlint-disable-next-line typescript/consistent-type-assertions, automation/no-type-assertion diff --git a/packages/fold-core/src/Api/ToolDefinition.ts b/packages/fold-core/src/Api/ToolDefinition.ts index e2c122c..7cf33ab 100644 --- a/packages/fold-core/src/Api/ToolDefinition.ts +++ b/packages/fold-core/src/Api/ToolDefinition.ts @@ -111,9 +111,9 @@ export type SessionToolContribution = { /** * One tool as configured on an agent, ready for the composition root to initialize. Built with - * {@link defineTool} or a system-tool factory (`skillTool`, `subagentTool`); consumed by `startSession` + * {@link defineTool} or a system-tool factory (`skillTool`, `subagentTool`); consumed by `Session.open` * and by subagent definitions. `R` is every service the tool needs from the host - its handler's and its - * init's - so an agent's type, and `startSession`'s, carry what the host must provide. + * init's - so an agent's type, and `Session.open`'s, carry what the host must provide. */ export type FoldTool = { readonly name: string @@ -225,7 +225,7 @@ export const defineTool = < // AI decodes model-supplied params against `parameters` before invoking it, so it only ever receives // `Params['Type']`. The runtime provides the per-call services around each call; the session's own // services and the host services in `Services` come from the context the toolkit is built in, and - // `startSession` requires the host ones from its caller because this tool's type carries them. + // `Session.open` requires the host ones from its caller because this tool's type carries them. // oxlint-disable-next-line typescript/consistent-type-assertions, automation/no-type-assertion handler: handler as ErasedToolHandler, promptBlock: null, diff --git a/packages/fold-core/src/Model/ModelCatalog.ts b/packages/fold-core/src/Model/ModelCatalog.ts index 621ecdd..1fb555e 100644 --- a/packages/fold-core/src/Model/ModelCatalog.ts +++ b/packages/fold-core/src/Model/ModelCatalog.ts @@ -3,7 +3,7 @@ * limits, reasoning support, vision support, and pricing, plus the lookup service the runtime * consults with the domain type callers already hold - an `ActiveModel`. Core owns only the contract * and the matching logic; `fold-agent` ships the models.dev-backed data source and hosts install the - * entries session-wide through `startSession`/`resumeSession`. + * entries session-wide through `Session.open`. * * The service is a `Context.Reference` whose default is the empty catalog (`lookup` always null), so * low-level composition roots never mention it and every consumer degrades gracefully: compaction diff --git a/packages/fold-core/src/Session/Profiles.ts b/packages/fold-core/src/Session/Profiles.ts index 6af5b66..4d35a5a 100644 --- a/packages/fold-core/src/Session/Profiles.ts +++ b/packages/fold-core/src/Session/Profiles.ts @@ -22,7 +22,7 @@ export type ProfileRole = typeof ProfileRole.Type export const isProfileRole = Schema.is(ProfileRole) /** - * A session's role->model bindings, as passed to `startSession`/`resumeSession`. A plain type, not a + * A session's role->model bindings, as passed to `Session.open`. A plain type, not a * schema: the values are full model descriptors carrying Redacted keys and provider Effects (the same * status as `FoldModel` itself); nothing durable is written from this map - role bindings resolve to * concrete models before any log row exists. `R` is the host services the bound models need. @@ -60,7 +60,7 @@ export type ProfilesService = { /** Profiles service tag; one instance per session, shared by the facade and the subagent operations. */ export class Profiles extends Context.Service()('fold/Profiles') {} -/** One session's profiles over the initial bindings from `startSession`/`resumeSession`. */ +/** One session's profiles over the initial bindings from `Session.open`. */ export const layerProfiles = (initial: SessionProfiles): Layer.Layer => Layer.effect( Profiles, diff --git a/packages/fold-core/src/Session/SessionService.ts b/packages/fold-core/src/Session/SessionService.ts index d479a2d..24e84f3 100644 --- a/packages/fold-core/src/Session/SessionService.ts +++ b/packages/fold-core/src/Session/SessionService.ts @@ -7,6 +7,7 @@ import { Context, type Effect, type Schema, type Stream } from 'effect' import type { FoldEvent } from '../AgentEvents/AgentEventsService' +import { openSession, type OpenSessionOptions } from '../Api/OpenSession' import type { ActiveModel, AgentFinishedLogEntry, CompactionLogEntry, LogSeq } from '../EventLog/Schemas' import type { AgentId, SessionId } from '../Ids' import type { SessionAlreadyStartedError, SessionNotStartedError } from './Errors' @@ -66,4 +67,9 @@ export type SessionService = { } /** Session service tag. One provided instance represents one session. */ -export class Session extends Context.Service()('fold/Session') {} +export class Session extends Context.Service()('fold/Session') { + /** Initialize a fresh log or resume an existing one. The handle lives in the caller's scope. */ + static open(options: OpenSessionOptions) { + return openSession(options) + } +} diff --git a/packages/fold-core/src/Skills/SkillSource.ts b/packages/fold-core/src/Skills/SkillSource.ts index c6f445b..505c433 100644 --- a/packages/fold-core/src/Skills/SkillSource.ts +++ b/packages/fold-core/src/Skills/SkillSource.ts @@ -90,8 +90,8 @@ export type FoldSkills = { export const skillsFromData = (skills: ReadonlyArray): FoldSkills => ({ make: skillSourceFromData(skills) }) /** - * Configure an agent's skills from a custom source implementation (the extension seam, mirroring - * `eventLogSource`): fold-agent exposes its disk loader through this. A construction failure is an + * Configure an agent's skills from a custom source implementation: fold-agent exposes its disk + * loader through this. A construction failure is an * infrastructure defect. */ export const skillSource = (make: Effect.Effect): FoldSkills => ({ diff --git a/packages/fold-core/src/Subagents/SessionAgents.ts b/packages/fold-core/src/Subagents/SessionAgents.ts index df9fafb..f3d6a80 100644 --- a/packages/fold-core/src/Subagents/SessionAgents.ts +++ b/packages/fold-core/src/Subagents/SessionAgents.ts @@ -1,7 +1,7 @@ /** * This file defines SessionAgents - how one session builds its agents: the flat registry of * dispatchable types, the tool contributions every agent's tools are realized against, the root - * agent's current configuration, and the session-wide auto-compaction policy. `startSession` provides + * agent's current configuration, and the session-wide auto-compaction policy. `Session.open` provides * it with the other session services; agent provisioning and the subagent operations read it. */ import { Context } from 'effect' diff --git a/packages/fold-core/src/Subagents/SubagentDefinition.ts b/packages/fold-core/src/Subagents/SubagentDefinition.ts index 2d0b83f..622fcfe 100644 --- a/packages/fold-core/src/Subagents/SubagentDefinition.ts +++ b/packages/fold-core/src/Subagents/SubagentDefinition.ts @@ -17,7 +17,7 @@ import type { ProfileRole } from '../Session/Profiles' /** * How a subagent type's model is configured: a concrete model descriptor, or a profile role name * resolved through the session's profiles map at every dispatch/resume (profiles slice). Role-bound - * types follow `FoldSession.setProfile` swaps on their very next run; `startSession`/`resumeSession` + * types follow `FoldSession.setProfile` swaps on their very next run; `Session.open` * must receive a `profiles` map covering every role the roster names (`orchestrator` falls back to * `smart`, D25). */ diff --git a/packages/fold-core/src/index.ts b/packages/fold-core/src/index.ts index 883be8c..7595ff7 100644 --- a/packages/fold-core/src/index.ts +++ b/packages/fold-core/src/index.ts @@ -2,10 +2,16 @@ export * from './Ids' export * from './AgentRuntime/AgentRuntimeLayer' export * from './AgentRuntime/AgentRuntimeService' export * from './Api/AgentDefinition' -export * from './Api/EventLogDescriptor' export * from './Api/ModelDescriptor' export * from './Api/Provisioning' -export * from './Api/StartSession' +export { + type OpenSessionOptions, + type FoldSession, + type SwitchModelOptions, + type AgentTargetOptions, + type CompactOptions, + type InjectedSkillEntries, +} from './Api/OpenSession' export * from './Api/ToolDefinition' export * from './Subagents/AgentIdRef' export * from './Subagents/AgentRegistry' diff --git a/packages/fold-core/test/Api/OpenSession.vi.test.ts b/packages/fold-core/test/Api/OpenSession.vi.test.ts new file mode 100644 index 0000000..8d062c3 --- /dev/null +++ b/packages/fold-core/test/Api/OpenSession.vi.test.ts @@ -0,0 +1,123 @@ +import { expect, it } from '@effect/vitest' +import { Cause, Context, Effect, Exit, Layer, Predicate, Ref, Stream } from 'effect' + +import { defineAgent, EventLog, layerInMemoryEventLog, Session, SessionId } from '../../src/index' +import { textTurn } from '../TestLayers/ScriptedLanguageModel' +import { gptActiveModel, scriptedModel } from './ApiTestHelpers' + +it.effect('one provided layer is shared by open, send, and host log readers', () => + Effect.gen(function* () { + const log = yield* EventLog + const { model } = yield* scriptedModel(gptActiveModel, [textTurn('answer')]) + const session = yield* Session.open({ agent: defineAgent({ model }), cwd: '/original', meta: { fresh: true } }) + yield* session.send('hello') + expect(yield* Stream.runCollect(log.entries())).toEqual(yield* session.entries) + expect((yield* session.entries).filter(Predicate.isTagged('session_started'))).toHaveLength(1) + }).pipe(Effect.provide(layerInMemoryEventLog), Effect.scoped), +) + +it.effect('host-owned backend survives session scopes and is acquired and released once', () => + Effect.gen(function* () { + const acquired = yield* Ref.make(0) + const released = yield* Ref.make(0) + const backend = Layer.effect( + EventLog, + Effect.gen(function* () { + yield* Ref.update(acquired, (n) => n + 1) + const context = yield* Layer.build(layerInMemoryEventLog) + return yield* Effect.acquireRelease(Effect.succeed(Context.get(context, EventLog)), () => + Ref.update(released, (n) => n + 1), + ) + }), + ) + yield* Effect.gen(function* () { + const log = yield* EventLog + const { model } = yield* scriptedModel(gptActiveModel, [textTurn('first'), textTurn('second')]) + const agent = defineAgent({ model }) + const firstId = yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* Session.open({ agent }) + yield* session.send('first') + return session.sessionId + }), + ) + expect(yield* Ref.get(released)).toBe(0) + yield* Effect.scoped( + Effect.gen(function* () { + const resumed = yield* Session.open({ agent }) + expect(resumed.sessionId).toBe(firstId) + yield* resumed.send('second') + }), + ) + expect( + (yield* Stream.runCollect(log.entries())).filter(Predicate.isTagged('session_started')), + ).toHaveLength(1) + expect(yield* Ref.get(released)).toBe(0) + }).pipe(Effect.provide(backend)) + expect(yield* Ref.get(acquired)).toBe(1) + expect(yield* Ref.get(released)).toBe(1) + }).pipe(Effect.scoped), +) + +it.effect('reopening preserves recorded metadata and rejects a conflicting identity without writing', () => + Effect.gen(function* () { + const { model } = yield* scriptedModel(gptActiveModel, []) + const agent = defineAgent({ model }) + const first = yield* Effect.scoped(Session.open({ agent, cwd: '/original', meta: { original: true } })) + const log = yield* EventLog + const before = yield* Stream.runCollect(log.entries()) + const resumed = yield* Session.open({ + agent, + cwd: '/ignored', + meta: { ignored: true }, + sessionId: first.sessionId, + }) + expect(yield* resumed.entries).toEqual(before) + const exit = yield* Session.open({ agent, sessionId: SessionId.create() }).pipe(Effect.exit) + if (!Exit.isFailure(exit)) throw new Error('expected identity conflict') + expect(String(Cause.squash(exit.cause))).toContain('conflicts') + expect(yield* Stream.runCollect(log.entries())).toEqual(before) + }).pipe(Effect.provide(layerInMemoryEventLog), Effect.scoped), +) + +it.effect('nonempty history without session identity is rejected, not initialized', () => + Effect.gen(function* () { + const log = yield* EventLog + yield* log.append({ + _tag: 'session_title', + agentId: null, + parentAgentId: null, + toolCallId: null, + title: 'orphan', + }) + const { model } = yield* scriptedModel(gptActiveModel, []) + const exit = yield* Session.open({ agent: defineAgent({ model }) }).pipe(Effect.exit) + if (!Exit.isFailure(exit)) throw new Error('expected invalid log') + expect(String(Cause.squash(exit.cause))).toContain('nonempty log') + expect(yield* Stream.runCollect(log.entries())).toHaveLength(1) + }).pipe(Effect.provide(layerInMemoryEventLog), Effect.scoped), +) + +it.effect('a log containing multiple session starts is rejected without appending', () => + Effect.gen(function* () { + const { model } = yield* scriptedModel(gptActiveModel, []) + const agent = defineAgent({ model }) + const first = yield* Effect.scoped(Session.open({ agent })) + const log = yield* EventLog + yield* log.append({ + _tag: 'session_started', + agentId: null, + parentAgentId: null, + toolCallId: null, + cwd: null, + meta: {}, + sessionId: first.sessionId, + rootAgentId: first.rootAgentId, + }) + const before = yield* Stream.runCollect(log.entries()) + const exit = yield* Session.open({ agent }).pipe(Effect.exit) + if (!Exit.isFailure(exit)) throw new Error('expected duplicate session identity rejection') + expect(String(Cause.squash(exit.cause))).toContain('one session_started') + expect(yield* Stream.runCollect(log.entries())).toEqual(before) + }).pipe(Effect.provide(layerInMemoryEventLog), Effect.scoped), +) diff --git a/packages/fold-core/test/Api/ResumeSession.vi.test.ts b/packages/fold-core/test/Api/ResumeSession.vi.test.ts index 22b2ba7..1f641f9 100644 --- a/packages/fold-core/test/Api/ResumeSession.vi.test.ts +++ b/packages/fold-core/test/Api/ResumeSession.vi.test.ts @@ -1,23 +1,21 @@ import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** - * Slice-2 resume tests: `resumeSession` ADOPTS an existing log - identity recovered from the replayed + * Slice-2 resume tests: `Session.open` ADOPTS an existing log - identity recovered from the replayed * `session_started`, no new session/agent rows - and the facade writes ONE epoch transition exactly * when the provided configuration no longer matches the log's projected root state: a different model * binding (D17 resume ruling) or different composed leading blocks (D20 resume rule - a changed skills * roster changes the block). An unchanged configuration writes nothing. */ import { expect, it } from '@effect/vitest' -import { Predicate, Cause, Context, Effect, Exit, Layer, Schema } from 'effect' +import { Predicate, Context, Effect, Layer, Schema } from 'effect' import { Prompt } from 'effect/ai' import { defineAgent, EventLog, - eventLogSource, layerInMemoryEventLog, MessageId, - resumeSession, - startSession, + Session, ToolCallId, type ActiveModel, type EventLogService, @@ -36,10 +34,9 @@ const runFirstSession = (sharedLog: EventLogService, systemPrompt: string) => Effect.scoped( Effect.gen(function* () { const scripted = yield* scriptedModel(claudeActiveModel, [textTurn('first answer')]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: scripted.model, systemPrompt }), - log: eventLogSource(Effect.succeed(sharedLog)), - }) + }).pipe(Effect.provideService(EventLog, sharedLog)) const finished = yield* session.send('go') expect(finished.outcome).toBe('completed') return { sessionId: session.sessionId, rootAgentId: session.rootAgentId } @@ -53,10 +50,9 @@ it.effect('resume adopts the log: same ids, no new rows, full continuity - and n // Same model binding, same prompt: adoption must write NOTHING before the next send. const resumedScripted = yield* scriptedModel(claudeActiveModel, [textTurn('second answer')]) - const session = yield* resumeSession({ + const session = yield* Session.open({ agent: defineAgent({ model: resumedScripted.model, systemPrompt: 'You are the assistant.' }), - log: eventLogSource(Effect.succeed(sharedLog)), - }) + }).pipe(Effect.provideService(EventLog, sharedLog)) expect(session.sessionId).toBe(first.sessionId) expect(session.rootAgentId).toBe(first.rootAgentId) @@ -108,10 +104,9 @@ it.effect('resume supplies a request-local failed result for a persisted danglin }) const resumedScripted = yield* scriptedModel(claudeActiveModel, [textTurn('recovered')]) - const session = yield* resumeSession({ + const session = yield* Session.open({ agent: defineAgent({ model: resumedScripted.model, systemPrompt: 'You are the assistant.' }), - log: eventLogSource(Effect.succeed(sharedLog)), - }) + }).pipe(Effect.provideService(EventLog, sharedLog)) const finished = yield* session.send('continue') expect(finished.resultText).toBe('recovered') @@ -147,10 +142,9 @@ it.effect('resume with a different model binding writes one epoch transition (D1 // A different provider family: the transition re-renders the epoch for the new model. const resumedScripted = yield* scriptedModel(gptActiveModel, [textTurn('answered by the new model')]) - const session = yield* resumeSession({ + const session = yield* Session.open({ agent: defineAgent({ model: resumedScripted.model, systemPrompt: 'You are the assistant.' }), - log: eventLogSource(Effect.succeed(sharedLog)), - }) + }).pipe(Effect.provideService(EventLog, sharedLog)) const beforeSend = yield* session.entries const modelChange = beforeSend.findLast((entry) => Predicate.isTagged(entry, 'model-change')) @@ -182,19 +176,17 @@ it.effect('resume records exactly one durable transition from GPT-6 Sol to Luna' yield* Effect.scoped( Effect.gen(function* () { const first = yield* scriptedModel(sol, [textTurn('first answer')]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: first.model, systemPrompt: 'You are the assistant.' }), - log: eventLogSource(Effect.succeed(sharedLog)), - }) + }).pipe(Effect.provideService(EventLog, sharedLog)) yield* session.send('go') }), ) const resumed = yield* scriptedModel(luna, [textTurn('second answer')]) - const session = yield* resumeSession({ + const session = yield* Session.open({ agent: defineAgent({ model: resumed.model, systemPrompt: 'You are the assistant.' }), - log: eventLogSource(Effect.succeed(sharedLog)), - }) + }).pipe(Effect.provideService(EventLog, sharedLog)) const entries = yield* session.entries const modelChanges = entries.filter((entry) => Predicate.isTagged(entry, 'model-change')) @@ -213,10 +205,9 @@ it.effect('resume with changed leading blocks transitions too (D20 resume rule)' // Same model binding; only the composed leading block set changed (the same comparison a // freshly scanned, changed skills roster would trip). const resumedScripted = yield* scriptedModel(claudeActiveModel, [textTurn('answered under v2')]) - const session = yield* resumeSession({ + const session = yield* Session.open({ agent: defineAgent({ model: resumedScripted.model, systemPrompt: 'prompt v2' }), - log: eventLogSource(Effect.succeed(sharedLog)), - }) + }).pipe(Effect.provideService(EventLog, sharedLog)) const beforeSend = yield* session.entries expect(beforeSend.some((entry) => Predicate.isTagged(entry, 'model-change'))).toBe(true) @@ -231,17 +222,15 @@ it.effect('resume with changed leading blocks transitions too (D20 resume rule)' }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) -it.effect('resuming an empty log is a defect with instructive guidance', () => +it.effect('opening an empty supplied log initializes it', () => Effect.gen(function* () { const sharedLog = yield* makeSharedLog const scripted = yield* scriptedModel(claudeActiveModel, []) - - const exit = yield* resumeSession({ - agent: defineAgent({ model: scripted.model }), - log: eventLogSource(Effect.succeed(sharedLog)), - }).pipe(Effect.exit) - - if (!Exit.isFailure(exit)) throw new Error('expected resume on an empty log to defect') - expect(String(Cause.squash(exit.cause))).toContain('no session_started') + const session = yield* Session.open({ agent: defineAgent({ model: scripted.model }) }).pipe( + Effect.provideService(EventLog, sharedLog), + ) + const entries = yield* session.entries + expect(entries.filter(Predicate.isTagged('session_started'))).toHaveLength(1) + expect(entries.filter(Predicate.isTagged('agent_started'))).toHaveLength(1) }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-core/test/Api/SessionInterrupt.vi.test.ts b/packages/fold-core/test/Api/SessionInterrupt.vi.test.ts index d93a81b..9d41d50 100644 --- a/packages/fold-core/test/Api/SessionInterrupt.vi.test.ts +++ b/packages/fold-core/test/Api/SessionInterrupt.vi.test.ts @@ -8,7 +8,7 @@ import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { expect, it } from '@effect/vitest' import { Predicate, Deferred, Effect, Fiber } from 'effect' -import { defineAgent, defineSubagent, shortAgentId, startSession, subagentTool } from '../../src/index' +import { defineAgent, defineSubagent, shortAgentId, Session, subagentTool } from '../../src/index' import { makeHangOnceModel } from '../Subagents/DriveHarness' import { textTurn, toolCallTurn } from '../TestLayers/ScriptedLanguageModel' import { claudeActiveModel, gptActiveModel, scriptedModel } from './ApiTestHelpers' @@ -20,7 +20,7 @@ it.effect('interrupt discards partial assistant text, writes the root marker, an textTurn('resumed cleanly'), ]) - const session = yield* startSession({ agent: defineAgent({ model: partialHang.model }) }) + const session = yield* Session.open({ agent: defineAgent({ model: partialHang.model }) }) const sendFiber = yield* Effect.forkScoped(session.send('go')) yield* partialHang.firstRequestStreaming @@ -72,7 +72,7 @@ it.effect('a targeted subagent interrupt folds into the dispatcher, which keeps textTurn('root synthesized after the interruption'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model, tools: [subagentTool([researcher])] }), }) diff --git a/packages/fold-core/test/Api/SessionIsolation.vi.test.ts b/packages/fold-core/test/Api/SessionIsolation.vi.test.ts index 658fa65..a6014e0 100644 --- a/packages/fold-core/test/Api/SessionIsolation.vi.test.ts +++ b/packages/fold-core/test/Api/SessionIsolation.vi.test.ts @@ -1,15 +1,15 @@ import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** - * Cross-session isolation: startSession builds with session-fresh memo maps, so two sessions started + * Cross-session isolation: Session.open builds with session-fresh memo maps, so two sessions started * inside one program never share module-level layers. This is the regression test for the v4 * CurrentMemoMap hazard - it.effect runs under `Effect.provide`, so an ambient memo map is present in * the fiber context, which is exactly the environment where by-reference layer memoization would hand * both sessions one shared EventLog and one shared event spine. */ import { expect, it } from '@effect/vitest' -import { Predicate, Effect } from 'effect' +import { Deferred, Fiber, Predicate, Effect, Stream } from 'effect' -import { defineAgent, startSession, type SessionStartedLogEntry } from '../../src/index' +import { defineAgent, Session, type SessionStartedLogEntry } from '../../src/index' import { textTurn } from '../TestLayers/ScriptedLanguageModel' import { gptActiveModel, scriptedModel } from './ApiTestHelpers' @@ -18,10 +18,10 @@ it.effect('two sessions in one program share no log, ids, or model runtime', () const first = yield* scriptedModel(gptActiveModel, [textTurn('from session A')]) const second = yield* scriptedModel(gptActiveModel, [textTurn('from session B')]) - const sessionA = yield* startSession({ + const sessionA = yield* Session.open({ agent: defineAgent({ model: first.model, systemPrompt: 'Session A agent.' }), }) - const sessionB = yield* startSession({ + const sessionB = yield* Session.open({ agent: defineAgent({ model: second.model, systemPrompt: 'Session B agent.' }), }) @@ -57,5 +57,24 @@ it.effect('two sessions in one program share no log, ids, or model runtime', () // Sequence numbers restart per log - interleaving into one shared log would break this. expect(entriesA.map((entry) => entry.seq)).toEqual(entriesA.map((_, index) => index)) expect(entriesB.map((entry) => entry.seq)).toEqual(entriesB.map((_, index) => index)) + const eventIdsA = new Set(entriesA.map((entry) => entry.eventId)) + expect(entriesB.some((entry) => eventIdsA.has(entry.eventId))).toBe(false) + + // The first replayed event proves the subscription is attached before either live append. + const subscribed = yield* Deferred.make() + const liveTitle = yield* Effect.forkChild( + sessionA.events().pipe( + Stream.tap(() => Deferred.succeed(subscribed, undefined)), + Stream.filter((event) => event.kind === 'log' && Predicate.isTagged(event.entry, 'session_title')), + Stream.take(1), + Stream.runCollect, + ), + ) + yield* Deferred.await(subscribed) + yield* sessionB.setTitle('session B title') + yield* sessionA.setTitle('session A title') + expect(yield* Fiber.join(liveTitle)).toMatchObject([ + { kind: 'log', entry: { _tag: 'session_title', title: 'session A title' } }, + ]) }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-core/test/Api/SessionProfiles.vi.test.ts b/packages/fold-core/test/Api/SessionProfiles.vi.test.ts index 114c0eb..86b9d82 100644 --- a/packages/fold-core/test/Api/SessionProfiles.vi.test.ts +++ b/packages/fold-core/test/Api/SessionProfiles.vi.test.ts @@ -1,6 +1,6 @@ import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** - * Facade tests for session profiles: `startSession({ profiles })` seeds the session-wide role->model + * Facade tests for session profiles: `Session.open({ profiles })` seeds the session-wide role->model * map and `FoldSession.setProfile` rebinds one role mid-session - children provision per dispatch, so * the swap binds the very NEXT dispatch of a role-bound type with no epoch machinery, while completed * runs keep their durable rows on the model that actually served them. diff --git a/packages/fold-core/test/Api/SessionSendTargets.vi.test.ts b/packages/fold-core/test/Api/SessionSendTargets.vi.test.ts index 5d19214..b0325ad 100644 --- a/packages/fold-core/test/Api/SessionSendTargets.vi.test.ts +++ b/packages/fold-core/test/Api/SessionSendTargets.vi.test.ts @@ -14,10 +14,9 @@ import { defineAgent, defineSubagent, EventLog, - eventLogSource, layerInMemoryEventLog, shortAgentId, - startSession, + Session, subagentTool, ToolCallId, type UserMessageLogEntry, @@ -36,7 +35,7 @@ it.effect('send while running joins the run as a follow-up; both senders get the textTurn('follow-up answer'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model, tools: [gate.tool] }), }) @@ -72,7 +71,7 @@ it.effect('a follow-up the stopped run never consumed starts its own fresh run', textTurn('fresh run answer'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model, tools: [gate.tool] }), }) @@ -119,7 +118,7 @@ it.effect('send targeting a finished subagent continues it directly under a null textTurn('root done'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model, tools: [subagentTool([researcher])] }), }) @@ -156,7 +155,7 @@ it.effect('send targeting a finished subagent continues it directly under a null it.effect('send to an unknown agent id fails typed', () => Effect.gen(function* () { const rootScripted = yield* scriptedModel(gptActiveModel, []) - const session = yield* startSession({ agent: defineAgent({ model: rootScripted.model }) }) + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model }) }) const failure = yield* session .send('hello?', { agentId: AgentId.make('agent_aaaaaaaaaaaaaaaaaaaaaaaa') }) @@ -189,7 +188,7 @@ it.effect('send targeting a finished subagent by its SHORT id continues it like textTurn('root done'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model, tools: [subagentTool([researcher])] }), }) @@ -214,10 +213,9 @@ it.effect('send with an ambiguous short reference fails typed, naming the candid const sharedLog = Context.get(logContext, EventLog) const rootScripted = yield* scriptedModel(gptActiveModel, []) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model }), - log: eventLogSource(Effect.succeed(sharedLog)), - }) + }).pipe(Effect.provideService(EventLog, sharedLog)) yield* Effect.forEach( [AgentId.make(`agent_abcdef11${'0'.repeat(16)}`), AgentId.make(`agent_abcdef22${'0'.repeat(16)}`)], diff --git a/packages/fold-core/test/Api/SessionSteer.vi.test.ts b/packages/fold-core/test/Api/SessionSteer.vi.test.ts index 51c1341..440005e 100644 --- a/packages/fold-core/test/Api/SessionSteer.vi.test.ts +++ b/packages/fold-core/test/Api/SessionSteer.vi.test.ts @@ -9,7 +9,7 @@ import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { expect, it } from '@effect/vitest' import { Predicate, Effect, Fiber } from 'effect' -import { defineAgent, defineSubagent, startSession, subagentTool, type UserMessageLogEntry } from '../../src/index' +import { defineAgent, defineSubagent, Session, subagentTool, type UserMessageLogEntry } from '../../src/index' import { textTurn, toolCallTurn } from '../TestLayers/ScriptedLanguageModel' import { claudeActiveModel, gptActiveModel, scriptedModel } from './ApiTestHelpers' import { makeGateTool } from './SessionControlHarness' @@ -22,7 +22,7 @@ it.effect('steering a running root drains between turns, exactly where the model textTurn('done'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model, tools: [gate.tool] }), }) @@ -62,7 +62,7 @@ it.effect('one-at-a-time steering drains one message per turn boundary', () => textTurn('done'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model, tools: [firstGate.tool, secondGate.tool] }), }) @@ -90,7 +90,7 @@ it.effect("steering mode 'all' drains the whole queue at one boundary", () => textTurn('done'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model, tools: [gate.tool] }), steering: 'all', }) @@ -111,7 +111,7 @@ it.effect("steering mode 'all' drains the whole queue at one boundary", () => it.effect('steering an idle agent fails typed, pointing at send', () => Effect.gen(function* () { const rootScripted = yield* scriptedModel(gptActiveModel, []) - const session = yield* startSession({ agent: defineAgent({ model: rootScripted.model }) }) + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model }) }) const failure = yield* session.steer('too late').pipe(Effect.flip) expect(failure._tag).toBe('AgentNotRunningError') @@ -144,7 +144,7 @@ it.effect("steering a running subagent drains between the child's turns under th textTurn('root done'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model, tools: [subagentTool([researcher])] }), }) diff --git a/packages/fold-core/test/Api/SessionStop.vi.test.ts b/packages/fold-core/test/Api/SessionStop.vi.test.ts index 8c4e04f..3b22f72 100644 --- a/packages/fold-core/test/Api/SessionStop.vi.test.ts +++ b/packages/fold-core/test/Api/SessionStop.vi.test.ts @@ -9,7 +9,7 @@ import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { expect, it } from '@effect/vitest' import { Predicate, Effect, Fiber } from 'effect' -import { defineAgent, defineSubagent, startSession, subagentTool } from '../../src/index' +import { defineAgent, defineSubagent, Session, subagentTool } from '../../src/index' import { textTurn, toolCallTurn } from '../TestLayers/ScriptedLanguageModel' import { claudeActiveModel, gptActiveModel, scriptedModel } from './ApiTestHelpers' import { makeGateTool } from './SessionControlHarness' @@ -22,7 +22,7 @@ it.effect('stop lets the in-flight batch finish, then ends the run with no furth textTurn('never requested'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model, tools: [gate.tool] }), }) @@ -72,7 +72,7 @@ it.effect('stop reaches the whole tree: the running subagent stops, then its dis textTurn('never requested (root)'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model, tools: [subagentTool([researcher])] }), }) diff --git a/packages/fold-core/test/Api/SkillsSession.vi.test.ts b/packages/fold-core/test/Api/SkillsSession.vi.test.ts index 03851ea..b3f3609 100644 --- a/packages/fold-core/test/Api/SkillsSession.vi.test.ts +++ b/packages/fold-core/test/Api/SkillsSession.vi.test.ts @@ -13,7 +13,7 @@ import { skillSource, skillsFromData, skillTool, - startSession, + Session, type AgentStartedLogEntry, type SkillMeta, type SkillSourceService, @@ -40,7 +40,7 @@ it.effect('renders the skills block into the leading prompt and installs the ski textTurn('loaded'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model, systemPrompt: 'You are a demo agent.', @@ -95,7 +95,7 @@ it.effect('adding a skill mid-session never changes rendered prompt bytes; refre textTurn('second'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model, systemPrompt: 'base', @@ -139,7 +139,7 @@ it.effect('a model switch carries the session-start skills block and skill tool const first = yield* scriptedModel(gptActiveModel, [textTurn('from gpt')]) const second = yield* scriptedModel(claudeActiveModel, [textTurn('from claude')]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: first.model, systemPrompt: 'base', diff --git a/packages/fold-core/test/Api/StartSession.vi.test.ts b/packages/fold-core/test/Api/StartSession.vi.test.ts index f742742..3dd908f 100644 --- a/packages/fold-core/test/Api/StartSession.vi.test.ts +++ b/packages/fold-core/test/Api/StartSession.vi.test.ts @@ -1,6 +1,6 @@ import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** - * Facade tests: startSession lowers agent/log/model descriptors into the full runtime graph. Real + * Facade tests: Session.open lowers agent/model descriptors and adopts the ambient EventLog into the full runtime graph. Real * EventLog, projections, hook runner, tool runtime, and session facade run under scripted language * models - only descriptors appear in test setup, mirroring how SDK callers use the API. Model and * configuration switching is covered separately in SwitchModel.vi.test.ts, cross-session isolation in @@ -13,8 +13,7 @@ import { defineAgent, defineTool, defineToolState, - eventLogSource, - startSession, + Session, EventLog, layerInMemoryEventLog, ToolEvents, @@ -46,7 +45,7 @@ it.effect('runs a tool-calling turn end to end from descriptors only', () => textTurn('The tool echoed: hello facade'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ name: 'facade-demo', model, @@ -100,7 +99,7 @@ it.effect('runs a tool-free agent with defaults (memory log, no tools, no failur Effect.gen(function* () { const { model, scripted } = yield* scriptedModel(gptActiveModel, [textTurn('Just text.')]) - const session = yield* startSession({ agent: defineAgent({ model }) }) + const session = yield* Session.open({ agent: defineAgent({ model }) }) const finished = yield* session.send('hi') const requests = yield* scripted.requests @@ -115,7 +114,7 @@ it.effect('runs a tool-free agent with defaults (memory log, no tools, no failur it.effect('injects a skill as a linked synthetic tool call and result without a user message', () => Effect.gen(function* () { const { model } = yield* scriptedModel(gptActiveModel, []) - const session = yield* startSession({ agent: defineAgent({ model }) }) + const session = yield* Session.open({ agent: defineAgent({ model }) }) const injected = yield* session.injectSkill('terminal-control', 'terminal instructions') const entries = yield* session.entries @@ -173,7 +172,7 @@ it.effect('tool handlers reach ToolState and ToolEvents; session.events carries textTurn('Tool said hi'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model, systemPrompt: 'You are a test agent.', tools: [progressTool] }), }) @@ -249,7 +248,7 @@ it.effect('agent hooks run in the facade: a preToolUse deny replaces the result textTurn('Understood, the tool was denied.'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model, systemPrompt: 'You are a test agent.', @@ -285,7 +284,7 @@ it.effect('a typed handler failure returns to the model schema-encoded with isFa textTurn('The tool failed, moving on.'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model, systemPrompt: 'You are a test agent.', tools: [flakyTool] }), }) @@ -304,17 +303,16 @@ it.effect('a typed handler failure returns to the model schema-encoded with isFa // ── Log backends and descriptor validation ────────────────────────────────── -it.effect('eventLogSource backs the session with a caller-supplied EventLog service', () => +it.effect('Session.open uses the host-supplied EventLog instance', () => Effect.gen(function* () { const external = yield* Layer.build(layerInMemoryEventLog).pipe( Effect.map((context) => Context.get(context, EventLog)), ) const { model } = yield* scriptedModel(gptActiveModel, [textTurn('logged externally')]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model, systemPrompt: 'You are a test agent.' }), - log: eventLogSource(Effect.succeed(external)), - }) + }).pipe(Effect.provideService(EventLog, external)) yield* session.send('hi') @@ -334,7 +332,7 @@ it.effect('rejects duplicate tool names as a defect', () => Effect.gen(function* () { const { model } = yield* scriptedModel(gptActiveModel, []) - const exit = yield* startSession({ + const exit = yield* Session.open({ agent: defineAgent({ model, tools: [echoTool, echoTool] }), }).pipe(Effect.exit) @@ -350,7 +348,7 @@ it.effect('runs a tool-calling session without a FileSystem when no descriptor d textTurn('done'), ]) - const session = yield* startSession({ agent: defineAgent({ model, tools: [echoTool] }) }) + const session = yield* Session.open({ agent: defineAgent({ model, tools: [echoTool] }) }) const finished = yield* session.send('echo something') expect(finished.outcome).toBe('completed') @@ -372,7 +370,7 @@ it.effect('a tool handler reads the FileSystem the session caller provided', () Effect.flatMap(FileSystem.FileSystem, (fs) => fs.readFileString('/note.txt')).pipe(Effect.orDie), }) - const session = yield* startSession({ agent: defineAgent({ model, tools: [readNote] }) }) + const session = yield* Session.open({ agent: defineAgent({ model, tools: [readNote] }) }) yield* session.send('read the note') expect(firstToolResultPart(yield* session.entries).result).toBe('from the host') @@ -402,7 +400,7 @@ it.effect('tool handlers keep the FileSystem the session started with when a tur }) const noteFileSystem = (note: string) => FileSystem.makeNoop({ readFileString: () => Effect.succeed(note) }) - const session = yield* startSession({ agent: defineAgent({ model, tools: [readNote] }) }).pipe( + const session = yield* Session.open({ agent: defineAgent({ model, tools: [readNote] }) }).pipe( Effect.provideService(FileSystem.FileSystem, noteFileSystem('from the session')), ) // A host whose request handling carries its own FileSystem, as Alchemy's Worker runtime does. diff --git a/packages/fold-core/test/Api/SwitchModel.vi.test.ts b/packages/fold-core/test/Api/SwitchModel.vi.test.ts index ae6677b..24780cb 100644 --- a/packages/fold-core/test/Api/SwitchModel.vi.test.ts +++ b/packages/fold-core/test/Api/SwitchModel.vi.test.ts @@ -13,7 +13,7 @@ import { defineAgent, defineSubagent, defineTool, - startSession, + Session, subagentTool, type ActiveModel, type AgentStartedLogEntry, @@ -46,7 +46,7 @@ it.effect('switchModel continues the same log on a new provider and records the const first = yield* scriptedModel(gptActiveModel, [textTurn('from gpt')]) const second = yield* scriptedModel(claudeActiveModel, [textTurn('from claude')]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: first.model, systemPrompt: 'Agent block.', @@ -124,7 +124,7 @@ it.effect('switchModel can replace the agent prompt blocks, and the replacement const second = yield* scriptedModel(claudeActiveModel, [textTurn('two')]) const third = yield* scriptedModel({ ...gptActiveModel, modelId: 'gpt-scripted-2' }, [textTurn('three')]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: first.model, systemPrompt: 'Original block.', @@ -170,7 +170,7 @@ it.effect('switchModel can replace the installed tools; the new tool executes an textTurn('lookup done'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: first.model, systemPrompt: 'Agent block.', tools: [oldTool.tool] }), }) @@ -210,7 +210,7 @@ it.effect('switchModel records thinking-change when the reasoning level changes const first = yield* scriptedModel(gptActiveModel, [textTurn('level off')]) const second = yield* scriptedModel(highGptModel, [textTurn('level high')]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: first.model, systemPrompt: 'Agent block.' }), }) @@ -264,7 +264,7 @@ it.effect('switchModel preserves exact GPT-6 Sol and Luna ids in the durable tra const luna: ActiveModel = { ...sol, modelId: 'gpt-6-luna' } const first = yield* scriptedModel(sol, [textTurn('from sol')]) const second = yield* scriptedModel(luna, [textTurn('from luna')]) - const session = yield* startSession({ agent: defineAgent({ model: first.model }) }) + const session = yield* Session.open({ agent: defineAgent({ model: first.model }) }) yield* session.send('first turn') yield* session.switchModel(second.model, { reason: 'switch canonical model' }) @@ -294,7 +294,7 @@ it.effect('switchModel preserves the exact GPT-6.1 Sol id in the durable transit const sol61: ActiveModel = { ...astra, modelId: 'gpt-6.1-sol' } const first = yield* scriptedModel(astra, [textTurn('from astra')]) const second = yield* scriptedModel(sol61, [textTurn('from gpt-6.1-sol')]) - const session = yield* startSession({ agent: defineAgent({ model: first.model }) }) + const session = yield* Session.open({ agent: defineAgent({ model: first.model }) }) yield* session.send('first turn') yield* session.switchModel(second.model, { reason: 'switch to the new default' }) @@ -315,7 +315,7 @@ it.effect('switchModel rejects duplicate tool names in the replacement toolset a const first = yield* scriptedModel(gptActiveModel, [textTurn('one')]) const second = yield* scriptedModel(claudeActiveModel, []) - const session = yield* startSession({ agent: defineAgent({ model: first.model }) }) + const session = yield* Session.open({ agent: defineAgent({ model: first.model }) }) yield* session.send('turn one') const exit = yield* session.switchModel(second.model, { tools: [echoTool, echoTool] }).pipe(Effect.exit) @@ -336,7 +336,7 @@ it.effect('switchModel extends the subagent registry at the switch boundary', () model: specialistModel.model, }) - const session = yield* startSession({ agent: defineAgent({ model: first.model }) }) + const session = yield* Session.open({ agent: defineAgent({ model: first.model }) }) yield* session.send('turn one') yield* session.switchModel(second.model, { tools: [subagentTool([specialist])], reason: 'new roster' }) yield* session.send('turn two') diff --git a/packages/fold-core/test/Compaction/AutoCompactSession.vi.test.ts b/packages/fold-core/test/Compaction/AutoCompactSession.vi.test.ts index 2166c4d..d1b1c4c 100644 --- a/packages/fold-core/test/Compaction/AutoCompactSession.vi.test.ts +++ b/packages/fold-core/test/Compaction/AutoCompactSession.vi.test.ts @@ -2,7 +2,7 @@ import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** * Facade-level auto-compaction tests (D11): sessions configured with `autoCompact` compact at the * top-of-turn threshold and on reactive provider overflow, write durable `compaction` entries, and - * keep running - all driven through `startSession`/`resumeSession` with scripted models, exactly as + * keep running - all driven through `Session.open` with scripted models, exactly as * SDK callers configure it. The summarization call runs on the session's own scripted model, so each * script interleaves the summarizer's response at the position the loop calls it. * @@ -17,12 +17,10 @@ import { Predicate, Context, Effect, Layer } from 'effect' import { defineAgent, - eventLogSource, layerInMemoryEventLog, messagesForAgent, - resumeSession, + Session, runtimeForAgent, - startSession, EventLog, type AutoCompactConfig, type CompactionArchiveAccessService, @@ -76,7 +74,7 @@ it.effect('compacts mid-run at the threshold and keeps running; config from befo instructions: ({ agentId }) => Effect.succeed(`agent=${agentId}`), } - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model, systemPrompt: 'You are the compaction demo agent.', @@ -162,7 +160,7 @@ it.effect( textTurn('third answer'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model, systemPrompt: 'Assistant.', autoCompact: compactConfig }), }) @@ -228,7 +226,7 @@ it.effect('stale pre-compaction usage never re-triggers: no second compaction wi textTurn('recovered fine'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model, autoCompact: compactConfig }), }) @@ -260,7 +258,7 @@ it.effect('enabled: false disables automatic compaction while leaving explicit c textTurn('second'), textTurn('manual summary'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model, autoCompact: { enabled: false } }), }) @@ -286,7 +284,7 @@ it.effect('automatic compaction is enabled when the definition omits autoCompact textTurn('default policy summary'), textTurn('second'), ]) - const session = yield* startSession({ agent: defineAgent({ model }) }) + const session = yield* Session.open({ agent: defineAgent({ model }) }) yield* session.send('one') const finished = yield* session.send('two') @@ -303,7 +301,7 @@ it.effect('manual facade compaction delegates through the provisioned root runti textTurn(`first answer ${'x'.repeat(120)}`), textTurn('## Goal\n- manual compaction summary'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model, autoCompact: compactConfig }), }) @@ -332,7 +330,7 @@ it.effect('manual compaction appends host guidance to the standard instruction w textTurn('first answer'), textTurn('guided summary'), ]) - const session = yield* startSession({ agent: defineAgent({ model }) }) + const session = yield* Session.open({ agent: defineAgent({ model }) }) yield* session.send('preserve the migration investigation') const compacted = yield* session.compact({ additionalInstructions: 'Keep the failed migration command.' }) @@ -352,7 +350,7 @@ it.effect('Codex compaction omits the unsupported max output token override', () textTurn('first answer'), textTurn('Codex summary'), ]) - const session = yield* startSession({ agent: defineAgent({ model }) }) + const session = yield* Session.open({ agent: defineAgent({ model }) }) yield* session.send('preserve the investigation') yield* session.compact() @@ -369,7 +367,7 @@ it.effect('split-turn compaction separately summarizes a coherent discarded pref textTurn(`kept suffix ${'z'.repeat(160)}`), textTurn('original request and early tool progress'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model, tools: [echoTool], autoCompact: compactConfig }), }) @@ -404,7 +402,7 @@ it.effect('a configured compactionPrompt replaces the default instruction templa textTurn('continuing'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model, autoCompact: { ...compactConfig, compactionPrompt: 'Reply with a CUSTOM CHECKPOINT of the work.' }, @@ -437,7 +435,7 @@ it.effect('a summarizer failure degrades to a durable error note; the run procee textTurn('answered anyway'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model, autoCompact: compactConfig }), }) @@ -470,7 +468,7 @@ it.effect('reactive overflow: compact and retry the turn once, then the run comp textTurn('recovered answer'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model, autoCompact: compactConfig }), }) @@ -503,7 +501,7 @@ it.effect('overflow recovery runs once per run: a second overflow becomes the du failureTurn('context_length_exceeded again'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model, autoCompact: compactConfig }), }) @@ -547,7 +545,7 @@ it.effect('a session-provided catalog supplies the compaction context window (no textTurn('post-catalog-compaction answer'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model, // No autoCompact.contextWindow: the window must come from the session catalog (10k -> @@ -585,7 +583,7 @@ it.effect('a Codex model uses the app context window instead of the public API c textTurn('post-codex-compaction answer'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model, autoCompact: { enabled: true, keepRecentTokens: 10 } }), catalog: [ { @@ -615,7 +613,7 @@ it.effect('GPT-6.1 Sol, GPT-6 Sol, and Luna compact against the 272k Codex windo textTurn(`${modelId} answer`), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model, autoCompact: { enabled: true, keepRecentTokens: 10 } }), catalog: [ { @@ -644,7 +642,7 @@ it.effect('an explicit autoCompact.contextWindow beats the catalog entry', () => textTurn('override answer'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model, // The explicit 10k window compacts on 7005 reported tokens; the catalog's 1M window @@ -677,10 +675,9 @@ it.effect('a resumed log projects the compacted history: summary plus post-cut m textTurn('## Goal\n- durable summary'), textTurn('answer two'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model, systemPrompt: 'Assistant.', autoCompact: compactConfig }), - log: eventLogSource(Effect.succeed(sharedLog)), - }) + }).pipe(Effect.provideService(EventLog, sharedLog)) yield* session.send('the secret phrase is xyzzy') const finished = yield* session.send('next') expect(finished.outcome).toBe('completed') @@ -690,10 +687,9 @@ it.effect('a resumed log projects the compacted history: summary plus post-cut m // Session B adopts the same log with the same configuration: replay rebuilds the compacted // projection - the durable summary and the kept tail, not the replaced history. const { model, scripted } = yield* scriptedModel(gptActiveModel, [textTurn('resumed answer')]) - const session = yield* resumeSession({ + const session = yield* Session.open({ agent: defineAgent({ model, systemPrompt: 'Assistant.', autoCompact: compactConfig }), - log: eventLogSource(Effect.succeed(sharedLog)), - }) + }).pipe(Effect.provideService(EventLog, sharedLog)) const finished = yield* session.send('continue') const entries = yield* session.entries diff --git a/packages/fold-core/test/Compaction/SubagentAutoCompact.vi.test.ts b/packages/fold-core/test/Compaction/SubagentAutoCompact.vi.test.ts index 77437ab..d8eca2d 100644 --- a/packages/fold-core/test/Compaction/SubagentAutoCompact.vi.test.ts +++ b/packages/fold-core/test/Compaction/SubagentAutoCompact.vi.test.ts @@ -13,7 +13,7 @@ import { defineAgent, defineSubagent, messagesForAgent, - startSession, + Session, subagentTool, type AgentStartedLogEntry, type AutoCompactConfig, @@ -66,7 +66,7 @@ it.effect('a dispatched subagent compacts its own context; the parent projection textTurn('root synthesis'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model, systemPrompt: 'You are the root agent.', @@ -141,7 +141,7 @@ it.effect('a fork compacts history including the parent folded range without tou textTurn('root closing words'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model, systemPrompt: 'You are the root agent.', diff --git a/packages/fold-core/test/Subagents/DriveHarness.ts b/packages/fold-core/test/Subagents/DriveHarness.ts index 99bacd2..99f2826 100644 --- a/packages/fold-core/test/Subagents/DriveHarness.ts +++ b/packages/fold-core/test/Subagents/DriveHarness.ts @@ -18,7 +18,7 @@ import { defineAgent, defineTool, renderSubagentResult, - startSession, + Session, subagentTool, type ActiveModel, type AgentId, @@ -113,7 +113,7 @@ export const makeDriveSession = (input: { systemPrompt: 'root', tools: [makeDriveTool(instructions, roster), subagentTool(input.definitions)], }) - const session = yield* startSession( + const session = yield* Session.open( input.profiles === undefined ? { agent } : { agent, profiles: input.profiles }, ) diff --git a/packages/fold-core/test/Subagents/SubagentDispatch.vi.test.ts b/packages/fold-core/test/Subagents/SubagentDispatch.vi.test.ts index e1af346..a19913e 100644 --- a/packages/fold-core/test/Subagents/SubagentDispatch.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentDispatch.vi.test.ts @@ -12,7 +12,7 @@ import { defineAgent, defineSubagent, shortAgentId, - startSession, + Session, subagentTool, type AgentStartedLogEntry, type ToolResultLogEntry, @@ -47,7 +47,7 @@ it.effect('dispatches a fresh subagent on the shared log and renders its result' textTurn('synthesized'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model, systemPrompt: 'You are the root agent.', diff --git a/packages/fold-core/test/Subagents/SubagentHooks.vi.test.ts b/packages/fold-core/test/Subagents/SubagentHooks.vi.test.ts index d2d59fe..9fdba36 100644 --- a/packages/fold-core/test/Subagents/SubagentHooks.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentHooks.vi.test.ts @@ -12,7 +12,7 @@ import { defineAgent, defineSubagent, defineToolState, - startSession, + Session, subagentTool, toolStateForAgent, type PreToolUseHook, @@ -63,7 +63,7 @@ it.effect('root and subagent run their own hook chains, and hook state stays per textTurn('root done'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model, tools: [subagentTool([researcher])], diff --git a/packages/fold-core/test/Subagents/SubagentLogReplay.vi.test.ts b/packages/fold-core/test/Subagents/SubagentLogReplay.vi.test.ts index 02a0f49..8ff8f4f 100644 --- a/packages/fold-core/test/Subagents/SubagentLogReplay.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentLogReplay.vi.test.ts @@ -14,10 +14,9 @@ import { defineForkAgent, defineSubagent, EventLog, - eventLogSource, layerInMemoryEventLog, shortAgentId, - startSession, + Session, subagentTool, type AgentId, type UserMessageLogEntry, @@ -26,12 +25,11 @@ import { claudeActiveModel, gptActiveModel, scriptedModel } from '../Api/ApiTest import { textTurn, toolCallTurn } from '../TestLayers/ScriptedLanguageModel' import { renderedDriveResult, subagentStartedEntries } from './DriveHarness' -it.effect("a new session over the same log resumes a prior session's subagent purely by replay", () => +it.effect('reopening the same log resumes its subagent purely by replay', () => Effect.gen(function* () { // One log service outliving both sessions - the in-memory stand-in for a JSONL file on disk. const logContext = yield* Layer.build(layerInMemoryEventLog) const sharedLog = Context.get(logContext, EventLog) - const sharedLogSource = eventLogSource(Effect.succeed(sharedLog)) // --- session A: dispatch the researcher, then close the session entirely --------------------- const dispatched = yield* Effect.scoped( @@ -55,14 +53,13 @@ it.effect("a new session over the same log resumes a prior session's subagent pu textTurn('root A done'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model, systemPrompt: 'root', tools: [subagentTool([researcher])], }), - log: sharedLogSource, - }) + }).pipe(Effect.provideService(EventLog, sharedLog)) const finished = yield* session.send('go') expect(finished.outcome).toBe('completed') @@ -95,22 +92,21 @@ it.effect("a new session over the same log resumes a prior session's subagent pu textTurn('root B done'), ]) - const sessionB = yield* startSession({ + const sessionB = yield* Session.open({ agent: defineAgent({ model: rootBScripted.model, systemPrompt: 'root', tools: [subagentTool([researcherB])], }), - log: sharedLogSource, - }) + }).pipe(Effect.provideService(EventLog, sharedLog)) const finishedB = yield* sessionB.send('continue where we left off') expect(finishedB.outcome).toBe('completed') const entries = yield* sessionB.entries - // Two session_started rows (an honest restart marker), but still exactly ONE subagent start. - expect(entries.filter((entry) => Predicate.isTagged(entry, 'session_started'))).toHaveLength(2) + // Reopening preserves one session/root identity and exactly one subagent start. + expect(entries.filter((entry) => Predicate.isTagged(entry, 'session_started'))).toHaveLength(1) expect(subagentStartedEntries(entries)).toHaveLength(1) // The resumed model call reconstructed A's context purely from the log rows. @@ -125,7 +121,8 @@ it.effect("a new session over the same log resumes a prior session's subagent pu const rootStartedRows = entries.filter( (entry) => Predicate.isTagged(entry, 'agent_started') && entry.parentAgentId === null, ) - const rootB = rootStartedRows[1] + expect(rootStartedRows).toHaveLength(1) + const rootB = rootStartedRows[0] if (!Predicate.isTagged(rootB, 'agent_started')) throw new Error("expected session B's root agent_started") const researcherUserMessages = entries.filter( (entry): entry is UserMessageLogEntry => @@ -135,7 +132,7 @@ it.effect("a new session over the same log resumes a prior session's subagent pu expect(researcherUserMessages[1]?.parentAgentId).toBe(rootB.agentId) expect(researcherUserMessages[1]?.toolCallId).not.toBe(researcherUserMessages[0]?.toolCallId) - // Turn totals fold across both sessions' rows: 1 this run, 2 lifetime. + // Turn totals fold across both activations' rows: 1 this run, 2 lifetime. const rendered = renderedDriveResult(entries, 1) expect(rendered).toContain(`agent_id: ${shortAgentId(dispatched.agentId)}`) expect(rendered).toContain('turns: 1 this run (2 total)') @@ -147,7 +144,6 @@ it.effect('replay restores a configured fork toolset before resuming the child', Effect.gen(function* () { const logContext = yield* Layer.build(layerInMemoryEventLog) const sharedLog = Context.get(logContext, EventLog) - const sharedLogSource = eventLogSource(Effect.succeed(sharedLog)) const dispatched = yield* Effect.scoped( Effect.gen(function* () { @@ -163,13 +159,12 @@ it.effect('replay restores a configured fork toolset before resuming the child', textTurn('child paused'), textTurn('root A done'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: scripted.model, tools: [subagentTool([], { forkAgent: delegatingFork })], }), - log: sharedLogSource, - }) + }).pipe(Effect.provideService(EventLog, sharedLog)) yield* session.send('go') const started = subagentStartedEntries(yield* session.entries)[0] if (started === undefined) throw new Error('expected a configured fork') @@ -191,10 +186,9 @@ it.effect('replay restores a configured fork toolset before resuming the child', textTurn('child resumed'), textTurn('root B done'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: scripted.model, tools: [subagentTool([], { forkAgent: delegatingFork })] }), - log: sharedLogSource, - }) + }).pipe(Effect.provideService(EventLog, sharedLog)) yield* session.send('resume') const started = subagentStartedEntries(yield* session.entries) diff --git a/packages/fold-core/test/Subagents/SubagentProfiles.vi.test.ts b/packages/fold-core/test/Subagents/SubagentProfiles.vi.test.ts index 8678681..0e72ce8 100644 --- a/packages/fold-core/test/Subagents/SubagentProfiles.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentProfiles.vi.test.ts @@ -10,7 +10,7 @@ import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { expect, it } from '@effect/vitest' import { Predicate, Cause, Effect, Exit } from 'effect' -import { defineAgent, defineSubagent, startSession, subagentTool, type ModelChangeLogEntry } from '../../src/index' +import { defineAgent, defineSubagent, Session, subagentTool, type ModelChangeLogEntry } from '../../src/index' import { claudeActiveModel, gptActiveModel, scriptedModel } from '../Api/ApiTestHelpers' import { textTurn } from '../TestLayers/ScriptedLanguageModel' import { makeDriveSession, subagentStartedEntries } from './DriveHarness' @@ -103,7 +103,7 @@ it.effect('a role-bound roster with no covering profile binding defects at sessi const scripted = yield* scriptedModel(claudeActiveModel, []) const researcher = defineSubagent({ name: 'researcher', description: 'explores', model: 'fast' }) - const exit = yield* startSession({ + const exit = yield* Session.open({ agent: defineAgent({ model: scripted.model, tools: [subagentTool([researcher])] }), }).pipe(Effect.exit) @@ -119,7 +119,7 @@ it.effect('an orchestrator binding is only covered by orchestrator or smart prof const scripted = yield* scriptedModel(claudeActiveModel, []) const planner = defineSubagent({ name: 'planner', description: 'plans', model: 'orchestrator' }) - const exit = yield* startSession({ + const exit = yield* Session.open({ agent: defineAgent({ model: scripted.model, tools: [subagentTool([planner])] }), profiles: { fast: scripted.model }, }).pipe(Effect.exit) diff --git a/packages/fold-core/test/Subagents/SubagentResume.vi.test.ts b/packages/fold-core/test/Subagents/SubagentResume.vi.test.ts index 5d97443..98a3489 100644 --- a/packages/fold-core/test/Subagents/SubagentResume.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentResume.vi.test.ts @@ -18,7 +18,7 @@ import { defineTool, renderSubagentResult, shortAgentId, - startSession, + Session, subagentTool, type AgentId, type AgentStartedLogEntry, @@ -86,7 +86,7 @@ const makeDriveSession = (input: { ]).flat(), ) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model, systemPrompt: 'root', diff --git a/packages/fold-core/test/Subagents/SubagentRoster.vi.test.ts b/packages/fold-core/test/Subagents/SubagentRoster.vi.test.ts index 8b7faa5..ff13528 100644 --- a/packages/fold-core/test/Subagents/SubagentRoster.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentRoster.vi.test.ts @@ -16,7 +16,7 @@ import { defineSubagent, defineTool, renderSubagentResult, - startSession, + Session, subagentTool, withSubagentCapabilities, type ForkAgentDefinition, @@ -88,7 +88,7 @@ it.effect('nested rosters give depth; out-of-roster dispatch fails instructively textTurn('root done'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model, tools: [subagentTool([generalPurpose, researcher])], @@ -127,7 +127,7 @@ it.effect('duplicate type names across distinct definitions defect at session st const first = defineSubagent({ name: 'twin', description: 'one', model: scripted.model }) const second = defineSubagent({ name: 'twin', description: 'two', model: scripted.model }) - const exit = yield* startSession({ + const exit = yield* Session.open({ agent: defineAgent({ model: scripted.model, tools: [subagentTool([first, second])] }), }).pipe(Effect.exit) @@ -153,7 +153,7 @@ it.effect('the same definition shared by two rosters is one registry entry', () ]) // `shared` is reachable through the root roster AND through middle's roster: one entry, no defect. - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model, tools: [subagentTool([middle, shared])], @@ -201,7 +201,7 @@ it.effect('host agent tools configure two fork generations structurally', () => textTurn('root done'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: scripted.model, tools: [hostAgentTool(delegatingFork)] }), }) const finished = yield* session.send('go') @@ -223,7 +223,7 @@ it.effect('duplicate fork agent definition ids defect at session start', () => const first = defineForkAgent({ id: 'duplicate', tools: [] }) const second = defineForkAgent({ id: 'duplicate', tools: [] }) - const exit = yield* startSession({ + const exit = yield* Session.open({ agent: defineAgent({ model: scripted.model, tools: [subagentTool([], { forkAgent: first }), subagentTool([], { forkAgent: second })], diff --git a/packages/fold-core/test/Subagents/SubagentSkills.vi.test.ts b/packages/fold-core/test/Subagents/SubagentSkills.vi.test.ts index 91b4e54..7a9a465 100644 --- a/packages/fold-core/test/Subagents/SubagentSkills.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentSkills.vi.test.ts @@ -14,7 +14,7 @@ import { defineSubagent, skillSource, skillTool, - startSession, + Session, subagentTool, type SkillSourceService, type UserMessageLogEntry, @@ -61,7 +61,7 @@ it.effect('a shared skillTool value scans once; the preload rides the dispatcher textTurn('root done'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model, tools: [sharedSkillTool, subagentTool([researcher])], @@ -114,7 +114,7 @@ it.effect('a dispatcher with no skillTool cannot preload: typed failure before a textTurn('root recovered'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model, tools: [subagentTool([worker])] }), }) diff --git a/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts b/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts index 5e5a270..0b69a6d 100644 --- a/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts @@ -15,10 +15,9 @@ import { defineAgent, defineSubagent, EventLog, - eventLogSource, layerInMemoryEventLog, shortAgentId, - startSession, + Session, subagentTool, ToolCallId, ToolResultText, @@ -46,7 +45,7 @@ it.effect('presents the rendered subagent result to the parent model as plain te ]), textTurn('parent synthesis'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model, tools: [subagentTool([researcher])] }), }) @@ -102,7 +101,7 @@ it.effect('the model resumes a subagent through the tool wire by its SHORT id: f textTurn('synthesized'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model, systemPrompt: 'root', @@ -190,7 +189,7 @@ it.effect('the public fork wire persists completed-history selection', () => textTurn('root complete'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model, systemPrompt: 'root', tools: [subagentTool([])] }), }) @@ -257,7 +256,7 @@ it.effect('malformed wire commands come back as instructive tool failures the mo textTurn('gave up gracefully'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model, tools: [subagentTool([researcher])] }), }) @@ -301,10 +300,9 @@ it.effect('an ambiguous short agent_id comes back as an instructive failure nami textTurn('understood, asking for more characters'), ]) - const session = yield* startSession({ + const session = yield* Session.open({ agent: defineAgent({ model: rootScripted.model, tools: [subagentTool([researcher])] }), - log: eventLogSource(Effect.succeed(sharedLog)), - }) + }).pipe(Effect.provideService(EventLog, sharedLog)) const rootStarted = (yield* session.entries).find( (entry) => Predicate.isTagged(entry, 'agent_started') && entry.parentAgentId === null,