@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.
- package/.turbo/turbo-build.log +5 -0
- package/dist/index.js +39540 -0
- package/package.json +31 -0
- package/src/chat.ts +630 -0
- package/src/connection.ts +133 -0
- package/src/index.ts +2 -0
- package/test/chat.test.ts +146 -0
- package/test/connection.test.ts +35 -0
- package/tsconfig.json +7 -0
|
@@ -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,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
|
+
})
|