@batonfx/foldkit 0.1.0 → 0.2.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/src/chat.ts CHANGED
@@ -1,21 +1,29 @@
1
1
  import { Cause, Equivalence, Effect, Option, Schema, Stream } from "effect"
2
- import * as Ai from "effect/unstable/ai"
3
- import * as Command from "foldkit/command"
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 * as Subscription from "foldkit/subscription"
6
+ import { make } from "foldkit/subscription"
7
7
  import { Wire } from "@batonfx/transport"
8
- import * as Connection from "./connection"
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: Connection.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 type ChatCommand = Command.Command<Message, any, Connection.AgentConnection>
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 Connection.SendFailed ? error.reason : String(error) })
348
+ FailedAgentCommand({ reason: error instanceof SendFailed ? error.reason : String(error) })
216
349
 
217
- const catchCommandFailure = <A>(effect: Effect.Effect<A, Connection.SendFailed, Connection.AgentConnection>) =>
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 = Command.define(
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
- Connection.AgentConnection.use((connection) =>
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 = Command.define(
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 Connection.AgentConnection.use((connection) =>
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 = Command.define(
392
+ export const CancelRun = define(
260
393
  "CancelRun",
261
394
  { sessionId: Schema.String },
262
395
  CancelledRun,
263
396
  FailedAgentCommand,
264
397
  )(({ sessionId }) =>
265
- Connection.AgentConnection.use((connection) =>
398
+ AgentConnection.use((connection) =>
266
399
  catchCommandFailure(connection.send({ _tag: "Cancel", sessionId }).pipe(Effect.as(CancelledRun()))),
267
400
  ),
268
401
  )
@@ -322,15 +455,22 @@ const flushStreaming = (model: Model): Model => {
322
455
  return { ...model, entries: [...model.entries, ...entry], streaming: null }
323
456
  }
324
457
 
325
- const upsertToolCall = (entries: ReadonlyArray<ChatEntry>, call: ToolCallLike): ReadonlyArray<ChatEntry> => {
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
465
+ const previousToolEntry = previous?._tag === "ToolEntry" ? previous : undefined
466
+ const nextPhase = previousToolEntry?.phase === "executing" || phase === "executing" ? "executing" : "called"
328
467
  const next = ToolEntry({
329
468
  callId: call.id,
330
469
  name: call.name,
331
- params: call.params,
332
- outcome: previous?._tag === "ToolEntry" ? previous.outcome : Pending(),
333
- progress: previous?._tag === "ToolEntry" ? previous.progress : [],
470
+ params: call.params === undefined ? previousToolEntry?.params : call.params,
471
+ phase: nextPhase,
472
+ outcome: previousToolEntry?.outcome ?? Pending(),
473
+ progress: previousToolEntry?.progress ?? [],
334
474
  })
335
475
  if (index < 0) return [...entries, next]
336
476
  return entries.map((entry, entryIndex) => (entryIndex === index ? next : entry))
@@ -344,6 +484,7 @@ const resolveTool = (entries: ReadonlyArray<ChatEntry>, result: ToolResultLike):
344
484
  callId: entry.callId,
345
485
  name: entry.name,
346
486
  params: entry.params,
487
+ phase: entry.phase,
347
488
  outcome: Completed({ isFailure: result.isFailure, result: result.result }),
348
489
  progress: entry.progress,
349
490
  })
@@ -358,6 +499,7 @@ const addProgress = (entries: ReadonlyArray<ChatEntry>, callId: string, message:
358
499
  callId: entry.callId,
359
500
  name: entry.name,
360
501
  params: entry.params,
502
+ phase: entry.phase,
361
503
  outcome: entry.outcome,
362
504
  progress: entry.progress.concat(message),
363
505
  })
@@ -436,6 +578,7 @@ const applyEvent = (model: Model, event: Wire.EventType): readonly [Model, Optio
436
578
  case "ModelPart":
437
579
  return [applyPart(model, event.turn, event.part), Option.none()]
438
580
  case "ToolExecutionStarted":
581
+ return [{ ...model, entries: upsertToolCall(model.entries, event.call, "executing") }, Option.none()]
439
582
  case "ApprovalRequested":
440
583
  return [{ ...model, entries: upsertToolCall(model.entries, event.call) }, Option.none()]
441
584
  case "ToolProgress":
@@ -444,7 +587,7 @@ const applyEvent = (model: Model, event: Wire.EventType): readonly [Model, Optio
444
587
  : [{ ...model, entries: addProgress(model.entries, event.toolCallId, event.message) }, Option.none()]
445
588
  case "ToolExecutionCompleted":
446
589
  return [
447
- { ...model, entries: resolveTool(upsertToolCall(model.entries, event.call), event.result) },
590
+ { ...model, entries: resolveTool(upsertToolCall(model.entries, event.call, "executing"), event.result) },
448
591
  Option.none(),
449
592
  ]
450
593
  case "TurnCompleted":
@@ -472,7 +615,7 @@ const reasoningFromContent = (content: ReadonlyArray<unknown>): string | null =>
472
615
  return reasoning.length === 0 ? null : reasoning
473
616
  }
474
617
 
475
- const projectPrompt = (prompt: Ai.Prompt.Prompt): ReadonlyArray<ChatEntry> => {
618
+ const projectPrompt = (prompt: Prompt.Prompt): ReadonlyArray<ChatEntry> => {
476
619
  let entries: ReadonlyArray<ChatEntry> = []
477
620
  for (const message of prompt.content) {
478
621
  if (message.role === "user") {
@@ -516,7 +659,94 @@ const applyFrame = (model: Model, frame: Wire.LooseServerFrameType): readonly [M
516
659
  }
517
660
  }
518
661
 
519
- const isServerFrame = (incoming: Connection.Incoming): incoming is Wire.LooseServerFrameType =>
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 =>
520
750
  incoming._tag === "Event" ||
521
751
  incoming._tag === "Suspended" ||
522
752
  incoming._tag === "Failed" ||
@@ -551,6 +781,20 @@ export const update = (
551
781
  Option.some(RunFailed({ message: message.incoming.reason })),
552
782
  ]
553
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
+ ]
554
798
  case "ChangedDraft":
555
799
  return [{ ...model, draft: message.text }, [], Option.none()]
556
800
  case "SubmittedMessage": {
@@ -606,7 +850,7 @@ export const update = (
606
850
  }
607
851
 
608
852
  /** @experimental */
609
- export const subscriptions = Subscription.make<Model, Message, Connection.AgentConnection>()((entry) => ({
853
+ export const subscriptions = make<Model, Message, AgentConnection>()((entry) => ({
610
854
  agentFrames: entry(
611
855
  { sessionId: Schema.NullOr(Schema.String), afterSeq: Schema.Number },
612
856
  {
@@ -615,7 +859,7 @@ export const subscriptions = Subscription.make<Model, Message, Connection.AgentC
615
859
  dependenciesToStream: ({ sessionId }, readDependencies) => {
616
860
  if (sessionId === null) return Stream.empty
617
861
  return Stream.unwrap(
618
- Connection.AgentConnection.use((connection) => {
862
+ AgentConnection.use((connection) => {
619
863
  const afterSeq = readDependencies().afterSeq
620
864
  return Effect.succeed(
621
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
+ })