@batonfx/foldkit 0.1.1 → 0.3.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 +2 -2
- package/dist/index.js +452 -23
- package/package.json +2 -2
- package/src/chat.ts +264 -25
- package/test/chat.scene.test.ts +152 -0
- package/test/chat.story.test.ts +110 -0
- package/test/chat.test.ts +13 -5
package/src/chat.ts
CHANGED
|
@@ -1,21 +1,29 @@
|
|
|
1
1
|
import { Cause, Equivalence, Effect, Option, Schema, Stream } from "effect"
|
|
2
|
-
import
|
|
3
|
-
import
|
|
2
|
+
import { Prompt } from "effect/unstable/ai"
|
|
3
|
+
import { define, type Command } from "foldkit/command"
|
|
4
4
|
import { m } from "foldkit/message"
|
|
5
5
|
import type { CallableTaggedStruct } from "foldkit/schema"
|
|
6
|
-
import
|
|
6
|
+
import { make } from "foldkit/subscription"
|
|
7
7
|
import { Wire } from "@batonfx/transport"
|
|
8
|
-
import
|
|
9
|
-
|
|
8
|
+
import { AgentConnection, Incoming, SendFailed } from "./connection"
|
|
10
9
|
const CompletedFields = { isFailure: Schema.Boolean, result: Schema.Unknown }
|
|
11
10
|
const UserEntryFields = { text: Schema.String }
|
|
12
11
|
const AssistantEntryFields = { text: Schema.String, reasoning: Schema.NullOr(Schema.String) }
|
|
13
12
|
const RunCompletedFields = { text: Schema.String }
|
|
14
13
|
const StringReasonFields = { reason: Schema.String }
|
|
14
|
+
const OpenedSessionFields = { sessionId: Schema.String }
|
|
15
|
+
|
|
16
|
+
/** @experimental */
|
|
17
|
+
export const ToolPendingPhase = Schema.Literals(["called", "executing"])
|
|
18
|
+
|
|
19
|
+
/** @experimental */
|
|
20
|
+
export type ToolPendingPhase = typeof ToolPendingPhase.Type
|
|
21
|
+
|
|
15
22
|
const ToolEntryFields = {
|
|
16
23
|
callId: Schema.String,
|
|
17
24
|
name: Schema.String,
|
|
18
25
|
params: Schema.Unknown,
|
|
26
|
+
phase: ToolPendingPhase,
|
|
19
27
|
outcome: Schema.suspend((): Schema.Schema<ToolOutcome> => ToolOutcome),
|
|
20
28
|
progress: Schema.Array(Schema.String),
|
|
21
29
|
}
|
|
@@ -26,7 +34,7 @@ const AwaitingApprovalFields = {
|
|
|
26
34
|
params: Schema.Unknown,
|
|
27
35
|
}
|
|
28
36
|
const ClickedDenyFields = { reason: Schema.NullOr(Schema.String) }
|
|
29
|
-
const ReceivedAgentFields = { incoming:
|
|
37
|
+
const ReceivedAgentFields = { incoming: Incoming }
|
|
30
38
|
const ModelStreaming = Schema.Struct({
|
|
31
39
|
turn: Schema.Number,
|
|
32
40
|
text: Schema.String,
|
|
@@ -112,6 +120,12 @@ export const ReceivedAgent: CallableTaggedStruct<"ReceivedAgent", typeof Receive
|
|
|
112
120
|
ReceivedAgentFields,
|
|
113
121
|
)
|
|
114
122
|
|
|
123
|
+
/** @experimental */
|
|
124
|
+
export const OpenedSession: CallableTaggedStruct<"OpenedSession", typeof OpenedSessionFields> = m(
|
|
125
|
+
"OpenedSession",
|
|
126
|
+
OpenedSessionFields,
|
|
127
|
+
)
|
|
128
|
+
|
|
115
129
|
/** @experimental */
|
|
116
130
|
export const ChangedDraft: CallableTaggedStruct<"ChangedDraft", typeof UserEntryFields> = m(
|
|
117
131
|
"ChangedDraft",
|
|
@@ -151,6 +165,7 @@ export const FailedAgentCommand: CallableTaggedStruct<"FailedAgentCommand", type
|
|
|
151
165
|
/** @experimental */
|
|
152
166
|
export type Message =
|
|
153
167
|
| typeof ReceivedAgent.Type
|
|
168
|
+
| typeof OpenedSession.Type
|
|
154
169
|
| typeof ChangedDraft.Type
|
|
155
170
|
| typeof SubmittedMessage.Type
|
|
156
171
|
| typeof ClickedCancel.Type
|
|
@@ -164,6 +179,7 @@ export type Message =
|
|
|
164
179
|
/** @experimental */
|
|
165
180
|
export const Message: Schema.Schema<Message> = Schema.Union([
|
|
166
181
|
ReceivedAgent,
|
|
182
|
+
OpenedSession,
|
|
167
183
|
ChangedDraft,
|
|
168
184
|
SubmittedMessage,
|
|
169
185
|
ClickedCancel,
|
|
@@ -196,7 +212,124 @@ export type OutMessage = typeof RunCompleted.Type | typeof ApprovalRequired.Type
|
|
|
196
212
|
export const OutMessage: Schema.Schema<OutMessage> = Schema.Union([RunCompleted, ApprovalRequired, RunFailed])
|
|
197
213
|
|
|
198
214
|
/** @experimental */
|
|
199
|
-
export
|
|
215
|
+
export const MessageAlign = Schema.Literals(["start", "end"])
|
|
216
|
+
|
|
217
|
+
/** @experimental */
|
|
218
|
+
export type MessageAlign = typeof MessageAlign.Type
|
|
219
|
+
|
|
220
|
+
/** @experimental */
|
|
221
|
+
export const PromptInputStatus = Schema.Literals(["idle", "submitted", "streaming", "error"])
|
|
222
|
+
|
|
223
|
+
/** @experimental */
|
|
224
|
+
export type PromptInputStatus = typeof PromptInputStatus.Type
|
|
225
|
+
|
|
226
|
+
/** @experimental */
|
|
227
|
+
export const ToolStatus = Schema.Literals(["input-streaming", "input-available", "output-available", "output-error"])
|
|
228
|
+
|
|
229
|
+
/** @experimental */
|
|
230
|
+
export type ToolStatus = typeof ToolStatus.Type
|
|
231
|
+
|
|
232
|
+
/** @experimental */
|
|
233
|
+
export const UserConversationItem: CallableTaggedStruct<
|
|
234
|
+
"UserConversationItem",
|
|
235
|
+
{ key: typeof Schema.String; align: typeof MessageAlign; entry: typeof UserEntry }
|
|
236
|
+
> = m("UserConversationItem", { key: Schema.String, align: MessageAlign, entry: UserEntry })
|
|
237
|
+
|
|
238
|
+
/** @experimental */
|
|
239
|
+
export const AssistantConversationItem: CallableTaggedStruct<
|
|
240
|
+
"AssistantConversationItem",
|
|
241
|
+
{ key: typeof Schema.String; align: typeof MessageAlign; entry: typeof AssistantEntry }
|
|
242
|
+
> = m("AssistantConversationItem", { key: Schema.String, align: MessageAlign, entry: AssistantEntry })
|
|
243
|
+
|
|
244
|
+
/** @experimental */
|
|
245
|
+
export const ToolConversationItem: CallableTaggedStruct<
|
|
246
|
+
"ToolConversationItem",
|
|
247
|
+
{
|
|
248
|
+
key: typeof Schema.String
|
|
249
|
+
align: typeof MessageAlign
|
|
250
|
+
entry: typeof ToolEntry
|
|
251
|
+
status: typeof ToolStatus
|
|
252
|
+
input: typeof Schema.String
|
|
253
|
+
}
|
|
254
|
+
> = m("ToolConversationItem", {
|
|
255
|
+
key: Schema.String,
|
|
256
|
+
align: MessageAlign,
|
|
257
|
+
entry: ToolEntry,
|
|
258
|
+
status: ToolStatus,
|
|
259
|
+
input: Schema.String,
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
/** @experimental */
|
|
263
|
+
export const StreamingConversationItem: CallableTaggedStruct<
|
|
264
|
+
"StreamingConversationItem",
|
|
265
|
+
{
|
|
266
|
+
key: typeof Schema.String
|
|
267
|
+
align: typeof MessageAlign
|
|
268
|
+
text: typeof Schema.String
|
|
269
|
+
reasoning: typeof Schema.String
|
|
270
|
+
isStreaming: typeof Schema.Boolean
|
|
271
|
+
}
|
|
272
|
+
> = m("StreamingConversationItem", {
|
|
273
|
+
key: Schema.String,
|
|
274
|
+
align: MessageAlign,
|
|
275
|
+
text: Schema.String,
|
|
276
|
+
reasoning: Schema.String,
|
|
277
|
+
isStreaming: Schema.Boolean,
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
/** @experimental */
|
|
281
|
+
export const WaitingConversationItem: CallableTaggedStruct<
|
|
282
|
+
"WaitingConversationItem",
|
|
283
|
+
{ key: typeof Schema.String; align: typeof MessageAlign }
|
|
284
|
+
> = m("WaitingConversationItem", { key: Schema.String, align: MessageAlign })
|
|
285
|
+
|
|
286
|
+
/** @experimental */
|
|
287
|
+
export const ApprovalConversationItem: CallableTaggedStruct<
|
|
288
|
+
"ApprovalConversationItem",
|
|
289
|
+
{
|
|
290
|
+
key: typeof Schema.String
|
|
291
|
+
align: typeof MessageAlign
|
|
292
|
+
token: typeof Schema.String
|
|
293
|
+
toolName: typeof Schema.String
|
|
294
|
+
params: typeof Schema.Unknown
|
|
295
|
+
}
|
|
296
|
+
> = m("ApprovalConversationItem", {
|
|
297
|
+
key: Schema.String,
|
|
298
|
+
align: MessageAlign,
|
|
299
|
+
token: Schema.String,
|
|
300
|
+
toolName: Schema.String,
|
|
301
|
+
params: Schema.Unknown,
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
/** @experimental */
|
|
305
|
+
export const FailureConversationItem: CallableTaggedStruct<
|
|
306
|
+
"FailureConversationItem",
|
|
307
|
+
{ key: typeof Schema.String; align: typeof MessageAlign; message: typeof Schema.String }
|
|
308
|
+
> = m("FailureConversationItem", { key: Schema.String, align: MessageAlign, message: Schema.String })
|
|
309
|
+
|
|
310
|
+
/** @experimental */
|
|
311
|
+
export type ConversationItem =
|
|
312
|
+
| typeof UserConversationItem.Type
|
|
313
|
+
| typeof AssistantConversationItem.Type
|
|
314
|
+
| typeof ToolConversationItem.Type
|
|
315
|
+
| typeof StreamingConversationItem.Type
|
|
316
|
+
| typeof WaitingConversationItem.Type
|
|
317
|
+
| typeof ApprovalConversationItem.Type
|
|
318
|
+
| typeof FailureConversationItem.Type
|
|
319
|
+
|
|
320
|
+
/** @experimental */
|
|
321
|
+
export const ConversationItem: Schema.Schema<ConversationItem> = Schema.Union([
|
|
322
|
+
UserConversationItem,
|
|
323
|
+
AssistantConversationItem,
|
|
324
|
+
ToolConversationItem,
|
|
325
|
+
StreamingConversationItem,
|
|
326
|
+
WaitingConversationItem,
|
|
327
|
+
ApprovalConversationItem,
|
|
328
|
+
FailureConversationItem,
|
|
329
|
+
])
|
|
330
|
+
|
|
331
|
+
/** @experimental */
|
|
332
|
+
export type ChatCommand = Command<Message, any, AgentConnection>
|
|
200
333
|
|
|
201
334
|
/** @experimental */
|
|
202
335
|
export const initialModel = (sessionId: string | null = null): Model => ({
|
|
@@ -212,19 +345,19 @@ export const initialModel = (sessionId: string | null = null): Model => ({
|
|
|
212
345
|
type FailedAgentCommandMessage = typeof FailedAgentCommand.Type
|
|
213
346
|
|
|
214
347
|
const commandFailed = (error: unknown): FailedAgentCommandMessage =>
|
|
215
|
-
FailedAgentCommand({ reason: error instanceof
|
|
348
|
+
FailedAgentCommand({ reason: error instanceof SendFailed ? error.reason : String(error) })
|
|
216
349
|
|
|
217
|
-
const catchCommandFailure = <A>(effect: Effect.Effect<A,
|
|
350
|
+
const catchCommandFailure = <A>(effect: Effect.Effect<A, SendFailed, AgentConnection>) =>
|
|
218
351
|
effect.pipe(Effect.catchCause((cause) => Effect.succeed(commandFailed(Cause.squash(cause)))))
|
|
219
352
|
|
|
220
353
|
/** @experimental */
|
|
221
|
-
export const SendUserMessage =
|
|
354
|
+
export const SendUserMessage = define(
|
|
222
355
|
"SendUserMessage",
|
|
223
356
|
{ sessionId: Schema.String, text: Schema.String },
|
|
224
357
|
SentUserMessage,
|
|
225
358
|
FailedAgentCommand,
|
|
226
359
|
)(({ sessionId, text }) =>
|
|
227
|
-
|
|
360
|
+
AgentConnection.use((connection) =>
|
|
228
361
|
catchCommandFailure(
|
|
229
362
|
connection.send({ _tag: "SendMessage", sessionId, prompt: text }).pipe(Effect.as(SentUserMessage())),
|
|
230
363
|
),
|
|
@@ -232,7 +365,7 @@ export const SendUserMessage = Command.define(
|
|
|
232
365
|
)
|
|
233
366
|
|
|
234
367
|
/** @experimental */
|
|
235
|
-
export const ResolveApproval =
|
|
368
|
+
export const ResolveApproval = define(
|
|
236
369
|
"ResolveApproval",
|
|
237
370
|
{
|
|
238
371
|
sessionId: Schema.String,
|
|
@@ -248,7 +381,7 @@ export const ResolveApproval = Command.define(
|
|
|
248
381
|
: reason === null
|
|
249
382
|
? { _tag: "Denied" }
|
|
250
383
|
: { _tag: "Denied", reason }
|
|
251
|
-
return
|
|
384
|
+
return AgentConnection.use((connection) =>
|
|
252
385
|
catchCommandFailure(
|
|
253
386
|
connection.send({ _tag: "ResolveApproval", sessionId, token, decision }).pipe(Effect.as(ResolvedApproval())),
|
|
254
387
|
),
|
|
@@ -256,13 +389,13 @@ export const ResolveApproval = Command.define(
|
|
|
256
389
|
})
|
|
257
390
|
|
|
258
391
|
/** @experimental */
|
|
259
|
-
export const CancelRun =
|
|
392
|
+
export const CancelRun = define(
|
|
260
393
|
"CancelRun",
|
|
261
394
|
{ sessionId: Schema.String },
|
|
262
395
|
CancelledRun,
|
|
263
396
|
FailedAgentCommand,
|
|
264
397
|
)(({ sessionId }) =>
|
|
265
|
-
|
|
398
|
+
AgentConnection.use((connection) =>
|
|
266
399
|
catchCommandFailure(connection.send({ _tag: "Cancel", sessionId }).pipe(Effect.as(CancelledRun()))),
|
|
267
400
|
),
|
|
268
401
|
)
|
|
@@ -322,18 +455,20 @@ const flushStreaming = (model: Model): Model => {
|
|
|
322
455
|
return { ...model, entries: [...model.entries, ...entry], streaming: null }
|
|
323
456
|
}
|
|
324
457
|
|
|
325
|
-
const upsertToolCall = (
|
|
458
|
+
const upsertToolCall = (
|
|
459
|
+
entries: ReadonlyArray<ChatEntry>,
|
|
460
|
+
call: ToolCallLike,
|
|
461
|
+
phase: ToolPendingPhase = "called",
|
|
462
|
+
): ReadonlyArray<ChatEntry> => {
|
|
326
463
|
const index = entries.findIndex((entry) => entry._tag === "ToolEntry" && entry.callId === call.id)
|
|
327
464
|
const previous = index >= 0 ? entries[index] : undefined
|
|
328
465
|
const previousToolEntry = previous?._tag === "ToolEntry" ? previous : undefined
|
|
466
|
+
const nextPhase = previousToolEntry?.phase === "executing" || phase === "executing" ? "executing" : "called"
|
|
329
467
|
const next = ToolEntry({
|
|
330
468
|
callId: call.id,
|
|
331
469
|
name: call.name,
|
|
332
|
-
// `resolveTool` re-upserts with `params: undefined` defensively (a tool
|
|
333
|
-
// result may arrive without this view ever having seen the matching
|
|
334
|
-
// tool-call part). Falling back to the previously tracked params keeps a
|
|
335
|
-
// real tool call's params from being wiped out when its result resolves.
|
|
336
470
|
params: call.params === undefined ? previousToolEntry?.params : call.params,
|
|
471
|
+
phase: nextPhase,
|
|
337
472
|
outcome: previousToolEntry?.outcome ?? Pending(),
|
|
338
473
|
progress: previousToolEntry?.progress ?? [],
|
|
339
474
|
})
|
|
@@ -349,6 +484,7 @@ const resolveTool = (entries: ReadonlyArray<ChatEntry>, result: ToolResultLike):
|
|
|
349
484
|
callId: entry.callId,
|
|
350
485
|
name: entry.name,
|
|
351
486
|
params: entry.params,
|
|
487
|
+
phase: entry.phase,
|
|
352
488
|
outcome: Completed({ isFailure: result.isFailure, result: result.result }),
|
|
353
489
|
progress: entry.progress,
|
|
354
490
|
})
|
|
@@ -363,6 +499,7 @@ const addProgress = (entries: ReadonlyArray<ChatEntry>, callId: string, message:
|
|
|
363
499
|
callId: entry.callId,
|
|
364
500
|
name: entry.name,
|
|
365
501
|
params: entry.params,
|
|
502
|
+
phase: entry.phase,
|
|
366
503
|
outcome: entry.outcome,
|
|
367
504
|
progress: entry.progress.concat(message),
|
|
368
505
|
})
|
|
@@ -441,6 +578,7 @@ const applyEvent = (model: Model, event: Wire.EventType): readonly [Model, Optio
|
|
|
441
578
|
case "ModelPart":
|
|
442
579
|
return [applyPart(model, event.turn, event.part), Option.none()]
|
|
443
580
|
case "ToolExecutionStarted":
|
|
581
|
+
return [{ ...model, entries: upsertToolCall(model.entries, event.call, "executing") }, Option.none()]
|
|
444
582
|
case "ApprovalRequested":
|
|
445
583
|
return [{ ...model, entries: upsertToolCall(model.entries, event.call) }, Option.none()]
|
|
446
584
|
case "ToolProgress":
|
|
@@ -449,7 +587,7 @@ const applyEvent = (model: Model, event: Wire.EventType): readonly [Model, Optio
|
|
|
449
587
|
: [{ ...model, entries: addProgress(model.entries, event.toolCallId, event.message) }, Option.none()]
|
|
450
588
|
case "ToolExecutionCompleted":
|
|
451
589
|
return [
|
|
452
|
-
{ ...model, entries: resolveTool(upsertToolCall(model.entries, event.call), event.result) },
|
|
590
|
+
{ ...model, entries: resolveTool(upsertToolCall(model.entries, event.call, "executing"), event.result) },
|
|
453
591
|
Option.none(),
|
|
454
592
|
]
|
|
455
593
|
case "TurnCompleted":
|
|
@@ -477,7 +615,7 @@ const reasoningFromContent = (content: ReadonlyArray<unknown>): string | null =>
|
|
|
477
615
|
return reasoning.length === 0 ? null : reasoning
|
|
478
616
|
}
|
|
479
617
|
|
|
480
|
-
const projectPrompt = (prompt:
|
|
618
|
+
const projectPrompt = (prompt: Prompt.Prompt): ReadonlyArray<ChatEntry> => {
|
|
481
619
|
let entries: ReadonlyArray<ChatEntry> = []
|
|
482
620
|
for (const message of prompt.content) {
|
|
483
621
|
if (message.role === "user") {
|
|
@@ -521,7 +659,94 @@ const applyFrame = (model: Model, frame: Wire.LooseServerFrameType): readonly [M
|
|
|
521
659
|
}
|
|
522
660
|
}
|
|
523
661
|
|
|
524
|
-
const
|
|
662
|
+
const jsonText = (value: unknown): string => {
|
|
663
|
+
try {
|
|
664
|
+
return JSON.stringify(value, null, 2) ?? "undefined"
|
|
665
|
+
} catch {
|
|
666
|
+
return String(value)
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** @experimental */
|
|
671
|
+
export const promptInputStatusOf = (run: RunState): PromptInputStatus => {
|
|
672
|
+
switch (run._tag) {
|
|
673
|
+
case "Idle":
|
|
674
|
+
return "idle"
|
|
675
|
+
case "Running":
|
|
676
|
+
return "streaming"
|
|
677
|
+
case "AwaitingApproval":
|
|
678
|
+
return "submitted"
|
|
679
|
+
case "Failed":
|
|
680
|
+
return "error"
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** @experimental */
|
|
685
|
+
export const toolStatusOf = (entry: typeof ToolEntry.Type): ToolStatus => {
|
|
686
|
+
switch (entry.outcome._tag) {
|
|
687
|
+
case "Pending":
|
|
688
|
+
return entry.phase === "executing" ? "input-available" : "input-streaming"
|
|
689
|
+
case "Completed":
|
|
690
|
+
return entry.outcome.isFailure ? "output-error" : "output-available"
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const conversationItemFor = (entry: ChatEntry, index: number): ConversationItem => {
|
|
695
|
+
switch (entry._tag) {
|
|
696
|
+
case "UserEntry":
|
|
697
|
+
return UserConversationItem({ key: `entry-${index}-user`, align: "end", entry })
|
|
698
|
+
case "AssistantEntry":
|
|
699
|
+
return AssistantConversationItem({ key: `entry-${index}-assistant`, align: "start", entry })
|
|
700
|
+
case "ToolEntry":
|
|
701
|
+
return ToolConversationItem({
|
|
702
|
+
key: `tool-${entry.callId}`,
|
|
703
|
+
align: "start",
|
|
704
|
+
entry,
|
|
705
|
+
status: toolStatusOf(entry),
|
|
706
|
+
input: jsonText(entry.params),
|
|
707
|
+
})
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** @experimental */
|
|
712
|
+
export const conversationItems = (model: Model): ReadonlyArray<ConversationItem> => {
|
|
713
|
+
const entries = model.entries.map(conversationItemFor)
|
|
714
|
+
const streaming =
|
|
715
|
+
model.streaming === null
|
|
716
|
+
? []
|
|
717
|
+
: [
|
|
718
|
+
StreamingConversationItem({
|
|
719
|
+
key: "streaming-assistant",
|
|
720
|
+
align: "start",
|
|
721
|
+
text: model.streaming.text,
|
|
722
|
+
reasoning: model.streaming.reasoning,
|
|
723
|
+
isStreaming: true,
|
|
724
|
+
}),
|
|
725
|
+
]
|
|
726
|
+
const waiting =
|
|
727
|
+
model.run._tag === "Running" && model.streaming === null
|
|
728
|
+
? [WaitingConversationItem({ key: "waiting-assistant", align: "start" })]
|
|
729
|
+
: []
|
|
730
|
+
const approval =
|
|
731
|
+
model.run._tag === "AwaitingApproval"
|
|
732
|
+
? [
|
|
733
|
+
ApprovalConversationItem({
|
|
734
|
+
key: `approval-${model.run.token}`,
|
|
735
|
+
align: "start",
|
|
736
|
+
token: model.run.token,
|
|
737
|
+
toolName: model.run.toolName,
|
|
738
|
+
params: model.run.params,
|
|
739
|
+
}),
|
|
740
|
+
]
|
|
741
|
+
: []
|
|
742
|
+
const failure =
|
|
743
|
+
model.run._tag === "Failed"
|
|
744
|
+
? [FailureConversationItem({ key: "run-failure", align: "start", message: model.run.message })]
|
|
745
|
+
: []
|
|
746
|
+
return [...entries, ...streaming, ...waiting, ...approval, ...failure]
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const isServerFrame = (incoming: Incoming): incoming is Wire.LooseServerFrameType =>
|
|
525
750
|
incoming._tag === "Event" ||
|
|
526
751
|
incoming._tag === "Suspended" ||
|
|
527
752
|
incoming._tag === "Failed" ||
|
|
@@ -556,6 +781,20 @@ export const update = (
|
|
|
556
781
|
Option.some(RunFailed({ message: message.incoming.reason })),
|
|
557
782
|
]
|
|
558
783
|
}
|
|
784
|
+
case "OpenedSession":
|
|
785
|
+
return [
|
|
786
|
+
{
|
|
787
|
+
...model,
|
|
788
|
+
sessionId: message.sessionId,
|
|
789
|
+
connection: "connecting",
|
|
790
|
+
lastSeq: -1,
|
|
791
|
+
run: Idle(),
|
|
792
|
+
entries: [],
|
|
793
|
+
streaming: null,
|
|
794
|
+
},
|
|
795
|
+
[],
|
|
796
|
+
Option.none(),
|
|
797
|
+
]
|
|
559
798
|
case "ChangedDraft":
|
|
560
799
|
return [{ ...model, draft: message.text }, [], Option.none()]
|
|
561
800
|
case "SubmittedMessage": {
|
|
@@ -611,7 +850,7 @@ export const update = (
|
|
|
611
850
|
}
|
|
612
851
|
|
|
613
852
|
/** @experimental */
|
|
614
|
-
export const subscriptions =
|
|
853
|
+
export const subscriptions = make<Model, Message, AgentConnection>()((entry) => ({
|
|
615
854
|
agentFrames: entry(
|
|
616
855
|
{ sessionId: Schema.NullOr(Schema.String), afterSeq: Schema.Number },
|
|
617
856
|
{
|
|
@@ -620,7 +859,7 @@ export const subscriptions = Subscription.make<Model, Message, Connection.AgentC
|
|
|
620
859
|
dependenciesToStream: ({ sessionId }, readDependencies) => {
|
|
621
860
|
if (sessionId === null) return Stream.empty
|
|
622
861
|
return Stream.unwrap(
|
|
623
|
-
|
|
862
|
+
AgentConnection.use((connection) => {
|
|
624
863
|
const afterSeq = readDependencies().afterSeq
|
|
625
864
|
return Effect.succeed(
|
|
626
865
|
connection
|
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
})
|