@batonfx/foldkit 0.4.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,66 @@
1
1
  # `@batonfx/foldkit`
2
2
 
3
- FoldKit adapter for Baton transport sessions and chat UI.
3
+ Focused composition guide for the FoldKit adapter to Baton transport sessions.
4
4
 
5
- See the [Baton documentation](https://github.com/In-Time-Tec/batonfx#readme) for installation, examples, and API guidance.
5
+ ## Install
6
+
7
+ ```sh
8
+ bun add effect foldkit @batonfx/foldkit
9
+ ```
10
+
11
+ ## Imports
12
+
13
+ Import the public namespace from the package root:
14
+
15
+ ```ts
16
+ import { Connection } from "@batonfx/foldkit"
17
+ ```
18
+
19
+ ## Layer graph
20
+
21
+ ```text
22
+ Connection.testLayer
23
+ └─ provides Connection.AgentConnection
24
+ └─ session acquisition requires Scope
25
+ └─ program closes it with Effect.scoped
26
+ ```
27
+
28
+ `testLayer` is a deterministic adapter for examples and tests. Production applications provide an `AgentConnection` backed by a Baton transport client; they do not use this synthetic frame stream.
29
+
30
+ ## Runnable program
31
+
32
+ Checked source: [`../../examples/package-composition-guides/src/foldkit.ts`](../../examples/package-composition-guides/src/foldkit.ts)
33
+
34
+ ```ts
35
+ import { Console, Effect, Stream } from "effect"
36
+ import { Connection } from "@batonfx/foldkit"
37
+
38
+ const connectionLayer = Connection.testLayer({
39
+ frames: () => Stream.make(Connection.ConnectionOpened()),
40
+ send: () => Effect.void,
41
+ })
42
+
43
+ const program = Connection.AgentConnection.use((connection) =>
44
+ Effect.scoped(
45
+ Effect.gen(function* () {
46
+ const session = yield* connection.session({ sessionId: "guide-session" })
47
+ const frames = yield* Stream.runCollect(session.frames)
48
+ yield* Console.log(`received ${frames.length} connection event`)
49
+ }),
50
+ ),
51
+ ).pipe(Effect.provide(connectionLayer))
52
+
53
+ await Effect.runPromise(program)
54
+ ```
55
+
56
+ From the repository root, run `bun examples/package-composition-guides/src/foldkit.ts`.
57
+
58
+ ## Errors, requirements, and resources
59
+
60
+ The fully provided program has type-level result `Effect<void, never, never>` and prints one received event. `Effect.scoped` delimits the session lifetime and discharges its `Scope` requirement; this test adapter has no release action, while production scoped connections release their transport resources. Production command writes can fail with schema-backed `Connection.SendFailed`; the test sender cannot fail. This example owns one session, allocates no queue, and creates no timers, detached fibers, or concurrent work.
61
+
62
+ ## More
63
+
64
+ - Current behavior: [FoldKit adapter](../../docs/features/foldkit.md)
65
+ - Deeper example: [deep research agent](../../examples/deep-research-agent/)
66
+ - Existing custom `AgentConnection` providers must implement the scoped `session` acquisition; `testLayer` remains a compatibility adapter for legacy test implementations.
package/dist/chat.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Equivalence, Effect, Option, Schema, Stream } from "effect";
2
2
  import { type Command } from "foldkit/command";
3
3
  import type { CallableTaggedStruct } from "foldkit/schema";
4
- import { AgentConnection, Incoming } from "./connection.js";
4
+ import { AgentCommandError, AgentConnection, CommandOperation, Incoming } from "./connection.js";
5
5
  declare const CompletedFields: {
6
6
  isFailure: Schema.Boolean;
7
7
  result: Schema.Unknown;
@@ -16,9 +16,6 @@ declare const AssistantEntryFields: {
16
16
  declare const RunCompletedFields: {
17
17
  text: Schema.String;
18
18
  };
19
- declare const StringReasonFields: {
20
- reason: Schema.String;
21
- };
22
19
  declare const OpenedSessionFields: {
23
20
  sessionId: Schema.String;
24
21
  };
@@ -35,7 +32,7 @@ declare const ToolEntryFields: {
35
32
  progress: Schema.$Array<Schema.String>;
36
33
  };
37
34
  declare const RunningFields: {
38
- turn: Schema.Number;
35
+ turn: Schema.Finite;
39
36
  };
40
37
  declare const AwaitingApprovalFields: {
41
38
  token: Schema.String;
@@ -49,7 +46,7 @@ declare const ReceivedAgentFields: {
49
46
  incoming: Schema.Schema<Incoming>;
50
47
  };
51
48
  declare const ModelStreaming: Schema.Struct<{
52
- readonly turn: Schema.Number;
49
+ readonly turn: Schema.Finite;
53
50
  readonly text: Schema.String;
54
51
  readonly reasoning: Schema.String;
55
52
  }>;
@@ -117,7 +114,11 @@ export declare const ResolvedApproval: CallableTaggedStruct<"ResolvedApproval",
117
114
  /** @experimental */
118
115
  export declare const CancelledRun: CallableTaggedStruct<"CancelledRun", {}>;
119
116
  /** @experimental */
120
- export declare const FailedAgentCommand: CallableTaggedStruct<"FailedAgentCommand", typeof StringReasonFields>;
117
+ export declare const FailedAgentCommand: CallableTaggedStruct<"FailedAgentCommand", {
118
+ operation: typeof CommandOperation;
119
+ error: typeof AgentCommandError;
120
+ reason: typeof Schema.String;
121
+ }>;
121
122
  /** @experimental */
122
123
  export type Message = typeof ReceivedAgent.Type | typeof OpenedSession.Type | typeof ChangedDraft.Type | typeof SubmittedMessage.Type | typeof ClickedCancel.Type | typeof ClickedApprove.Type | typeof ClickedDeny.Type | typeof SentUserMessage.Type | typeof ResolvedApproval.Type | typeof CancelledRun.Type | typeof FailedAgentCommand.Type;
123
124
  /** @experimental */
@@ -198,7 +199,7 @@ export type ConversationItem = typeof UserConversationItem.Type | typeof Assista
198
199
  /** @experimental */
199
200
  export declare const ConversationItem: Schema.Schema<ConversationItem>;
200
201
  /** @experimental */
201
- export type ChatCommand = Command<Message, any, AgentConnection>;
202
+ export type ChatCommand = Command<Message, AgentCommandError, AgentConnection>;
202
203
  /** @experimental */
203
204
  export declare const initialModel: (sessionId?: string | null) => Model;
204
205
  /** @experimental */
@@ -207,7 +208,9 @@ export declare const SendUserMessage: import("foldkit/command").CommandDefinitio
207
208
  text: Schema.String;
208
209
  }, Effect.Effect<Schema.Struct.ReadonlySide<{
209
210
  readonly _tag: Schema.tag<"FailedAgentCommand">;
210
- reason: Schema.String;
211
+ operation: typeof CommandOperation;
212
+ error: typeof AgentCommandError;
213
+ reason: typeof Schema.String;
211
214
  }, "Type"> | {
212
215
  readonly _tag: "SentUserMessage";
213
216
  }, never, AgentConnection>>;
@@ -219,7 +222,9 @@ export declare const ResolveApproval: import("foldkit/command").CommandDefinitio
219
222
  reason: Schema.NullOr<Schema.String>;
220
223
  }, Effect.Effect<Schema.Struct.ReadonlySide<{
221
224
  readonly _tag: Schema.tag<"FailedAgentCommand">;
222
- reason: Schema.String;
225
+ operation: typeof CommandOperation;
226
+ error: typeof AgentCommandError;
227
+ reason: typeof Schema.String;
223
228
  }, "Type"> | {
224
229
  readonly _tag: "ResolvedApproval";
225
230
  }, never, AgentConnection>>;
@@ -228,7 +233,9 @@ export declare const CancelRun: import("foldkit/command").CommandDefinitionWithA
228
233
  sessionId: Schema.String;
229
234
  }, Effect.Effect<Schema.Struct.ReadonlySide<{
230
235
  readonly _tag: Schema.tag<"FailedAgentCommand">;
231
- reason: Schema.String;
236
+ operation: typeof CommandOperation;
237
+ error: typeof AgentCommandError;
238
+ reason: typeof Schema.String;
232
239
  }, "Type"> | {
233
240
  readonly _tag: "CancelledRun";
234
241
  }, never, AgentConnection>>;
@@ -239,30 +246,33 @@ export declare const toolStatusOf: (entry: typeof ToolEntry.Type) => ToolStatus;
239
246
  /** @experimental */
240
247
  export declare const conversationItems: (model: Model) => ReadonlyArray<ConversationItem>;
241
248
  /** @experimental */
242
- export declare const update: (model: Model, message: Message) => readonly [Model, ReadonlyArray<ChatCommand>, Option.Option<OutMessage>];
249
+ export declare const update: {
250
+ (message: Message): (model: Model) => readonly [Model, ReadonlyArray<ChatCommand>, Option.Option<OutMessage>];
251
+ (model: Model, message: Message): readonly [Model, ReadonlyArray<ChatCommand>, Option.Option<OutMessage>];
252
+ };
243
253
  /** @experimental */
244
254
  export declare const subscriptions: {
245
255
  readonly agentFrames: {
246
256
  readonly dependenciesSchema: Schema.Schema<Schema.Struct.ReadonlySide<{
247
257
  readonly sessionId: Schema.NullOr<Schema.String>;
248
- readonly afterSeq: Schema.Number;
258
+ readonly afterSeq: Schema.Finite;
249
259
  }, "Type">> & {
250
260
  readonly fields: Schema.Struct.Fields;
251
261
  };
252
262
  readonly modelToDependencies: (model: Model) => Schema.Struct.ReadonlySide<{
253
263
  readonly sessionId: Schema.NullOr<Schema.String>;
254
- readonly afterSeq: Schema.Number;
264
+ readonly afterSeq: Schema.Finite;
255
265
  }, "Type">;
256
266
  readonly keepAliveEquivalence: Equivalence.Equivalence<Schema.Struct.ReadonlySide<{
257
267
  readonly sessionId: Schema.NullOr<Schema.String>;
258
- readonly afterSeq: Schema.Number;
268
+ readonly afterSeq: Schema.Finite;
259
269
  }, "Type">>;
260
270
  readonly dependenciesToStream: (dependencies: Schema.Struct.ReadonlySide<{
261
271
  readonly sessionId: Schema.NullOr<Schema.String>;
262
- readonly afterSeq: Schema.Number;
272
+ readonly afterSeq: Schema.Finite;
263
273
  }, "Type">, readDependencies: () => Schema.Struct.ReadonlySide<{
264
274
  readonly sessionId: Schema.NullOr<Schema.String>;
265
- readonly afterSeq: Schema.Number;
275
+ readonly afterSeq: Schema.Finite;
266
276
  }, "Type">) => Stream.Stream<Message, never, AgentConnection>;
267
277
  } & {
268
278
  readonly __subscription: never;
package/dist/chat.js CHANGED
@@ -1,15 +1,15 @@
1
- import { Cause, Equivalence, Effect, Option, Schema, Stream } from "effect";
1
+ import { Cause, Equivalence, Effect, Option, Result, Schema, Stream } from "effect";
2
+ import { dual } from "effect/Function";
2
3
  import { Prompt } from "effect/unstable/ai";
3
4
  import { define } from "foldkit/command";
4
5
  import { m } from "foldkit/message";
5
6
  import { make } from "foldkit/subscription";
6
7
  import { Wire } from "@batonfx/transport";
7
- import { AgentConnection, Incoming, SendFailed } from "./connection.js";
8
+ import { AgentCommandError, AgentConnection, CommandOperation, Incoming, SendFailed } from "./connection.js";
8
9
  const CompletedFields = { isFailure: Schema.Boolean, result: Schema.Unknown };
9
10
  const UserEntryFields = { text: Schema.String };
10
11
  const AssistantEntryFields = { text: Schema.String, reasoning: Schema.NullOr(Schema.String) };
11
12
  const RunCompletedFields = { text: Schema.String };
12
- const StringReasonFields = { reason: Schema.String };
13
13
  const OpenedSessionFields = { sessionId: Schema.String };
14
14
  /** @experimental */
15
15
  export const ToolPendingPhase = Schema.Literals(["called", "executing"]);
@@ -21,7 +21,7 @@ const ToolEntryFields = {
21
21
  outcome: Schema.suspend(() => ToolOutcome),
22
22
  progress: Schema.Array(Schema.String),
23
23
  };
24
- const RunningFields = { turn: Schema.Number };
24
+ const RunningFields = { turn: Schema.Finite };
25
25
  const AwaitingApprovalFields = {
26
26
  token: Schema.String,
27
27
  toolName: Schema.String,
@@ -30,7 +30,7 @@ const AwaitingApprovalFields = {
30
30
  const ClickedDenyFields = { reason: Schema.NullOr(Schema.String) };
31
31
  const ReceivedAgentFields = { incoming: Incoming };
32
32
  const ModelStreaming = Schema.Struct({
33
- turn: Schema.Number,
33
+ turn: Schema.Finite,
34
34
  text: Schema.String,
35
35
  reasoning: Schema.String,
36
36
  });
@@ -63,7 +63,7 @@ export const RunState = Schema.Union([Idle, Running, AwaitingApproval, Failed]);
63
63
  export const Model = Schema.Struct({
64
64
  sessionId: Schema.NullOr(Schema.String),
65
65
  connection: ModelConnection,
66
- lastSeq: Schema.Number,
66
+ lastSeq: Schema.Finite,
67
67
  run: RunState,
68
68
  entries: Schema.Array(ChatEntry),
69
69
  streaming: Schema.NullOr(ModelStreaming),
@@ -90,7 +90,11 @@ export const ResolvedApproval = m("ResolvedApproval");
90
90
  /** @experimental */
91
91
  export const CancelledRun = m("CancelledRun");
92
92
  /** @experimental */
93
- export const FailedAgentCommand = m("FailedAgentCommand", StringReasonFields);
93
+ export const FailedAgentCommand = m("FailedAgentCommand", {
94
+ operation: CommandOperation,
95
+ error: AgentCommandError,
96
+ reason: Schema.String,
97
+ });
94
98
  /** @experimental */
95
99
  export const Message = Schema.Union([
96
100
  ReceivedAgent,
@@ -173,10 +177,28 @@ export const initialModel = (sessionId = null) => ({
173
177
  streaming: null,
174
178
  draft: "",
175
179
  });
176
- const commandFailed = (error) => FailedAgentCommand({ reason: error instanceof SendFailed ? error.reason : String(error) });
177
- const catchCommandFailure = (effect) => effect.pipe(Effect.catchCause((cause) => Effect.succeed(commandFailed(Cause.squash(cause)))));
180
+ const unexpectedCause = (cause) => {
181
+ const reasons = [];
182
+ for (const reason of cause.reasons) {
183
+ if (Cause.isDieReason(reason) || Cause.isInterruptReason(reason))
184
+ reasons.push(reason);
185
+ }
186
+ return reasons.length === 0 ? Option.none() : Option.some(Cause.fromReasons(reasons));
187
+ };
188
+ const commandFailed = (operation, error) => FailedAgentCommand({
189
+ operation,
190
+ error,
191
+ reason: Schema.is(SendFailed)(error) ? error.reason : error.message,
192
+ });
193
+ const catchCommandFailure = (operation, effect) => effect.pipe(Effect.catchCause((cause) => Option.match(unexpectedCause(cause), {
194
+ onNone: () => Result.match(Cause.findError(cause), {
195
+ onFailure: Effect.failCause,
196
+ onSuccess: (error) => Effect.succeed(commandFailed(operation, error)),
197
+ }),
198
+ onSome: Effect.failCause,
199
+ })));
178
200
  /** @experimental */
179
- export const SendUserMessage = define("SendUserMessage", { sessionId: Schema.String, text: Schema.String }, SentUserMessage, FailedAgentCommand)(({ sessionId, text }) => AgentConnection.use((connection) => catchCommandFailure(connection.send({ _tag: "SendMessage", sessionId, prompt: text }).pipe(Effect.as(SentUserMessage())))));
201
+ export const SendUserMessage = define("SendUserMessage", { sessionId: Schema.String, text: Schema.String }, SentUserMessage, FailedAgentCommand)(({ sessionId, text }) => AgentConnection.use((connection) => catchCommandFailure("send", connection.send({ _tag: "SendMessage", sessionId, prompt: text }).pipe(Effect.as(SentUserMessage())))));
180
202
  /** @experimental */
181
203
  export const ResolveApproval = define("ResolveApproval", {
182
204
  sessionId: Schema.String,
@@ -189,10 +211,10 @@ export const ResolveApproval = define("ResolveApproval", {
189
211
  : reason === null
190
212
  ? { _tag: "Denied" }
191
213
  : { _tag: "Denied", reason };
192
- return AgentConnection.use((connection) => catchCommandFailure(connection.send({ _tag: "ResolveApproval", sessionId, token, decision }).pipe(Effect.as(ResolvedApproval()))));
214
+ return AgentConnection.use((connection) => catchCommandFailure("resolveApproval", connection.send({ _tag: "ResolveApproval", sessionId, token, decision }).pipe(Effect.as(ResolvedApproval()))));
193
215
  });
194
216
  /** @experimental */
195
- export const CancelRun = define("CancelRun", { sessionId: Schema.String }, CancelledRun, FailedAgentCommand)(({ sessionId }) => AgentConnection.use((connection) => catchCommandFailure(connection.send({ _tag: "Cancel", sessionId }).pipe(Effect.as(CancelledRun())))));
217
+ export const CancelRun = define("CancelRun", { sessionId: Schema.String }, CancelledRun, FailedAgentCommand)(({ sessionId }) => AgentConnection.use((connection) => catchCommandFailure("cancel", connection.send({ _tag: "Cancel", sessionId }).pipe(Effect.as(CancelledRun())))));
196
218
  const isRecord = (value) => typeof value === "object" && value !== null;
197
219
  const isToolCall = (value) => isRecord(value) &&
198
220
  value.type === "tool-call" &&
@@ -261,10 +283,20 @@ const failureMessage = (failure) => {
261
283
  switch (failure._tag) {
262
284
  case "@batonfx/core/AgentError":
263
285
  return failure.message;
286
+ case "@batonfx/core/TurnPolicyError":
287
+ return failure.message;
288
+ case "@batonfx/core/TurnPolicyStopped":
289
+ return failure.reason._tag === "Policy" ? failure.reason.detail : failure.reason._tag;
264
290
  case "@batonfx/core/MiddlewareViolation":
265
291
  return failure.detail;
266
292
  case "@batonfx/core/TurnLimitExceeded":
267
293
  return `Turn limit exceeded at turn ${failure.turn}`;
294
+ case "@batonfx/core/ResumeMismatch":
295
+ return failure.reason === "identity-mismatch"
296
+ ? "Resume suspension does not match the current checkpoint"
297
+ : "Resume checkpoint not found";
298
+ case "@batonfx/core/FrameworkFailure":
299
+ return `${failure.tool} ${failure.stage}: ${failure.message}`;
268
300
  }
269
301
  };
270
302
  const applyPart = (model, turn, part) => {
@@ -389,6 +421,9 @@ const projectPrompt = (prompt) => {
389
421
  return entries;
390
422
  };
391
423
  const applyFrame = (model, frame) => {
424
+ if (frame._tag === "Snapshot") {
425
+ return [{ ...model, lastSeq: frame.seq, entries: projectPrompt(frame.transcript), streaming: null }, Option.none()];
426
+ }
392
427
  if (frame.seq <= model.lastSeq)
393
428
  return [model, Option.none()];
394
429
  const withSeq = { ...model, lastSeq: frame.seq };
@@ -403,8 +438,6 @@ const applyFrame = (model, frame) => {
403
438
  }
404
439
  case "Ended":
405
440
  return [withSeq, Option.none()];
406
- case "Snapshot":
407
- return [{ ...withSeq, entries: projectPrompt(frame.transcript), streaming: null }, Option.none()];
408
441
  case "SessionStatus":
409
442
  return applyStatus(withSeq, frame.status);
410
443
  }
@@ -495,7 +528,7 @@ const isServerFrame = (incoming) => incoming._tag === "Event" ||
495
528
  incoming._tag === "Snapshot" ||
496
529
  incoming._tag === "SessionStatus";
497
530
  /** @experimental */
498
- export const update = (model, message) => {
531
+ export const update = dual(2, (model, message) => {
499
532
  switch (message._tag) {
500
533
  case "ReceivedAgent":
501
534
  if (isServerFrame(message.incoming)) {
@@ -585,10 +618,10 @@ export const update = (model, message) => {
585
618
  case "CancelledRun":
586
619
  return [model, [], Option.none()];
587
620
  }
588
- };
621
+ });
589
622
  /** @experimental */
590
623
  export const subscriptions = make()((entry) => ({
591
- agentFrames: entry({ sessionId: Schema.NullOr(Schema.String), afterSeq: Schema.Number }, {
624
+ agentFrames: entry({ sessionId: Schema.NullOr(Schema.String), afterSeq: Schema.Finite }, {
592
625
  modelToDependencies: (model) => ({ sessionId: model.sessionId, afterSeq: model.lastSeq }),
593
626
  keepAliveEquivalence: Equivalence.make((left, right) => left.sessionId === right.sessionId),
594
627
  dependenciesToStream: ({ sessionId }, readDependencies) => {
@@ -596,9 +629,9 @@ export const subscriptions = make()((entry) => ({
596
629
  return Stream.empty;
597
630
  return Stream.unwrap(AgentConnection.use((connection) => {
598
631
  const afterSeq = readDependencies().afterSeq;
599
- return Effect.succeed(connection
600
- .frames(afterSeq < 0 ? { sessionId } : { sessionId, afterSeq })
601
- .pipe(Stream.map((incoming) => ReceivedAgent({ incoming }))));
632
+ return connection
633
+ .session(afterSeq < 0 ? { sessionId } : { sessionId, afterSeq })
634
+ .pipe(Effect.map((sessionConnection) => sessionConnection.frames.pipe(Stream.map((incoming) => ReceivedAgent({ incoming })))));
602
635
  }));
603
636
  },
604
637
  }),
@@ -1,13 +1,15 @@
1
- import { Cause, Context, Effect, Layer, Schema, Stream } from "effect";
1
+ import { Cause, Context, Effect, Layer, Schema, Scope, Stream } from "effect";
2
2
  import { Socket } from "effect/unstable/socket";
3
3
  import type { CallableTaggedStruct } from "foldkit/schema";
4
- import { Wire } from "@batonfx/transport";
4
+ import { Errors, Wire } from "@batonfx/transport";
5
5
  /** @experimental */
6
6
  export declare const ConnectionOpened: CallableTaggedStruct<"ConnectionOpened", {}>;
7
7
  /** @experimental */
8
8
  export declare const ConnectionLost: CallableTaggedStruct<"ConnectionLost", {}>;
9
9
  /** @experimental */
10
10
  export declare const ConnectionFailed: CallableTaggedStruct<"ConnectionFailed", {
11
+ operation: Schema.Literal<"connect">;
12
+ error: typeof Errors.TransportError;
11
13
  reason: typeof Schema.String;
12
14
  }>;
13
15
  /** @experimental */
@@ -21,19 +23,40 @@ declare const SendFailed_base: Schema.Class<SendFailed, Schema.TaggedStruct<"@ba
21
23
  export declare class SendFailed extends SendFailed_base {
22
24
  }
23
25
  /** @experimental */
26
+ export declare const AgentCommandError: Schema.Union<readonly [typeof Errors.TransportError, typeof SendFailed]>;
27
+ /** @experimental */
28
+ export type AgentCommandError = typeof AgentCommandError.Type;
29
+ /** @experimental */
30
+ export declare const CommandOperation: Schema.Literals<readonly ["send", "cancel", "resolveApproval"]>;
31
+ /** @experimental */
32
+ export type CommandOperation = typeof CommandOperation.Type;
33
+ /** @experimental */
34
+ export interface SessionConnection {
35
+ readonly sessionId: string;
36
+ readonly frames: Stream.Stream<Incoming, never>;
37
+ readonly send: (frame: Exclude<Wire.ClientFrameType, {
38
+ readonly _tag: "Attach";
39
+ }>) => Effect.Effect<void, AgentCommandError>;
40
+ }
41
+ /** @experimental */
24
42
  export interface Interface {
43
+ readonly session: (options: {
44
+ readonly sessionId: string;
45
+ readonly afterSeq?: number;
46
+ }) => Effect.Effect<SessionConnection, never, Scope.Scope>;
25
47
  readonly frames: (options: {
26
48
  readonly sessionId: string;
27
49
  readonly afterSeq?: number;
28
50
  }) => Stream.Stream<Incoming, never>;
29
- readonly send: (frame: Wire.ClientFrameType) => Effect.Effect<void, SendFailed>;
51
+ readonly send: (frame: Wire.ClientFrameType) => Effect.Effect<void, AgentCommandError>;
30
52
  }
31
- declare const AgentConnection_base: Context.ServiceClass<AgentConnection, "@batonfx/foldkit/AgentConnection", Interface>;
53
+ declare const AgentConnection_base: Context.ServiceClass<AgentConnection, "@batonfx/foldkit/connection/AgentConnection", Interface>;
32
54
  /** @experimental */
33
55
  export declare class AgentConnection extends AgentConnection_base {
34
56
  }
57
+ type LegacyInterface = Omit<Interface, "session">;
35
58
  /** @experimental */
36
- export declare const testLayer: (implementation: Interface) => Layer.Layer<AgentConnection>;
59
+ export declare const testLayer: (implementation: Interface | LegacyInterface) => Layer.Layer<AgentConnection>;
37
60
  /** @experimental */
38
61
  export declare const layerWebSocket: (options: {
39
62
  readonly url: string;
@@ -1,13 +1,17 @@
1
- import { Cause, Context, Effect, Layer, Option, Ref, Result, Schema, Stream } from "effect";
1
+ import { Cause, Context, Effect, Layer, Option, Ref, Result, Schema, Scope, Stream } from "effect";
2
2
  import { Socket } from "effect/unstable/socket";
3
3
  import { m } from "foldkit/message";
4
- import { Client, Wire } from "@batonfx/transport";
4
+ import { Client, Errors, Wire } from "@batonfx/transport";
5
5
  /** @experimental */
6
6
  export const ConnectionOpened = m("ConnectionOpened");
7
7
  /** @experimental */
8
8
  export const ConnectionLost = m("ConnectionLost");
9
9
  /** @experimental */
10
- export const ConnectionFailed = m("ConnectionFailed", { reason: Schema.String });
10
+ export const ConnectionFailed = m("ConnectionFailed", {
11
+ operation: Schema.Literal("connect"),
12
+ error: Errors.TransportError,
13
+ reason: Schema.String,
14
+ });
11
15
  /** @experimental */
12
16
  export const Incoming = Schema.Union([
13
17
  Wire.LooseServerFrame,
@@ -21,50 +25,93 @@ export class SendFailed extends Schema.TaggedErrorClass()("@batonfx/foldkit/Send
21
25
  }) {
22
26
  }
23
27
  /** @experimental */
24
- export class AgentConnection extends Context.Service()("@batonfx/foldkit/AgentConnection") {
28
+ export const AgentCommandError = Schema.Union([Errors.TransportError, SendFailed]);
29
+ /** @experimental */
30
+ export const CommandOperation = Schema.Literals(["send", "cancel", "resolveApproval"]);
31
+ /** @experimental */
32
+ export class AgentConnection extends Context.Service()("@batonfx/foldkit/connection/AgentConnection") {
25
33
  }
26
- const reasonFrom = (error) => {
27
- if (error instanceof SendFailed)
28
- return error.reason;
29
- if (error instanceof Error)
30
- return error.message;
31
- return String(error);
34
+ const sendThrough = (connection, frame) => connection.send(frame);
35
+ const unexpectedCause = (cause) => {
36
+ const reasons = [];
37
+ for (const reason of cause.reasons) {
38
+ if (Cause.isDieReason(reason) || Cause.isInterruptReason(reason))
39
+ reasons.push(reason);
40
+ }
41
+ return reasons.length === 0 ? Option.none() : Option.some(Cause.fromReasons(reasons));
32
42
  };
33
43
  const statusIncoming = (status) => {
34
44
  switch (status._tag) {
35
- case "Open":
45
+ case "Connected":
36
46
  return Option.some(ConnectionOpened());
37
- case "Reconnecting":
38
- case "Closed":
47
+ case "Disconnected":
48
+ case "Retrying":
39
49
  return Option.some(ConnectionLost());
40
50
  case "Connecting":
41
51
  return Option.none();
42
52
  }
43
53
  };
44
54
  /** @experimental */
45
- export const testLayer = (implementation) => Layer.succeed(AgentConnection, AgentConnection.of(implementation));
55
+ export const testLayer = (implementation) => {
56
+ const session = "session" in implementation
57
+ ? implementation.session
58
+ : (options) => Effect.succeed({
59
+ sessionId: options.sessionId,
60
+ frames: implementation.frames(options),
61
+ send: (frame) => frame.sessionId === options.sessionId
62
+ ? implementation.send(frame)
63
+ : Effect.fail(SendFailed.make({
64
+ reason: `Session ${options.sessionId} cannot send a command for ${frame.sessionId}`,
65
+ })),
66
+ });
67
+ return Layer.succeed(AgentConnection, AgentConnection.of({ ...implementation, session }));
68
+ };
46
69
  /** @experimental */
47
70
  export const layerWebSocket = (options) => Layer.effect(AgentConnection, Effect.gen(function* () {
48
71
  const client = yield* Client.AgentClient;
49
- const active = yield* Ref.make(Option.none());
50
- const clearActive = (connection) => Ref.update(active, (current) => Option.isSome(current) && current.value.connection === connection ? Option.none() : current);
72
+ const active = yield* Ref.make(new Map());
73
+ const clearActive = (owner) => Ref.update(active, (current) => {
74
+ if (current.get(owner.sessionId) !== owner)
75
+ return current;
76
+ const updated = new Map(current);
77
+ updated.delete(owner.sessionId);
78
+ return updated;
79
+ });
80
+ const session = ({ sessionId }) => Effect.gen(function* () {
81
+ const connection = yield* client.connect({ url: options.url, sessionId });
82
+ const owner = { sessionId, connection };
83
+ yield* Effect.acquireRelease(Ref.update(active, (current) => {
84
+ const updated = new Map(current);
85
+ updated.set(sessionId, owner);
86
+ return updated;
87
+ }), () => clearActive(owner));
88
+ const statuses = connection.status.pipe(Stream.filterMap((status) => Option.match(statusIncoming(status), {
89
+ onNone: () => Result.fail(undefined),
90
+ onSome: Result.succeed,
91
+ })));
92
+ const frames = connection.frames.pipe(Stream.map((frame) => frame), Stream.catchCause((cause) => Option.match(unexpectedCause(cause), {
93
+ onNone: () => Result.match(Cause.findError(cause), {
94
+ onFailure: Stream.failCause,
95
+ onSuccess: (error) => Stream.succeed(ConnectionFailed({ operation: "connect", error, reason: error.message })),
96
+ }),
97
+ onSome: Stream.failCause,
98
+ })));
99
+ return {
100
+ sessionId,
101
+ frames: statuses.pipe(Stream.merge(frames)),
102
+ send: (frame) => frame.sessionId === sessionId
103
+ ? sendThrough(connection, frame)
104
+ : Effect.fail(SendFailed.make({ reason: `Session ${sessionId} cannot send a command for ${frame.sessionId}` })),
105
+ };
106
+ });
51
107
  return AgentConnection.of({
52
- frames: ({ sessionId }) => Stream.unwrap(Effect.gen(function* () {
53
- const connection = yield* client.connect({ url: options.url, sessionId });
54
- yield* Ref.set(active, Option.some({ sessionId, connection }));
55
- yield* Effect.addFinalizer(() => clearActive(connection));
56
- const statuses = connection.status.pipe(Stream.filterMap((status) => Option.match(statusIncoming(status), {
57
- onNone: () => Result.fail(undefined),
58
- onSome: Result.succeed,
59
- })));
60
- const frames = connection.frames.pipe(Stream.map((frame) => frame), Stream.catchCause((cause) => Stream.succeed(ConnectionFailed({ reason: reasonFrom(Cause.squash(cause)) }))));
61
- return statuses.pipe(Stream.merge(frames));
108
+ session,
109
+ frames: (sessionOptions) => Stream.unwrap(session(sessionOptions).pipe(Effect.map((owned) => owned.frames))),
110
+ send: (frame) => Ref.get(active).pipe(Effect.flatMap((current) => {
111
+ const owner = current.get(frame.sessionId);
112
+ return owner === undefined
113
+ ? Effect.fail(SendFailed.make({ reason: `No active agent connection for session ${frame.sessionId}` }))
114
+ : sendThrough(owner.connection, frame);
62
115
  })),
63
- send: (frame) => Ref.get(active).pipe(Effect.flatMap(Option.match({
64
- onNone: () => Effect.fail(new SendFailed({ reason: "No active agent connection" })),
65
- onSome: ({ connection }) => connection
66
- .send(frame)
67
- .pipe(Effect.mapError((error) => new SendFailed({ reason: reasonFrom(error) }))),
68
- }))),
69
116
  });
70
117
  })).pipe(Layer.provide(Client.layerWebSocket));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@batonfx/foldkit",
4
- "version": "0.4.3",
4
+ "version": "0.6.0",
5
5
  "private": false,
6
6
  "type": "module",
7
7
  "exports": {
@@ -17,7 +17,7 @@
17
17
  "typecheck": "bun tsc --noEmit"
18
18
  },
19
19
  "dependencies": {
20
- "@batonfx/transport": "0.4.3"
20
+ "@batonfx/transport": "0.6.0"
21
21
  },
22
22
  "peerDependencies": {
23
23
  "effect": ">=4.0.0-beta.88 <4.0.1",
@@ -28,7 +28,7 @@
28
28
  "@types/bun": "1.3.13",
29
29
  "effect": "4.0.0-beta.93",
30
30
  "foldkit": "0.122.0",
31
- "typescript": "5.8.2",
31
+ "typescript": "7.0.2",
32
32
  "vitest": "4.0.16"
33
33
  },
34
34
  "description": "FoldKit adapter for Baton transport sessions and chat UI",