@pikku/assistant-ui 0.12.0 → 0.12.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.
@@ -1,19 +1,482 @@
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
+ let response: Response
355
+ try {
356
+ response = await fetch(`${opts.api}/${opts.agentName}/stream`, {
357
+ method: 'POST',
358
+ headers: { 'Content-Type': 'application/json', ...opts.headers },
359
+ body: JSON.stringify({
360
+ agentName: opts.agentName,
361
+ message: messageText,
362
+ threadId: opts.threadId,
363
+ resourceId: opts.resourceId,
364
+ model: opts.model,
365
+ temperature: opts.temperature,
366
+ }),
367
+ signal: abortSignal,
368
+ credentials: opts.credentials,
369
+ })
370
+ } catch (e: any) {
371
+ const msg = e?.message || 'Unknown error'
372
+ let errorText = 'Failed to connect to the agent.'
373
+ if (msg.includes('Failed to fetch') || msg.includes('NetworkError') || msg.includes('CORS')) {
374
+ errorText = 'Unable to reach the agent server. The deployment may be down or the URL may be incorrect.'
375
+ } else if (msg.includes('abort')) {
376
+ return
377
+ }
378
+ yield { content: [{ type: 'text' as const, text: `⚠️ ${errorText}` }] }
379
+ return
380
+ }
381
+
382
+ if (!response.ok || !response.body) {
383
+ let errorText = `Request failed (${response.status}).`
384
+ if (response.status === 401 || response.status === 403) {
385
+ errorText = 'Authentication error.'
386
+ } else if (response.status === 404) {
387
+ errorText = 'Agent not found — the agent may not be configured for this project.'
388
+ } else if (response.status === 502 || response.status === 503) {
389
+ errorText = 'The agent server is currently unavailable. Try again in a moment.'
390
+ } else if (response.status === 429) {
391
+ errorText = 'Rate limited — too many requests. Please wait a moment.'
392
+ }
393
+ yield { content: [{ type: 'text' as const, text: `⚠️ ${errorText}` }] }
394
+ return
395
+ }
396
+
397
+ const text = { value: '' }
398
+ const toolCalls: ToolCall[] = []
399
+ let pendingContent: any[] | null = null
400
+ const yieldContent = () => {
401
+ const content = buildContent(text, toolCalls)
402
+ if (content.length > 0) pendingContent = content
403
+ }
404
+
405
+ const reader = response.body.getReader()
406
+ const approvals = await processStream(
407
+ reader,
408
+ text,
409
+ toolCalls,
410
+ yieldContent,
411
+ onFinishRef.current ?? undefined
412
+ )
413
+
414
+ if (approvals.length === 0) {
415
+ // No approval needed — yield final content and done
416
+ if (pendingContent) {
417
+ yield { content: pendingContent }
418
+ }
419
+ return
420
+ }
421
+
422
+ // Approvals requested: store them for the next run() call
423
+ pendingApprovalsRef.current = approvals
424
+ setPendingApprovalsRef.current(approvals)
425
+
426
+ // Each approval tool call needs to be shown without a result
427
+ // so assistant-ui renders them as requires-action
428
+ for (const approval of approvals) {
429
+ const approvalToolCall: ToolCall = {
430
+ type: 'tool-call',
431
+ toolCallId: approval.toolCallId,
432
+ toolName: approval.toolName,
433
+ args: {
434
+ ...(typeof approval.args === 'object' && approval.args !== null
435
+ ? (approval.args as Record<string, unknown>)
436
+ : {}),
437
+ ...(approval.reason ? { __approvalReason: approval.reason } : {}),
438
+ },
439
+ }
440
+
441
+ // Replace the existing tool call (if any) with the approval version
442
+ const parentIdx = toolCalls.findIndex(
443
+ (tc) => tc.toolCallId === approval.toolCallId && !tc.result
444
+ )
445
+ if (parentIdx !== -1) {
446
+ toolCalls[parentIdx] = approvalToolCall
447
+ } else {
448
+ toolCalls.push(approvalToolCall)
449
+ }
450
+ }
451
+
452
+ // Remove any forwarded sub-agent tool calls that duplicate approval tool names
453
+ const approvalToolCallIds = new Set(approvals.map((a) => a.toolCallId))
454
+ const approvalToolNames = new Set(approvals.map((a) => a.toolName))
455
+ for (let i = toolCalls.length - 1; i >= 0; i--) {
456
+ if (
457
+ approvalToolNames.has(toolCalls[i].toolName) &&
458
+ !approvalToolCallIds.has(toolCalls[i].toolCallId)
459
+ ) {
460
+ toolCalls.splice(i, 1)
461
+ }
462
+ }
463
+
464
+ const content = buildContent(text, toolCalls)
465
+ yield {
466
+ content,
467
+ status: {
468
+ type: 'requires-action' as const,
469
+ reason: 'tool-calls' as const,
470
+ },
471
+ }
472
+ // Generator returns here. assistant-ui will show the approval UI for each tool call.
473
+ // When the user clicks Approve/Deny on ALL tools → handleApproval accumulates decisions →
474
+ // addResult for each → run() is called again when all have results.
475
+ },
476
+ }
477
+ }
478
+
479
+ export const convertDbMessages = (dbMessages: any[]): ThreadMessageLike[] => {
17
480
  const result: ThreadMessageLike[] = []
18
481
  let currentAssistant: ThreadMessageLike | null = null
19
482
 
@@ -125,86 +588,331 @@ const convertDbMessages = (dbMessages: any[]): ThreadMessageLike[] => {
125
588
  }
126
589
 
127
590
  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]
591
+ const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>(
592
+ []
164
593
  )
165
594
 
166
- const onFinishCb = useCallback(() => {
167
- onFinish?.()
168
- }, [onFinish])
595
+ const optionsRef = useRef(options)
596
+ optionsRef.current = options
597
+
598
+ const onFinishRef = useRef(options.onFinish)
599
+ onFinishRef.current = options.onFinish
600
+
601
+ const pendingApprovalsRef = useRef<PendingApproval[]>([])
602
+ const approvalDecisionsRef = useRef<
603
+ { toolCallId: string; approved: boolean }[]
604
+ >([])
605
+
606
+ const setPendingApprovalsRef = useRef(setPendingApprovals)
607
+ setPendingApprovalsRef.current = setPendingApprovals
608
+
609
+ const adapter = useMemo(
610
+ () =>
611
+ createPikkuStreamingAdapter(
612
+ optionsRef,
613
+ pendingApprovalsRef,
614
+ approvalDecisionsRef,
615
+ setPendingApprovalsRef,
616
+ onFinishRef
617
+ ),
618
+ []
619
+ )
169
620
 
170
621
  const initialMessages = useMemo(
171
- () => (rawInitialMessages ? convertDbMessages(rawInitialMessages) : []),
172
- [rawInitialMessages]
622
+ () =>
623
+ options.initialMessages
624
+ ? convertDbMessages(options.initialMessages)
625
+ : undefined,
626
+ [options.initialMessages]
173
627
  )
174
628
 
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
629
+ const runtime = useLocalRuntime(adapter, { initialMessages })
630
+
631
+ // handleApproval is called from the Approve/Deny button click handler.
632
+ // It accumulates the decision in the ref. assistant-ui will call run()
633
+ // when ALL tool calls have results (via addResult).
634
+ const handleApproval = useCallback(
635
+ (toolCallId: string, approved: boolean) => {
636
+ approvalDecisionsRef.current.push({ toolCallId, approved })
637
+ },
638
+ []
639
+ )
640
+
641
+ const isAwaitingApproval = pendingApprovals.length > 0
642
+
643
+ return {
644
+ runtime,
645
+ pendingApprovals,
646
+ isAwaitingApproval,
647
+ handleApproval,
648
+ }
649
+ }
650
+
651
+ function createPikkuNonStreamingAdapter(
652
+ optionsRef: React.RefObject<PikkuAgentRuntimeOptions>,
653
+ pendingApprovalsRef: React.RefObject<PendingApproval[]>,
654
+ approvalDecisionsRef: React.RefObject<
655
+ { toolCallId: string; approved: boolean }[]
656
+ >,
657
+ setPendingApprovalsRef: React.RefObject<
658
+ (approvals: PendingApproval[]) => void
659
+ >,
660
+ onFinishRef: React.RefObject<(() => void) | undefined>
661
+ ): ChatModelAdapter {
662
+ return {
663
+ async *run({ messages, abortSignal }) {
664
+ const opts = optionsRef.current!
665
+
666
+ // Continuation after approval decisions
667
+ const pendingApprovals = pendingApprovalsRef.current
668
+ if (pendingApprovals.length > 0) {
669
+ const decisions = approvalDecisionsRef.current
670
+ approvalDecisionsRef.current = []
671
+
672
+ if (decisions.length === 0) return
673
+
674
+ pendingApprovalsRef.current = []
675
+ setPendingApprovalsRef.current([])
676
+
677
+ // Resume uses SSE (same as streaming mode)
678
+ let lastText = { value: '' }
679
+ let lastToolCalls: ToolCall[] = []
680
+ let nextApprovals: PendingApproval[] = []
681
+
682
+ for (let i = 0; i < decisions.length; i++) {
683
+ const decision = decisions[i]
684
+ const matchingApproval = pendingApprovals.find(
685
+ (p) => p.toolCallId === decision.toolCallId
686
+ )
687
+ const runId = matchingApproval?.runId ?? pendingApprovals[0]?.runId
688
+
689
+ const resumeResponse = await fetch(
690
+ `${opts.api}/${opts.agentName}/resume`,
691
+ {
692
+ method: 'POST',
693
+ headers: {
694
+ 'Content-Type': 'application/json',
695
+ ...opts.headers,
696
+ },
697
+ body: JSON.stringify({
698
+ runId,
699
+ toolCallId: decision.toolCallId,
700
+ approved: decision.approved,
701
+ }),
702
+ signal: abortSignal,
703
+ credentials: opts.credentials,
704
+ }
705
+ )
706
+
707
+ if (!resumeResponse.ok || !resumeResponse.body) {
708
+ const errorText = resumeResponse.body
709
+ ? await resumeResponse.text().catch(() => '')
710
+ : ''
711
+ throw new Error(
712
+ `Resume failed: ${resumeResponse.status}${errorText ? ` - ${errorText}` : ''}`
713
+ )
714
+ }
715
+
716
+ const text = { value: '' }
717
+ const toolCalls: ToolCall[] = []
718
+ const reader = resumeResponse.body.getReader()
719
+ const streamApprovals = await processStream(
720
+ reader,
721
+ text,
722
+ toolCalls,
723
+ () => {},
724
+ i === decisions.length - 1
725
+ ? (onFinishRef.current ?? undefined)
726
+ : undefined
727
+ )
728
+
729
+ lastText = text
730
+ lastToolCalls = toolCalls
731
+ if (streamApprovals.length > 0) {
732
+ nextApprovals = streamApprovals
733
+ }
734
+ }
735
+
736
+ const content = buildContent(lastText, lastToolCalls)
737
+
738
+ if (nextApprovals.length > 0) {
739
+ pendingApprovalsRef.current = nextApprovals
740
+ setPendingApprovalsRef.current(nextApprovals)
741
+
742
+ for (const approval of nextApprovals) {
743
+ const approvalToolCall: ToolCall = {
744
+ type: 'tool-call',
745
+ toolCallId: approval.toolCallId,
746
+ toolName: approval.toolName,
747
+ args: {
748
+ ...(typeof approval.args === 'object' && approval.args !== null
749
+ ? (approval.args as Record<string, unknown>)
750
+ : {}),
751
+ ...(approval.reason
752
+ ? { __approvalReason: approval.reason }
753
+ : {}),
754
+ },
755
+ }
756
+ const idx = lastToolCalls.findIndex(
757
+ (tc) => tc.toolCallId === approval.toolCallId && !tc.result
758
+ )
759
+ if (idx !== -1) {
760
+ lastToolCalls[idx] = approvalToolCall
761
+ } else {
762
+ lastToolCalls.push(approvalToolCall)
763
+ }
764
+ }
765
+
766
+ const updatedContent = buildContent(lastText, lastToolCalls)
767
+ yield {
768
+ content: updatedContent,
769
+ status: {
770
+ type: 'requires-action' as const,
771
+ reason: 'tool-calls' as const,
772
+ },
773
+ }
774
+ } else if (content.length > 0) {
775
+ yield { content }
776
+ }
194
777
  return
195
778
  }
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([])
779
+
780
+ // Normal flow: new user message → non-streaming POST
781
+ const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user')
782
+ if (!lastUserMsg) return
783
+
784
+ let messageText = ''
785
+ if (lastUserMsg.content) {
786
+ for (const part of lastUserMsg.content) {
787
+ if ('text' in part && part.type === 'text') {
788
+ messageText += (part as { text: string }).text
789
+ }
790
+ }
791
+ }
792
+
793
+ const response = await fetch(`${opts.api}/${opts.agentName}`, {
794
+ method: 'POST',
795
+ headers: { 'Content-Type': 'application/json', ...opts.headers },
796
+ body: JSON.stringify({
797
+ agentName: opts.agentName,
798
+ message: messageText,
799
+ threadId: opts.threadId,
800
+ resourceId: opts.resourceId,
801
+ model: opts.model,
802
+ temperature: opts.temperature,
803
+ }),
804
+ signal: abortSignal,
805
+ credentials: opts.credentials,
806
+ })
807
+
808
+ if (!response.ok) {
809
+ throw new Error(`Agent run failed: ${response.status}`)
810
+ }
811
+
812
+ const json = await response.json()
813
+
814
+ if (json.status === 'suspended' && json.pendingApprovals?.length > 0) {
815
+ const approvals: PendingApproval[] = json.pendingApprovals
816
+ pendingApprovalsRef.current = approvals
817
+ setPendingApprovalsRef.current(approvals)
818
+
819
+ const toolCalls: ToolCall[] = approvals.map((approval) => ({
820
+ type: 'tool-call' as const,
821
+ toolCallId: approval.toolCallId,
822
+ toolName: approval.toolName,
823
+ args: {
824
+ ...(typeof approval.args === 'object' && approval.args !== null
825
+ ? (approval.args as Record<string, unknown>)
826
+ : {}),
827
+ ...(approval.reason ? { __approvalReason: approval.reason } : {}),
828
+ },
829
+ }))
830
+
831
+ const content: any[] = []
832
+ if (json.result) {
833
+ content.push({ type: 'text' as const, text: json.result })
834
+ }
835
+ content.push(...toolCalls)
836
+
837
+ yield {
838
+ content,
839
+ status: {
840
+ type: 'requires-action' as const,
841
+ reason: 'tool-calls' as const,
842
+ },
843
+ }
844
+ return
202
845
  }
203
- } else if (!hasResetRef.current && rawInitialMessages) {
204
- ;(runtime as any).thread.reset(convertDbMessages(rawInitialMessages))
205
- hasResetRef.current = true
206
- }
207
- }, [threadId, rawInitialMessages, runtime])
208
846
 
209
- return runtime
847
+ // No approvals — yield complete content
848
+ onFinishRef.current?.()
849
+ const content: any[] = []
850
+ if (json.result) {
851
+ content.push({ type: 'text' as const, text: String(json.result) })
852
+ }
853
+ if (content.length > 0) {
854
+ yield { content }
855
+ }
856
+ },
857
+ }
858
+ }
859
+
860
+ export function usePikkuAgentNonStreamingRuntime(
861
+ options: PikkuAgentRuntimeOptions
862
+ ) {
863
+ const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>(
864
+ []
865
+ )
866
+
867
+ const optionsRef = useRef(options)
868
+ optionsRef.current = options
869
+
870
+ const onFinishRef = useRef(options.onFinish)
871
+ onFinishRef.current = options.onFinish
872
+
873
+ const pendingApprovalsRef = useRef<PendingApproval[]>([])
874
+ const approvalDecisionsRef = useRef<
875
+ { toolCallId: string; approved: boolean }[]
876
+ >([])
877
+
878
+ const setPendingApprovalsRef = useRef(setPendingApprovals)
879
+ setPendingApprovalsRef.current = setPendingApprovals
880
+
881
+ const adapter = useMemo(
882
+ () =>
883
+ createPikkuNonStreamingAdapter(
884
+ optionsRef,
885
+ pendingApprovalsRef,
886
+ approvalDecisionsRef,
887
+ setPendingApprovalsRef,
888
+ onFinishRef
889
+ ),
890
+ []
891
+ )
892
+
893
+ const initialMessages = useMemo(
894
+ () =>
895
+ options.initialMessages
896
+ ? convertDbMessages(options.initialMessages)
897
+ : undefined,
898
+ [options.initialMessages]
899
+ )
900
+
901
+ const runtime = useLocalRuntime(adapter, { initialMessages })
902
+
903
+ const handleApproval = useCallback(
904
+ (toolCallId: string, approved: boolean) => {
905
+ approvalDecisionsRef.current.push({ toolCallId, approved })
906
+ },
907
+ []
908
+ )
909
+
910
+ const isAwaitingApproval = pendingApprovals.length > 0
911
+
912
+ return {
913
+ runtime,
914
+ pendingApprovals,
915
+ isAwaitingApproval,
916
+ handleApproval,
917
+ }
210
918
  }