@pikku/assistant-ui 0.12.0 → 0.12.1

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.
@@ -1,19 +1,458 @@
1
- import { useMemo, useEffect, useRef, useCallback } from 'react'
2
- import { type ThreadMessageLike } from '@assistant-ui/react'
3
- import { useDataStreamRuntime } from '@assistant-ui/react-data-stream'
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ useCallback,
8
+ } from 'react'
9
+ import {
10
+ useLocalRuntime,
11
+ type ChatModelAdapter,
12
+ type ThreadMessageLike,
13
+ } from '@assistant-ui/react'
4
14
 
5
15
  export interface PikkuAgentRuntimeOptions {
6
16
  api: string
7
- threadId?: string | null
17
+ agentName: string
18
+ threadId: string
19
+ resourceId: string
8
20
  initialMessages?: any[]
9
- onThreadCreated?: (id: string) => void
10
21
  onFinish?: () => void
11
- onApprovalRequest?: (data: { toolCallId: string }) => void
12
22
  credentials?: RequestCredentials
13
23
  headers?: Record<string, string>
24
+ model?: string
25
+ temperature?: number
14
26
  }
15
27
 
16
- const convertDbMessages = (dbMessages: any[]): ThreadMessageLike[] => {
28
+ export interface PendingApproval {
29
+ toolCallId: string
30
+ toolName: string
31
+ args: unknown
32
+ reason?: string
33
+ runId: string
34
+ }
35
+
36
+ export interface PikkuApprovalContextValue {
37
+ pendingApprovals: PendingApproval[]
38
+ handleApproval: (toolCallId: string, approved: boolean) => void
39
+ }
40
+
41
+ export const PikkuApprovalContext = createContext<PikkuApprovalContextValue>({
42
+ pendingApprovals: [],
43
+ handleApproval: () => {},
44
+ })
45
+
46
+ export const usePikkuApproval = () => useContext(PikkuApprovalContext)
47
+
48
+ async function* parseSSEStream(
49
+ reader: ReadableStreamDefaultReader<Uint8Array>
50
+ ) {
51
+ const decoder = new TextDecoder()
52
+ let buffer = ''
53
+
54
+ while (true) {
55
+ const { done, value } = await reader.read()
56
+ if (done) break
57
+
58
+ buffer += decoder.decode(value, { stream: true })
59
+ const lines = buffer.split('\n')
60
+ buffer = lines.pop()!
61
+
62
+ for (const line of lines) {
63
+ if (line.startsWith('data: ')) {
64
+ const data = line.slice(6)
65
+ if (data) {
66
+ try {
67
+ yield JSON.parse(data)
68
+ } catch {
69
+ // skip unparseable lines
70
+ }
71
+ }
72
+ }
73
+ }
74
+ }
75
+ }
76
+
77
+ type ToolCall = {
78
+ type: 'tool-call'
79
+ toolCallId: string
80
+ toolName: string
81
+ args: Record<string, unknown>
82
+ result?: string
83
+ isError?: boolean
84
+ }
85
+
86
+ /**
87
+ * Shared helper: consume an SSE stream and populate text/toolCalls.
88
+ * Returns an array of PendingApprovals when the stream requests them, or empty when done.
89
+ */
90
+ async function processStream(
91
+ reader: ReadableStreamDefaultReader<Uint8Array>,
92
+ text: { value: string },
93
+ toolCalls: ToolCall[],
94
+ yieldContent: () => void,
95
+ onFinish?: () => void
96
+ ): Promise<PendingApproval[]> {
97
+ const pendingApprovals: PendingApproval[] = []
98
+
99
+ for await (const event of parseSSEStream(reader)) {
100
+ switch (event.type) {
101
+ case 'text-delta':
102
+ text.value += event.text
103
+ break
104
+ case 'tool-call': {
105
+ let parsedArgs = event.args
106
+ if (typeof event.args === 'string') {
107
+ try {
108
+ parsedArgs = JSON.parse(event.args)
109
+ } catch {
110
+ parsedArgs = {}
111
+ }
112
+ }
113
+ toolCalls.push({
114
+ type: 'tool-call',
115
+ toolCallId: event.toolCallId,
116
+ toolName: event.toolName,
117
+ args: parsedArgs,
118
+ })
119
+ break
120
+ }
121
+ case 'tool-result': {
122
+ // Skip tool-results that contain __approvalRequired
123
+ const resultObj = typeof event.result === 'object' ? event.result : null
124
+ if (resultObj && '__approvalRequired' in resultObj) {
125
+ break
126
+ }
127
+ const tc = toolCalls.find((t) => t.toolCallId === event.toolCallId)
128
+ if (tc) {
129
+ tc.result =
130
+ typeof event.result === 'string'
131
+ ? event.result
132
+ : JSON.stringify(event.result)
133
+ if (event.isError) {
134
+ tc.isError = true
135
+ }
136
+ }
137
+ break
138
+ }
139
+ case 'approval-request':
140
+ pendingApprovals.push({
141
+ toolCallId: event.toolCallId,
142
+ toolName: event.toolName,
143
+ args: event.args,
144
+ reason: event.reason,
145
+ runId: event.runId,
146
+ })
147
+ break
148
+ case 'error':
149
+ text.value += `\n\nError: ${event.message || event.errorText || 'Unknown error'}`
150
+ break
151
+ case 'done':
152
+ onFinish?.()
153
+ continue
154
+ }
155
+ yieldContent()
156
+ }
157
+
158
+ return pendingApprovals
159
+ }
160
+
161
+ export function isDeniedResult(result: unknown): boolean {
162
+ if (result == null) return false
163
+ try {
164
+ const parsed = typeof result === 'string' ? JSON.parse(result) : result
165
+ return parsed && typeof parsed === 'object' && parsed.approved === false
166
+ } catch {
167
+ return false
168
+ }
169
+ }
170
+
171
+ export type PikkuToolStatusType =
172
+ | 'running'
173
+ | 'requires-action'
174
+ | 'completed'
175
+ | 'denied'
176
+ | 'error'
177
+
178
+ export type PikkuToolStatus = { type: PikkuToolStatusType }
179
+
180
+ export function resolvePikkuToolStatus(
181
+ status: { type: string },
182
+ result?: unknown
183
+ ): PikkuToolStatus {
184
+ if (status.type === 'running') return { type: 'running' }
185
+ if (status.type === 'requires-action') return { type: 'requires-action' }
186
+ if (isDeniedResult(result)) return { type: 'denied' }
187
+ if (typeof result === 'string' && result.startsWith('Error:'))
188
+ return { type: 'error' }
189
+ return { type: 'completed' }
190
+ }
191
+
192
+ function buildContent(text: { value: string }, toolCalls: ToolCall[]): any[] {
193
+ const content: any[] = []
194
+ if (text.value) content.push({ type: 'text' as const, text: text.value })
195
+ content.push(...toolCalls)
196
+ return content
197
+ }
198
+
199
+ function createPikkuStreamingAdapter(
200
+ optionsRef: React.RefObject<PikkuAgentRuntimeOptions>,
201
+ pendingApprovalsRef: React.RefObject<PendingApproval[]>,
202
+ approvalDecisionsRef: React.RefObject<
203
+ { toolCallId: string; approved: boolean }[]
204
+ >,
205
+ setPendingApprovalsRef: React.RefObject<
206
+ (approvals: PendingApproval[]) => void
207
+ >,
208
+ onFinishRef: React.RefObject<(() => void) | undefined>
209
+ ): ChatModelAdapter {
210
+ return {
211
+ async *run({ messages, abortSignal }) {
212
+ const opts = optionsRef.current!
213
+
214
+ // Check if this run() is a continuation after approval decisions.
215
+ // assistant-ui calls run() again after addResult provides tool results for ALL tool calls.
216
+ const pendingApprovals = pendingApprovalsRef.current
217
+ if (pendingApprovals.length > 0) {
218
+ // Read the decisions accumulated by handleApproval() from click handlers.
219
+ const decisions = approvalDecisionsRef.current
220
+ approvalDecisionsRef.current = []
221
+
222
+ if (decisions.length === 0) {
223
+ // No decisions set — shouldn't happen if composer is disabled.
224
+ return
225
+ }
226
+
227
+ // Clear pending approvals state
228
+ pendingApprovalsRef.current = []
229
+ setPendingApprovalsRef.current([])
230
+
231
+ // Send /resume for each decision sequentially.
232
+ // All but the last resume will return quickly (just tool-result + done).
233
+ // The last resume triggers continuation (next LLM step).
234
+ let lastText = { value: '' }
235
+ let lastToolCalls: ToolCall[] = []
236
+ let nextApprovals: PendingApproval[] = []
237
+
238
+ for (let i = 0; i < decisions.length; i++) {
239
+ const decision = decisions[i]
240
+ // Find the runId from the matching pending approval
241
+ const matchingApproval = pendingApprovals.find(
242
+ (p) => p.toolCallId === decision.toolCallId
243
+ )
244
+ const runId = matchingApproval?.runId ?? pendingApprovals[0]?.runId
245
+
246
+ const resumeResponse = await fetch(
247
+ `${opts.api}/${opts.agentName}/resume`,
248
+ {
249
+ method: 'POST',
250
+ headers: {
251
+ 'Content-Type': 'application/json',
252
+ ...opts.headers,
253
+ },
254
+ body: JSON.stringify({
255
+ runId,
256
+ toolCallId: decision.toolCallId,
257
+ approved: decision.approved,
258
+ }),
259
+ signal: abortSignal,
260
+ credentials: opts.credentials,
261
+ }
262
+ )
263
+
264
+ if (!resumeResponse.ok || !resumeResponse.body) {
265
+ const errorText = resumeResponse.body
266
+ ? await resumeResponse.text().catch(() => '')
267
+ : ''
268
+ throw new Error(
269
+ `Resume failed: ${resumeResponse.status}${errorText ? ` - ${errorText}` : ''}`
270
+ )
271
+ }
272
+
273
+ const text = { value: '' }
274
+ const toolCalls: ToolCall[] = []
275
+ const reader = resumeResponse.body.getReader()
276
+ const streamApprovals = await processStream(
277
+ reader,
278
+ text,
279
+ toolCalls,
280
+ () => {},
281
+ i === decisions.length - 1
282
+ ? (onFinishRef.current ?? undefined)
283
+ : undefined
284
+ )
285
+
286
+ // Keep the last resume's output (it has continuation content)
287
+ lastText = text
288
+ lastToolCalls = toolCalls
289
+ if (streamApprovals.length > 0) {
290
+ nextApprovals = streamApprovals
291
+ }
292
+ }
293
+
294
+ // Build content from the last resume's output
295
+ const content = buildContent(lastText, lastToolCalls)
296
+
297
+ if (nextApprovals.length > 0) {
298
+ // More approvals from continuation — show them
299
+ pendingApprovalsRef.current = nextApprovals
300
+ setPendingApprovalsRef.current(nextApprovals)
301
+
302
+ // Add approval tool calls to content
303
+ for (const approval of nextApprovals) {
304
+ const approvalToolCall: ToolCall = {
305
+ type: 'tool-call',
306
+ toolCallId: approval.toolCallId,
307
+ toolName: approval.toolName,
308
+ args: {
309
+ ...(typeof approval.args === 'object' && approval.args !== null
310
+ ? (approval.args as Record<string, unknown>)
311
+ : {}),
312
+ ...(approval.reason
313
+ ? { __approvalReason: approval.reason }
314
+ : {}),
315
+ },
316
+ }
317
+ const idx = lastToolCalls.findIndex(
318
+ (tc) => tc.toolCallId === approval.toolCallId && !tc.result
319
+ )
320
+ if (idx !== -1) {
321
+ lastToolCalls[idx] = approvalToolCall
322
+ } else {
323
+ lastToolCalls.push(approvalToolCall)
324
+ }
325
+ }
326
+
327
+ const updatedContent = buildContent(lastText, lastToolCalls)
328
+ yield {
329
+ content: updatedContent,
330
+ status: {
331
+ type: 'requires-action' as const,
332
+ reason: 'tool-calls' as const,
333
+ },
334
+ }
335
+ } else if (content.length > 0) {
336
+ yield { content }
337
+ }
338
+ return
339
+ }
340
+
341
+ // Normal flow: new user message → stream
342
+ const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user')
343
+ if (!lastUserMsg) return
344
+
345
+ let messageText = ''
346
+ if (lastUserMsg.content) {
347
+ for (const part of lastUserMsg.content) {
348
+ if ('text' in part && part.type === 'text') {
349
+ messageText += (part as { text: string }).text
350
+ }
351
+ }
352
+ }
353
+
354
+ const response = await fetch(`${opts.api}/${opts.agentName}/stream`, {
355
+ method: 'POST',
356
+ headers: { 'Content-Type': 'application/json', ...opts.headers },
357
+ body: JSON.stringify({
358
+ agentName: opts.agentName,
359
+ message: messageText,
360
+ threadId: opts.threadId,
361
+ resourceId: opts.resourceId,
362
+ model: opts.model,
363
+ temperature: opts.temperature,
364
+ }),
365
+ signal: abortSignal,
366
+ credentials: opts.credentials,
367
+ })
368
+
369
+ if (!response.ok || !response.body) {
370
+ throw new Error(`Agent stream failed: ${response.status}`)
371
+ }
372
+
373
+ const text = { value: '' }
374
+ const toolCalls: ToolCall[] = []
375
+ let pendingContent: any[] | null = null
376
+ const yieldContent = () => {
377
+ const content = buildContent(text, toolCalls)
378
+ if (content.length > 0) pendingContent = content
379
+ }
380
+
381
+ const reader = response.body.getReader()
382
+ const approvals = await processStream(
383
+ reader,
384
+ text,
385
+ toolCalls,
386
+ yieldContent,
387
+ onFinishRef.current ?? undefined
388
+ )
389
+
390
+ if (approvals.length === 0) {
391
+ // No approval needed — yield final content and done
392
+ if (pendingContent) {
393
+ yield { content: pendingContent }
394
+ }
395
+ return
396
+ }
397
+
398
+ // Approvals requested: store them for the next run() call
399
+ pendingApprovalsRef.current = approvals
400
+ setPendingApprovalsRef.current(approvals)
401
+
402
+ // Each approval tool call needs to be shown without a result
403
+ // so assistant-ui renders them as requires-action
404
+ for (const approval of approvals) {
405
+ const approvalToolCall: ToolCall = {
406
+ type: 'tool-call',
407
+ toolCallId: approval.toolCallId,
408
+ toolName: approval.toolName,
409
+ args: {
410
+ ...(typeof approval.args === 'object' && approval.args !== null
411
+ ? (approval.args as Record<string, unknown>)
412
+ : {}),
413
+ ...(approval.reason ? { __approvalReason: approval.reason } : {}),
414
+ },
415
+ }
416
+
417
+ // Replace the existing tool call (if any) with the approval version
418
+ const parentIdx = toolCalls.findIndex(
419
+ (tc) => tc.toolCallId === approval.toolCallId && !tc.result
420
+ )
421
+ if (parentIdx !== -1) {
422
+ toolCalls[parentIdx] = approvalToolCall
423
+ } else {
424
+ toolCalls.push(approvalToolCall)
425
+ }
426
+ }
427
+
428
+ // Remove any forwarded sub-agent tool calls that duplicate approval tool names
429
+ const approvalToolCallIds = new Set(approvals.map((a) => a.toolCallId))
430
+ const approvalToolNames = new Set(approvals.map((a) => a.toolName))
431
+ for (let i = toolCalls.length - 1; i >= 0; i--) {
432
+ if (
433
+ approvalToolNames.has(toolCalls[i].toolName) &&
434
+ !approvalToolCallIds.has(toolCalls[i].toolCallId)
435
+ ) {
436
+ toolCalls.splice(i, 1)
437
+ }
438
+ }
439
+
440
+ const content = buildContent(text, toolCalls)
441
+ yield {
442
+ content,
443
+ status: {
444
+ type: 'requires-action' as const,
445
+ reason: 'tool-calls' as const,
446
+ },
447
+ }
448
+ // Generator returns here. assistant-ui will show the approval UI for each tool call.
449
+ // When the user clicks Approve/Deny on ALL tools → handleApproval accumulates decisions →
450
+ // addResult for each → run() is called again when all have results.
451
+ },
452
+ }
453
+ }
454
+
455
+ export const convertDbMessages = (dbMessages: any[]): ThreadMessageLike[] => {
17
456
  const result: ThreadMessageLike[] = []
18
457
  let currentAssistant: ThreadMessageLike | null = null
19
458
 
@@ -125,86 +564,331 @@ const convertDbMessages = (dbMessages: any[]): ThreadMessageLike[] => {
125
564
  }
126
565
 
127
566
  export function usePikkuAgentRuntime(options: PikkuAgentRuntimeOptions) {
128
- const {
129
- api,
130
- threadId = null,
131
- initialMessages: rawInitialMessages,
132
- onThreadCreated,
133
- onFinish,
134
- onApprovalRequest,
135
- credentials,
136
- headers,
137
- } = options
138
-
139
- const threadIdRef = useRef(threadId)
140
- threadIdRef.current = threadId
141
-
142
- const justCreatedThreadRef = useRef(false)
143
-
144
- const bodyFn = useCallback(() => {
145
- let currentThreadId = threadIdRef.current
146
- if (!currentThreadId) {
147
- currentThreadId = crypto.randomUUID()
148
- justCreatedThreadRef.current = true
149
- onThreadCreated?.(currentThreadId)
150
- }
151
- return { threadId: currentThreadId }
152
- }, [onThreadCreated])
153
-
154
- const onData = useCallback(
155
- (event: { type: string; name: string; data: unknown }) => {
156
- if (event.name === 'approval-request') {
157
- const approval = event.data as any
158
- onApprovalRequest?.({
159
- toolCallId: approval.toolCallId,
160
- })
161
- }
162
- },
163
- [onApprovalRequest]
567
+ const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>(
568
+ []
164
569
  )
165
570
 
166
- const onFinishCb = useCallback(() => {
167
- onFinish?.()
168
- }, [onFinish])
571
+ const optionsRef = useRef(options)
572
+ optionsRef.current = options
573
+
574
+ const onFinishRef = useRef(options.onFinish)
575
+ onFinishRef.current = options.onFinish
576
+
577
+ const pendingApprovalsRef = useRef<PendingApproval[]>([])
578
+ const approvalDecisionsRef = useRef<
579
+ { toolCallId: string; approved: boolean }[]
580
+ >([])
581
+
582
+ const setPendingApprovalsRef = useRef(setPendingApprovals)
583
+ setPendingApprovalsRef.current = setPendingApprovals
584
+
585
+ const adapter = useMemo(
586
+ () =>
587
+ createPikkuStreamingAdapter(
588
+ optionsRef,
589
+ pendingApprovalsRef,
590
+ approvalDecisionsRef,
591
+ setPendingApprovalsRef,
592
+ onFinishRef
593
+ ),
594
+ []
595
+ )
169
596
 
170
597
  const initialMessages = useMemo(
171
- () => (rawInitialMessages ? convertDbMessages(rawInitialMessages) : []),
172
- [rawInitialMessages]
598
+ () =>
599
+ options.initialMessages
600
+ ? convertDbMessages(options.initialMessages)
601
+ : undefined,
602
+ [options.initialMessages]
603
+ )
604
+
605
+ const runtime = useLocalRuntime(adapter, { initialMessages })
606
+
607
+ // handleApproval is called from the Approve/Deny button click handler.
608
+ // It accumulates the decision in the ref. assistant-ui will call run()
609
+ // when ALL tool calls have results (via addResult).
610
+ const handleApproval = useCallback(
611
+ (toolCallId: string, approved: boolean) => {
612
+ approvalDecisionsRef.current.push({ toolCallId, approved })
613
+ },
614
+ []
173
615
  )
174
616
 
175
- const runtime = useDataStreamRuntime({
176
- api,
177
- protocol: 'ui-message-stream',
178
- body: bodyFn,
179
- onData,
180
- onFinish: onFinishCb,
181
- initialMessages,
182
- credentials,
183
- headers,
184
- })
185
-
186
- const prevThreadIdRef = useRef(threadId)
187
- const hasResetRef = useRef(false)
188
- useEffect(() => {
189
- if (prevThreadIdRef.current !== threadId) {
190
- prevThreadIdRef.current = threadId
191
- if (justCreatedThreadRef.current) {
192
- justCreatedThreadRef.current = false
193
- hasResetRef.current = true
617
+ const isAwaitingApproval = pendingApprovals.length > 0
618
+
619
+ return {
620
+ runtime,
621
+ pendingApprovals,
622
+ isAwaitingApproval,
623
+ handleApproval,
624
+ }
625
+ }
626
+
627
+ function createPikkuNonStreamingAdapter(
628
+ optionsRef: React.RefObject<PikkuAgentRuntimeOptions>,
629
+ pendingApprovalsRef: React.RefObject<PendingApproval[]>,
630
+ approvalDecisionsRef: React.RefObject<
631
+ { toolCallId: string; approved: boolean }[]
632
+ >,
633
+ setPendingApprovalsRef: React.RefObject<
634
+ (approvals: PendingApproval[]) => void
635
+ >,
636
+ onFinishRef: React.RefObject<(() => void) | undefined>
637
+ ): ChatModelAdapter {
638
+ return {
639
+ async *run({ messages, abortSignal }) {
640
+ const opts = optionsRef.current!
641
+
642
+ // Continuation after approval decisions
643
+ const pendingApprovals = pendingApprovalsRef.current
644
+ if (pendingApprovals.length > 0) {
645
+ const decisions = approvalDecisionsRef.current
646
+ approvalDecisionsRef.current = []
647
+
648
+ if (decisions.length === 0) return
649
+
650
+ pendingApprovalsRef.current = []
651
+ setPendingApprovalsRef.current([])
652
+
653
+ // Resume uses SSE (same as streaming mode)
654
+ let lastText = { value: '' }
655
+ let lastToolCalls: ToolCall[] = []
656
+ let nextApprovals: PendingApproval[] = []
657
+
658
+ for (let i = 0; i < decisions.length; i++) {
659
+ const decision = decisions[i]
660
+ const matchingApproval = pendingApprovals.find(
661
+ (p) => p.toolCallId === decision.toolCallId
662
+ )
663
+ const runId = matchingApproval?.runId ?? pendingApprovals[0]?.runId
664
+
665
+ const resumeResponse = await fetch(
666
+ `${opts.api}/${opts.agentName}/resume`,
667
+ {
668
+ method: 'POST',
669
+ headers: {
670
+ 'Content-Type': 'application/json',
671
+ ...opts.headers,
672
+ },
673
+ body: JSON.stringify({
674
+ runId,
675
+ toolCallId: decision.toolCallId,
676
+ approved: decision.approved,
677
+ }),
678
+ signal: abortSignal,
679
+ credentials: opts.credentials,
680
+ }
681
+ )
682
+
683
+ if (!resumeResponse.ok || !resumeResponse.body) {
684
+ const errorText = resumeResponse.body
685
+ ? await resumeResponse.text().catch(() => '')
686
+ : ''
687
+ throw new Error(
688
+ `Resume failed: ${resumeResponse.status}${errorText ? ` - ${errorText}` : ''}`
689
+ )
690
+ }
691
+
692
+ const text = { value: '' }
693
+ const toolCalls: ToolCall[] = []
694
+ const reader = resumeResponse.body.getReader()
695
+ const streamApprovals = await processStream(
696
+ reader,
697
+ text,
698
+ toolCalls,
699
+ () => {},
700
+ i === decisions.length - 1
701
+ ? (onFinishRef.current ?? undefined)
702
+ : undefined
703
+ )
704
+
705
+ lastText = text
706
+ lastToolCalls = toolCalls
707
+ if (streamApprovals.length > 0) {
708
+ nextApprovals = streamApprovals
709
+ }
710
+ }
711
+
712
+ const content = buildContent(lastText, lastToolCalls)
713
+
714
+ if (nextApprovals.length > 0) {
715
+ pendingApprovalsRef.current = nextApprovals
716
+ setPendingApprovalsRef.current(nextApprovals)
717
+
718
+ for (const approval of nextApprovals) {
719
+ const approvalToolCall: ToolCall = {
720
+ type: 'tool-call',
721
+ toolCallId: approval.toolCallId,
722
+ toolName: approval.toolName,
723
+ args: {
724
+ ...(typeof approval.args === 'object' && approval.args !== null
725
+ ? (approval.args as Record<string, unknown>)
726
+ : {}),
727
+ ...(approval.reason
728
+ ? { __approvalReason: approval.reason }
729
+ : {}),
730
+ },
731
+ }
732
+ const idx = lastToolCalls.findIndex(
733
+ (tc) => tc.toolCallId === approval.toolCallId && !tc.result
734
+ )
735
+ if (idx !== -1) {
736
+ lastToolCalls[idx] = approvalToolCall
737
+ } else {
738
+ lastToolCalls.push(approvalToolCall)
739
+ }
740
+ }
741
+
742
+ const updatedContent = buildContent(lastText, lastToolCalls)
743
+ yield {
744
+ content: updatedContent,
745
+ status: {
746
+ type: 'requires-action' as const,
747
+ reason: 'tool-calls' as const,
748
+ },
749
+ }
750
+ } else if (content.length > 0) {
751
+ yield { content }
752
+ }
194
753
  return
195
754
  }
196
- hasResetRef.current = false
197
- if (rawInitialMessages) {
198
- ;(runtime as any).thread.reset(convertDbMessages(rawInitialMessages))
199
- hasResetRef.current = true
200
- } else {
201
- ;(runtime as any).thread.reset([])
755
+
756
+ // Normal flow: new user message → non-streaming POST
757
+ const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user')
758
+ if (!lastUserMsg) return
759
+
760
+ let messageText = ''
761
+ if (lastUserMsg.content) {
762
+ for (const part of lastUserMsg.content) {
763
+ if ('text' in part && part.type === 'text') {
764
+ messageText += (part as { text: string }).text
765
+ }
766
+ }
767
+ }
768
+
769
+ const response = await fetch(`${opts.api}/${opts.agentName}`, {
770
+ method: 'POST',
771
+ headers: { 'Content-Type': 'application/json', ...opts.headers },
772
+ body: JSON.stringify({
773
+ agentName: opts.agentName,
774
+ message: messageText,
775
+ threadId: opts.threadId,
776
+ resourceId: opts.resourceId,
777
+ model: opts.model,
778
+ temperature: opts.temperature,
779
+ }),
780
+ signal: abortSignal,
781
+ credentials: opts.credentials,
782
+ })
783
+
784
+ if (!response.ok) {
785
+ throw new Error(`Agent run failed: ${response.status}`)
202
786
  }
203
- } else if (!hasResetRef.current && rawInitialMessages) {
204
- ;(runtime as any).thread.reset(convertDbMessages(rawInitialMessages))
205
- hasResetRef.current = true
206
- }
207
- }, [threadId, rawInitialMessages, runtime])
208
787
 
209
- return runtime
788
+ const json = await response.json()
789
+
790
+ if (json.status === 'suspended' && json.pendingApprovals?.length > 0) {
791
+ const approvals: PendingApproval[] = json.pendingApprovals
792
+ pendingApprovalsRef.current = approvals
793
+ setPendingApprovalsRef.current(approvals)
794
+
795
+ const toolCalls: ToolCall[] = approvals.map((approval) => ({
796
+ type: 'tool-call' as const,
797
+ toolCallId: approval.toolCallId,
798
+ toolName: approval.toolName,
799
+ args: {
800
+ ...(typeof approval.args === 'object' && approval.args !== null
801
+ ? (approval.args as Record<string, unknown>)
802
+ : {}),
803
+ ...(approval.reason ? { __approvalReason: approval.reason } : {}),
804
+ },
805
+ }))
806
+
807
+ const content: any[] = []
808
+ if (json.result) {
809
+ content.push({ type: 'text' as const, text: json.result })
810
+ }
811
+ content.push(...toolCalls)
812
+
813
+ yield {
814
+ content,
815
+ status: {
816
+ type: 'requires-action' as const,
817
+ reason: 'tool-calls' as const,
818
+ },
819
+ }
820
+ return
821
+ }
822
+
823
+ // No approvals — yield complete content
824
+ onFinishRef.current?.()
825
+ const content: any[] = []
826
+ if (json.result) {
827
+ content.push({ type: 'text' as const, text: String(json.result) })
828
+ }
829
+ if (content.length > 0) {
830
+ yield { content }
831
+ }
832
+ },
833
+ }
834
+ }
835
+
836
+ export function usePikkuAgentNonStreamingRuntime(
837
+ options: PikkuAgentRuntimeOptions
838
+ ) {
839
+ const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>(
840
+ []
841
+ )
842
+
843
+ const optionsRef = useRef(options)
844
+ optionsRef.current = options
845
+
846
+ const onFinishRef = useRef(options.onFinish)
847
+ onFinishRef.current = options.onFinish
848
+
849
+ const pendingApprovalsRef = useRef<PendingApproval[]>([])
850
+ const approvalDecisionsRef = useRef<
851
+ { toolCallId: string; approved: boolean }[]
852
+ >([])
853
+
854
+ const setPendingApprovalsRef = useRef(setPendingApprovals)
855
+ setPendingApprovalsRef.current = setPendingApprovals
856
+
857
+ const adapter = useMemo(
858
+ () =>
859
+ createPikkuNonStreamingAdapter(
860
+ optionsRef,
861
+ pendingApprovalsRef,
862
+ approvalDecisionsRef,
863
+ setPendingApprovalsRef,
864
+ onFinishRef
865
+ ),
866
+ []
867
+ )
868
+
869
+ const initialMessages = useMemo(
870
+ () =>
871
+ options.initialMessages
872
+ ? convertDbMessages(options.initialMessages)
873
+ : undefined,
874
+ [options.initialMessages]
875
+ )
876
+
877
+ const runtime = useLocalRuntime(adapter, { initialMessages })
878
+
879
+ const handleApproval = useCallback(
880
+ (toolCallId: string, approved: boolean) => {
881
+ approvalDecisionsRef.current.push({ toolCallId, approved })
882
+ },
883
+ []
884
+ )
885
+
886
+ const isAwaitingApproval = pendingApprovals.length > 0
887
+
888
+ return {
889
+ runtime,
890
+ pendingApprovals,
891
+ isAwaitingApproval,
892
+ handleApproval,
893
+ }
210
894
  }