import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as EffectAcpClient from "effect-acp/client"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import type * as EffectAcpProtocol from "effect-acp/protocol"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { collectSessionConfigOptionValues, extractModelConfigId, findSessionConfigOption, mergeToolCallState, parseSessionModeState, parseSessionUpdateEvent, sessionUpdateIsReplay, waitForSessionLoadReplayIdle, type SessionLoadGate, type AcpParsedSessionEvent, type AcpSessionModeState, type AcpToolCallState, } from "./AcpRuntimeModel.ts"; function formatConfigOptionValue(value: string | boolean): string { return JSON.stringify(value); } export interface AcpSessionEventStreamBarrier { readonly _tag: "EventStreamBarrier"; readonly acknowledge: Deferred.Deferred; } export type AcpSessionRuntimeEvent = AcpParsedSessionEvent | AcpSessionEventStreamBarrier; const defaultSessionLoadTimeout = Duration.seconds(90); const defaultSessionLoadReplayIdleGap = Duration.seconds(2); export interface AcpSpawnInput { readonly command: string; readonly args: ReadonlyArray; readonly cwd?: string; readonly env?: NodeJS.ProcessEnv; } export interface AcpSessionRuntimeOptions { readonly spawn: AcpSpawnInput; readonly cwd: string; readonly resumeSessionId?: string; readonly sessionLoadTimeout?: Duration.Input; readonly sessionLoadReplayIdleGap?: Duration.Input; readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; readonly clientInfo: { readonly name: string; readonly version: string; }; readonly authMethodId: string; readonly mcpServers?: ReadonlyArray; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; readonly protocolLogging?: { readonly logIncoming?: boolean; readonly logOutgoing?: boolean; readonly logger?: (event: EffectAcpProtocol.AcpProtocolLogEvent) => Effect.Effect; }; } export interface AcpSessionRequestLogEvent { readonly method: string; readonly payload: unknown; readonly status: "started" | "succeeded" | "failed"; readonly result?: unknown; readonly cause?: Cause.Cause; } export interface AcpSessionRuntimeStartResult { readonly sessionId: string; readonly initializeResult: EffectAcpSchema.InitializeResponse; readonly sessionSetupResult: | EffectAcpSchema.LoadSessionResponse | EffectAcpSchema.NewSessionResponse | EffectAcpSchema.ResumeSessionResponse; readonly modelConfigId: string | undefined; } export class AcpSessionRuntime extends Context.Service< AcpSessionRuntime, { /** * Registers a handler for `session/request_permission`. * @see https://agentclientprotocol.com/protocol/schema#session/request_permission */ readonly handleRequestPermission: EffectAcpClient.AcpClient["Service"]["handleRequestPermission"]; /** * Registers a handler for `session/elicitation`. * @see https://agentclientprotocol.com/protocol/schema#session/elicitation */ readonly handleElicitation: EffectAcpClient.AcpClient["Service"]["handleElicitation"]; /** * Registers a handler for `fs/read_text_file`. * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file */ readonly handleReadTextFile: EffectAcpClient.AcpClient["Service"]["handleReadTextFile"]; /** * Registers a handler for `fs/write_text_file`. * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file */ readonly handleWriteTextFile: EffectAcpClient.AcpClient["Service"]["handleWriteTextFile"]; /** * Registers a handler for `terminal/create`. * @see https://agentclientprotocol.com/protocol/schema#terminal/create */ readonly handleCreateTerminal: EffectAcpClient.AcpClient["Service"]["handleCreateTerminal"]; /** * Registers a handler for `terminal/output`. * @see https://agentclientprotocol.com/protocol/schema#terminal/output */ readonly handleTerminalOutput: EffectAcpClient.AcpClient["Service"]["handleTerminalOutput"]; /** * Registers a handler for `terminal/wait_for_exit`. * @see https://agentclientprotocol.com/protocol/schema#terminal/wait_for_exit */ readonly handleTerminalWaitForExit: EffectAcpClient.AcpClient["Service"]["handleTerminalWaitForExit"]; /** * Registers a handler for `terminal/kill`. * @see https://agentclientprotocol.com/protocol/schema#terminal/kill */ readonly handleTerminalKill: EffectAcpClient.AcpClient["Service"]["handleTerminalKill"]; /** * Registers a handler for `terminal/release`. * @see https://agentclientprotocol.com/protocol/schema#terminal/release */ readonly handleTerminalRelease: EffectAcpClient.AcpClient["Service"]["handleTerminalRelease"]; /** * Registers a handler for `session/update`. * @see https://agentclientprotocol.com/protocol/schema#session/update */ readonly handleSessionUpdate: EffectAcpClient.AcpClient["Service"]["handleSessionUpdate"]; /** * Registers a handler for `session/elicitation/complete`. * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete */ readonly handleElicitationComplete: EffectAcpClient.AcpClient["Service"]["handleElicitationComplete"]; /** * Registers a fallback extension request handler. * @see https://agentclientprotocol.com/protocol/extensibility */ readonly handleUnknownExtRequest: EffectAcpClient.AcpClient["Service"]["handleUnknownExtRequest"]; /** * Registers a fallback extension notification handler. * @see https://agentclientprotocol.com/protocol/extensibility */ readonly handleUnknownExtNotification: EffectAcpClient.AcpClient["Service"]["handleUnknownExtNotification"]; /** * Registers a typed extension request handler. * @see https://agentclientprotocol.com/protocol/extensibility */ readonly handleExtRequest: EffectAcpClient.AcpClient["Service"]["handleExtRequest"]; /** * Registers a typed extension notification handler. * @see https://agentclientprotocol.com/protocol/extensibility */ readonly handleExtNotification: EffectAcpClient.AcpClient["Service"]["handleExtNotification"]; /** * Initializes the ACP connection, authenticates, and loads, resumes, or creates the session. * Concurrent calls share the same in-flight startup and a failed startup may be retried. */ readonly start: () => Effect.Effect; /** Stream of parsed ACP session events emitted after startup. */ readonly getEvents: () => Stream.Stream; /** Waits until the current event consumer has processed every queued event. */ readonly drainEvents: Effect.Effect; /** Latest mode state observed from session setup and `session/update` notifications. */ readonly getModeState: Effect.Effect; /** Latest configuration options observed from session setup and configuration writes. */ readonly getConfigOptions: Effect.Effect>; /** * Sends a prompt turn to the active session. * @see https://agentclientprotocol.com/protocol/schema#session/prompt */ readonly prompt: ( payload: Omit, ) => Effect.Effect; /** * Sends a real ACP `session/cancel` notification for the active session. * @see https://agentclientprotocol.com/protocol/schema#session/cancel */ readonly cancel: Effect.Effect; /** * Selects the active mode through the negotiated `mode` configuration option. * This is a no-op when the requested mode is already active. * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option */ readonly setMode: ( modeId: string, ) => Effect.Effect; /** * Updates a session configuration option and the runtime configuration snapshot. * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option */ readonly setConfigOption: ( configId: string, value: string | boolean, ) => Effect.Effect; /** * Selects the base model through the negotiated model configuration option. * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option */ readonly setModel: (model: string) => Effect.Effect; /** * Selects the active model through the unstable ACP `session/set_model` capability. * @see https://agentclientprotocol.com/protocol/schema#session/set_model */ readonly setSessionModel: ( modelId: string, ) => Effect.Effect; /** * Sends a generic ACP extension request and records it through the request logger. * @see https://agentclientprotocol.com/protocol/extensibility */ readonly request: ( method: string, payload: unknown, ) => Effect.Effect; /** * Sends a generic ACP extension notification. * @see https://agentclientprotocol.com/protocol/extensibility */ readonly notify: ( method: string, payload: unknown, ) => Effect.Effect; } >()("t3/provider/acp/AcpSessionRuntime") {} interface AcpStartedState extends AcpSessionRuntimeStartResult {} type AcpStartState = | { readonly _tag: "NotStarted" } | { readonly _tag: "Starting"; readonly deferred: Deferred.Deferred; } | { readonly _tag: "Started"; readonly result: AcpStartedState }; interface AcpAssistantSegmentState { readonly nextSegmentIndex: number; readonly activeItemId?: string; } interface EnsureActiveAssistantSegmentResult { readonly itemId: string; readonly startedEvent?: Extract; } export const make = ( options: AcpSessionRuntimeOptions, ): Effect.Effect< AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, ChildProcessSpawner.ChildProcessSpawner | Scope.Scope > => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const runtimeScope = yield* Scope.Scope; const eventQueue = yield* Queue.unbounded(); const modeStateRef = yield* Ref.make(undefined); const toolCallsRef = yield* Ref.make(new Map()); const assistantSegmentRef = yield* Ref.make({ nextSegmentIndex: 0 }); const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); const startStateRef = yield* Ref.make({ _tag: "NotStarted" }); const promptSerializationSemaphore = yield* Semaphore.make(1); const activePromptFiberRef = yield* Ref.make< Option.Option> >(Option.none()); const sessionLoadGateRef = yield* Ref.make>(Option.none()); const logRequest = (event: AcpSessionRequestLogEvent) => options.requestLogger ? options.requestLogger(event) : Effect.void; const runLoggedRequest = ( method: string, payload: unknown, effect: Effect.Effect, ): Effect.Effect => logRequest({ method, payload, status: "started" }).pipe( Effect.flatMap(() => effect.pipe( Effect.tap((result) => logRequest({ method, payload, status: "succeeded", result, }), ), Effect.onError((cause) => logRequest({ method, payload, status: "failed", cause, }), ), ), ), ); const spawnCommand = yield* resolveSpawnCommand( options.spawn.command, options.spawn.args, options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}, ); const child = yield* spawner .spawn( ChildProcess.make(spawnCommand.command, spawnCommand.args, { ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), ...(options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}), shell: spawnCommand.shell, }), ) .pipe( Effect.provideService(Scope.Scope, runtimeScope), Effect.mapError( (cause) => new EffectAcpErrors.AcpSpawnError({ command: options.spawn.command, cause, }), ), ); const acpContext = yield* Layer.build( EffectAcpClient.layerChildProcess(child, { ...(options.protocolLogging?.logIncoming !== undefined ? { logIncoming: options.protocolLogging.logIncoming } : {}), ...(options.protocolLogging?.logOutgoing !== undefined ? { logOutgoing: options.protocolLogging.logOutgoing } : {}), ...(options.protocolLogging?.logger ? { logger: options.protocolLogging.logger } : {}), }), ).pipe(Effect.provideService(Scope.Scope, runtimeScope)); const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext)); yield* acp.handleSessionUpdate((notification) => Effect.gen(function* () { const gate = yield* Ref.get(sessionLoadGateRef); if (Option.isSome(gate) && gate.value.active) { const lastActivityAtMillis = yield* Clock.currentTimeMillis; yield* Ref.set( sessionLoadGateRef, Option.some({ ...gate.value, lastActivityAtMillis, }), ); return; } if (sessionUpdateIsReplay(notification)) { return; } const startState = yield* Ref.get(startStateRef); // One runtime projects one root ACP session. Child-session updates need // explicit lineage routing and must never be flattened into this stream. if ( startState._tag !== "Started" || notification.sessionId !== startState.result.sessionId ) { return; } yield* handleSessionUpdate({ queue: eventQueue, modeStateRef, toolCallsRef, assistantSegmentRef, params: notification, }); }), ); const initializeClientCapabilities = { fs: { readTextFile: false, writeTextFile: false, ...options.clientCapabilities?.fs, }, terminal: options.clientCapabilities?.terminal ?? false, ...(options.clientCapabilities?.auth ? { auth: options.clientCapabilities.auth } : {}), ...(options.clientCapabilities?.elicitation ? { elicitation: options.clientCapabilities.elicitation } : {}), ...(options.clientCapabilities?._meta ? { _meta: options.clientCapabilities._meta } : {}), } satisfies NonNullable; const getStartedState = Effect.gen(function* () { const state = yield* Ref.get(startStateRef); if (state._tag === "Started") { return state.result; } return yield* new EffectAcpErrors.AcpTransportError({ detail: "ACP session runtime has not been started", cause: "ACP session runtime has not been started", }); }); const validateConfigOptionValue = ( configId: string, value: string | boolean, ): Effect.Effect => Effect.gen(function* () { const configOption = findSessionConfigOption(yield* Ref.get(configOptionsRef), configId); if (!configOption) { return; } if (configOption.type === "boolean") { if (typeof value === "boolean") { return; } return yield* new EffectAcpErrors.AcpRequestError({ code: -32602, errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected boolean`, data: { configId: configOption.id, expectedType: "boolean", receivedValue: value, }, }); } if (typeof value !== "string") { return yield* new EffectAcpErrors.AcpRequestError({ code: -32602, errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected string`, data: { configId: configOption.id, expectedType: "string", receivedValue: value, }, }); } const allowedValues = collectSessionConfigOptionValues(configOption); if (allowedValues.includes(value)) { return; } return yield* new EffectAcpErrors.AcpRequestError({ code: -32602, errorMessage: `Invalid value ${formatConfigOptionValue(value)} for session config option "${configOption.id}": expected one of ${allowedValues.join(", ")}`, data: { configId: configOption.id, allowedValues, receivedValue: value, }, }); }); const updateConfigOptions = ( response: | EffectAcpSchema.SetSessionConfigOptionResponse | EffectAcpSchema.LoadSessionResponse | EffectAcpSchema.NewSessionResponse | EffectAcpSchema.ResumeSessionResponse, ): Effect.Effect => Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(response)); const updateCurrentModeId = (modeId: string): Effect.Effect => Ref.update(modeStateRef, (current) => current ? { ...current, currentModeId: modeId } : current, ); const setConfigOption = ( configId: string, value: string | boolean, ): Effect.Effect => validateConfigOptionValue(configId, value).pipe( Effect.flatMap(() => getStartedState), Effect.flatMap((started) => Ref.get(configOptionsRef).pipe( Effect.flatMap((configOptions) => { const existing = findSessionConfigOption(configOptions, configId); if (existing && configOptionCurrentValueMatches(existing, value)) { return Effect.succeed({ configOptions, } satisfies EffectAcpSchema.SetSessionConfigOptionResponse); } const requestPayload = typeof value === "boolean" ? ({ sessionId: started.sessionId, configId, type: "boolean", value, } satisfies EffectAcpSchema.SetSessionConfigOptionRequest) : ({ sessionId: started.sessionId, configId, value: String(value), } satisfies EffectAcpSchema.SetSessionConfigOptionRequest); return runLoggedRequest( "session/set_config_option", requestPayload, acp.agent.setSessionConfigOption(requestPayload), ).pipe(Effect.tap((response) => updateConfigOptions(response))); }), ), ), ); const startOnce = Effect.gen(function* () { const initializePayload = { protocolVersion: 1, clientCapabilities: initializeClientCapabilities, clientInfo: options.clientInfo, } satisfies EffectAcpSchema.InitializeRequest; const initializeResult = yield* runLoggedRequest( "initialize", initializePayload, acp.agent.initialize(initializePayload), ); const authenticatePayload = { methodId: options.authMethodId, } satisfies EffectAcpSchema.AuthenticateRequest; yield* runLoggedRequest( "authenticate", authenticatePayload, acp.agent.authenticate(authenticatePayload), ); let sessionId: string; let sessionSetupResult: | EffectAcpSchema.LoadSessionResponse | EffectAcpSchema.NewSessionResponse | EffectAcpSchema.ResumeSessionResponse; if (options.resumeSessionId) { const loadPayload = { sessionId: options.resumeSessionId, cwd: options.cwd, mcpServers: options.mcpServers ?? [], } satisfies EffectAcpSchema.LoadSessionRequest; const sessionLoadTimeout = Duration.fromInputUnsafe( options.sessionLoadTimeout ?? defaultSessionLoadTimeout, ); const sessionLoadReplayIdleGap = Duration.fromInputUnsafe( options.sessionLoadReplayIdleGap ?? defaultSessionLoadReplayIdleGap, ); yield* Ref.set( sessionLoadGateRef, Option.some({ active: true, lastActivityAtMillis: undefined, idleGap: sessionLoadReplayIdleGap, initializeResult, }), ); sessionId = options.resumeSessionId; sessionSetupResult = yield* Effect.gen(function* () { yield* logRequest({ method: "session/load", payload: loadPayload, status: "started", }); const idleFiber = yield* waitForSessionLoadReplayIdle({ gateRef: sessionLoadGateRef, }).pipe(Effect.forkIn(runtimeScope)); const loaded = yield* Effect.raceFirst( acp.agent.loadSession(loadPayload), Fiber.join(idleFiber), ).pipe( Effect.ensuring(Fiber.interrupt(idleFiber).pipe(Effect.ignore)), Effect.timeoutOption(sessionLoadTimeout), Effect.flatMap((result) => Option.match(result, { onNone: () => Effect.fail( new EffectAcpErrors.AcpTransportError({ operation: "call-rpc", method: "session/load", detail: "session/load timed out waiting for RPC response or replay idle gap", cause: undefined, }), ), onSome: Effect.succeed, }), ), Effect.tap((result) => logRequest({ method: "session/load", payload: loadPayload, status: "succeeded", result, }), ), Effect.onError((cause) => logRequest({ method: "session/load", payload: loadPayload, status: "failed", cause, }), ), ); return loaded; }).pipe(Effect.ensuring(Ref.set(sessionLoadGateRef, Option.none()))); } else { const createPayload = { cwd: options.cwd, mcpServers: options.mcpServers ?? [], } satisfies EffectAcpSchema.NewSessionRequest; const created = yield* runLoggedRequest( "session/new", createPayload, acp.agent.createSession(createPayload), ); sessionId = created.sessionId; sessionSetupResult = created; } yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult)); yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); const nextState = { sessionId, initializeResult, sessionSetupResult, modelConfigId: extractModelConfigId(sessionSetupResult), } satisfies AcpStartedState; return nextState; }); const start = Effect.gen(function* () { const deferred = yield* Deferred.make< AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError >(); const effect = yield* Ref.modify(startStateRef, (state) => { switch (state._tag) { case "Started": return [Effect.succeed(state.result), state] as const; case "Starting": return [Deferred.await(state.deferred), state] as const; case "NotStarted": return [ startOnce.pipe( Effect.tap((result) => Ref.set(startStateRef, { _tag: "Started", result }).pipe( Effect.andThen(Deferred.succeed(deferred, result)), ), ), Effect.onError((cause) => Deferred.failCause(deferred, cause).pipe( Effect.andThen(Ref.set(startStateRef, { _tag: "NotStarted" })), ), ), ), { _tag: "Starting", deferred } satisfies AcpStartState, ] as const; } }); return yield* effect; }); return { handleRequestPermission: acp.handleRequestPermission, handleElicitation: acp.handleElicitation, handleReadTextFile: acp.handleReadTextFile, handleWriteTextFile: acp.handleWriteTextFile, handleCreateTerminal: acp.handleCreateTerminal, handleTerminalOutput: acp.handleTerminalOutput, handleTerminalWaitForExit: acp.handleTerminalWaitForExit, handleTerminalKill: acp.handleTerminalKill, handleTerminalRelease: acp.handleTerminalRelease, handleSessionUpdate: acp.handleSessionUpdate, handleElicitationComplete: acp.handleElicitationComplete, handleUnknownExtRequest: acp.handleUnknownExtRequest, handleUnknownExtNotification: acp.handleUnknownExtNotification, handleExtRequest: acp.handleExtRequest, handleExtNotification: acp.handleExtNotification, start: () => start, getEvents: () => Stream.fromQueue(eventQueue), drainEvents: Effect.gen(function* () { const acknowledge = yield* Deferred.make(); yield* Queue.offer(eventQueue, { _tag: "EventStreamBarrier", acknowledge, }); yield* Deferred.await(acknowledge); }), getModeState: Ref.get(modeStateRef), getConfigOptions: Ref.get(configOptionsRef), prompt: (payload) => promptSerializationSemaphore.withPermit( Effect.gen(function* () { const started = yield* getStartedState; yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef, }); const requestPayload = { sessionId: started.sessionId, ...payload, } satisfies EffectAcpSchema.PromptRequest; const cancelledResponse = { stopReason: "cancelled", } satisfies EffectAcpSchema.PromptResponse; const promptRpcFiber = yield* runLoggedRequest( "session/prompt", requestPayload, acp.agent.prompt(requestPayload), ).pipe(Effect.forkIn(runtimeScope)); yield* Ref.set(activePromptFiberRef, Option.some(promptRpcFiber)); return yield* Fiber.join(promptRpcFiber).pipe( Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.succeed(cancelledResponse) : Effect.failCause(cause), ), Effect.ensuring( Effect.gen(function* () { yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore); yield* Ref.set(activePromptFiberRef, Option.none()); }), ), Effect.tap(() => closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef, }), ), ); }), ), cancel: getStartedState.pipe( Effect.flatMap((started) => Effect.gen(function* () { const activePromptFiber = yield* Ref.get(activePromptFiberRef); if (Option.isSome(activePromptFiber)) { yield* Fiber.interrupt(activePromptFiber.value).pipe(Effect.ignore); } yield* acp.agent .cancel({ sessionId: started.sessionId }) .pipe(Effect.ignore, Effect.forkIn(runtimeScope)); }), ), ), setMode: (modeId) => Ref.get(modeStateRef).pipe( Effect.flatMap((modeState) => { if (modeState?.currentModeId === modeId) { return Effect.succeed({} satisfies EffectAcpSchema.SetSessionModeResponse); } return setConfigOption("mode", modeId).pipe( Effect.tap(() => updateCurrentModeId(modeId)), Effect.as({} satisfies EffectAcpSchema.SetSessionModeResponse), ); }), ), setConfigOption, setModel: (model) => getStartedState.pipe( Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)), Effect.asVoid, ), setSessionModel: (modelId) => getStartedState.pipe( Effect.flatMap((started) => { const requestPayload = { sessionId: started.sessionId, modelId, } satisfies EffectAcpSchema.SetSessionModelRequest; return runLoggedRequest( "session/set_model", requestPayload, acp.agent.setSessionModel(requestPayload), ); }), ), request: (method, payload) => runLoggedRequest(method, payload, acp.raw.request(method, payload)), notify: acp.raw.notify, } satisfies AcpSessionRuntime["Service"]; }); export const layer = ( options: AcpSessionRuntimeOptions, ): Layer.Layer< AcpSessionRuntime, EffectAcpErrors.AcpError, ChildProcessSpawner.ChildProcessSpawner > => Layer.effect(AcpSessionRuntime, make(options)); function sessionConfigOptionsFromSetup( response: | { readonly configOptions?: ReadonlyArray | null; } | undefined, ): ReadonlyArray { return response?.configOptions ?? []; } function configOptionCurrentValueMatches( configOption: EffectAcpSchema.SessionConfigOption, value: string | boolean, ): boolean { const currentValue = configOption.currentValue; if (configOption.type === "boolean") { return currentValue === value; } if (typeof currentValue !== "string") { return false; } return currentValue.trim() === String(value).trim(); } const handleSessionUpdate = ({ queue, modeStateRef, toolCallsRef, assistantSegmentRef, params, }: { readonly queue: Queue.Queue; readonly modeStateRef: Ref.Ref; readonly toolCallsRef: Ref.Ref>; readonly assistantSegmentRef: Ref.Ref; readonly params: EffectAcpSchema.SessionNotification; }): Effect.Effect => Effect.gen(function* () { const parsed = parseSessionUpdateEvent(params); if (parsed.modeId) { yield* Ref.update(modeStateRef, (current) => current === undefined ? current : updateModeState(current, parsed.modeId!), ); } for (const event of parsed.events) { if (event._tag === "ToolCallUpdated") { yield* closeActiveAssistantSegment({ queue, assistantSegmentRef, }); const { previous, merged } = yield* Ref.modify(toolCallsRef, (current) => { const previous = current.get(event.toolCall.toolCallId); const nextToolCall = mergeToolCallState(previous, event.toolCall); const next = new Map(current); if (nextToolCall.status === "completed" || nextToolCall.status === "failed") { next.delete(nextToolCall.toolCallId); } else { next.set(nextToolCall.toolCallId, nextToolCall); } return [{ previous, merged: nextToolCall }, next] as const; }); if (!shouldEmitToolCallUpdate(previous, merged)) { continue; } yield* Queue.offer(queue, { _tag: "ToolCallUpdated", toolCall: merged, rawPayload: event.rawPayload, }); continue; } if (event._tag === "ContentDelta") { if (event.text.trim().length === 0) { const assistantSegmentState = yield* Ref.get(assistantSegmentRef); if (!assistantSegmentState.activeItemId) { continue; } } const itemId = yield* ensureActiveAssistantSegment({ queue, assistantSegmentRef, sessionId: params.sessionId, }); yield* Queue.offer(queue, { ...event, itemId, }); continue; } yield* Queue.offer(queue, event); } }); function updateModeState(modeState: AcpSessionModeState, nextModeId: string): AcpSessionModeState { const normalized = nextModeId.trim(); if (!normalized) { return modeState; } return modeState.availableModes.some((mode) => mode.id === normalized) ? { ...modeState, currentModeId: normalized, } : modeState; } function shouldEmitToolCallUpdate( previous: AcpToolCallState | undefined, next: AcpToolCallState, ): boolean { if (next.status === "completed" || next.status === "failed") { return true; } if (!next.detail) { return false; } return previous === undefined || previous.title !== next.title || previous.detail !== next.detail; } const assistantItemId = (sessionId: string, segmentIndex: number) => `assistant:${sessionId}:segment:${segmentIndex}`; const ensureActiveAssistantSegment = ({ queue, assistantSegmentRef, sessionId, }: { readonly queue: Queue.Queue; readonly assistantSegmentRef: Ref.Ref; readonly sessionId: string; }) => Ref.modify( assistantSegmentRef, (current) => { if (current.activeItemId) { return [{ itemId: current.activeItemId }, current] as const; } const itemId = assistantItemId(sessionId, current.nextSegmentIndex); return [ { itemId, startedEvent: { _tag: "AssistantItemStarted", itemId, } satisfies Extract, }, { nextSegmentIndex: current.nextSegmentIndex + 1, activeItemId: itemId, } satisfies AcpAssistantSegmentState, ] as const; }, ).pipe( Effect.flatMap((result) => result.startedEvent ? Queue.offer(queue, result.startedEvent).pipe(Effect.as(result.itemId)) : Effect.succeed(result.itemId), ), ); const closeActiveAssistantSegment = ({ queue, assistantSegmentRef, }: { readonly queue: Queue.Queue; readonly assistantSegmentRef: Ref.Ref; }) => Ref.modify(assistantSegmentRef, (current) => { if (!current.activeItemId) { return [undefined, current] as const; } return [ { _tag: "AssistantItemCompleted", itemId: current.activeItemId, } satisfies AcpParsedSessionEvent, { nextSegmentIndex: current.nextSegmentIndex, } satisfies AcpAssistantSegmentState, ] as const; }).pipe(Effect.flatMap((event) => (event ? Queue.offer(queue, event) : Effect.void)));