@pikku/assistant-ui 0.12.6 → 0.12.8

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,15 +1,24 @@
1
1
  import {
2
2
  createContext,
3
+ useCallback,
3
4
  useContext,
5
+ useEffect,
4
6
  useMemo,
5
7
  useRef,
6
8
  useState,
7
- useCallback,
8
9
  } from 'react'
10
+ import { HttpAgent } from '@ag-ui/client'
11
+ import type {
12
+ RunAgentInput,
13
+ BaseEvent,
14
+ CustomEvent as AgUiCustomEvent,
15
+ } from '@ag-ui/client'
16
+ import type { Observable } from 'rxjs'
17
+ import { useAgUiRuntime } from '@assistant-ui/react-ag-ui'
9
18
  import {
10
- useLocalRuntime,
11
- type ChatModelAdapter,
19
+ ExportedMessageRepository,
12
20
  type ThreadMessageLike,
21
+ type ThreadHistoryAdapter,
13
22
  } from '@assistant-ui/react'
14
23
 
15
24
  export interface PikkuAgentRuntimeOptions {
@@ -17,7 +26,6 @@ export interface PikkuAgentRuntimeOptions {
17
26
  agentName: string
18
27
  threadId: string
19
28
  resourceId: string
20
- initialMessages?: any[]
21
29
  onFinish?: () => void
22
30
  credentials?: RequestCredentials
23
31
  headers?: Record<string, string>
@@ -27,6 +35,11 @@ export interface PikkuAgentRuntimeOptions {
27
35
  * Provide upfront state (e.g. current org/project/branch/deployment IDs)
28
36
  * so the agent can call tools without asking the user. */
29
37
  context?: string
38
+ /** Prior messages to hydrate the thread with (e.g. converted from persisted
39
+ * DB history via `convertDbMessages`). Loaded once on mount, so the
40
+ * consumer must keep the chat unmounted until these are available (key or
41
+ * gate on load) — the runtime does not re-hydrate when they change later. */
42
+ initialMessages?: ThreadMessageLike[]
30
43
  }
31
44
 
32
45
  export interface PendingApproval {
@@ -34,7 +47,7 @@ export interface PendingApproval {
34
47
  toolName: string
35
48
  args: unknown
36
49
  reason?: string
37
- runId: string
50
+ runId?: string
38
51
  type?: 'approval-request' | 'credential-request'
39
52
  credentialName?: string
40
53
  credentialType?: 'oauth2' | 'apikey'
@@ -43,179 +56,19 @@ export interface PendingApproval {
43
56
 
44
57
  export interface PikkuApprovalContextValue {
45
58
  pendingApprovals: PendingApproval[]
46
- handleApproval: (toolCallId: string, approved: boolean) => void
59
+ /** Resolve an approval/credential request. Returns `true` when the request
60
+ * was found and acknowledged — callers must gate their `addResult` call on
61
+ * this so a stray result can't start a resume run with nothing queued. */
62
+ handleApproval: (toolCallId: string, approved: boolean) => Promise<boolean>
47
63
  }
48
64
 
49
65
  export const PikkuApprovalContext = createContext<PikkuApprovalContextValue>({
50
66
  pendingApprovals: [],
51
- handleApproval: () => {},
67
+ handleApproval: async () => false,
52
68
  })
53
69
 
54
70
  export const usePikkuApproval = () => useContext(PikkuApprovalContext)
55
71
 
56
- async function* parseSSEStream(
57
- reader: ReadableStreamDefaultReader<Uint8Array>
58
- ) {
59
- const decoder = new TextDecoder()
60
- let buffer = ''
61
-
62
- while (true) {
63
- const { done, value } = await reader.read()
64
- if (done) break
65
-
66
- buffer += decoder.decode(value, { stream: true })
67
- const lines = buffer.split('\n')
68
- buffer = lines.pop()!
69
-
70
- for (const line of lines) {
71
- if (line.startsWith('data: ')) {
72
- const data = line.slice(6)
73
- if (data) {
74
- try {
75
- yield JSON.parse(data)
76
- } catch {
77
- // skip unparseable lines
78
- }
79
- }
80
- }
81
- }
82
- }
83
- }
84
-
85
- type ToolCall = {
86
- type: 'tool-call'
87
- toolCallId: string
88
- toolName: string
89
- args: Record<string, unknown>
90
- result?: string
91
- isError?: boolean
92
- }
93
-
94
- type StructuredPart =
95
- | {
96
- type: 'generative-ui'
97
- spec: unknown
98
- }
99
- | {
100
- type: 'data'
101
- name: string
102
- data: unknown
103
- }
104
-
105
- /**
106
- * Shared helper: consume an SSE stream and populate text/toolCalls.
107
- * Returns an array of PendingApprovals when the stream requests them, or empty when done.
108
- */
109
- async function processStream(
110
- reader: ReadableStreamDefaultReader<Uint8Array>,
111
- text: { value: string },
112
- toolCalls: ToolCall[],
113
- structuredParts: StructuredPart[],
114
- yieldContent: () => void,
115
- onFinish?: () => void
116
- ): Promise<PendingApproval[]> {
117
- const pendingApprovals: PendingApproval[] = []
118
-
119
- for await (const event of parseSSEStream(reader)) {
120
- switch (event.type) {
121
- case 'text-delta':
122
- text.value += event.text
123
- break
124
- case 'tool-call': {
125
- let parsedArgs = event.args
126
- if (typeof event.args === 'string') {
127
- try {
128
- parsedArgs = JSON.parse(event.args)
129
- } catch {
130
- parsedArgs = {}
131
- }
132
- }
133
- toolCalls.push({
134
- type: 'tool-call',
135
- toolCallId: event.toolCallId,
136
- toolName: event.toolName,
137
- args: parsedArgs,
138
- })
139
- break
140
- }
141
- case 'tool-result': {
142
- // Skip tool-results that contain __approvalRequired
143
- const resultObj = typeof event.result === 'object' ? event.result : null
144
- if (resultObj && '__approvalRequired' in resultObj) {
145
- break
146
- }
147
- const tc = toolCalls.find((t) => t.toolCallId === event.toolCallId)
148
- if (tc) {
149
- tc.result =
150
- typeof event.result === 'string'
151
- ? event.result
152
- : JSON.stringify(event.result)
153
- if (event.isError) {
154
- tc.isError = true
155
- }
156
- }
157
- break
158
- }
159
- case 'generative-ui': {
160
- const nextPart = {
161
- type: 'generative-ui' as const,
162
- spec: event.spec,
163
- }
164
- const existingIndex = structuredParts.findIndex(
165
- (part) => part.type === 'generative-ui'
166
- )
167
- if (existingIndex === -1) structuredParts.push(nextPart)
168
- else structuredParts[existingIndex] = nextPart
169
- break
170
- }
171
- case 'data': {
172
- const nextPart = {
173
- type: 'data' as const,
174
- name: event.name,
175
- data: event.data,
176
- }
177
- const existingIndex = structuredParts.findIndex(
178
- (part) => part.type === 'data' && part.name === event.name
179
- )
180
- if (existingIndex === -1) structuredParts.push(nextPart)
181
- else structuredParts[existingIndex] = nextPart
182
- break
183
- }
184
- case 'approval-request':
185
- pendingApprovals.push({
186
- toolCallId: event.toolCallId,
187
- toolName: event.toolName,
188
- args: event.args,
189
- reason: event.reason,
190
- runId: event.runId,
191
- type: 'approval-request',
192
- })
193
- break
194
- case 'credential-request':
195
- pendingApprovals.push({
196
- toolCallId: event.toolCallId,
197
- toolName: event.toolName,
198
- args: event.args,
199
- runId: event.runId,
200
- type: 'credential-request',
201
- credentialName: event.credentialName,
202
- credentialType: event.credentialType,
203
- connectUrl: event.connectUrl,
204
- })
205
- break
206
- case 'error':
207
- text.value += `\n\nError: ${event.message || event.errorText || 'Unknown error'}`
208
- break
209
- case 'done':
210
- onFinish?.()
211
- continue
212
- }
213
- yieldContent()
214
- }
215
-
216
- return pendingApprovals
217
- }
218
-
219
72
  export function isDeniedResult(result: unknown): boolean {
220
73
  if (result == null) return false
221
74
  try {
@@ -278,341 +131,6 @@ export function resolvePikkuToolStatus(
278
131
  return { type: 'completed' }
279
132
  }
280
133
 
281
- function buildRichContent(
282
- text: { value: string },
283
- structuredParts: StructuredPart[],
284
- toolCalls: ToolCall[]
285
- ): any[] {
286
- const content: any[] = []
287
- if (text.value) content.push({ type: 'text' as const, text: text.value })
288
- content.push(...structuredParts)
289
- content.push(...toolCalls)
290
- return content
291
- }
292
-
293
- function buildContentFromAgentResult(result: unknown): any[] {
294
- const content: any[] = []
295
-
296
- if (typeof result === 'string') {
297
- if (result) content.push({ type: 'text' as const, text: result })
298
- return content
299
- }
300
-
301
- if (!result || typeof result !== 'object') return content
302
-
303
- const record = result as Record<string, unknown>
304
- if (typeof record.text === 'string' && record.text) {
305
- content.push({ type: 'text' as const, text: record.text })
306
- }
307
- if (record.ui != null) {
308
- content.push({ type: 'generative-ui' as const, spec: record.ui })
309
- }
310
-
311
- return content
312
- }
313
-
314
- function createPikkuStreamingAdapter(
315
- optionsRef: React.RefObject<PikkuAgentRuntimeOptions>,
316
- pendingApprovalsRef: React.RefObject<PendingApproval[]>,
317
- approvalDecisionsRef: React.RefObject<
318
- { toolCallId: string; approved: boolean }[]
319
- >,
320
- setPendingApprovalsRef: React.RefObject<
321
- (approvals: PendingApproval[]) => void
322
- >,
323
- onFinishRef: React.RefObject<(() => void) | undefined>
324
- ): ChatModelAdapter {
325
- return {
326
- async *run({ messages, abortSignal }) {
327
- const opts = optionsRef.current!
328
-
329
- // Check if this run() is a continuation after approval decisions.
330
- // assistant-ui calls run() again after addResult provides tool results for ALL tool calls.
331
- const pendingApprovals = pendingApprovalsRef.current
332
- if (pendingApprovals.length > 0) {
333
- // Read the decisions accumulated by handleApproval() from click handlers.
334
- const decisions = approvalDecisionsRef.current
335
- approvalDecisionsRef.current = []
336
-
337
- if (decisions.length === 0) {
338
- // No decisions set — shouldn't happen if composer is disabled.
339
- return
340
- }
341
-
342
- // Clear pending approvals state
343
- pendingApprovalsRef.current = []
344
- setPendingApprovalsRef.current([])
345
-
346
- // Send /resume for each decision sequentially.
347
- // All but the last resume will return quickly (just tool-result + done).
348
- // The last resume triggers continuation (next LLM step).
349
- let lastText = { value: '' }
350
- let lastToolCalls: ToolCall[] = []
351
- let lastStructuredParts: StructuredPart[] = []
352
- let nextApprovals: PendingApproval[] = []
353
-
354
- for (let i = 0; i < decisions.length; i++) {
355
- const decision = decisions[i]
356
- // Find the runId from the matching pending approval
357
- const matchingApproval = pendingApprovals.find(
358
- (p) => p.toolCallId === decision.toolCallId
359
- )
360
- const runId = matchingApproval?.runId ?? pendingApprovals[0]?.runId
361
-
362
- const resumeResponse = await fetch(
363
- `${opts.api}/${opts.agentName}/resume`,
364
- {
365
- method: 'POST',
366
- headers: {
367
- 'Content-Type': 'application/json',
368
- ...opts.headers,
369
- },
370
- body: JSON.stringify({
371
- runId,
372
- toolCallId: decision.toolCallId,
373
- approved: decision.approved,
374
- }),
375
- signal: abortSignal,
376
- credentials: opts.credentials,
377
- }
378
- )
379
-
380
- if (!resumeResponse.ok || !resumeResponse.body) {
381
- const errorText = resumeResponse.body
382
- ? await resumeResponse.text().catch(() => '')
383
- : ''
384
- throw new Error(
385
- `Resume failed: ${resumeResponse.status}${errorText ? ` - ${errorText}` : ''}`
386
- )
387
- }
388
-
389
- const text = { value: '' }
390
- const toolCalls: ToolCall[] = []
391
- const structuredParts: StructuredPart[] = []
392
- const reader = resumeResponse.body.getReader()
393
- const streamApprovals = await processStream(
394
- reader,
395
- text,
396
- toolCalls,
397
- structuredParts,
398
- () => {},
399
- i === decisions.length - 1
400
- ? (onFinishRef.current ?? undefined)
401
- : undefined
402
- )
403
-
404
- // Keep the last resume's output (it has continuation content)
405
- lastText = text
406
- lastToolCalls = toolCalls
407
- lastStructuredParts = structuredParts
408
- if (streamApprovals.length > 0) {
409
- nextApprovals = streamApprovals
410
- }
411
- }
412
-
413
- // Build content from the last resume's output
414
- const content = buildRichContent(
415
- lastText,
416
- lastStructuredParts,
417
- lastToolCalls
418
- )
419
-
420
- if (nextApprovals.length > 0) {
421
- // More approvals from continuation — show them
422
- pendingApprovalsRef.current = nextApprovals
423
- setPendingApprovalsRef.current(nextApprovals)
424
-
425
- // Add approval tool calls to content
426
- for (const approval of nextApprovals) {
427
- const approvalToolCall: ToolCall = {
428
- type: 'tool-call',
429
- toolCallId: approval.toolCallId,
430
- toolName: approval.toolName,
431
- args: {
432
- ...(typeof approval.args === 'object' && approval.args !== null
433
- ? (approval.args as Record<string, unknown>)
434
- : {}),
435
- ...(approval.reason
436
- ? { __approvalReason: approval.reason }
437
- : {}),
438
- },
439
- }
440
- const idx = lastToolCalls.findIndex(
441
- (tc) => tc.toolCallId === approval.toolCallId && !tc.result
442
- )
443
- if (idx !== -1) {
444
- lastToolCalls[idx] = approvalToolCall
445
- } else {
446
- lastToolCalls.push(approvalToolCall)
447
- }
448
- }
449
-
450
- const updatedContent = buildRichContent(
451
- lastText,
452
- lastStructuredParts,
453
- lastToolCalls
454
- )
455
- yield {
456
- content: updatedContent,
457
- status: {
458
- type: 'requires-action' as const,
459
- reason: 'tool-calls' as const,
460
- },
461
- }
462
- } else if (content.length > 0) {
463
- yield { content }
464
- }
465
- return
466
- }
467
-
468
- // Normal flow: new user message → stream
469
- const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user')
470
- if (!lastUserMsg) return
471
-
472
- let messageText = ''
473
- if (lastUserMsg.content) {
474
- for (const part of lastUserMsg.content) {
475
- if ('text' in part && part.type === 'text') {
476
- messageText += (part as { text: string }).text
477
- }
478
- }
479
- }
480
-
481
- let response: Response
482
- try {
483
- response = await fetch(`${opts.api}/${opts.agentName}/stream`, {
484
- method: 'POST',
485
- headers: { 'Content-Type': 'application/json', ...opts.headers },
486
- body: JSON.stringify({
487
- agentName: opts.agentName,
488
- message: messageText,
489
- threadId: opts.threadId,
490
- resourceId: opts.resourceId,
491
- model: opts.model,
492
- temperature: opts.temperature,
493
- ...(opts.context ? { context: opts.context } : {}),
494
- }),
495
- signal: abortSignal,
496
- credentials: opts.credentials,
497
- })
498
- } catch (e: any) {
499
- const msg = e?.message || 'Unknown error'
500
- let errorText = 'Failed to connect to the agent.'
501
- if (
502
- msg.includes('Failed to fetch') ||
503
- msg.includes('NetworkError') ||
504
- msg.includes('CORS')
505
- ) {
506
- errorText =
507
- 'Unable to reach the agent server. The deployment may be down or the URL may be incorrect.'
508
- } else if (msg.includes('abort')) {
509
- return
510
- }
511
- yield { content: [{ type: 'text' as const, text: `⚠️ ${errorText}` }] }
512
- return
513
- }
514
-
515
- if (!response.ok || !response.body) {
516
- let errorText = `Request failed (${response.status}).`
517
- if (response.status === 401 || response.status === 403) {
518
- errorText = 'Authentication error.'
519
- } else if (response.status === 404) {
520
- errorText =
521
- 'Agent not found — the agent may not be configured for this project.'
522
- } else if (response.status === 502 || response.status === 503) {
523
- errorText =
524
- 'The agent server is currently unavailable. Try again in a moment.'
525
- } else if (response.status === 429) {
526
- errorText = 'Rate limited — too many requests. Please wait a moment.'
527
- }
528
- yield { content: [{ type: 'text' as const, text: `⚠️ ${errorText}` }] }
529
- return
530
- }
531
-
532
- const text = { value: '' }
533
- const toolCalls: ToolCall[] = []
534
- const structuredParts: StructuredPart[] = []
535
- let pendingContent: any[] | null = null
536
- const yieldContent = () => {
537
- const content = buildRichContent(text, structuredParts, toolCalls)
538
- if (content.length > 0) pendingContent = content
539
- }
540
-
541
- const reader = response.body.getReader()
542
- const approvals = await processStream(
543
- reader,
544
- text,
545
- toolCalls,
546
- structuredParts,
547
- yieldContent,
548
- onFinishRef.current ?? undefined
549
- )
550
-
551
- if (approvals.length === 0) {
552
- // No approval needed — yield final content and done
553
- if (pendingContent) {
554
- yield { content: pendingContent }
555
- }
556
- return
557
- }
558
-
559
- // Approvals requested: store them for the next run() call
560
- pendingApprovalsRef.current = approvals
561
- setPendingApprovalsRef.current(approvals)
562
-
563
- // Each approval tool call needs to be shown without a result
564
- // so assistant-ui renders them as requires-action
565
- for (const approval of approvals) {
566
- const approvalToolCall: ToolCall = {
567
- type: 'tool-call',
568
- toolCallId: approval.toolCallId,
569
- toolName: approval.toolName,
570
- args: {
571
- ...(typeof approval.args === 'object' && approval.args !== null
572
- ? (approval.args as Record<string, unknown>)
573
- : {}),
574
- ...(approval.reason ? { __approvalReason: approval.reason } : {}),
575
- },
576
- }
577
-
578
- // Replace the existing tool call (if any) with the approval version
579
- const parentIdx = toolCalls.findIndex(
580
- (tc) => tc.toolCallId === approval.toolCallId && !tc.result
581
- )
582
- if (parentIdx !== -1) {
583
- toolCalls[parentIdx] = approvalToolCall
584
- } else {
585
- toolCalls.push(approvalToolCall)
586
- }
587
- }
588
-
589
- // Remove any forwarded sub-agent tool calls that duplicate approval tool names
590
- const approvalToolCallIds = new Set(approvals.map((a) => a.toolCallId))
591
- const approvalToolNames = new Set(approvals.map((a) => a.toolName))
592
- for (let i = toolCalls.length - 1; i >= 0; i--) {
593
- if (
594
- approvalToolNames.has(toolCalls[i].toolName) &&
595
- !approvalToolCallIds.has(toolCalls[i].toolCallId)
596
- ) {
597
- toolCalls.splice(i, 1)
598
- }
599
- }
600
-
601
- const content = buildRichContent(text, structuredParts, toolCalls)
602
- yield {
603
- content,
604
- status: {
605
- type: 'requires-action' as const,
606
- reason: 'tool-calls' as const,
607
- },
608
- }
609
- // Generator returns here. assistant-ui will show the approval UI for each tool call.
610
- // When the user clicks Approve/Deny on ALL tools → handleApproval accumulates decisions →
611
- // addResult for each → run() is called again when all have results.
612
- },
613
- }
614
- }
615
-
616
134
  export const convertDbMessages = (dbMessages: any[]): ThreadMessageLike[] => {
617
135
  const result: ThreadMessageLike[] = []
618
136
  let currentAssistant: ThreadMessageLike | null = null
@@ -728,340 +246,238 @@ export const convertDbMessages = (dbMessages: any[]): ThreadMessageLike[] => {
728
246
  return result
729
247
  }
730
248
 
731
- export function usePikkuAgentRuntime(options: PikkuAgentRuntimeOptions) {
732
- const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>(
733
- []
734
- )
735
-
736
- const optionsRef = useRef(options)
737
- optionsRef.current = options
738
-
739
- const onFinishRef = useRef(options.onFinish)
740
- onFinishRef.current = options.onFinish
741
-
742
- const pendingApprovalsRef = useRef<PendingApproval[]>([])
743
- const approvalDecisionsRef = useRef<
744
- { toolCallId: string; approved: boolean }[]
745
- >([])
746
-
747
- const setPendingApprovalsRef = useRef(setPendingApprovals)
748
- setPendingApprovalsRef.current = setPendingApprovals
749
-
750
- const adapter = useMemo(
751
- () =>
752
- createPikkuStreamingAdapter(
753
- optionsRef,
754
- pendingApprovalsRef,
755
- approvalDecisionsRef,
756
- setPendingApprovalsRef,
757
- onFinishRef
758
- ),
759
- []
760
- )
761
-
762
- const initialMessages = useMemo(
763
- () =>
764
- options.initialMessages
765
- ? convertDbMessages(options.initialMessages)
766
- : undefined,
767
- [options.initialMessages]
768
- )
769
-
770
- const runtime = useLocalRuntime(adapter, { initialMessages })
771
-
772
- // handleApproval is called from the Approve/Deny button click handler.
773
- // It accumulates the decision in the ref. assistant-ui will call run()
774
- // when ALL tool calls have results (via addResult).
775
- const handleApproval = useCallback(
776
- (toolCallId: string, approved: boolean) => {
777
- approvalDecisionsRef.current.push({ toolCallId, approved })
778
- },
779
- []
780
- )
781
-
782
- const isAwaitingApproval = pendingApprovals.length > 0
249
+ type PendingResume = {
250
+ runId: string
251
+ toolCallId: string
252
+ approved: boolean
253
+ }
783
254
 
784
- return {
785
- runtime,
786
- pendingApprovals,
787
- isAwaitingApproval,
788
- handleApproval,
255
+ function extractLastUserMessage(messages: RunAgentInput['messages']): string {
256
+ const lastUser = [...messages].reverse().find((m) => m.role === 'user')
257
+ if (!lastUser) return ''
258
+ const content = lastUser.content
259
+ if (typeof content === 'string') return content
260
+ if (Array.isArray(content)) {
261
+ return content
262
+ .filter((p: any) => p.type === 'text')
263
+ .map((p: any) => (p.text as string) ?? '')
264
+ .join('')
789
265
  }
266
+ return ''
790
267
  }
791
268
 
792
- function createPikkuNonStreamingAdapter(
793
- optionsRef: React.RefObject<PikkuAgentRuntimeOptions>,
794
- pendingApprovalsRef: React.RefObject<PendingApproval[]>,
795
- approvalDecisionsRef: React.RefObject<
796
- { toolCallId: string; approved: boolean }[]
797
- >,
798
- setPendingApprovalsRef: React.RefObject<
799
- (approvals: PendingApproval[]) => void
800
- >,
801
- onFinishRef: React.RefObject<(() => void) | undefined>
802
- ): ChatModelAdapter {
803
- return {
804
- async *run({ messages, abortSignal }) {
805
- const opts = optionsRef.current!
806
-
807
- // Continuation after approval decisions
808
- const pendingApprovals = pendingApprovalsRef.current
809
- if (pendingApprovals.length > 0) {
810
- const decisions = approvalDecisionsRef.current
811
- approvalDecisionsRef.current = []
812
-
813
- if (decisions.length === 0) return
814
-
815
- pendingApprovalsRef.current = []
816
- setPendingApprovalsRef.current([])
817
-
818
- // Resume uses SSE (same as streaming mode)
819
- let lastText = { value: '' }
820
- let lastToolCalls: ToolCall[] = []
821
- let lastStructuredParts: StructuredPart[] = []
822
- let nextApprovals: PendingApproval[] = []
823
-
824
- for (let i = 0; i < decisions.length; i++) {
825
- const decision = decisions[i]
826
- const matchingApproval = pendingApprovals.find(
827
- (p) => p.toolCallId === decision.toolCallId
828
- )
829
- const runId = matchingApproval?.runId ?? pendingApprovals[0]?.runId
830
-
831
- const resumeResponse = await fetch(
832
- `${opts.api}/${opts.agentName}/resume`,
833
- {
834
- method: 'POST',
835
- headers: {
836
- 'Content-Type': 'application/json',
837
- ...opts.headers,
838
- },
839
- body: JSON.stringify({
840
- runId,
841
- toolCallId: decision.toolCallId,
842
- approved: decision.approved,
843
- }),
844
- signal: abortSignal,
845
- credentials: opts.credentials,
846
- }
847
- )
848
-
849
- if (!resumeResponse.ok || !resumeResponse.body) {
850
- const errorText = resumeResponse.body
851
- ? await resumeResponse.text().catch(() => '')
852
- : ''
853
- throw new Error(
854
- `Resume failed: ${resumeResponse.status}${errorText ? ` - ${errorText}` : ''}`
855
- )
856
- }
857
-
858
- const text = { value: '' }
859
- const toolCalls: ToolCall[] = []
860
- const structuredParts: StructuredPart[] = []
861
- const reader = resumeResponse.body.getReader()
862
- const streamApprovals = await processStream(
863
- reader,
864
- text,
865
- toolCalls,
866
- structuredParts,
867
- () => {},
868
- i === decisions.length - 1
869
- ? (onFinishRef.current ?? undefined)
870
- : undefined
871
- )
872
-
873
- lastText = text
874
- lastToolCalls = toolCalls
875
- lastStructuredParts = structuredParts
876
- if (streamApprovals.length > 0) {
877
- nextApprovals = streamApprovals
878
- }
879
- }
880
-
881
- const content = buildRichContent(
882
- lastText,
883
- lastStructuredParts,
884
- lastToolCalls
885
- )
886
-
887
- if (nextApprovals.length > 0) {
888
- pendingApprovalsRef.current = nextApprovals
889
- setPendingApprovalsRef.current(nextApprovals)
269
+ class PikkuAgent extends HttpAgent {
270
+ private pikkuOpts: PikkuAgentRuntimeOptions
271
+ private _pendingResume: PendingResume | null = null
272
+ private _currentResume: PendingResume | null = null
273
+
274
+ constructor(opts: PikkuAgentRuntimeOptions) {
275
+ super({
276
+ url: `${opts.api}/${opts.agentName}/stream`,
277
+ threadId: opts.threadId,
278
+ })
279
+ this.pikkuOpts = opts
280
+ }
890
281
 
891
- for (const approval of nextApprovals) {
892
- const approvalToolCall: ToolCall = {
893
- type: 'tool-call',
894
- toolCallId: approval.toolCallId,
895
- toolName: approval.toolName,
896
- args: {
897
- ...(typeof approval.args === 'object' && approval.args !== null
898
- ? (approval.args as Record<string, unknown>)
899
- : {}),
900
- ...(approval.reason
901
- ? { __approvalReason: approval.reason }
902
- : {}),
903
- },
904
- }
905
- const idx = lastToolCalls.findIndex(
906
- (tc) => tc.toolCallId === approval.toolCallId && !tc.result
907
- )
908
- if (idx !== -1) {
909
- lastToolCalls[idx] = approvalToolCall
910
- } else {
911
- lastToolCalls.push(approvalToolCall)
912
- }
913
- }
282
+ updateOpts(opts: PikkuAgentRuntimeOptions) {
283
+ this.pikkuOpts = opts
284
+ }
914
285
 
915
- const updatedContent = buildRichContent(
916
- lastText,
917
- lastStructuredParts,
918
- lastToolCalls
919
- )
920
- yield {
921
- content: updatedContent,
922
- status: {
923
- type: 'requires-action' as const,
924
- reason: 'tool-calls' as const,
925
- },
926
- }
927
- } else if (content.length > 0) {
928
- yield { content }
929
- }
930
- return
931
- }
286
+ queueResume(data: PendingResume) {
287
+ this._pendingResume = data
288
+ }
932
289
 
933
- // Normal flow: new user message → non-streaming POST
934
- const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user')
935
- if (!lastUserMsg) return
290
+ run(input: RunAgentInput): Observable<BaseEvent> {
291
+ const resume = this._pendingResume
292
+ this._pendingResume = null
293
+ this._currentResume = resume
294
+ this.url = resume
295
+ ? `${this.pikkuOpts.api}/${this.pikkuOpts.agentName}/resume`
296
+ : `${this.pikkuOpts.api}/${this.pikkuOpts.agentName}/stream`
297
+ return super.run(input)
298
+ }
936
299
 
937
- let messageText = ''
938
- if (lastUserMsg.content) {
939
- for (const part of lastUserMsg.content) {
940
- if ('text' in part && part.type === 'text') {
941
- messageText += (part as { text: string }).text
942
- }
943
- }
944
- }
300
+ protected requestInit(input: RunAgentInput): RequestInit {
301
+ const base = super.requestInit(input)
302
+ const resume = this._currentResume
303
+ const opts = this.pikkuOpts
304
+ const headers: Record<string, string> = {
305
+ 'Content-Type': 'application/json',
306
+ Accept: 'text/event-stream',
307
+ ...opts.headers,
308
+ }
945
309
 
946
- const response = await fetch(`${opts.api}/${opts.agentName}`, {
947
- method: 'POST',
948
- headers: { 'Content-Type': 'application/json', ...opts.headers },
949
- body: JSON.stringify({
950
- agentName: opts.agentName,
951
- message: messageText,
952
- threadId: opts.threadId,
953
- resourceId: opts.resourceId,
954
- model: opts.model,
955
- temperature: opts.temperature,
956
- ...(opts.context ? { context: opts.context } : {}),
957
- }),
958
- signal: abortSignal,
310
+ if (resume) {
311
+ return {
312
+ ...base,
313
+ headers,
959
314
  credentials: opts.credentials,
960
- })
961
-
962
- if (!response.ok) {
963
- throw new Error(`Agent run failed: ${response.status}`)
964
- }
965
-
966
- const json = await response.json()
967
-
968
- if (json.status === 'suspended' && json.pendingApprovals?.length > 0) {
969
- const approvals: PendingApproval[] = json.pendingApprovals
970
- pendingApprovalsRef.current = approvals
971
- setPendingApprovalsRef.current(approvals)
972
-
973
- const toolCalls: ToolCall[] = approvals.map((approval) => ({
974
- type: 'tool-call' as const,
975
- toolCallId: approval.toolCallId,
976
- toolName: approval.toolName,
977
- args: {
978
- ...(typeof approval.args === 'object' && approval.args !== null
979
- ? (approval.args as Record<string, unknown>)
980
- : {}),
981
- ...(approval.reason ? { __approvalReason: approval.reason } : {}),
982
- },
983
- }))
984
-
985
- const content: any[] = []
986
- content.push(...buildContentFromAgentResult(json.result))
987
- content.push(...toolCalls)
988
-
989
- yield {
990
- content,
991
- status: {
992
- type: 'requires-action' as const,
993
- reason: 'tool-calls' as const,
994
- },
995
- }
996
- return
315
+ body: JSON.stringify(resume),
997
316
  }
317
+ }
998
318
 
999
- // No approvals — yield complete content
1000
- onFinishRef.current?.()
1001
- const content = buildContentFromAgentResult(json.result)
1002
- if (content.length > 0) {
1003
- yield { content }
1004
- }
1005
- },
319
+ return {
320
+ ...base,
321
+ headers,
322
+ credentials: opts.credentials,
323
+ body: JSON.stringify({
324
+ agentName: opts.agentName,
325
+ message: extractLastUserMessage(input.messages),
326
+ threadId: opts.threadId,
327
+ resourceId: opts.resourceId,
328
+ model: opts.model,
329
+ temperature: opts.temperature,
330
+ ...(opts.context ? { context: opts.context } : {}),
331
+ }),
332
+ }
1006
333
  }
1007
334
  }
1008
335
 
1009
- export function usePikkuAgentNonStreamingRuntime(
1010
- options: PikkuAgentRuntimeOptions
1011
- ) {
336
+ export function usePikkuAgentRuntime(options: PikkuAgentRuntimeOptions) {
1012
337
  const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>(
1013
338
  []
1014
339
  )
1015
-
1016
- const optionsRef = useRef(options)
1017
- optionsRef.current = options
340
+ // Authoritative pending list, mutated synchronously so two approval clicks
341
+ // in the same tick can't both read a stale "remaining" and skip the resume.
342
+ // State mirrors it purely for rendering.
343
+ const pendingApprovalsRef = useRef<PendingApproval[]>([])
344
+ const commitPending = useCallback((next: PendingApproval[]) => {
345
+ pendingApprovalsRef.current = next
346
+ setPendingApprovals(next)
347
+ }, [])
1018
348
 
1019
349
  const onFinishRef = useRef(options.onFinish)
1020
350
  onFinishRef.current = options.onFinish
1021
351
 
1022
- const pendingApprovalsRef = useRef<PendingApproval[]>([])
1023
- const approvalDecisionsRef = useRef<
1024
- { toolCallId: string; approved: boolean }[]
1025
- >([])
1026
-
1027
- const setPendingApprovalsRef = useRef(setPendingApprovals)
1028
- setPendingApprovalsRef.current = setPendingApprovals
1029
-
1030
- const adapter = useMemo(
1031
- () =>
1032
- createPikkuNonStreamingAdapter(
1033
- optionsRef,
1034
- pendingApprovalsRef,
1035
- approvalDecisionsRef,
1036
- setPendingApprovalsRef,
1037
- onFinishRef
1038
- ),
352
+ const agent = useMemo(
353
+ () => new PikkuAgent(options),
354
+ // agent is intentionally created once; opts are synced via updateOpts
355
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1039
356
  []
1040
357
  )
1041
358
 
1042
- const initialMessages = useMemo(
1043
- () =>
359
+ useEffect(() => {
360
+ agent.updateOpts(options)
361
+ })
362
+
363
+ useEffect(() => {
364
+ const { unsubscribe } = agent.subscribe({
365
+ onCustomEvent: ({ event }) => {
366
+ const e = event as AgUiCustomEvent
367
+ if (e.name === 'pikku:approval-request') {
368
+ const v = e.value as any
369
+ commitPending([
370
+ ...pendingApprovalsRef.current,
371
+ {
372
+ toolCallId: v.toolCallId,
373
+ toolName: v.toolName,
374
+ args: v.args,
375
+ reason: v.reason,
376
+ runId: v.runId,
377
+ type: 'approval-request' as const,
378
+ },
379
+ ])
380
+ } else if (e.name === 'pikku:credential-request') {
381
+ const v = e.value as any
382
+ commitPending([
383
+ ...pendingApprovalsRef.current,
384
+ {
385
+ toolCallId: v.toolCallId,
386
+ toolName: v.toolName,
387
+ args: v.args,
388
+ runId: v.runId,
389
+ type: 'credential-request' as const,
390
+ credentialName: v.credentialName,
391
+ credentialType: v.credentialType,
392
+ connectUrl: v.connectUrl,
393
+ },
394
+ ])
395
+ }
396
+ },
397
+ onRunFinalized: () => {
398
+ onFinishRef.current?.()
399
+ },
400
+ })
401
+ return unsubscribe
402
+ }, [agent, commitPending])
403
+
404
+ // Hydrate the thread from prior messages via the AG-UI history adapter,
405
+ // which useAgUiRuntime loads once on mount. Built once — see the
406
+ // initialMessages doc note about keeping the chat unmounted until ready.
407
+ const history = useMemo<ThreadHistoryAdapter | undefined>(() => {
408
+ if (!options.initialMessages?.length) return undefined
409
+ const repository = ExportedMessageRepository.fromArray(
1044
410
  options.initialMessages
1045
- ? convertDbMessages(options.initialMessages)
1046
- : undefined,
1047
- [options.initialMessages]
411
+ )
412
+ return {
413
+ load: async () => repository,
414
+ append: async () => {},
415
+ }
416
+ // eslint-disable-next-line react-hooks/exhaustive-deps
417
+ }, [])
418
+
419
+ const runtime = useAgUiRuntime(
420
+ history ? { agent, adapters: { history } } : { agent }
1048
421
  )
1049
422
 
1050
- const runtime = useLocalRuntime(adapter, { initialMessages })
423
+ const optionsRef = useRef(options)
424
+ optionsRef.current = options
425
+ const resumeChainRef = useRef<Promise<void>>(Promise.resolve())
1051
426
 
427
+ // The runtime only aggregates events from runs it starts itself, so the
428
+ // final approval is queued on the agent and triggered by the caller's
429
+ // addResult (which makes the runtime start the resume run once every tool
430
+ // call has a result). Earlier approvals in a batch are acknowledged with
431
+ // plain requests — their stream carries no content beyond 'done'.
1052
432
  const handleApproval = useCallback(
1053
- (toolCallId: string, approved: boolean) => {
1054
- approvalDecisionsRef.current.push({ toolCallId, approved })
433
+ async (toolCallId: string, approved: boolean): Promise<boolean> => {
434
+ const approval = pendingApprovalsRef.current.find(
435
+ (p) => p.toolCallId === toolCallId
436
+ )
437
+ if (!approval || !approval.runId) return false
438
+ const remaining = pendingApprovalsRef.current.filter(
439
+ (p) => p.toolCallId !== toolCallId
440
+ )
441
+ commitPending(remaining)
442
+ const resume = { runId: approval.runId, toolCallId, approved }
443
+
444
+ if (remaining.length > 0) {
445
+ resumeChainRef.current = resumeChainRef.current.then(async () => {
446
+ const opts = optionsRef.current
447
+ try {
448
+ const response = await fetch(
449
+ `${opts.api}/${opts.agentName}/resume`,
450
+ {
451
+ method: 'POST',
452
+ headers: {
453
+ 'Content-Type': 'application/json',
454
+ Accept: 'text/event-stream',
455
+ ...opts.headers,
456
+ },
457
+ credentials: opts.credentials,
458
+ body: JSON.stringify(resume),
459
+ }
460
+ )
461
+ await response.text()
462
+ } catch (err) {
463
+ console.error('[pikku] failed to resolve approval', err)
464
+ }
465
+ })
466
+ await resumeChainRef.current
467
+ return true
468
+ }
469
+
470
+ await resumeChainRef.current
471
+ agent.queueResume(resume)
472
+ return true
1055
473
  },
1056
- []
474
+ [agent, commitPending]
1057
475
  )
1058
476
 
1059
- const isAwaitingApproval = pendingApprovals.length > 0
1060
-
1061
477
  return {
1062
478
  runtime,
1063
479
  pendingApprovals,
1064
- isAwaitingApproval,
480
+ isAwaitingApproval: pendingApprovals.length > 0,
1065
481
  handleApproval,
1066
482
  }
1067
483
  }