@batonfx/foldkit 0.1.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/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/package.json",
3
+ "name": "@batonfx/foldkit",
4
+ "version": "0.1.0",
5
+ "private": false,
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "scripts": {
11
+ "build": "bun build ./src/index.ts --outdir dist --target bun --format esm",
12
+ "lint": "bun --cwd ../.. oxlint packages/foldkit/src packages/foldkit/test",
13
+ "test": "bun --cwd ../.. vitest run packages/foldkit/test --passWithNoTests",
14
+ "typecheck": "bun tsc --noEmit"
15
+ },
16
+ "dependencies": {
17
+ "@batonfx/transport": "0.1.0"
18
+ },
19
+ "peerDependencies": {
20
+ "effect": ">=4.0.0-beta.88 <4.0.1",
21
+ "foldkit": ">=0.122.0 <1"
22
+ },
23
+ "devDependencies": {
24
+ "@effect/vitest": "4.0.0-beta.93",
25
+ "@types/bun": "1.3.13",
26
+ "effect": "4.0.0-beta.93",
27
+ "foldkit": "0.122.0",
28
+ "typescript": "5.8.2",
29
+ "vitest": "4.0.16"
30
+ }
31
+ }
package/src/chat.ts ADDED
@@ -0,0 +1,630 @@
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"
4
+ import { m } from "foldkit/message"
5
+ import type { CallableTaggedStruct } from "foldkit/schema"
6
+ import * as Subscription from "foldkit/subscription"
7
+ import { Wire } from "@batonfx/transport"
8
+ import * as Connection from "./connection"
9
+
10
+ const CompletedFields = { isFailure: Schema.Boolean, result: Schema.Unknown }
11
+ const UserEntryFields = { text: Schema.String }
12
+ const AssistantEntryFields = { text: Schema.String, reasoning: Schema.NullOr(Schema.String) }
13
+ const RunCompletedFields = { text: Schema.String }
14
+ const StringReasonFields = { reason: Schema.String }
15
+ const ToolEntryFields = {
16
+ callId: Schema.String,
17
+ name: Schema.String,
18
+ params: Schema.Unknown,
19
+ outcome: Schema.suspend((): Schema.Schema<ToolOutcome> => ToolOutcome),
20
+ progress: Schema.Array(Schema.String),
21
+ }
22
+ const RunningFields = { turn: Schema.Number }
23
+ const AwaitingApprovalFields = {
24
+ token: Schema.String,
25
+ toolName: Schema.String,
26
+ params: Schema.Unknown,
27
+ }
28
+ const ClickedDenyFields = { reason: Schema.NullOr(Schema.String) }
29
+ const ReceivedAgentFields = { incoming: Connection.Incoming }
30
+ const ModelStreaming = Schema.Struct({
31
+ turn: Schema.Number,
32
+ text: Schema.String,
33
+ reasoning: Schema.String,
34
+ })
35
+ const ModelConnection = Schema.Literals(["disconnected", "connecting", "open", "reconnecting"])
36
+
37
+ const Pending: CallableTaggedStruct<"Pending", {}> = m("Pending")
38
+ const Completed: CallableTaggedStruct<"Completed", typeof CompletedFields> = m("Completed", CompletedFields)
39
+
40
+ /** @experimental */
41
+ export type ToolOutcome = typeof Pending.Type | typeof Completed.Type
42
+
43
+ /** @experimental */
44
+ export const ToolOutcome: Schema.Schema<ToolOutcome> = Schema.Union([Pending, Completed])
45
+
46
+ /** @experimental */
47
+ export const UserEntry: CallableTaggedStruct<"UserEntry", typeof UserEntryFields> = m("UserEntry", UserEntryFields)
48
+
49
+ /** @experimental */
50
+ export const AssistantEntry: CallableTaggedStruct<"AssistantEntry", typeof AssistantEntryFields> = m(
51
+ "AssistantEntry",
52
+ AssistantEntryFields,
53
+ )
54
+
55
+ /** @experimental */
56
+ export const ToolEntry: CallableTaggedStruct<"ToolEntry", typeof ToolEntryFields> = m("ToolEntry", ToolEntryFields)
57
+
58
+ /** @experimental */
59
+ export type ChatEntry = typeof UserEntry.Type | typeof AssistantEntry.Type | typeof ToolEntry.Type
60
+
61
+ /** @experimental */
62
+ export const ChatEntry: Schema.Schema<ChatEntry> = Schema.Union([UserEntry, AssistantEntry, ToolEntry])
63
+
64
+ /** @experimental */
65
+ export const Idle: CallableTaggedStruct<"Idle", {}> = m("Idle")
66
+
67
+ /** @experimental */
68
+ export const Running: CallableTaggedStruct<"Running", typeof RunningFields> = m("Running", RunningFields)
69
+
70
+ /** @experimental */
71
+ export const AwaitingApproval: CallableTaggedStruct<"AwaitingApproval", typeof AwaitingApprovalFields> = m(
72
+ "AwaitingApproval",
73
+ AwaitingApprovalFields,
74
+ )
75
+
76
+ /** @experimental */
77
+ export const Failed: CallableTaggedStruct<"Failed", { message: typeof Schema.String }> = m("Failed", {
78
+ message: Schema.String,
79
+ })
80
+
81
+ /** @experimental */
82
+ export type RunState = typeof Idle.Type | typeof Running.Type | typeof AwaitingApproval.Type | typeof Failed.Type
83
+
84
+ /** @experimental */
85
+ export const RunState: Schema.Schema<RunState> = Schema.Union([Idle, Running, AwaitingApproval, Failed])
86
+
87
+ /** @experimental */
88
+ export interface Model {
89
+ readonly sessionId: string | null
90
+ readonly connection: typeof ModelConnection.Type
91
+ readonly lastSeq: number
92
+ readonly run: RunState
93
+ readonly entries: ReadonlyArray<ChatEntry>
94
+ readonly streaming: typeof ModelStreaming.Type | null
95
+ readonly draft: string
96
+ }
97
+
98
+ /** @experimental */
99
+ export const Model: Schema.Schema<Model> = Schema.Struct({
100
+ sessionId: Schema.NullOr(Schema.String),
101
+ connection: ModelConnection,
102
+ lastSeq: Schema.Number,
103
+ run: RunState,
104
+ entries: Schema.Array(ChatEntry),
105
+ streaming: Schema.NullOr(ModelStreaming),
106
+ draft: Schema.String,
107
+ })
108
+
109
+ /** @experimental */
110
+ export const ReceivedAgent: CallableTaggedStruct<"ReceivedAgent", typeof ReceivedAgentFields> = m(
111
+ "ReceivedAgent",
112
+ ReceivedAgentFields,
113
+ )
114
+
115
+ /** @experimental */
116
+ export const ChangedDraft: CallableTaggedStruct<"ChangedDraft", typeof UserEntryFields> = m(
117
+ "ChangedDraft",
118
+ UserEntryFields,
119
+ )
120
+
121
+ /** @experimental */
122
+ export const SubmittedMessage: CallableTaggedStruct<"SubmittedMessage", {}> = m("SubmittedMessage")
123
+
124
+ /** @experimental */
125
+ export const ClickedCancel: CallableTaggedStruct<"ClickedCancel", {}> = m("ClickedCancel")
126
+
127
+ /** @experimental */
128
+ export const ClickedApprove: CallableTaggedStruct<"ClickedApprove", {}> = m("ClickedApprove")
129
+
130
+ /** @experimental */
131
+ export const ClickedDeny: CallableTaggedStruct<"ClickedDeny", typeof ClickedDenyFields> = m(
132
+ "ClickedDeny",
133
+ ClickedDenyFields,
134
+ )
135
+
136
+ /** @experimental */
137
+ export const SentUserMessage: CallableTaggedStruct<"SentUserMessage", {}> = m("SentUserMessage")
138
+
139
+ /** @experimental */
140
+ export const ResolvedApproval: CallableTaggedStruct<"ResolvedApproval", {}> = m("ResolvedApproval")
141
+
142
+ /** @experimental */
143
+ export const CancelledRun: CallableTaggedStruct<"CancelledRun", {}> = m("CancelledRun")
144
+
145
+ /** @experimental */
146
+ export const FailedAgentCommand: CallableTaggedStruct<"FailedAgentCommand", typeof StringReasonFields> = m(
147
+ "FailedAgentCommand",
148
+ StringReasonFields,
149
+ )
150
+
151
+ /** @experimental */
152
+ export type Message =
153
+ | typeof ReceivedAgent.Type
154
+ | typeof ChangedDraft.Type
155
+ | typeof SubmittedMessage.Type
156
+ | typeof ClickedCancel.Type
157
+ | typeof ClickedApprove.Type
158
+ | typeof ClickedDeny.Type
159
+ | typeof SentUserMessage.Type
160
+ | typeof ResolvedApproval.Type
161
+ | typeof CancelledRun.Type
162
+ | typeof FailedAgentCommand.Type
163
+
164
+ /** @experimental */
165
+ export const Message: Schema.Schema<Message> = Schema.Union([
166
+ ReceivedAgent,
167
+ ChangedDraft,
168
+ SubmittedMessage,
169
+ ClickedCancel,
170
+ ClickedApprove,
171
+ ClickedDeny,
172
+ SentUserMessage,
173
+ ResolvedApproval,
174
+ CancelledRun,
175
+ FailedAgentCommand,
176
+ ])
177
+
178
+ /** @experimental */
179
+ export const RunCompleted: CallableTaggedStruct<"RunCompleted", typeof RunCompletedFields> = m(
180
+ "RunCompleted",
181
+ RunCompletedFields,
182
+ )
183
+
184
+ /** @experimental */
185
+ export const ApprovalRequired: CallableTaggedStruct<"ApprovalRequired", {}> = m("ApprovalRequired")
186
+
187
+ /** @experimental */
188
+ export const RunFailed: CallableTaggedStruct<"RunFailed", { message: typeof Schema.String }> = m("RunFailed", {
189
+ message: Schema.String,
190
+ })
191
+
192
+ /** @experimental */
193
+ export type OutMessage = typeof RunCompleted.Type | typeof ApprovalRequired.Type | typeof RunFailed.Type
194
+
195
+ /** @experimental */
196
+ export const OutMessage: Schema.Schema<OutMessage> = Schema.Union([RunCompleted, ApprovalRequired, RunFailed])
197
+
198
+ /** @experimental */
199
+ export type ChatCommand = Command.Command<Message, any, Connection.AgentConnection>
200
+
201
+ /** @experimental */
202
+ export const initialModel = (sessionId: string | null = null): Model => ({
203
+ sessionId,
204
+ connection: "disconnected",
205
+ lastSeq: -1,
206
+ run: Idle(),
207
+ entries: [],
208
+ streaming: null,
209
+ draft: "",
210
+ })
211
+
212
+ type FailedAgentCommandMessage = typeof FailedAgentCommand.Type
213
+
214
+ const commandFailed = (error: unknown): FailedAgentCommandMessage =>
215
+ FailedAgentCommand({ reason: error instanceof Connection.SendFailed ? error.reason : String(error) })
216
+
217
+ const catchCommandFailure = <A>(effect: Effect.Effect<A, Connection.SendFailed, Connection.AgentConnection>) =>
218
+ effect.pipe(Effect.catchCause((cause) => Effect.succeed(commandFailed(Cause.squash(cause)))))
219
+
220
+ /** @experimental */
221
+ export const SendUserMessage = Command.define(
222
+ "SendUserMessage",
223
+ { sessionId: Schema.String, text: Schema.String },
224
+ SentUserMessage,
225
+ FailedAgentCommand,
226
+ )(({ sessionId, text }) =>
227
+ Connection.AgentConnection.use((connection) =>
228
+ catchCommandFailure(
229
+ connection.send({ _tag: "SendMessage", sessionId, prompt: text }).pipe(Effect.as(SentUserMessage())),
230
+ ),
231
+ ),
232
+ )
233
+
234
+ /** @experimental */
235
+ export const ResolveApproval = Command.define(
236
+ "ResolveApproval",
237
+ {
238
+ sessionId: Schema.String,
239
+ token: Schema.String,
240
+ approved: Schema.Boolean,
241
+ reason: Schema.NullOr(Schema.String),
242
+ },
243
+ ResolvedApproval,
244
+ FailedAgentCommand,
245
+ )(({ sessionId, token, approved, reason }) => {
246
+ const decision: Wire.ClientApproval = approved
247
+ ? { _tag: "Approved" }
248
+ : reason === null
249
+ ? { _tag: "Denied" }
250
+ : { _tag: "Denied", reason }
251
+ return Connection.AgentConnection.use((connection) =>
252
+ catchCommandFailure(
253
+ connection.send({ _tag: "ResolveApproval", sessionId, token, decision }).pipe(Effect.as(ResolvedApproval())),
254
+ ),
255
+ )
256
+ })
257
+
258
+ /** @experimental */
259
+ export const CancelRun = Command.define(
260
+ "CancelRun",
261
+ { sessionId: Schema.String },
262
+ CancelledRun,
263
+ FailedAgentCommand,
264
+ )(({ sessionId }) =>
265
+ Connection.AgentConnection.use((connection) =>
266
+ catchCommandFailure(connection.send({ _tag: "Cancel", sessionId }).pipe(Effect.as(CancelledRun()))),
267
+ ),
268
+ )
269
+
270
+ interface StreamingState {
271
+ readonly turn: number
272
+ readonly text: string
273
+ readonly reasoning: string
274
+ }
275
+
276
+ interface ToolCallLike {
277
+ readonly type: "tool-call"
278
+ readonly id: string
279
+ readonly name: string
280
+ readonly params: unknown
281
+ }
282
+
283
+ interface ToolResultLike {
284
+ readonly type: "tool-result"
285
+ readonly id: string
286
+ readonly name: string
287
+ readonly result: unknown
288
+ readonly isFailure: boolean
289
+ }
290
+
291
+ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
292
+ typeof value === "object" && value !== null
293
+
294
+ const isToolCall = (value: unknown): value is ToolCallLike =>
295
+ isRecord(value) &&
296
+ value.type === "tool-call" &&
297
+ typeof value.id === "string" &&
298
+ typeof value.name === "string" &&
299
+ "params" in value
300
+
301
+ const isToolResult = (value: unknown): value is ToolResultLike =>
302
+ isRecord(value) &&
303
+ value.type === "tool-result" &&
304
+ typeof value.id === "string" &&
305
+ typeof value.name === "string" &&
306
+ "result" in value &&
307
+ typeof value.isFailure === "boolean"
308
+
309
+ const streamingFor = (model: Model, turn: number): StreamingState =>
310
+ model.streaming ?? { turn, text: "", reasoning: "" }
311
+
312
+ const appendStreaming = (model: Model, turn: number, field: "text" | "reasoning", delta: string): Model => {
313
+ const streaming = streamingFor(model, turn)
314
+ return { ...model, streaming: { ...streaming, [field]: streaming[field] + delta } }
315
+ }
316
+
317
+ const flushStreaming = (model: Model): Model => {
318
+ if (model.streaming === null) return model
319
+ const { text, reasoning } = model.streaming
320
+ const entry =
321
+ text.length === 0 && reasoning.length === 0 ? [] : [AssistantEntry({ text, reasoning: reasoning || null })]
322
+ return { ...model, entries: [...model.entries, ...entry], streaming: null }
323
+ }
324
+
325
+ const upsertToolCall = (entries: ReadonlyArray<ChatEntry>, call: ToolCallLike): ReadonlyArray<ChatEntry> => {
326
+ const index = entries.findIndex((entry) => entry._tag === "ToolEntry" && entry.callId === call.id)
327
+ const previous = index >= 0 ? entries[index] : undefined
328
+ const next = ToolEntry({
329
+ callId: call.id,
330
+ name: call.name,
331
+ params: call.params,
332
+ outcome: previous?._tag === "ToolEntry" ? previous.outcome : Pending(),
333
+ progress: previous?._tag === "ToolEntry" ? previous.progress : [],
334
+ })
335
+ if (index < 0) return [...entries, next]
336
+ return entries.map((entry, entryIndex) => (entryIndex === index ? next : entry))
337
+ }
338
+
339
+ const resolveTool = (entries: ReadonlyArray<ChatEntry>, result: ToolResultLike): ReadonlyArray<ChatEntry> => {
340
+ const withCall = upsertToolCall(entries, { type: "tool-call", id: result.id, name: result.name, params: undefined })
341
+ return withCall.map((entry) =>
342
+ entry._tag === "ToolEntry" && entry.callId === result.id
343
+ ? ToolEntry({
344
+ callId: entry.callId,
345
+ name: entry.name,
346
+ params: entry.params,
347
+ outcome: Completed({ isFailure: result.isFailure, result: result.result }),
348
+ progress: entry.progress,
349
+ })
350
+ : entry,
351
+ )
352
+ }
353
+
354
+ const addProgress = (entries: ReadonlyArray<ChatEntry>, callId: string, message: string): ReadonlyArray<ChatEntry> =>
355
+ entries.map((entry) =>
356
+ entry._tag === "ToolEntry" && entry.callId === callId
357
+ ? ToolEntry({
358
+ callId: entry.callId,
359
+ name: entry.name,
360
+ params: entry.params,
361
+ outcome: entry.outcome,
362
+ progress: entry.progress.concat(message),
363
+ })
364
+ : entry,
365
+ )
366
+
367
+ const failureMessage = (failure: Wire.RunFailure): string => {
368
+ switch (failure._tag) {
369
+ case "@batonfx/core/AgentError":
370
+ return failure.message
371
+ case "@batonfx/core/MiddlewareViolation":
372
+ return failure.detail
373
+ case "@batonfx/core/TurnLimitExceeded":
374
+ return `Turn limit exceeded at turn ${failure.turn}`
375
+ }
376
+ }
377
+
378
+ const applyPart = (model: Model, turn: number, part: unknown): Model => {
379
+ if (!isRecord(part) || typeof part.type !== "string") return model
380
+ switch (part.type) {
381
+ case "text-delta":
382
+ return typeof part.delta === "string" ? appendStreaming(model, turn, "text", part.delta) : model
383
+ case "reasoning-delta":
384
+ return typeof part.delta === "string" ? appendStreaming(model, turn, "reasoning", part.delta) : model
385
+ case "tool-call":
386
+ return isToolCall(part) ? { ...model, entries: upsertToolCall(model.entries, part) } : model
387
+ case "tool-result":
388
+ return isToolResult(part) ? { ...model, entries: resolveTool(model.entries, part) } : model
389
+ default:
390
+ return model
391
+ }
392
+ }
393
+
394
+ type Suspension = Extract<Wire.ServerFrameType, { readonly _tag: "Suspended" }>["suspension"]
395
+
396
+ const applySuspension = (model: Model, suspension: Suspension) => {
397
+ if (suspension.reason === "approval") {
398
+ return [
399
+ {
400
+ ...model,
401
+ run: AwaitingApproval({
402
+ token: suspension.token,
403
+ toolName: suspension.tool_name,
404
+ params: suspension.tool_params,
405
+ }),
406
+ },
407
+ Option.some(ApprovalRequired()),
408
+ ] as const
409
+ }
410
+ const message = `Tool wait suspension for ${suspension.tool_name} is not resolvable by the FoldKit adapter`
411
+ return [{ ...model, run: Failed({ message }) }, Option.some(RunFailed({ message }))] as const
412
+ }
413
+
414
+ const applyStatus = (model: Model, status: Wire.SessionStatus): readonly [Model, Option.Option<OutMessage>] => {
415
+ switch (status._tag) {
416
+ case "Idle":
417
+ return [{ ...model, run: Idle() }, Option.none()]
418
+ case "Running":
419
+ return [{ ...model, run: Running({ turn: status.turn }) }, Option.none()]
420
+ case "Suspended":
421
+ return applySuspension(model, status.suspension)
422
+ case "Failed": {
423
+ const message = failureMessage(status.error)
424
+ return [{ ...model, run: Failed({ message }) }, Option.some(RunFailed({ message }))]
425
+ }
426
+ }
427
+ }
428
+
429
+ const applyEvent = (model: Model, event: Wire.EventType): readonly [Model, Option.Option<OutMessage>] => {
430
+ switch (event._tag) {
431
+ case "TurnStarted":
432
+ return [
433
+ { ...model, run: Running({ turn: event.turn }), streaming: { turn: event.turn, text: "", reasoning: "" } },
434
+ Option.none(),
435
+ ]
436
+ case "ModelPart":
437
+ return [applyPart(model, event.turn, event.part), Option.none()]
438
+ case "ToolExecutionStarted":
439
+ case "ApprovalRequested":
440
+ return [{ ...model, entries: upsertToolCall(model.entries, event.call) }, Option.none()]
441
+ case "ToolProgress":
442
+ return event.message === undefined
443
+ ? [model, Option.none()]
444
+ : [{ ...model, entries: addProgress(model.entries, event.toolCallId, event.message) }, Option.none()]
445
+ case "ToolExecutionCompleted":
446
+ return [
447
+ { ...model, entries: resolveTool(upsertToolCall(model.entries, event.call), event.result) },
448
+ Option.none(),
449
+ ]
450
+ case "TurnCompleted":
451
+ return [flushStreaming(model), Option.none()]
452
+ case "StructuredOutput":
453
+ return [model, Option.none()]
454
+ case "Completed": {
455
+ const flushed = flushStreaming(model)
456
+ return [{ ...flushed, run: Idle() }, Option.some(RunCompleted({ text: event.text }))]
457
+ }
458
+ }
459
+ }
460
+
461
+ const textFromContent = (content: ReadonlyArray<unknown>): string =>
462
+ content
463
+ .filter((part) => isRecord(part) && part.type === "text" && typeof part.text === "string")
464
+ .map((part) => (part as { readonly text: string }).text)
465
+ .join("")
466
+
467
+ const reasoningFromContent = (content: ReadonlyArray<unknown>): string | null => {
468
+ const reasoning = content
469
+ .filter((part) => isRecord(part) && part.type === "reasoning" && typeof part.text === "string")
470
+ .map((part) => (part as { readonly text: string }).text)
471
+ .join("")
472
+ return reasoning.length === 0 ? null : reasoning
473
+ }
474
+
475
+ const projectPrompt = (prompt: Ai.Prompt.Prompt): ReadonlyArray<ChatEntry> => {
476
+ let entries: ReadonlyArray<ChatEntry> = []
477
+ for (const message of prompt.content) {
478
+ if (message.role === "user") {
479
+ const text = textFromContent(message.content)
480
+ if (text.length > 0) entries = entries.concat(UserEntry({ text }))
481
+ } else if (message.role === "assistant") {
482
+ const text = textFromContent(message.content)
483
+ const reasoning = reasoningFromContent(message.content)
484
+ if (text.length > 0 || reasoning !== null) entries = entries.concat(AssistantEntry({ text, reasoning }))
485
+ for (const part of message.content) {
486
+ if (isToolCall(part)) entries = upsertToolCall(entries, part)
487
+ if (isToolResult(part)) entries = resolveTool(entries, part)
488
+ }
489
+ } else if (message.role === "tool") {
490
+ for (const part of message.content) {
491
+ if (isToolResult(part)) entries = resolveTool(entries, part)
492
+ }
493
+ }
494
+ }
495
+ return entries
496
+ }
497
+
498
+ const applyFrame = (model: Model, frame: Wire.LooseServerFrameType): readonly [Model, Option.Option<OutMessage>] => {
499
+ if (frame.seq <= model.lastSeq) return [model, Option.none()]
500
+ const withSeq = { ...model, lastSeq: frame.seq }
501
+ switch (frame._tag) {
502
+ case "Event":
503
+ return applyEvent(withSeq, frame.event)
504
+ case "Suspended":
505
+ return applySuspension(withSeq, frame.suspension)
506
+ case "Failed": {
507
+ const message = failureMessage(frame.error)
508
+ return [{ ...withSeq, run: Failed({ message }) }, Option.some(RunFailed({ message }))]
509
+ }
510
+ case "Ended":
511
+ return [withSeq, Option.none()]
512
+ case "Snapshot":
513
+ return [{ ...withSeq, entries: projectPrompt(frame.transcript), streaming: null }, Option.none()]
514
+ case "SessionStatus":
515
+ return applyStatus(withSeq, frame.status)
516
+ }
517
+ }
518
+
519
+ const isServerFrame = (incoming: Connection.Incoming): incoming is Wire.LooseServerFrameType =>
520
+ incoming._tag === "Event" ||
521
+ incoming._tag === "Suspended" ||
522
+ incoming._tag === "Failed" ||
523
+ incoming._tag === "Ended" ||
524
+ incoming._tag === "Snapshot" ||
525
+ incoming._tag === "SessionStatus"
526
+
527
+ /** @experimental */
528
+ export const update = (
529
+ model: Model,
530
+ message: Message,
531
+ ): readonly [Model, ReadonlyArray<ChatCommand>, Option.Option<OutMessage>] => {
532
+ switch (message._tag) {
533
+ case "ReceivedAgent":
534
+ if (isServerFrame(message.incoming)) {
535
+ const [next, out] = applyFrame(model, message.incoming)
536
+ return [next, [], out]
537
+ }
538
+ switch (message.incoming._tag) {
539
+ case "ConnectionOpened":
540
+ return [{ ...model, connection: "open" }, [], Option.none()]
541
+ case "ConnectionLost":
542
+ return [
543
+ { ...model, connection: model.sessionId === null ? "disconnected" : "reconnecting" },
544
+ [],
545
+ Option.none(),
546
+ ]
547
+ case "ConnectionFailed":
548
+ return [
549
+ { ...model, connection: "disconnected", run: Failed({ message: message.incoming.reason }) },
550
+ [],
551
+ Option.some(RunFailed({ message: message.incoming.reason })),
552
+ ]
553
+ }
554
+ case "ChangedDraft":
555
+ return [{ ...model, draft: message.text }, [], Option.none()]
556
+ case "SubmittedMessage": {
557
+ const text = model.draft.trim()
558
+ if (model.sessionId === null || text.length === 0) return [model, [], Option.none()]
559
+ return [
560
+ { ...model, draft: "", entries: [...model.entries, UserEntry({ text })] },
561
+ [SendUserMessage({ sessionId: model.sessionId, text })],
562
+ Option.none(),
563
+ ]
564
+ }
565
+ case "ClickedApprove":
566
+ return model.sessionId !== null && model.run._tag === "AwaitingApproval"
567
+ ? [
568
+ model,
569
+ [
570
+ ResolveApproval({
571
+ sessionId: model.sessionId,
572
+ token: model.run.token,
573
+ approved: true,
574
+ reason: null,
575
+ }),
576
+ ],
577
+ Option.none(),
578
+ ]
579
+ : [model, [], Option.none()]
580
+ case "ClickedDeny":
581
+ return model.sessionId !== null && model.run._tag === "AwaitingApproval"
582
+ ? [
583
+ model,
584
+ [
585
+ ResolveApproval({
586
+ sessionId: model.sessionId,
587
+ token: model.run.token,
588
+ approved: false,
589
+ reason: message.reason,
590
+ }),
591
+ ],
592
+ Option.none(),
593
+ ]
594
+ : [model, [], Option.none()]
595
+ case "ClickedCancel":
596
+ return model.sessionId === null
597
+ ? [model, [], Option.none()]
598
+ : [model, [CancelRun({ sessionId: model.sessionId })], Option.none()]
599
+ case "FailedAgentCommand":
600
+ return [{ ...model, run: Failed({ message: message.reason }) }, [], Option.none()]
601
+ case "SentUserMessage":
602
+ case "ResolvedApproval":
603
+ case "CancelledRun":
604
+ return [model, [], Option.none()]
605
+ }
606
+ }
607
+
608
+ /** @experimental */
609
+ export const subscriptions = Subscription.make<Model, Message, Connection.AgentConnection>()((entry) => ({
610
+ agentFrames: entry(
611
+ { sessionId: Schema.NullOr(Schema.String), afterSeq: Schema.Number },
612
+ {
613
+ modelToDependencies: (model) => ({ sessionId: model.sessionId, afterSeq: model.lastSeq }),
614
+ keepAliveEquivalence: Equivalence.make((left, right) => left.sessionId === right.sessionId),
615
+ dependenciesToStream: ({ sessionId }, readDependencies) => {
616
+ if (sessionId === null) return Stream.empty
617
+ return Stream.unwrap(
618
+ Connection.AgentConnection.use((connection) => {
619
+ const afterSeq = readDependencies().afterSeq
620
+ return Effect.succeed(
621
+ connection
622
+ .frames(afterSeq < 0 ? { sessionId } : { sessionId, afterSeq })
623
+ .pipe(Stream.map((incoming) => ReceivedAgent({ incoming }))),
624
+ )
625
+ }),
626
+ )
627
+ },
628
+ },
629
+ ),
630
+ }))