@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/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,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 = (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
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: Ai.Prompt.Prompt): ReadonlyArray<ChatEntry> => {
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 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 =>
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 = Subscription.make<Model, Message, Connection.AgentConnection>()((entry) => ({
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
- Connection.AgentConnection.use((connection) => {
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
+ })