@batonfx/foldkit 0.1.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.
@@ -0,0 +1,133 @@
1
+ import { Cause, Context, Effect, Layer, Option, Ref, Result, Schema, Stream } from "effect"
2
+ import { Socket } from "effect/unstable/socket"
3
+ import { m } from "foldkit/message"
4
+ import type { CallableTaggedStruct } from "foldkit/schema"
5
+ import { Client, Wire } from "@batonfx/transport"
6
+
7
+ /** @experimental */
8
+ export const ConnectionOpened: CallableTaggedStruct<"ConnectionOpened", {}> = m("ConnectionOpened")
9
+
10
+ /** @experimental */
11
+ export const ConnectionLost: CallableTaggedStruct<"ConnectionLost", {}> = m("ConnectionLost")
12
+
13
+ /** @experimental */
14
+ export const ConnectionFailed: CallableTaggedStruct<"ConnectionFailed", { reason: typeof Schema.String }> = m(
15
+ "ConnectionFailed",
16
+ { reason: Schema.String },
17
+ )
18
+
19
+ /** @experimental */
20
+ export type Incoming =
21
+ | Wire.LooseServerFrameType
22
+ | typeof ConnectionOpened.Type
23
+ | typeof ConnectionLost.Type
24
+ | typeof ConnectionFailed.Type
25
+
26
+ /** @experimental */
27
+ export const Incoming: Schema.Schema<Incoming> = Schema.Union([
28
+ Wire.LooseServerFrame,
29
+ ConnectionOpened,
30
+ ConnectionLost,
31
+ ConnectionFailed,
32
+ ])
33
+
34
+ /** @experimental */
35
+ export class SendFailed extends Schema.TaggedErrorClass<SendFailed>()("@batonfx/foldkit/SendFailed", {
36
+ reason: Schema.String,
37
+ }) {}
38
+
39
+ /** @experimental */
40
+ export interface Interface {
41
+ readonly frames: (options: {
42
+ readonly sessionId: string
43
+ readonly afterSeq?: number
44
+ }) => Stream.Stream<Incoming, never>
45
+ readonly send: (frame: Wire.ClientFrameType) => Effect.Effect<void, SendFailed>
46
+ }
47
+
48
+ /** @experimental */
49
+ export class AgentConnection extends Context.Service<AgentConnection, Interface>()(
50
+ "@batonfx/foldkit/AgentConnection",
51
+ ) {}
52
+
53
+ interface ActiveConnection {
54
+ readonly sessionId: string
55
+ readonly connection: Client.Connection
56
+ }
57
+
58
+ const reasonFrom = (error: unknown): string => {
59
+ if (error instanceof SendFailed) return error.reason
60
+ if (error instanceof Error) return error.message
61
+ return String(error)
62
+ }
63
+
64
+ const statusIncoming = (status: Client.ConnectionStatus): Option.Option<Incoming> => {
65
+ switch (status._tag) {
66
+ case "Open":
67
+ return Option.some(ConnectionOpened())
68
+ case "Reconnecting":
69
+ case "Closed":
70
+ return Option.some(ConnectionLost())
71
+ case "Connecting":
72
+ return Option.none()
73
+ }
74
+ }
75
+
76
+ /** @experimental */
77
+ export const testLayer = (implementation: Interface): Layer.Layer<AgentConnection> =>
78
+ Layer.succeed(AgentConnection, AgentConnection.of(implementation))
79
+
80
+ /** @experimental */
81
+ export const layerWebSocket = (options: {
82
+ readonly url: string
83
+ }): Layer.Layer<AgentConnection, never, Socket.WebSocketConstructor> =>
84
+ Layer.effect(
85
+ AgentConnection,
86
+ Effect.gen(function* () {
87
+ const client = yield* Client.AgentClient
88
+ const active = yield* Ref.make<Option.Option<ActiveConnection>>(Option.none())
89
+
90
+ const clearActive = (connection: Client.Connection) =>
91
+ Ref.update(active, (current) =>
92
+ Option.isSome(current) && current.value.connection === connection ? Option.none() : current,
93
+ )
94
+
95
+ return AgentConnection.of({
96
+ frames: ({ sessionId }) =>
97
+ Stream.unwrap(
98
+ Effect.gen(function* () {
99
+ const connection = yield* client.connect({ url: options.url, sessionId })
100
+ yield* Ref.set(active, Option.some({ sessionId, connection }))
101
+ yield* Effect.addFinalizer(() => clearActive(connection))
102
+ const statuses = connection.status.pipe(
103
+ Stream.filterMap((status) =>
104
+ Option.match(statusIncoming(status), {
105
+ onNone: () => Result.fail(undefined),
106
+ onSome: Result.succeed,
107
+ }),
108
+ ),
109
+ )
110
+ const frames = connection.frames.pipe(
111
+ Stream.map((frame): Incoming => frame),
112
+ Stream.catchCause((cause) =>
113
+ Stream.succeed(ConnectionFailed({ reason: reasonFrom(Cause.squash(cause)) })),
114
+ ),
115
+ )
116
+ return statuses.pipe(Stream.merge(frames))
117
+ }),
118
+ ),
119
+ send: (frame) =>
120
+ Ref.get(active).pipe(
121
+ Effect.flatMap(
122
+ Option.match({
123
+ onNone: () => Effect.fail(new SendFailed({ reason: "No active agent connection" })),
124
+ onSome: ({ connection }) =>
125
+ connection
126
+ .send(frame)
127
+ .pipe(Effect.mapError((error) => new SendFailed({ reason: reasonFrom(error) }))),
128
+ }),
129
+ ),
130
+ ),
131
+ })
132
+ }),
133
+ ).pipe(Layer.provide(Client.layerWebSocket))
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * as Chat from "./chat"
2
+ export * as Connection from "./connection"
@@ -0,0 +1,146 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import { Effect, Option, Schema, Stream } from "effect"
3
+ import * as Ai from "effect/unstable/ai"
4
+ import { Wire } from "@batonfx/transport"
5
+ import { Chat, Connection } from "../src/index"
6
+
7
+ const eventFrame = (seq: number, event: Wire.EventType): Wire.LooseServerFrameType => ({ _tag: "Event", seq, event })
8
+
9
+ const updateWith = (model: Chat.Model, incoming: Connection.Incoming) =>
10
+ Chat.update(model, Chat.ReceivedAgent({ incoming }))
11
+
12
+ describe("Chat", () => {
13
+ it("folds a full run into display entries and a completion out-message", () => {
14
+ let model = Chat.initialModel("s-chat")
15
+ let out: Option.Option<Chat.OutMessage> = Option.none()
16
+
17
+ ;[model, , out] = updateWith(model, eventFrame(0, { _tag: "TurnStarted", turn: 0 }))
18
+ expect(model.run).toEqual({ _tag: "Running", turn: 0 })
19
+ expect(model.streaming).toEqual({ turn: 0, text: "", reasoning: "" })
20
+ expect(Option.isNone(out)).toBe(true)
21
+ ;[model] = updateWith(
22
+ model,
23
+ eventFrame(1, {
24
+ _tag: "ModelPart",
25
+ turn: 0,
26
+ part: Ai.Response.makePart("text-delta", { id: "text-1", delta: "Hello " }),
27
+ }),
28
+ )
29
+ ;[model] = updateWith(
30
+ model,
31
+ eventFrame(2, {
32
+ _tag: "ModelPart",
33
+ turn: 0,
34
+ part: Ai.Response.makePart("text-delta", { id: "text-1", delta: "world" }),
35
+ }),
36
+ )
37
+
38
+ const call = Ai.Response.makePart("tool-call", {
39
+ id: "call-1",
40
+ name: "lookup",
41
+ params: { q: "baton" },
42
+ providerExecuted: false,
43
+ })
44
+ ;[model] = updateWith(model, eventFrame(3, { _tag: "ModelPart", turn: 0, part: call }))
45
+ expect(model.entries).toEqual([
46
+ {
47
+ _tag: "ToolEntry",
48
+ callId: "call-1",
49
+ name: "lookup",
50
+ params: { q: "baton" },
51
+ outcome: { _tag: "Pending" },
52
+ progress: [],
53
+ },
54
+ ])
55
+
56
+ const result = Ai.Response.makePart("tool-result", {
57
+ id: "call-1",
58
+ name: "lookup",
59
+ result: { ok: true },
60
+ encodedResult: { ok: true },
61
+ isFailure: false,
62
+ providerExecuted: false,
63
+ preliminary: false,
64
+ })
65
+ ;[model] = updateWith(model, eventFrame(4, { _tag: "ToolExecutionCompleted", turn: 0, call, result }))
66
+ expect(model.entries[0]).toMatchObject({
67
+ _tag: "ToolEntry",
68
+ outcome: { _tag: "Completed", isFailure: false, result: { ok: true } },
69
+ })
70
+ ;[model] = updateWith(model, eventFrame(5, { _tag: "TurnCompleted", turn: 0 }))
71
+ expect(model.streaming).toBeNull()
72
+ expect(model.entries[1]).toEqual({ _tag: "AssistantEntry", text: "Hello world", reasoning: null })
73
+ ;[model, , out] = updateWith(model, eventFrame(6, { _tag: "Completed", turns: 1, text: "Done" }))
74
+ expect(model.run).toEqual({ _tag: "Idle" })
75
+ expect(Option.getOrUndefined(out)).toEqual({ _tag: "RunCompleted", text: "Done" })
76
+ })
77
+
78
+ it("drops replayed frames whose seq is not newer than lastSeq", () => {
79
+ const model = { ...Chat.initialModel("s-chat"), lastSeq: 5, entries: [Chat.UserEntry({ text: "already" })] }
80
+ const [next, commands, out] = updateWith(model, eventFrame(5, { _tag: "TurnStarted", turn: 1 }))
81
+
82
+ expect(next).toEqual(model)
83
+ expect(commands).toEqual([])
84
+ expect(Option.isNone(out)).toBe(true)
85
+ })
86
+
87
+ it("surfaces approval suspension and emits approval commands", () => {
88
+ let model = Chat.initialModel("s-chat")
89
+ let out: Option.Option<Chat.OutMessage> = Option.none()
90
+ ;[model, , out] = updateWith(
91
+ model,
92
+ Schema.decodeUnknownSync(Wire.LooseServerFrame)({
93
+ _tag: "Suspended",
94
+ seq: 0,
95
+ suspension: {
96
+ _tag: "@batonfx/core/AgentSuspended",
97
+ token: "approval-token",
98
+ reason: "approval",
99
+ tool_call_id: "call-approval",
100
+ tool_name: "lookup",
101
+ tool_params: { q: "baton" },
102
+ },
103
+ }),
104
+ )
105
+
106
+ expect(model.run).toEqual({
107
+ _tag: "AwaitingApproval",
108
+ token: "approval-token",
109
+ toolName: "lookup",
110
+ params: { q: "baton" },
111
+ })
112
+ expect(Option.getOrUndefined(out)).toEqual({ _tag: "ApprovalRequired" })
113
+
114
+ const [, commands] = Chat.update(model, Chat.ClickedApprove())
115
+ expect(commands).toHaveLength(1)
116
+ expect(commands[0]?.name).toBe("ResolveApproval")
117
+ expect(commands[0]?.args).toEqual({
118
+ sessionId: "s-chat",
119
+ token: "approval-token",
120
+ approved: true,
121
+ reason: null,
122
+ })
123
+ })
124
+
125
+ it.effect("converts command send failures into FailedAgentCommand messages", () =>
126
+ Effect.gen(function* () {
127
+ let model = Chat.initialModel("s-chat")
128
+ ;[model] = Chat.update(model, Chat.ChangedDraft({ text: "hello" }))
129
+ const [next, commands] = Chat.update(model, Chat.SubmittedMessage())
130
+
131
+ expect(next.draft).toBe("")
132
+ expect(next.entries).toEqual([Chat.UserEntry({ text: "hello" })])
133
+ expect(commands).toHaveLength(1)
134
+ const message = yield* commands[0]!.effect
135
+
136
+ expect(message).toEqual({ _tag: "FailedAgentCommand", reason: "offline" })
137
+ }).pipe(
138
+ Effect.provide(
139
+ Connection.testLayer({
140
+ frames: () => Stream.empty,
141
+ send: () => Effect.fail(new Connection.SendFailed({ reason: "offline" })),
142
+ }),
143
+ ),
144
+ ),
145
+ )
146
+ })
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import { Effect, Stream } from "effect"
3
+ import { Chat, Connection } from "../src/index"
4
+
5
+ describe("Connection", () => {
6
+ it.effect("testLayer provides AgentConnection frames and send", () =>
7
+ Effect.gen(function* () {
8
+ const incoming = Connection.ConnectionOpened()
9
+ const frames = yield* Connection.AgentConnection.use((connection) =>
10
+ connection.frames({ sessionId: "s", afterSeq: 1 }).pipe(Stream.runCollect),
11
+ )
12
+ yield* Connection.AgentConnection.use((connection) => connection.send({ _tag: "Cancel", sessionId: "s" }))
13
+
14
+ expect(frames).toEqual([incoming])
15
+ }).pipe(
16
+ Effect.provide(
17
+ Connection.testLayer({
18
+ frames: () => Stream.fromIterable([Connection.ConnectionOpened()]),
19
+ send: () => Effect.void,
20
+ }),
21
+ ),
22
+ ),
23
+ )
24
+
25
+ it("keeps the chat subscription alive across afterSeq-only changes", () => {
26
+ const subscription = Chat.subscriptions.agentFrames
27
+
28
+ expect(subscription.keepAliveEquivalence?.({ sessionId: "s", afterSeq: 1 }, { sessionId: "s", afterSeq: 2 })).toBe(
29
+ true,
30
+ )
31
+ expect(
32
+ subscription.keepAliveEquivalence?.({ sessionId: "s-1", afterSeq: 2 }, { sessionId: "s-2", afterSeq: 2 }),
33
+ ).toBe(false)
34
+ })
35
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "."
5
+ },
6
+ "include": ["src/**/*.ts", "test/**/*.ts"]
7
+ }