@batonfx/foldkit 0.4.0 → 0.4.2

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 DELETED
@@ -1,876 +0,0 @@
1
- import { Cause, Equivalence, Effect, Option, Schema, Stream } from "effect"
2
- import { Prompt } from "effect/unstable/ai"
3
- import { define, type Command } from "foldkit/command"
4
- import { m } from "foldkit/message"
5
- import type { CallableTaggedStruct } from "foldkit/schema"
6
- import { make } from "foldkit/subscription"
7
- import { Wire } from "@batonfx/transport"
8
- import { AgentConnection, Incoming, SendFailed } from "./connection"
9
- const CompletedFields = { isFailure: Schema.Boolean, result: Schema.Unknown }
10
- const UserEntryFields = { text: Schema.String }
11
- const AssistantEntryFields = { text: Schema.String, reasoning: Schema.NullOr(Schema.String) }
12
- const RunCompletedFields = { text: Schema.String }
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
-
22
- const ToolEntryFields = {
23
- callId: Schema.String,
24
- name: Schema.String,
25
- params: Schema.Unknown,
26
- phase: ToolPendingPhase,
27
- outcome: Schema.suspend((): Schema.Schema<ToolOutcome> => ToolOutcome),
28
- progress: Schema.Array(Schema.String),
29
- }
30
- const RunningFields = { turn: Schema.Number }
31
- const AwaitingApprovalFields = {
32
- token: Schema.String,
33
- toolName: Schema.String,
34
- params: Schema.Unknown,
35
- }
36
- const ClickedDenyFields = { reason: Schema.NullOr(Schema.String) }
37
- const ReceivedAgentFields = { incoming: Incoming }
38
- const ModelStreaming = Schema.Struct({
39
- turn: Schema.Number,
40
- text: Schema.String,
41
- reasoning: Schema.String,
42
- })
43
- const ModelConnection = Schema.Literals(["disconnected", "connecting", "open", "reconnecting"])
44
-
45
- const Pending: CallableTaggedStruct<"Pending", {}> = m("Pending")
46
- const Completed: CallableTaggedStruct<"Completed", typeof CompletedFields> = m("Completed", CompletedFields)
47
-
48
- /** @experimental */
49
- export type ToolOutcome = typeof Pending.Type | typeof Completed.Type
50
-
51
- /** @experimental */
52
- export const ToolOutcome: Schema.Schema<ToolOutcome> = Schema.Union([Pending, Completed])
53
-
54
- /** @experimental */
55
- export const UserEntry: CallableTaggedStruct<"UserEntry", typeof UserEntryFields> = m("UserEntry", UserEntryFields)
56
-
57
- /** @experimental */
58
- export const AssistantEntry: CallableTaggedStruct<"AssistantEntry", typeof AssistantEntryFields> = m(
59
- "AssistantEntry",
60
- AssistantEntryFields,
61
- )
62
-
63
- /** @experimental */
64
- export const ToolEntry: CallableTaggedStruct<"ToolEntry", typeof ToolEntryFields> = m("ToolEntry", ToolEntryFields)
65
-
66
- /** @experimental */
67
- export type ChatEntry = typeof UserEntry.Type | typeof AssistantEntry.Type | typeof ToolEntry.Type
68
-
69
- /** @experimental */
70
- export const ChatEntry: Schema.Schema<ChatEntry> = Schema.Union([UserEntry, AssistantEntry, ToolEntry])
71
-
72
- /** @experimental */
73
- export const Idle: CallableTaggedStruct<"Idle", {}> = m("Idle")
74
-
75
- /** @experimental */
76
- export const Running: CallableTaggedStruct<"Running", typeof RunningFields> = m("Running", RunningFields)
77
-
78
- /** @experimental */
79
- export const AwaitingApproval: CallableTaggedStruct<"AwaitingApproval", typeof AwaitingApprovalFields> = m(
80
- "AwaitingApproval",
81
- AwaitingApprovalFields,
82
- )
83
-
84
- /** @experimental */
85
- export const Failed: CallableTaggedStruct<"Failed", { message: typeof Schema.String }> = m("Failed", {
86
- message: Schema.String,
87
- })
88
-
89
- /** @experimental */
90
- export type RunState = typeof Idle.Type | typeof Running.Type | typeof AwaitingApproval.Type | typeof Failed.Type
91
-
92
- /** @experimental */
93
- export const RunState: Schema.Schema<RunState> = Schema.Union([Idle, Running, AwaitingApproval, Failed])
94
-
95
- /** @experimental */
96
- export interface Model {
97
- readonly sessionId: string | null
98
- readonly connection: typeof ModelConnection.Type
99
- readonly lastSeq: number
100
- readonly run: RunState
101
- readonly entries: ReadonlyArray<ChatEntry>
102
- readonly streaming: typeof ModelStreaming.Type | null
103
- readonly draft: string
104
- }
105
-
106
- /** @experimental */
107
- export const Model: Schema.Schema<Model> = Schema.Struct({
108
- sessionId: Schema.NullOr(Schema.String),
109
- connection: ModelConnection,
110
- lastSeq: Schema.Number,
111
- run: RunState,
112
- entries: Schema.Array(ChatEntry),
113
- streaming: Schema.NullOr(ModelStreaming),
114
- draft: Schema.String,
115
- })
116
-
117
- /** @experimental */
118
- export const ReceivedAgent: CallableTaggedStruct<"ReceivedAgent", typeof ReceivedAgentFields> = m(
119
- "ReceivedAgent",
120
- ReceivedAgentFields,
121
- )
122
-
123
- /** @experimental */
124
- export const OpenedSession: CallableTaggedStruct<"OpenedSession", typeof OpenedSessionFields> = m(
125
- "OpenedSession",
126
- OpenedSessionFields,
127
- )
128
-
129
- /** @experimental */
130
- export const ChangedDraft: CallableTaggedStruct<"ChangedDraft", typeof UserEntryFields> = m(
131
- "ChangedDraft",
132
- UserEntryFields,
133
- )
134
-
135
- /** @experimental */
136
- export const SubmittedMessage: CallableTaggedStruct<"SubmittedMessage", {}> = m("SubmittedMessage")
137
-
138
- /** @experimental */
139
- export const ClickedCancel: CallableTaggedStruct<"ClickedCancel", {}> = m("ClickedCancel")
140
-
141
- /** @experimental */
142
- export const ClickedApprove: CallableTaggedStruct<"ClickedApprove", {}> = m("ClickedApprove")
143
-
144
- /** @experimental */
145
- export const ClickedDeny: CallableTaggedStruct<"ClickedDeny", typeof ClickedDenyFields> = m(
146
- "ClickedDeny",
147
- ClickedDenyFields,
148
- )
149
-
150
- /** @experimental */
151
- export const SentUserMessage: CallableTaggedStruct<"SentUserMessage", {}> = m("SentUserMessage")
152
-
153
- /** @experimental */
154
- export const ResolvedApproval: CallableTaggedStruct<"ResolvedApproval", {}> = m("ResolvedApproval")
155
-
156
- /** @experimental */
157
- export const CancelledRun: CallableTaggedStruct<"CancelledRun", {}> = m("CancelledRun")
158
-
159
- /** @experimental */
160
- export const FailedAgentCommand: CallableTaggedStruct<"FailedAgentCommand", typeof StringReasonFields> = m(
161
- "FailedAgentCommand",
162
- StringReasonFields,
163
- )
164
-
165
- /** @experimental */
166
- export type Message =
167
- | typeof ReceivedAgent.Type
168
- | typeof OpenedSession.Type
169
- | typeof ChangedDraft.Type
170
- | typeof SubmittedMessage.Type
171
- | typeof ClickedCancel.Type
172
- | typeof ClickedApprove.Type
173
- | typeof ClickedDeny.Type
174
- | typeof SentUserMessage.Type
175
- | typeof ResolvedApproval.Type
176
- | typeof CancelledRun.Type
177
- | typeof FailedAgentCommand.Type
178
-
179
- /** @experimental */
180
- export const Message: Schema.Schema<Message> = Schema.Union([
181
- ReceivedAgent,
182
- OpenedSession,
183
- ChangedDraft,
184
- SubmittedMessage,
185
- ClickedCancel,
186
- ClickedApprove,
187
- ClickedDeny,
188
- SentUserMessage,
189
- ResolvedApproval,
190
- CancelledRun,
191
- FailedAgentCommand,
192
- ])
193
-
194
- /** @experimental */
195
- export const RunCompleted: CallableTaggedStruct<"RunCompleted", typeof RunCompletedFields> = m(
196
- "RunCompleted",
197
- RunCompletedFields,
198
- )
199
-
200
- /** @experimental */
201
- export const ApprovalRequired: CallableTaggedStruct<"ApprovalRequired", {}> = m("ApprovalRequired")
202
-
203
- /** @experimental */
204
- export const RunFailed: CallableTaggedStruct<"RunFailed", { message: typeof Schema.String }> = m("RunFailed", {
205
- message: Schema.String,
206
- })
207
-
208
- /** @experimental */
209
- export type OutMessage = typeof RunCompleted.Type | typeof ApprovalRequired.Type | typeof RunFailed.Type
210
-
211
- /** @experimental */
212
- export const OutMessage: Schema.Schema<OutMessage> = Schema.Union([RunCompleted, ApprovalRequired, RunFailed])
213
-
214
- /** @experimental */
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>
333
-
334
- /** @experimental */
335
- export const initialModel = (sessionId: string | null = null): Model => ({
336
- sessionId,
337
- connection: "disconnected",
338
- lastSeq: -1,
339
- run: Idle(),
340
- entries: [],
341
- streaming: null,
342
- draft: "",
343
- })
344
-
345
- type FailedAgentCommandMessage = typeof FailedAgentCommand.Type
346
-
347
- const commandFailed = (error: unknown): FailedAgentCommandMessage =>
348
- FailedAgentCommand({ reason: error instanceof SendFailed ? error.reason : String(error) })
349
-
350
- const catchCommandFailure = <A>(effect: Effect.Effect<A, SendFailed, AgentConnection>) =>
351
- effect.pipe(Effect.catchCause((cause) => Effect.succeed(commandFailed(Cause.squash(cause)))))
352
-
353
- /** @experimental */
354
- export const SendUserMessage = define(
355
- "SendUserMessage",
356
- { sessionId: Schema.String, text: Schema.String },
357
- SentUserMessage,
358
- FailedAgentCommand,
359
- )(({ sessionId, text }) =>
360
- AgentConnection.use((connection) =>
361
- catchCommandFailure(
362
- connection.send({ _tag: "SendMessage", sessionId, prompt: text }).pipe(Effect.as(SentUserMessage())),
363
- ),
364
- ),
365
- )
366
-
367
- /** @experimental */
368
- export const ResolveApproval = define(
369
- "ResolveApproval",
370
- {
371
- sessionId: Schema.String,
372
- token: Schema.String,
373
- approved: Schema.Boolean,
374
- reason: Schema.NullOr(Schema.String),
375
- },
376
- ResolvedApproval,
377
- FailedAgentCommand,
378
- )(({ sessionId, token, approved, reason }) => {
379
- const decision: Wire.ClientApproval = approved
380
- ? { _tag: "Approved" }
381
- : reason === null
382
- ? { _tag: "Denied" }
383
- : { _tag: "Denied", reason }
384
- return AgentConnection.use((connection) =>
385
- catchCommandFailure(
386
- connection.send({ _tag: "ResolveApproval", sessionId, token, decision }).pipe(Effect.as(ResolvedApproval())),
387
- ),
388
- )
389
- })
390
-
391
- /** @experimental */
392
- export const CancelRun = define(
393
- "CancelRun",
394
- { sessionId: Schema.String },
395
- CancelledRun,
396
- FailedAgentCommand,
397
- )(({ sessionId }) =>
398
- AgentConnection.use((connection) =>
399
- catchCommandFailure(connection.send({ _tag: "Cancel", sessionId }).pipe(Effect.as(CancelledRun()))),
400
- ),
401
- )
402
-
403
- interface StreamingState {
404
- readonly turn: number
405
- readonly text: string
406
- readonly reasoning: string
407
- }
408
-
409
- interface ToolCallLike {
410
- readonly type: "tool-call"
411
- readonly id: string
412
- readonly name: string
413
- readonly params: unknown
414
- }
415
-
416
- interface ToolResultLike {
417
- readonly type: "tool-result"
418
- readonly id: string
419
- readonly name: string
420
- readonly result: unknown
421
- readonly isFailure: boolean
422
- }
423
-
424
- const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
425
- typeof value === "object" && value !== null
426
-
427
- const isToolCall = (value: unknown): value is ToolCallLike =>
428
- isRecord(value) &&
429
- value.type === "tool-call" &&
430
- typeof value.id === "string" &&
431
- typeof value.name === "string" &&
432
- "params" in value
433
-
434
- const isToolResult = (value: unknown): value is ToolResultLike =>
435
- isRecord(value) &&
436
- value.type === "tool-result" &&
437
- typeof value.id === "string" &&
438
- typeof value.name === "string" &&
439
- "result" in value &&
440
- typeof value.isFailure === "boolean"
441
-
442
- const streamingFor = (model: Model, turn: number): StreamingState =>
443
- model.streaming ?? { turn, text: "", reasoning: "" }
444
-
445
- const appendStreaming = (model: Model, turn: number, field: "text" | "reasoning", delta: string): Model => {
446
- const streaming = streamingFor(model, turn)
447
- return { ...model, streaming: { ...streaming, [field]: streaming[field] + delta } }
448
- }
449
-
450
- const flushStreaming = (model: Model): Model => {
451
- if (model.streaming === null) return model
452
- const { text, reasoning } = model.streaming
453
- const entry =
454
- text.length === 0 && reasoning.length === 0 ? [] : [AssistantEntry({ text, reasoning: reasoning || null })]
455
- return { ...model, entries: [...model.entries, ...entry], streaming: null }
456
- }
457
-
458
- const upsertToolCall = (
459
- entries: ReadonlyArray<ChatEntry>,
460
- call: ToolCallLike,
461
- phase: ToolPendingPhase = "called",
462
- ): ReadonlyArray<ChatEntry> => {
463
- const index = entries.findIndex((entry) => entry._tag === "ToolEntry" && entry.callId === call.id)
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"
467
- const next = ToolEntry({
468
- callId: call.id,
469
- name: call.name,
470
- params: call.params === undefined ? previousToolEntry?.params : call.params,
471
- phase: nextPhase,
472
- outcome: previousToolEntry?.outcome ?? Pending(),
473
- progress: previousToolEntry?.progress ?? [],
474
- })
475
- if (index < 0) return [...entries, next]
476
- return entries.map((entry, entryIndex) => (entryIndex === index ? next : entry))
477
- }
478
-
479
- const resolveTool = (entries: ReadonlyArray<ChatEntry>, result: ToolResultLike): ReadonlyArray<ChatEntry> => {
480
- const withCall = upsertToolCall(entries, { type: "tool-call", id: result.id, name: result.name, params: undefined })
481
- return withCall.map((entry) =>
482
- entry._tag === "ToolEntry" && entry.callId === result.id
483
- ? ToolEntry({
484
- callId: entry.callId,
485
- name: entry.name,
486
- params: entry.params,
487
- phase: entry.phase,
488
- outcome: Completed({ isFailure: result.isFailure, result: result.result }),
489
- progress: entry.progress,
490
- })
491
- : entry,
492
- )
493
- }
494
-
495
- const addProgress = (entries: ReadonlyArray<ChatEntry>, callId: string, message: string): ReadonlyArray<ChatEntry> =>
496
- entries.map((entry) =>
497
- entry._tag === "ToolEntry" && entry.callId === callId
498
- ? ToolEntry({
499
- callId: entry.callId,
500
- name: entry.name,
501
- params: entry.params,
502
- phase: entry.phase,
503
- outcome: entry.outcome,
504
- progress: entry.progress.concat(message),
505
- })
506
- : entry,
507
- )
508
-
509
- const failureMessage = (failure: Wire.RunFailure): string => {
510
- switch (failure._tag) {
511
- case "@batonfx/core/AgentError":
512
- return failure.message
513
- case "@batonfx/core/MiddlewareViolation":
514
- return failure.detail
515
- case "@batonfx/core/TurnLimitExceeded":
516
- return `Turn limit exceeded at turn ${failure.turn}`
517
- }
518
- }
519
-
520
- const applyPart = (model: Model, turn: number, part: unknown): Model => {
521
- if (!isRecord(part) || typeof part.type !== "string") return model
522
- switch (part.type) {
523
- case "text-delta":
524
- return typeof part.delta === "string" ? appendStreaming(model, turn, "text", part.delta) : model
525
- case "reasoning-delta":
526
- return typeof part.delta === "string" ? appendStreaming(model, turn, "reasoning", part.delta) : model
527
- case "tool-call":
528
- return isToolCall(part) ? { ...model, entries: upsertToolCall(model.entries, part) } : model
529
- case "tool-result":
530
- return isToolResult(part) ? { ...model, entries: resolveTool(model.entries, part) } : model
531
- default:
532
- return model
533
- }
534
- }
535
-
536
- type Suspension = Extract<Wire.ServerFrameType, { readonly _tag: "Suspended" }>["suspension"]
537
-
538
- const applySuspension = (model: Model, suspension: Suspension) => {
539
- if (suspension.reason === "approval") {
540
- return [
541
- {
542
- ...model,
543
- run: AwaitingApproval({
544
- token: suspension.token,
545
- toolName: suspension.tool_name,
546
- params: suspension.tool_params,
547
- }),
548
- },
549
- Option.some(ApprovalRequired()),
550
- ] as const
551
- }
552
- const message = `Tool wait suspension for ${suspension.tool_name} is not resolvable by the FoldKit adapter`
553
- return [{ ...model, run: Failed({ message }) }, Option.some(RunFailed({ message }))] as const
554
- }
555
-
556
- const applyStatus = (model: Model, status: Wire.SessionStatus): readonly [Model, Option.Option<OutMessage>] => {
557
- switch (status._tag) {
558
- case "Idle":
559
- return [{ ...model, run: Idle() }, Option.none()]
560
- case "Running":
561
- return [{ ...model, run: Running({ turn: status.turn }) }, Option.none()]
562
- case "Suspended":
563
- return applySuspension(model, status.suspension)
564
- case "Failed": {
565
- const message = failureMessage(status.error)
566
- return [{ ...model, run: Failed({ message }) }, Option.some(RunFailed({ message }))]
567
- }
568
- }
569
- }
570
-
571
- const applyEvent = (model: Model, event: Wire.EventType): readonly [Model, Option.Option<OutMessage>] => {
572
- switch (event._tag) {
573
- case "TurnStarted":
574
- return [
575
- { ...model, run: Running({ turn: event.turn }), streaming: { turn: event.turn, text: "", reasoning: "" } },
576
- Option.none(),
577
- ]
578
- case "ModelPart":
579
- return [applyPart(model, event.turn, event.part), Option.none()]
580
- case "ToolExecutionStarted":
581
- return [{ ...model, entries: upsertToolCall(model.entries, event.call, "executing") }, Option.none()]
582
- case "ApprovalRequested":
583
- return [{ ...model, entries: upsertToolCall(model.entries, event.call) }, Option.none()]
584
- case "ToolProgress":
585
- return event.message === undefined
586
- ? [model, Option.none()]
587
- : [{ ...model, entries: addProgress(model.entries, event.toolCallId, event.message) }, Option.none()]
588
- case "ToolExecutionCompleted":
589
- return [
590
- { ...model, entries: resolveTool(upsertToolCall(model.entries, event.call, "executing"), event.result) },
591
- Option.none(),
592
- ]
593
- case "SteeringDrained":
594
- return [model, Option.none()]
595
- case "TurnCompleted":
596
- return [flushStreaming(model), Option.none()]
597
- case "StructuredOutput":
598
- return [model, Option.none()]
599
- case "Completed": {
600
- const flushed = flushStreaming(model)
601
- return [{ ...flushed, run: Idle() }, Option.some(RunCompleted({ text: event.text }))]
602
- }
603
- }
604
- }
605
-
606
- const textFromContent = (content: ReadonlyArray<unknown>): string =>
607
- content
608
- .filter((part) => isRecord(part) && part.type === "text" && typeof part.text === "string")
609
- .map((part) => (part as { readonly text: string }).text)
610
- .join("")
611
-
612
- const reasoningFromContent = (content: ReadonlyArray<unknown>): string | null => {
613
- const reasoning = content
614
- .filter((part) => isRecord(part) && part.type === "reasoning" && typeof part.text === "string")
615
- .map((part) => (part as { readonly text: string }).text)
616
- .join("")
617
- return reasoning.length === 0 ? null : reasoning
618
- }
619
-
620
- const projectPrompt = (prompt: Prompt.Prompt): ReadonlyArray<ChatEntry> => {
621
- let entries: ReadonlyArray<ChatEntry> = []
622
- for (const message of prompt.content) {
623
- if (message.role === "user") {
624
- const text = textFromContent(message.content)
625
- if (text.length > 0) entries = entries.concat(UserEntry({ text }))
626
- } else if (message.role === "assistant") {
627
- const text = textFromContent(message.content)
628
- const reasoning = reasoningFromContent(message.content)
629
- if (text.length > 0 || reasoning !== null) entries = entries.concat(AssistantEntry({ text, reasoning }))
630
- for (const part of message.content) {
631
- if (isToolCall(part)) entries = upsertToolCall(entries, part)
632
- if (isToolResult(part)) entries = resolveTool(entries, part)
633
- }
634
- } else if (message.role === "tool") {
635
- for (const part of message.content) {
636
- if (isToolResult(part)) entries = resolveTool(entries, part)
637
- }
638
- }
639
- }
640
- return entries
641
- }
642
-
643
- const applyFrame = (model: Model, frame: Wire.LooseServerFrameType): readonly [Model, Option.Option<OutMessage>] => {
644
- if (frame.seq <= model.lastSeq) return [model, Option.none()]
645
- const withSeq = { ...model, lastSeq: frame.seq }
646
- switch (frame._tag) {
647
- case "Event":
648
- return applyEvent(withSeq, frame.event)
649
- case "Suspended":
650
- return applySuspension(withSeq, frame.suspension)
651
- case "Failed": {
652
- const message = failureMessage(frame.error)
653
- return [{ ...withSeq, run: Failed({ message }) }, Option.some(RunFailed({ message }))]
654
- }
655
- case "Ended":
656
- return [withSeq, Option.none()]
657
- case "Snapshot":
658
- return [{ ...withSeq, entries: projectPrompt(frame.transcript), streaming: null }, Option.none()]
659
- case "SessionStatus":
660
- return applyStatus(withSeq, frame.status)
661
- }
662
- }
663
-
664
- const jsonText = (value: unknown): string => {
665
- try {
666
- return JSON.stringify(value, null, 2) ?? "undefined"
667
- } catch {
668
- return String(value)
669
- }
670
- }
671
-
672
- /** @experimental */
673
- export const promptInputStatusOf = (run: RunState): PromptInputStatus => {
674
- switch (run._tag) {
675
- case "Idle":
676
- return "idle"
677
- case "Running":
678
- return "streaming"
679
- case "AwaitingApproval":
680
- return "submitted"
681
- case "Failed":
682
- return "error"
683
- }
684
- }
685
-
686
- /** @experimental */
687
- export const toolStatusOf = (entry: typeof ToolEntry.Type): ToolStatus => {
688
- switch (entry.outcome._tag) {
689
- case "Pending":
690
- return entry.phase === "executing" ? "input-available" : "input-streaming"
691
- case "Completed":
692
- return entry.outcome.isFailure ? "output-error" : "output-available"
693
- }
694
- }
695
-
696
- const conversationItemFor = (entry: ChatEntry, index: number): ConversationItem => {
697
- switch (entry._tag) {
698
- case "UserEntry":
699
- return UserConversationItem({ key: `entry-${index}-user`, align: "end", entry })
700
- case "AssistantEntry":
701
- return AssistantConversationItem({ key: `entry-${index}-assistant`, align: "start", entry })
702
- case "ToolEntry":
703
- return ToolConversationItem({
704
- key: `tool-${entry.callId}`,
705
- align: "start",
706
- entry,
707
- status: toolStatusOf(entry),
708
- input: jsonText(entry.params),
709
- })
710
- }
711
- }
712
-
713
- /** @experimental */
714
- export const conversationItems = (model: Model): ReadonlyArray<ConversationItem> => {
715
- const entries = model.entries.map(conversationItemFor)
716
- const streaming =
717
- model.streaming === null
718
- ? []
719
- : [
720
- StreamingConversationItem({
721
- key: "streaming-assistant",
722
- align: "start",
723
- text: model.streaming.text,
724
- reasoning: model.streaming.reasoning,
725
- isStreaming: true,
726
- }),
727
- ]
728
- const waiting =
729
- model.run._tag === "Running" && model.streaming === null
730
- ? [WaitingConversationItem({ key: "waiting-assistant", align: "start" })]
731
- : []
732
- const approval =
733
- model.run._tag === "AwaitingApproval"
734
- ? [
735
- ApprovalConversationItem({
736
- key: `approval-${model.run.token}`,
737
- align: "start",
738
- token: model.run.token,
739
- toolName: model.run.toolName,
740
- params: model.run.params,
741
- }),
742
- ]
743
- : []
744
- const failure =
745
- model.run._tag === "Failed"
746
- ? [FailureConversationItem({ key: "run-failure", align: "start", message: model.run.message })]
747
- : []
748
- return [...entries, ...streaming, ...waiting, ...approval, ...failure]
749
- }
750
-
751
- const isServerFrame = (incoming: Incoming): incoming is Wire.LooseServerFrameType =>
752
- incoming._tag === "Event" ||
753
- incoming._tag === "Suspended" ||
754
- incoming._tag === "Failed" ||
755
- incoming._tag === "Ended" ||
756
- incoming._tag === "Snapshot" ||
757
- incoming._tag === "SessionStatus"
758
-
759
- /** @experimental */
760
- export const update = (
761
- model: Model,
762
- message: Message,
763
- ): readonly [Model, ReadonlyArray<ChatCommand>, Option.Option<OutMessage>] => {
764
- switch (message._tag) {
765
- case "ReceivedAgent":
766
- if (isServerFrame(message.incoming)) {
767
- const [next, out] = applyFrame(model, message.incoming)
768
- return [next, [], out]
769
- }
770
- switch (message.incoming._tag) {
771
- case "ConnectionOpened":
772
- return [{ ...model, connection: "open" }, [], Option.none()]
773
- case "ConnectionLost":
774
- return [
775
- { ...model, connection: model.sessionId === null ? "disconnected" : "reconnecting" },
776
- [],
777
- Option.none(),
778
- ]
779
- case "ConnectionFailed":
780
- return [
781
- { ...model, connection: "disconnected", run: Failed({ message: message.incoming.reason }) },
782
- [],
783
- Option.some(RunFailed({ message: message.incoming.reason })),
784
- ]
785
- }
786
- case "OpenedSession":
787
- return [
788
- {
789
- ...model,
790
- sessionId: message.sessionId,
791
- connection: "connecting",
792
- lastSeq: -1,
793
- run: Idle(),
794
- entries: [],
795
- streaming: null,
796
- },
797
- [],
798
- Option.none(),
799
- ]
800
- case "ChangedDraft":
801
- return [{ ...model, draft: message.text }, [], Option.none()]
802
- case "SubmittedMessage": {
803
- const text = model.draft.trim()
804
- if (model.sessionId === null || text.length === 0) return [model, [], Option.none()]
805
- return [
806
- { ...model, draft: "", entries: [...model.entries, UserEntry({ text })] },
807
- [SendUserMessage({ sessionId: model.sessionId, text })],
808
- Option.none(),
809
- ]
810
- }
811
- case "ClickedApprove":
812
- return model.sessionId !== null && model.run._tag === "AwaitingApproval"
813
- ? [
814
- model,
815
- [
816
- ResolveApproval({
817
- sessionId: model.sessionId,
818
- token: model.run.token,
819
- approved: true,
820
- reason: null,
821
- }),
822
- ],
823
- Option.none(),
824
- ]
825
- : [model, [], Option.none()]
826
- case "ClickedDeny":
827
- return model.sessionId !== null && model.run._tag === "AwaitingApproval"
828
- ? [
829
- model,
830
- [
831
- ResolveApproval({
832
- sessionId: model.sessionId,
833
- token: model.run.token,
834
- approved: false,
835
- reason: message.reason,
836
- }),
837
- ],
838
- Option.none(),
839
- ]
840
- : [model, [], Option.none()]
841
- case "ClickedCancel":
842
- return model.sessionId === null
843
- ? [model, [], Option.none()]
844
- : [model, [CancelRun({ sessionId: model.sessionId })], Option.none()]
845
- case "FailedAgentCommand":
846
- return [{ ...model, run: Failed({ message: message.reason }) }, [], Option.none()]
847
- case "SentUserMessage":
848
- case "ResolvedApproval":
849
- case "CancelledRun":
850
- return [model, [], Option.none()]
851
- }
852
- }
853
-
854
- /** @experimental */
855
- export const subscriptions = make<Model, Message, AgentConnection>()((entry) => ({
856
- agentFrames: entry(
857
- { sessionId: Schema.NullOr(Schema.String), afterSeq: Schema.Number },
858
- {
859
- modelToDependencies: (model) => ({ sessionId: model.sessionId, afterSeq: model.lastSeq }),
860
- keepAliveEquivalence: Equivalence.make((left, right) => left.sessionId === right.sessionId),
861
- dependenciesToStream: ({ sessionId }, readDependencies) => {
862
- if (sessionId === null) return Stream.empty
863
- return Stream.unwrap(
864
- AgentConnection.use((connection) => {
865
- const afterSeq = readDependencies().afterSeq
866
- return Effect.succeed(
867
- connection
868
- .frames(afterSeq < 0 ? { sessionId } : { sessionId, afterSeq })
869
- .pipe(Stream.map((incoming) => ReceivedAgent({ incoming }))),
870
- )
871
- }),
872
- )
873
- },
874
- },
875
- ),
876
- }))