@batonfx/foldkit 0.4.0 → 0.4.1
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 +5 -0
- package/dist/chat.d.ts +271 -0
- package/dist/chat.js +605 -0
- package/dist/connection.d.ts +41 -0
- package/dist/connection.js +70 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -40015
- package/package.json +29 -4
- package/.turbo/turbo-build.log +0 -5
- package/src/chat.ts +0 -876
- package/src/connection.ts +0 -133
- package/src/index.ts +0 -2
- package/test/chat.scene.test.ts +0 -152
- package/test/chat.story.test.ts +0 -110
- package/test/chat.test.ts +0 -154
- package/test/connection.test.ts +0 -35
- package/tsconfig.json +0 -7
package/src/connection.ts
DELETED
|
@@ -1,133 +0,0 @@
|
|
|
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
DELETED
package/test/chat.scene.test.ts
DELETED
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
// @vitest-environment happy-dom
|
|
2
|
-
|
|
3
|
-
import { Chat, Connection } from "../src/index"
|
|
4
|
-
import { Wire } from "@batonfx/transport"
|
|
5
|
-
import { Response } from "effect/unstable/ai"
|
|
6
|
-
import type { Document, Html } from "foldkit/html"
|
|
7
|
-
import { html } from "foldkit/html"
|
|
8
|
-
import { Command, click, expect, placeholder, role, scene, type } from "foldkit/scene"
|
|
9
|
-
import type { SceneSimulation } from "foldkit/scene"
|
|
10
|
-
import { describe, test } from "vitest"
|
|
11
|
-
|
|
12
|
-
const sessionId = "foldkit-scene-session"
|
|
13
|
-
|
|
14
|
-
const eventFrame = (seq: number, event: Wire.EventType): Wire.LooseServerFrameType => ({ _tag: "Event", seq, event })
|
|
15
|
-
|
|
16
|
-
const toolCall = Response.makePart("tool-call", {
|
|
17
|
-
id: "lookup-1",
|
|
18
|
-
name: "lookup",
|
|
19
|
-
params: { query: "baton foldkit" },
|
|
20
|
-
providerExecuted: false,
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
const toolResult = Response.makePart("tool-result", {
|
|
24
|
-
id: "lookup-1",
|
|
25
|
-
name: "lookup",
|
|
26
|
-
result: { answer: "transport binding" },
|
|
27
|
-
encodedResult: { answer: "transport binding" },
|
|
28
|
-
isFailure: false,
|
|
29
|
-
providerExecuted: false,
|
|
30
|
-
preliminary: false,
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
const frames: ReadonlyArray<Wire.LooseServerFrameType> = [
|
|
34
|
-
eventFrame(0, { _tag: "TurnStarted", turn: 0 }),
|
|
35
|
-
eventFrame(1, { _tag: "ModelPart", turn: 0, part: toolCall }),
|
|
36
|
-
eventFrame(2, { _tag: "ToolExecutionStarted", turn: 0, call: toolCall }),
|
|
37
|
-
eventFrame(3, { _tag: "ToolExecutionCompleted", turn: 0, call: toolCall, result: toolResult }),
|
|
38
|
-
eventFrame(4, { _tag: "TurnCompleted", turn: 0 }),
|
|
39
|
-
eventFrame(5, { _tag: "TurnStarted", turn: 1 }),
|
|
40
|
-
eventFrame(6, {
|
|
41
|
-
_tag: "ModelPart",
|
|
42
|
-
turn: 1,
|
|
43
|
-
part: Response.makePart("reasoning-delta", { id: "reasoning-1", delta: "Check the transport stream." }),
|
|
44
|
-
}),
|
|
45
|
-
eventFrame(7, {
|
|
46
|
-
_tag: "ModelPart",
|
|
47
|
-
turn: 1,
|
|
48
|
-
part: Response.makePart("text-delta", { id: "answer-1", delta: "Final answer" }),
|
|
49
|
-
}),
|
|
50
|
-
eventFrame(8, { _tag: "TurnCompleted", turn: 1 }),
|
|
51
|
-
eventFrame(9, { _tag: "Completed", turns: 2, text: "Final answer" }),
|
|
52
|
-
]
|
|
53
|
-
|
|
54
|
-
const reduceMessage = (model: Chat.Model, message: Chat.Message): Chat.Model => Chat.update(model, message)[0]
|
|
55
|
-
|
|
56
|
-
const withModel = <Model>(initialModel: Model) =>
|
|
57
|
-
Object.assign(
|
|
58
|
-
<M, Message, OutMessage = undefined>(simulation: SceneSimulation<M, Message, OutMessage>) =>
|
|
59
|
-
({ ...simulation, model: initialModel }) as unknown as SceneSimulation<M, Message, OutMessage>,
|
|
60
|
-
{ _phantomModel: undefined },
|
|
61
|
-
)
|
|
62
|
-
|
|
63
|
-
const scriptedModel = (): Chat.Model => {
|
|
64
|
-
let model = Chat.initialModel(null)
|
|
65
|
-
model = reduceMessage(model, Chat.OpenedSession({ sessionId }))
|
|
66
|
-
model = reduceMessage(model, Chat.ReceivedAgent({ incoming: Connection.ConnectionOpened() }))
|
|
67
|
-
model = reduceMessage(model, Chat.ChangedDraft({ text: "Render this run" }))
|
|
68
|
-
model = reduceMessage(model, Chat.SubmittedMessage())
|
|
69
|
-
for (const frame of frames) {
|
|
70
|
-
model = reduceMessage(model, Chat.ReceivedAgent({ incoming: frame }))
|
|
71
|
-
}
|
|
72
|
-
return model
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const rowView = (item: Chat.ConversationItem): Html => {
|
|
76
|
-
const h = html<Chat.Message>()
|
|
77
|
-
switch (item._tag) {
|
|
78
|
-
case "UserConversationItem":
|
|
79
|
-
return h.section([h.Role("article"), h.AriaLabel("User message")], [item.entry.text])
|
|
80
|
-
case "AssistantConversationItem":
|
|
81
|
-
return h.section(
|
|
82
|
-
[h.Role("article"), h.AriaLabel("Assistant message")],
|
|
83
|
-
[item.entry.reasoning ?? "", item.entry.text],
|
|
84
|
-
)
|
|
85
|
-
case "ToolConversationItem":
|
|
86
|
-
return h.section(
|
|
87
|
-
[h.Role("article"), h.AriaLabel(`Tool ${item.entry.name}`), h.DataAttribute("status", item.status)],
|
|
88
|
-
[item.entry.name, item.status, item.input, JSON.stringify(item.entry.outcome)],
|
|
89
|
-
)
|
|
90
|
-
case "StreamingConversationItem":
|
|
91
|
-
return h.section([h.Role("article"), h.AriaLabel("Streaming assistant message")], [item.reasoning, item.text])
|
|
92
|
-
case "WaitingConversationItem":
|
|
93
|
-
return h.section([h.Role("status")], ["Thinking"])
|
|
94
|
-
case "ApprovalConversationItem":
|
|
95
|
-
return h.section([h.Role("article"), h.AriaLabel("Approval required")], [item.toolName])
|
|
96
|
-
case "FailureConversationItem":
|
|
97
|
-
return h.section([h.Role("alert")], [item.message])
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const view = (model: Chat.Model): Document => {
|
|
102
|
-
const h = html<Chat.Message>()
|
|
103
|
-
return {
|
|
104
|
-
title: "Chat scene",
|
|
105
|
-
body: h.main(
|
|
106
|
-
[],
|
|
107
|
-
[
|
|
108
|
-
h.div([h.Role("log")], Chat.conversationItems(model).map(rowView)),
|
|
109
|
-
h.form(
|
|
110
|
-
[h.OnSubmit(Chat.SubmittedMessage())],
|
|
111
|
-
[
|
|
112
|
-
h.input([
|
|
113
|
-
h.AriaLabel("Message"),
|
|
114
|
-
h.Placeholder("Message"),
|
|
115
|
-
h.Value(model.draft),
|
|
116
|
-
h.OnInput((text) => Chat.ChangedDraft({ text })),
|
|
117
|
-
]),
|
|
118
|
-
h.button([h.Type("submit")], ["Send"]),
|
|
119
|
-
],
|
|
120
|
-
),
|
|
121
|
-
],
|
|
122
|
-
),
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
describe("Chat Scene", () => {
|
|
127
|
-
test("renders scripted stream rows for user, tool execution, and assistant completion", () => {
|
|
128
|
-
scene(
|
|
129
|
-
{ update: Chat.update, view },
|
|
130
|
-
withModel(scriptedModel()),
|
|
131
|
-
expect(role("article", { name: "User message" })).toContainText("Render this run"),
|
|
132
|
-
expect(role("article", { name: "Tool lookup" })).toContainText("lookup"),
|
|
133
|
-
expect(role("article", { name: "Tool lookup" })).toContainText("output-available"),
|
|
134
|
-
expect(role("article", { name: "Tool lookup" })).toContainText('"answer":"transport binding"'),
|
|
135
|
-
expect(role("article", { name: "Assistant message" })).toContainText("Check the transport stream."),
|
|
136
|
-
expect(role("article", { name: "Assistant message" })).toContainText("Final answer"),
|
|
137
|
-
)
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
test("send message dispatches the Baton send command", () => {
|
|
141
|
-
const model = reduceMessage(Chat.initialModel(null), Chat.OpenedSession({ sessionId }))
|
|
142
|
-
scene(
|
|
143
|
-
{ update: Chat.update, view },
|
|
144
|
-
withModel(model),
|
|
145
|
-
type(placeholder("Message"), "hello baton"),
|
|
146
|
-
click(role("button", { name: "Send" })),
|
|
147
|
-
Command.expectExact(Chat.SendUserMessage({ sessionId, text: "hello baton" })),
|
|
148
|
-
Command.resolve(Chat.SendUserMessage({ sessionId, text: "hello baton" }), Chat.SentUserMessage()),
|
|
149
|
-
expect(role("article", { name: "User message" })).toContainText("hello baton"),
|
|
150
|
-
)
|
|
151
|
-
})
|
|
152
|
-
})
|
package/test/chat.story.test.ts
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
import { Chat } from "../src/index"
|
|
2
|
-
import { Wire } from "@batonfx/transport"
|
|
3
|
-
import { Response } from "effect/unstable/ai"
|
|
4
|
-
import { Command, expectOutMessage, message, model, story } from "foldkit/story"
|
|
5
|
-
import type { StorySimulation } from "foldkit/story"
|
|
6
|
-
import { describe, expect, test } from "vitest"
|
|
7
|
-
|
|
8
|
-
const sessionId = "foldkit-story-session"
|
|
9
|
-
|
|
10
|
-
const eventFrame = (seq: number, event: Wire.EventType): Wire.LooseServerFrameType => ({ _tag: "Event", seq, event })
|
|
11
|
-
|
|
12
|
-
const receivedFrame = (frame: Wire.LooseServerFrameType) => Chat.ReceivedAgent({ incoming: frame })
|
|
13
|
-
|
|
14
|
-
const withModel = <Model>(initialModel: Model) =>
|
|
15
|
-
Object.assign(
|
|
16
|
-
<M, Message, OutMessage = undefined>(simulation: StorySimulation<M, Message, OutMessage>) =>
|
|
17
|
-
({ ...simulation, model: initialModel }) as unknown as StorySimulation<M, Message, OutMessage>,
|
|
18
|
-
{ _phantomModel: undefined },
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
const toolCall = Response.makePart("tool-call", {
|
|
22
|
-
id: "lookup-1",
|
|
23
|
-
name: "lookup",
|
|
24
|
-
params: { query: "baton foldkit" },
|
|
25
|
-
providerExecuted: false,
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
const toolResult = Response.makePart("tool-result", {
|
|
29
|
-
id: "lookup-1",
|
|
30
|
-
name: "lookup",
|
|
31
|
-
result: { answer: "transport binding" },
|
|
32
|
-
encodedResult: { answer: "transport binding" },
|
|
33
|
-
isFailure: false,
|
|
34
|
-
providerExecuted: false,
|
|
35
|
-
preliminary: false,
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
const frames: ReadonlyArray<Wire.LooseServerFrameType> = [
|
|
39
|
-
eventFrame(0, { _tag: "TurnStarted", turn: 0 }),
|
|
40
|
-
eventFrame(1, { _tag: "ModelPart", turn: 0, part: toolCall }),
|
|
41
|
-
eventFrame(2, { _tag: "ToolExecutionStarted", turn: 0, call: toolCall }),
|
|
42
|
-
eventFrame(3, { _tag: "ToolProgress", turn: 0, toolCallId: "lookup-1", message: "looking up" }),
|
|
43
|
-
eventFrame(4, { _tag: "ToolExecutionCompleted", turn: 0, call: toolCall, result: toolResult }),
|
|
44
|
-
eventFrame(5, { _tag: "TurnCompleted", turn: 0 }),
|
|
45
|
-
eventFrame(6, { _tag: "TurnStarted", turn: 1 }),
|
|
46
|
-
eventFrame(7, {
|
|
47
|
-
_tag: "ModelPart",
|
|
48
|
-
turn: 1,
|
|
49
|
-
part: Response.makePart("reasoning-delta", { id: "reasoning-1", delta: "Check the transport stream." }),
|
|
50
|
-
}),
|
|
51
|
-
eventFrame(8, {
|
|
52
|
-
_tag: "ModelPart",
|
|
53
|
-
turn: 1,
|
|
54
|
-
part: Response.makePart("text-delta", { id: "answer-1", delta: "Final answer" }),
|
|
55
|
-
}),
|
|
56
|
-
eventFrame(9, { _tag: "TurnCompleted", turn: 1 }),
|
|
57
|
-
eventFrame(10, { _tag: "Completed", turns: 2, text: "Final answer" }),
|
|
58
|
-
]
|
|
59
|
-
|
|
60
|
-
describe("Chat Story", () => {
|
|
61
|
-
test("scripted agent frames drive tool call, execution, and completion state", () => {
|
|
62
|
-
story(
|
|
63
|
-
Chat.update,
|
|
64
|
-
withModel(Chat.initialModel(null)),
|
|
65
|
-
message(Chat.OpenedSession({ sessionId })),
|
|
66
|
-
model((currentModel) => {
|
|
67
|
-
expect(currentModel.sessionId).toBe(sessionId)
|
|
68
|
-
expect(currentModel.connection).toBe("connecting")
|
|
69
|
-
}),
|
|
70
|
-
message(Chat.ChangedDraft({ text: "Render this run" })),
|
|
71
|
-
message(Chat.SubmittedMessage()),
|
|
72
|
-
Command.expectExact(Chat.SendUserMessage({ sessionId, text: "Render this run" })),
|
|
73
|
-
Command.resolve(Chat.SendUserMessage({ sessionId, text: "Render this run" }), Chat.SentUserMessage()),
|
|
74
|
-
message(receivedFrame(frames[0]!)),
|
|
75
|
-
message(receivedFrame(frames[1]!)),
|
|
76
|
-
model((currentModel) => {
|
|
77
|
-
const tool = currentModel.entries[1]
|
|
78
|
-
if (tool?._tag !== "ToolEntry") throw new Error("expected tool call entry")
|
|
79
|
-
expect(Chat.toolStatusOf(tool)).toBe("input-streaming")
|
|
80
|
-
}),
|
|
81
|
-
message(receivedFrame(frames[2]!)),
|
|
82
|
-
model((currentModel) => {
|
|
83
|
-
const tool = currentModel.entries[1]
|
|
84
|
-
if (tool?._tag !== "ToolEntry") throw new Error("expected executing tool entry")
|
|
85
|
-
expect(Chat.toolStatusOf(tool)).toBe("input-available")
|
|
86
|
-
}),
|
|
87
|
-
...frames.slice(3).map((frame) => message(receivedFrame(frame))),
|
|
88
|
-
expectOutMessage(Chat.RunCompleted({ text: "Final answer" })),
|
|
89
|
-
model((currentModel) => {
|
|
90
|
-
expect(currentModel.run).toEqual(Chat.Idle())
|
|
91
|
-
expect(currentModel.streaming).toBeNull()
|
|
92
|
-
expect(Chat.conversationItems(currentModel).map((item) => item._tag)).toEqual([
|
|
93
|
-
"UserConversationItem",
|
|
94
|
-
"ToolConversationItem",
|
|
95
|
-
"AssistantConversationItem",
|
|
96
|
-
])
|
|
97
|
-
|
|
98
|
-
const tool = currentModel.entries[1]
|
|
99
|
-
if (tool?._tag !== "ToolEntry") throw new Error("expected completed tool entry")
|
|
100
|
-
expect(tool.progress).toEqual(["looking up"])
|
|
101
|
-
expect(Chat.toolStatusOf(tool)).toBe("output-available")
|
|
102
|
-
|
|
103
|
-
const assistant = currentModel.entries[2]
|
|
104
|
-
expect(assistant).toEqual(
|
|
105
|
-
Chat.AssistantEntry({ text: "Final answer", reasoning: "Check the transport stream." }),
|
|
106
|
-
)
|
|
107
|
-
}),
|
|
108
|
-
)
|
|
109
|
-
})
|
|
110
|
-
})
|
package/test/chat.test.ts
DELETED
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
-
import { Effect, Option, Schema, Stream } from "effect"
|
|
3
|
-
import { Response } 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: 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: Response.makePart("text-delta", { id: "text-1", delta: "world" }),
|
|
35
|
-
}),
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
const call = 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
|
-
phase: "called",
|
|
52
|
-
outcome: { _tag: "Pending" },
|
|
53
|
-
progress: [],
|
|
54
|
-
},
|
|
55
|
-
])
|
|
56
|
-
const pendingTool = model.entries[0]
|
|
57
|
-
if (pendingTool?._tag !== "ToolEntry") throw new Error("expected pending tool entry")
|
|
58
|
-
expect(Chat.toolStatusOf(pendingTool)).toBe("input-streaming")
|
|
59
|
-
|
|
60
|
-
const result = Response.makePart("tool-result", {
|
|
61
|
-
id: "call-1",
|
|
62
|
-
name: "lookup",
|
|
63
|
-
result: { ok: true },
|
|
64
|
-
encodedResult: { ok: true },
|
|
65
|
-
isFailure: false,
|
|
66
|
-
providerExecuted: false,
|
|
67
|
-
preliminary: false,
|
|
68
|
-
})
|
|
69
|
-
;[model] = updateWith(model, eventFrame(4, { _tag: "ToolExecutionCompleted", turn: 0, call, result }))
|
|
70
|
-
expect(model.entries[0]).toMatchObject({
|
|
71
|
-
_tag: "ToolEntry",
|
|
72
|
-
phase: "executing",
|
|
73
|
-
outcome: { _tag: "Completed", isFailure: false, result: { ok: true } },
|
|
74
|
-
})
|
|
75
|
-
const completedTool = model.entries[0]
|
|
76
|
-
if (completedTool?._tag !== "ToolEntry") throw new Error("expected completed tool entry")
|
|
77
|
-
expect(Chat.toolStatusOf(completedTool)).toBe("output-available")
|
|
78
|
-
;[model] = updateWith(model, eventFrame(5, { _tag: "TurnCompleted", turn: 0 }))
|
|
79
|
-
expect(model.streaming).toBeNull()
|
|
80
|
-
expect(model.entries[1]).toEqual({ _tag: "AssistantEntry", text: "Hello world", reasoning: null })
|
|
81
|
-
;[model, , out] = updateWith(model, eventFrame(6, { _tag: "Completed", turns: 1, text: "Done" }))
|
|
82
|
-
expect(model.run).toEqual({ _tag: "Idle" })
|
|
83
|
-
expect(Option.getOrUndefined(out)).toEqual({ _tag: "RunCompleted", text: "Done" })
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
it("drops replayed frames whose seq is not newer than lastSeq", () => {
|
|
87
|
-
const model = { ...Chat.initialModel("s-chat"), lastSeq: 5, entries: [Chat.UserEntry({ text: "already" })] }
|
|
88
|
-
const [next, commands, out] = updateWith(model, eventFrame(5, { _tag: "TurnStarted", turn: 1 }))
|
|
89
|
-
|
|
90
|
-
expect(next).toEqual(model)
|
|
91
|
-
expect(commands).toEqual([])
|
|
92
|
-
expect(Option.isNone(out)).toBe(true)
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
it("surfaces approval suspension and emits approval commands", () => {
|
|
96
|
-
let model = Chat.initialModel("s-chat")
|
|
97
|
-
let out: Option.Option<Chat.OutMessage> = Option.none()
|
|
98
|
-
;[model, , out] = updateWith(
|
|
99
|
-
model,
|
|
100
|
-
Schema.decodeUnknownSync(Wire.LooseServerFrame)({
|
|
101
|
-
_tag: "Suspended",
|
|
102
|
-
seq: 0,
|
|
103
|
-
suspension: {
|
|
104
|
-
_tag: "@batonfx/core/AgentSuspended",
|
|
105
|
-
token: "approval-token",
|
|
106
|
-
reason: "approval",
|
|
107
|
-
tool_call_id: "call-approval",
|
|
108
|
-
tool_name: "lookup",
|
|
109
|
-
tool_params: { q: "baton" },
|
|
110
|
-
},
|
|
111
|
-
}),
|
|
112
|
-
)
|
|
113
|
-
|
|
114
|
-
expect(model.run).toEqual({
|
|
115
|
-
_tag: "AwaitingApproval",
|
|
116
|
-
token: "approval-token",
|
|
117
|
-
toolName: "lookup",
|
|
118
|
-
params: { q: "baton" },
|
|
119
|
-
})
|
|
120
|
-
expect(Option.getOrUndefined(out)).toEqual({ _tag: "ApprovalRequired" })
|
|
121
|
-
|
|
122
|
-
const [, commands] = Chat.update(model, Chat.ClickedApprove())
|
|
123
|
-
expect(commands).toHaveLength(1)
|
|
124
|
-
expect(commands[0]?.name).toBe("ResolveApproval")
|
|
125
|
-
expect(commands[0]?.args).toEqual({
|
|
126
|
-
sessionId: "s-chat",
|
|
127
|
-
token: "approval-token",
|
|
128
|
-
approved: true,
|
|
129
|
-
reason: null,
|
|
130
|
-
})
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
it.effect("converts command send failures into FailedAgentCommand messages", () =>
|
|
134
|
-
Effect.gen(function* () {
|
|
135
|
-
let model = Chat.initialModel("s-chat")
|
|
136
|
-
;[model] = Chat.update(model, Chat.ChangedDraft({ text: "hello" }))
|
|
137
|
-
const [next, commands] = Chat.update(model, Chat.SubmittedMessage())
|
|
138
|
-
|
|
139
|
-
expect(next.draft).toBe("")
|
|
140
|
-
expect(next.entries).toEqual([Chat.UserEntry({ text: "hello" })])
|
|
141
|
-
expect(commands).toHaveLength(1)
|
|
142
|
-
const message = yield* commands[0]!.effect
|
|
143
|
-
|
|
144
|
-
expect(message).toEqual({ _tag: "FailedAgentCommand", reason: "offline" })
|
|
145
|
-
}).pipe(
|
|
146
|
-
Effect.provide(
|
|
147
|
-
Connection.testLayer({
|
|
148
|
-
frames: () => Stream.empty,
|
|
149
|
-
send: () => Effect.fail(new Connection.SendFailed({ reason: "offline" })),
|
|
150
|
-
}),
|
|
151
|
-
),
|
|
152
|
-
),
|
|
153
|
-
)
|
|
154
|
-
})
|
package/test/connection.test.ts
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
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
|
-
})
|