@pikku/assistant-ui 0.12.7 → 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,140 +1,12 @@
1
- import { createContext, useContext, useMemo, useRef, useState, useCallback, } from 'react';
2
- import { useLocalRuntime, } from '@assistant-ui/react';
1
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
2
+ import { HttpAgent } from '@ag-ui/client';
3
+ import { useAgUiRuntime } from '@assistant-ui/react-ag-ui';
4
+ import { ExportedMessageRepository, } from '@assistant-ui/react';
3
5
  export const PikkuApprovalContext = createContext({
4
6
  pendingApprovals: [],
5
- handleApproval: () => { },
7
+ handleApproval: async () => false,
6
8
  });
7
9
  export const usePikkuApproval = () => useContext(PikkuApprovalContext);
8
- async function* parseSSEStream(reader) {
9
- const decoder = new TextDecoder();
10
- let buffer = '';
11
- while (true) {
12
- const { done, value } = await reader.read();
13
- if (done)
14
- break;
15
- buffer += decoder.decode(value, { stream: true });
16
- const lines = buffer.split('\n');
17
- buffer = lines.pop();
18
- for (const line of lines) {
19
- if (line.startsWith('data: ')) {
20
- const data = line.slice(6);
21
- if (data) {
22
- try {
23
- yield JSON.parse(data);
24
- }
25
- catch {
26
- // skip unparseable lines
27
- }
28
- }
29
- }
30
- }
31
- }
32
- }
33
- /**
34
- * Shared helper: consume an SSE stream and populate text/toolCalls.
35
- * Returns an array of PendingApprovals when the stream requests them, or empty when done.
36
- */
37
- async function processStream(reader, text, toolCalls, structuredParts, yieldContent, onFinish) {
38
- const pendingApprovals = [];
39
- for await (const event of parseSSEStream(reader)) {
40
- switch (event.type) {
41
- case 'text-delta':
42
- text.value += event.text;
43
- break;
44
- case 'tool-call': {
45
- let parsedArgs = event.args;
46
- if (typeof event.args === 'string') {
47
- try {
48
- parsedArgs = JSON.parse(event.args);
49
- }
50
- catch {
51
- parsedArgs = {};
52
- }
53
- }
54
- toolCalls.push({
55
- type: 'tool-call',
56
- toolCallId: event.toolCallId,
57
- toolName: event.toolName,
58
- args: parsedArgs,
59
- });
60
- break;
61
- }
62
- case 'tool-result': {
63
- // Skip tool-results that contain __approvalRequired
64
- const resultObj = typeof event.result === 'object' ? event.result : null;
65
- if (resultObj && '__approvalRequired' in resultObj) {
66
- break;
67
- }
68
- const tc = toolCalls.find((t) => t.toolCallId === event.toolCallId);
69
- if (tc) {
70
- tc.result =
71
- typeof event.result === 'string'
72
- ? event.result
73
- : JSON.stringify(event.result);
74
- if (event.isError) {
75
- tc.isError = true;
76
- }
77
- }
78
- break;
79
- }
80
- case 'generative-ui': {
81
- const nextPart = {
82
- type: 'generative-ui',
83
- spec: event.spec,
84
- };
85
- const existingIndex = structuredParts.findIndex((part) => part.type === 'generative-ui');
86
- if (existingIndex === -1)
87
- structuredParts.push(nextPart);
88
- else
89
- structuredParts[existingIndex] = nextPart;
90
- break;
91
- }
92
- case 'data': {
93
- const nextPart = {
94
- type: 'data',
95
- name: event.name,
96
- data: event.data,
97
- };
98
- const existingIndex = structuredParts.findIndex((part) => part.type === 'data' && part.name === event.name);
99
- if (existingIndex === -1)
100
- structuredParts.push(nextPart);
101
- else
102
- structuredParts[existingIndex] = nextPart;
103
- break;
104
- }
105
- case 'approval-request':
106
- pendingApprovals.push({
107
- toolCallId: event.toolCallId,
108
- toolName: event.toolName,
109
- args: event.args,
110
- reason: event.reason,
111
- runId: event.runId,
112
- type: 'approval-request',
113
- });
114
- break;
115
- case 'credential-request':
116
- pendingApprovals.push({
117
- toolCallId: event.toolCallId,
118
- toolName: event.toolName,
119
- args: event.args,
120
- runId: event.runId,
121
- type: 'credential-request',
122
- credentialName: event.credentialName,
123
- credentialType: event.credentialType,
124
- connectUrl: event.connectUrl,
125
- });
126
- break;
127
- case 'error':
128
- text.value += `\n\nError: ${event.message || event.errorText || 'Unknown error'}`;
129
- break;
130
- case 'done':
131
- onFinish?.();
132
- continue;
133
- }
134
- yieldContent();
135
- }
136
- return pendingApprovals;
137
- }
138
10
  export function isDeniedResult(result) {
139
11
  if (result == null)
140
12
  return false;
@@ -176,271 +48,6 @@ export function resolvePikkuToolStatus(status, result) {
176
48
  return { type: 'error' };
177
49
  return { type: 'completed' };
178
50
  }
179
- function buildRichContent(text, structuredParts, toolCalls) {
180
- const content = [];
181
- if (text.value)
182
- content.push({ type: 'text', text: text.value });
183
- content.push(...structuredParts);
184
- content.push(...toolCalls);
185
- return content;
186
- }
187
- function buildContentFromAgentResult(result) {
188
- const content = [];
189
- if (typeof result === 'string') {
190
- if (result)
191
- content.push({ type: 'text', text: result });
192
- return content;
193
- }
194
- if (!result || typeof result !== 'object')
195
- return content;
196
- const record = result;
197
- if (typeof record.text === 'string' && record.text) {
198
- content.push({ type: 'text', text: record.text });
199
- }
200
- if (record.ui != null) {
201
- content.push({ type: 'generative-ui', spec: record.ui });
202
- }
203
- return content;
204
- }
205
- function createPikkuStreamingAdapter(optionsRef, pendingApprovalsRef, approvalDecisionsRef, setPendingApprovalsRef, onFinishRef) {
206
- return {
207
- async *run({ messages, abortSignal }) {
208
- const opts = optionsRef.current;
209
- // Check if this run() is a continuation after approval decisions.
210
- // assistant-ui calls run() again after addResult provides tool results for ALL tool calls.
211
- const pendingApprovals = pendingApprovalsRef.current;
212
- if (pendingApprovals.length > 0) {
213
- // Read the decisions accumulated by handleApproval() from click handlers.
214
- const decisions = approvalDecisionsRef.current;
215
- approvalDecisionsRef.current = [];
216
- if (decisions.length === 0) {
217
- // No decisions set — shouldn't happen if composer is disabled.
218
- return;
219
- }
220
- // Clear pending approvals state
221
- pendingApprovalsRef.current = [];
222
- setPendingApprovalsRef.current([]);
223
- // Send /resume for each decision sequentially.
224
- // All but the last resume will return quickly (just tool-result + done).
225
- // The last resume triggers continuation (next LLM step).
226
- let lastText = { value: '' };
227
- let lastToolCalls = [];
228
- let lastStructuredParts = [];
229
- let nextApprovals = [];
230
- for (let i = 0; i < decisions.length; i++) {
231
- const decision = decisions[i];
232
- // Find the runId from the matching pending approval
233
- const matchingApproval = pendingApprovals.find((p) => p.toolCallId === decision.toolCallId);
234
- const runId = matchingApproval?.runId ?? pendingApprovals[0]?.runId;
235
- const resumeResponse = await fetch(`${opts.api}/${opts.agentName}/resume`, {
236
- method: 'POST',
237
- headers: {
238
- 'Content-Type': 'application/json',
239
- ...opts.headers,
240
- },
241
- body: JSON.stringify({
242
- runId,
243
- toolCallId: decision.toolCallId,
244
- approved: decision.approved,
245
- }),
246
- signal: abortSignal,
247
- credentials: opts.credentials,
248
- });
249
- if (!resumeResponse.ok || !resumeResponse.body) {
250
- const errorText = resumeResponse.body
251
- ? await resumeResponse.text().catch(() => '')
252
- : '';
253
- throw new Error(`Resume failed: ${resumeResponse.status}${errorText ? ` - ${errorText}` : ''}`);
254
- }
255
- const text = { value: '' };
256
- const toolCalls = [];
257
- const structuredParts = [];
258
- const reader = resumeResponse.body.getReader();
259
- const streamApprovals = await processStream(reader, text, toolCalls, structuredParts, () => { }, i === decisions.length - 1
260
- ? (onFinishRef.current ?? undefined)
261
- : undefined);
262
- // Keep the last resume's output (it has continuation content)
263
- lastText = text;
264
- lastToolCalls = toolCalls;
265
- lastStructuredParts = structuredParts;
266
- if (streamApprovals.length > 0) {
267
- nextApprovals = streamApprovals;
268
- }
269
- }
270
- // Build content from the last resume's output
271
- const content = buildRichContent(lastText, lastStructuredParts, lastToolCalls);
272
- if (nextApprovals.length > 0) {
273
- // More approvals from continuation — show them
274
- pendingApprovalsRef.current = nextApprovals;
275
- setPendingApprovalsRef.current(nextApprovals);
276
- // Add approval tool calls to content
277
- for (const approval of nextApprovals) {
278
- const approvalToolCall = {
279
- type: 'tool-call',
280
- toolCallId: approval.toolCallId,
281
- toolName: approval.toolName,
282
- args: {
283
- ...(typeof approval.args === 'object' && approval.args !== null
284
- ? approval.args
285
- : {}),
286
- ...(approval.reason
287
- ? { __approvalReason: approval.reason }
288
- : {}),
289
- },
290
- };
291
- const idx = lastToolCalls.findIndex((tc) => tc.toolCallId === approval.toolCallId && !tc.result);
292
- if (idx !== -1) {
293
- lastToolCalls[idx] = approvalToolCall;
294
- }
295
- else {
296
- lastToolCalls.push(approvalToolCall);
297
- }
298
- }
299
- const updatedContent = buildRichContent(lastText, lastStructuredParts, lastToolCalls);
300
- yield {
301
- content: updatedContent,
302
- status: {
303
- type: 'requires-action',
304
- reason: 'tool-calls',
305
- },
306
- };
307
- }
308
- else if (content.length > 0) {
309
- yield { content };
310
- }
311
- return;
312
- }
313
- // Normal flow: new user message → stream
314
- const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
315
- if (!lastUserMsg)
316
- return;
317
- let messageText = '';
318
- if (lastUserMsg.content) {
319
- for (const part of lastUserMsg.content) {
320
- if ('text' in part && part.type === 'text') {
321
- messageText += part.text;
322
- }
323
- }
324
- }
325
- let response;
326
- try {
327
- response = await fetch(`${opts.api}/${opts.agentName}/stream`, {
328
- method: 'POST',
329
- headers: { 'Content-Type': 'application/json', ...opts.headers },
330
- body: JSON.stringify({
331
- agentName: opts.agentName,
332
- message: messageText,
333
- threadId: opts.threadId,
334
- resourceId: opts.resourceId,
335
- model: opts.model,
336
- temperature: opts.temperature,
337
- ...(opts.context ? { context: opts.context } : {}),
338
- }),
339
- signal: abortSignal,
340
- credentials: opts.credentials,
341
- });
342
- }
343
- catch (e) {
344
- const msg = e?.message || 'Unknown error';
345
- let errorText = 'Failed to connect to the agent.';
346
- if (msg.includes('Failed to fetch') ||
347
- msg.includes('NetworkError') ||
348
- msg.includes('CORS')) {
349
- errorText =
350
- 'Unable to reach the agent server. The deployment may be down or the URL may be incorrect.';
351
- }
352
- else if (msg.includes('abort')) {
353
- return;
354
- }
355
- yield { content: [{ type: 'text', text: `⚠️ ${errorText}` }] };
356
- return;
357
- }
358
- if (!response.ok || !response.body) {
359
- let errorText = `Request failed (${response.status}).`;
360
- if (response.status === 401 || response.status === 403) {
361
- errorText = 'Authentication error.';
362
- }
363
- else if (response.status === 404) {
364
- errorText =
365
- 'Agent not found — the agent may not be configured for this project.';
366
- }
367
- else if (response.status === 502 || response.status === 503) {
368
- errorText =
369
- 'The agent server is currently unavailable. Try again in a moment.';
370
- }
371
- else if (response.status === 429) {
372
- errorText = 'Rate limited — too many requests. Please wait a moment.';
373
- }
374
- yield { content: [{ type: 'text', text: `⚠️ ${errorText}` }] };
375
- return;
376
- }
377
- const text = { value: '' };
378
- const toolCalls = [];
379
- const structuredParts = [];
380
- let pendingContent = null;
381
- const yieldContent = () => {
382
- const content = buildRichContent(text, structuredParts, toolCalls);
383
- if (content.length > 0)
384
- pendingContent = content;
385
- };
386
- const reader = response.body.getReader();
387
- const approvals = await processStream(reader, text, toolCalls, structuredParts, yieldContent, onFinishRef.current ?? undefined);
388
- if (approvals.length === 0) {
389
- // No approval needed — yield final content and done
390
- if (pendingContent) {
391
- yield { content: pendingContent };
392
- }
393
- return;
394
- }
395
- // Approvals requested: store them for the next run() call
396
- pendingApprovalsRef.current = approvals;
397
- setPendingApprovalsRef.current(approvals);
398
- // Each approval tool call needs to be shown without a result
399
- // so assistant-ui renders them as requires-action
400
- for (const approval of approvals) {
401
- const approvalToolCall = {
402
- type: 'tool-call',
403
- toolCallId: approval.toolCallId,
404
- toolName: approval.toolName,
405
- args: {
406
- ...(typeof approval.args === 'object' && approval.args !== null
407
- ? approval.args
408
- : {}),
409
- ...(approval.reason ? { __approvalReason: approval.reason } : {}),
410
- },
411
- };
412
- // Replace the existing tool call (if any) with the approval version
413
- const parentIdx = toolCalls.findIndex((tc) => tc.toolCallId === approval.toolCallId && !tc.result);
414
- if (parentIdx !== -1) {
415
- toolCalls[parentIdx] = approvalToolCall;
416
- }
417
- else {
418
- toolCalls.push(approvalToolCall);
419
- }
420
- }
421
- // Remove any forwarded sub-agent tool calls that duplicate approval tool names
422
- const approvalToolCallIds = new Set(approvals.map((a) => a.toolCallId));
423
- const approvalToolNames = new Set(approvals.map((a) => a.toolName));
424
- for (let i = toolCalls.length - 1; i >= 0; i--) {
425
- if (approvalToolNames.has(toolCalls[i].toolName) &&
426
- !approvalToolCallIds.has(toolCalls[i].toolCallId)) {
427
- toolCalls.splice(i, 1);
428
- }
429
- }
430
- const content = buildRichContent(text, structuredParts, toolCalls);
431
- yield {
432
- content,
433
- status: {
434
- type: 'requires-action',
435
- reason: 'tool-calls',
436
- },
437
- };
438
- // Generator returns here. assistant-ui will show the approval UI for each tool call.
439
- // When the user clicks Approve/Deny on ALL tools → handleApproval accumulates decisions →
440
- // addResult for each → run() is called again when all have results.
441
- },
442
- };
443
- }
444
51
  export const convertDbMessages = (dbMessages) => {
445
52
  const result = [];
446
53
  let currentAssistant = null;
@@ -542,221 +149,200 @@ export const convertDbMessages = (dbMessages) => {
542
149
  }
543
150
  return result;
544
151
  };
152
+ function extractLastUserMessage(messages) {
153
+ const lastUser = [...messages].reverse().find((m) => m.role === 'user');
154
+ if (!lastUser)
155
+ return '';
156
+ const content = lastUser.content;
157
+ if (typeof content === 'string')
158
+ return content;
159
+ if (Array.isArray(content)) {
160
+ return content
161
+ .filter((p) => p.type === 'text')
162
+ .map((p) => p.text ?? '')
163
+ .join('');
164
+ }
165
+ return '';
166
+ }
167
+ class PikkuAgent extends HttpAgent {
168
+ pikkuOpts;
169
+ _pendingResume = null;
170
+ _currentResume = null;
171
+ constructor(opts) {
172
+ super({
173
+ url: `${opts.api}/${opts.agentName}/stream`,
174
+ threadId: opts.threadId,
175
+ });
176
+ this.pikkuOpts = opts;
177
+ }
178
+ updateOpts(opts) {
179
+ this.pikkuOpts = opts;
180
+ }
181
+ queueResume(data) {
182
+ this._pendingResume = data;
183
+ }
184
+ run(input) {
185
+ const resume = this._pendingResume;
186
+ this._pendingResume = null;
187
+ this._currentResume = resume;
188
+ this.url = resume
189
+ ? `${this.pikkuOpts.api}/${this.pikkuOpts.agentName}/resume`
190
+ : `${this.pikkuOpts.api}/${this.pikkuOpts.agentName}/stream`;
191
+ return super.run(input);
192
+ }
193
+ requestInit(input) {
194
+ const base = super.requestInit(input);
195
+ const resume = this._currentResume;
196
+ const opts = this.pikkuOpts;
197
+ const headers = {
198
+ 'Content-Type': 'application/json',
199
+ Accept: 'text/event-stream',
200
+ ...opts.headers,
201
+ };
202
+ if (resume) {
203
+ return {
204
+ ...base,
205
+ headers,
206
+ credentials: opts.credentials,
207
+ body: JSON.stringify(resume),
208
+ };
209
+ }
210
+ return {
211
+ ...base,
212
+ headers,
213
+ credentials: opts.credentials,
214
+ body: JSON.stringify({
215
+ agentName: opts.agentName,
216
+ message: extractLastUserMessage(input.messages),
217
+ threadId: opts.threadId,
218
+ resourceId: opts.resourceId,
219
+ model: opts.model,
220
+ temperature: opts.temperature,
221
+ ...(opts.context ? { context: opts.context } : {}),
222
+ }),
223
+ };
224
+ }
225
+ }
545
226
  export function usePikkuAgentRuntime(options) {
546
227
  const [pendingApprovals, setPendingApprovals] = useState([]);
547
- const optionsRef = useRef(options);
548
- optionsRef.current = options;
228
+ // Authoritative pending list, mutated synchronously so two approval clicks
229
+ // in the same tick can't both read a stale "remaining" and skip the resume.
230
+ // State mirrors it purely for rendering.
231
+ const pendingApprovalsRef = useRef([]);
232
+ const commitPending = useCallback((next) => {
233
+ pendingApprovalsRef.current = next;
234
+ setPendingApprovals(next);
235
+ }, []);
549
236
  const onFinishRef = useRef(options.onFinish);
550
237
  onFinishRef.current = options.onFinish;
551
- const pendingApprovalsRef = useRef([]);
552
- const approvalDecisionsRef = useRef([]);
553
- const setPendingApprovalsRef = useRef(setPendingApprovals);
554
- setPendingApprovalsRef.current = setPendingApprovals;
555
- const adapter = useMemo(() => createPikkuStreamingAdapter(optionsRef, pendingApprovalsRef, approvalDecisionsRef, setPendingApprovalsRef, onFinishRef), []);
556
- const initialMessages = useMemo(() => options.initialMessages
557
- ? convertDbMessages(options.initialMessages)
558
- : undefined, [options.initialMessages]);
559
- const runtime = useLocalRuntime(adapter, { initialMessages });
560
- // handleApproval is called from the Approve/Deny button click handler.
561
- // It accumulates the decision in the ref. assistant-ui will call run()
562
- // when ALL tool calls have results (via addResult).
563
- const handleApproval = useCallback((toolCallId, approved) => {
564
- approvalDecisionsRef.current.push({ toolCallId, approved });
238
+ const agent = useMemo(() => new PikkuAgent(options),
239
+ // agent is intentionally created once; opts are synced via updateOpts
240
+ // eslint-disable-next-line react-hooks/exhaustive-deps
241
+ []);
242
+ useEffect(() => {
243
+ agent.updateOpts(options);
244
+ });
245
+ useEffect(() => {
246
+ const { unsubscribe } = agent.subscribe({
247
+ onCustomEvent: ({ event }) => {
248
+ const e = event;
249
+ if (e.name === 'pikku:approval-request') {
250
+ const v = e.value;
251
+ commitPending([
252
+ ...pendingApprovalsRef.current,
253
+ {
254
+ toolCallId: v.toolCallId,
255
+ toolName: v.toolName,
256
+ args: v.args,
257
+ reason: v.reason,
258
+ runId: v.runId,
259
+ type: 'approval-request',
260
+ },
261
+ ]);
262
+ }
263
+ else if (e.name === 'pikku:credential-request') {
264
+ const v = e.value;
265
+ commitPending([
266
+ ...pendingApprovalsRef.current,
267
+ {
268
+ toolCallId: v.toolCallId,
269
+ toolName: v.toolName,
270
+ args: v.args,
271
+ runId: v.runId,
272
+ type: 'credential-request',
273
+ credentialName: v.credentialName,
274
+ credentialType: v.credentialType,
275
+ connectUrl: v.connectUrl,
276
+ },
277
+ ]);
278
+ }
279
+ },
280
+ onRunFinalized: () => {
281
+ onFinishRef.current?.();
282
+ },
283
+ });
284
+ return unsubscribe;
285
+ }, [agent, commitPending]);
286
+ // Hydrate the thread from prior messages via the AG-UI history adapter,
287
+ // which useAgUiRuntime loads once on mount. Built once — see the
288
+ // initialMessages doc note about keeping the chat unmounted until ready.
289
+ const history = useMemo(() => {
290
+ if (!options.initialMessages?.length)
291
+ return undefined;
292
+ const repository = ExportedMessageRepository.fromArray(options.initialMessages);
293
+ return {
294
+ load: async () => repository,
295
+ append: async () => { },
296
+ };
297
+ // eslint-disable-next-line react-hooks/exhaustive-deps
565
298
  }, []);
566
- const isAwaitingApproval = pendingApprovals.length > 0;
567
- return {
568
- runtime,
569
- pendingApprovals,
570
- isAwaitingApproval,
571
- handleApproval,
572
- };
573
- }
574
- function createPikkuNonStreamingAdapter(optionsRef, pendingApprovalsRef, approvalDecisionsRef, setPendingApprovalsRef, onFinishRef) {
575
- return {
576
- async *run({ messages, abortSignal }) {
577
- const opts = optionsRef.current;
578
- // Continuation after approval decisions
579
- const pendingApprovals = pendingApprovalsRef.current;
580
- if (pendingApprovals.length > 0) {
581
- const decisions = approvalDecisionsRef.current;
582
- approvalDecisionsRef.current = [];
583
- if (decisions.length === 0)
584
- return;
585
- pendingApprovalsRef.current = [];
586
- setPendingApprovalsRef.current([]);
587
- // Resume uses SSE (same as streaming mode)
588
- let lastText = { value: '' };
589
- let lastToolCalls = [];
590
- let lastStructuredParts = [];
591
- let nextApprovals = [];
592
- for (let i = 0; i < decisions.length; i++) {
593
- const decision = decisions[i];
594
- const matchingApproval = pendingApprovals.find((p) => p.toolCallId === decision.toolCallId);
595
- const runId = matchingApproval?.runId ?? pendingApprovals[0]?.runId;
596
- const resumeResponse = await fetch(`${opts.api}/${opts.agentName}/resume`, {
299
+ const runtime = useAgUiRuntime(history ? { agent, adapters: { history } } : { agent });
300
+ const optionsRef = useRef(options);
301
+ optionsRef.current = options;
302
+ const resumeChainRef = useRef(Promise.resolve());
303
+ // The runtime only aggregates events from runs it starts itself, so the
304
+ // final approval is queued on the agent and triggered by the caller's
305
+ // addResult (which makes the runtime start the resume run once every tool
306
+ // call has a result). Earlier approvals in a batch are acknowledged with
307
+ // plain requests their stream carries no content beyond 'done'.
308
+ const handleApproval = useCallback(async (toolCallId, approved) => {
309
+ const approval = pendingApprovalsRef.current.find((p) => p.toolCallId === toolCallId);
310
+ if (!approval || !approval.runId)
311
+ return false;
312
+ const remaining = pendingApprovalsRef.current.filter((p) => p.toolCallId !== toolCallId);
313
+ commitPending(remaining);
314
+ const resume = { runId: approval.runId, toolCallId, approved };
315
+ if (remaining.length > 0) {
316
+ resumeChainRef.current = resumeChainRef.current.then(async () => {
317
+ const opts = optionsRef.current;
318
+ try {
319
+ const response = await fetch(`${opts.api}/${opts.agentName}/resume`, {
597
320
  method: 'POST',
598
321
  headers: {
599
322
  'Content-Type': 'application/json',
323
+ Accept: 'text/event-stream',
600
324
  ...opts.headers,
601
325
  },
602
- body: JSON.stringify({
603
- runId,
604
- toolCallId: decision.toolCallId,
605
- approved: decision.approved,
606
- }),
607
- signal: abortSignal,
608
326
  credentials: opts.credentials,
327
+ body: JSON.stringify(resume),
609
328
  });
610
- if (!resumeResponse.ok || !resumeResponse.body) {
611
- const errorText = resumeResponse.body
612
- ? await resumeResponse.text().catch(() => '')
613
- : '';
614
- throw new Error(`Resume failed: ${resumeResponse.status}${errorText ? ` - ${errorText}` : ''}`);
615
- }
616
- const text = { value: '' };
617
- const toolCalls = [];
618
- const structuredParts = [];
619
- const reader = resumeResponse.body.getReader();
620
- const streamApprovals = await processStream(reader, text, toolCalls, structuredParts, () => { }, i === decisions.length - 1
621
- ? (onFinishRef.current ?? undefined)
622
- : undefined);
623
- lastText = text;
624
- lastToolCalls = toolCalls;
625
- lastStructuredParts = structuredParts;
626
- if (streamApprovals.length > 0) {
627
- nextApprovals = streamApprovals;
628
- }
329
+ await response.text();
629
330
  }
630
- const content = buildRichContent(lastText, lastStructuredParts, lastToolCalls);
631
- if (nextApprovals.length > 0) {
632
- pendingApprovalsRef.current = nextApprovals;
633
- setPendingApprovalsRef.current(nextApprovals);
634
- for (const approval of nextApprovals) {
635
- const approvalToolCall = {
636
- type: 'tool-call',
637
- toolCallId: approval.toolCallId,
638
- toolName: approval.toolName,
639
- args: {
640
- ...(typeof approval.args === 'object' && approval.args !== null
641
- ? approval.args
642
- : {}),
643
- ...(approval.reason
644
- ? { __approvalReason: approval.reason }
645
- : {}),
646
- },
647
- };
648
- const idx = lastToolCalls.findIndex((tc) => tc.toolCallId === approval.toolCallId && !tc.result);
649
- if (idx !== -1) {
650
- lastToolCalls[idx] = approvalToolCall;
651
- }
652
- else {
653
- lastToolCalls.push(approvalToolCall);
654
- }
655
- }
656
- const updatedContent = buildRichContent(lastText, lastStructuredParts, lastToolCalls);
657
- yield {
658
- content: updatedContent,
659
- status: {
660
- type: 'requires-action',
661
- reason: 'tool-calls',
662
- },
663
- };
331
+ catch (err) {
332
+ console.error('[pikku] failed to resolve approval', err);
664
333
  }
665
- else if (content.length > 0) {
666
- yield { content };
667
- }
668
- return;
669
- }
670
- // Normal flow: new user message → non-streaming POST
671
- const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
672
- if (!lastUserMsg)
673
- return;
674
- let messageText = '';
675
- if (lastUserMsg.content) {
676
- for (const part of lastUserMsg.content) {
677
- if ('text' in part && part.type === 'text') {
678
- messageText += part.text;
679
- }
680
- }
681
- }
682
- const response = await fetch(`${opts.api}/${opts.agentName}`, {
683
- method: 'POST',
684
- headers: { 'Content-Type': 'application/json', ...opts.headers },
685
- body: JSON.stringify({
686
- agentName: opts.agentName,
687
- message: messageText,
688
- threadId: opts.threadId,
689
- resourceId: opts.resourceId,
690
- model: opts.model,
691
- temperature: opts.temperature,
692
- ...(opts.context ? { context: opts.context } : {}),
693
- }),
694
- signal: abortSignal,
695
- credentials: opts.credentials,
696
334
  });
697
- if (!response.ok) {
698
- throw new Error(`Agent run failed: ${response.status}`);
699
- }
700
- const json = await response.json();
701
- if (json.status === 'suspended' && json.pendingApprovals?.length > 0) {
702
- const approvals = json.pendingApprovals;
703
- pendingApprovalsRef.current = approvals;
704
- setPendingApprovalsRef.current(approvals);
705
- const toolCalls = approvals.map((approval) => ({
706
- type: 'tool-call',
707
- toolCallId: approval.toolCallId,
708
- toolName: approval.toolName,
709
- args: {
710
- ...(typeof approval.args === 'object' && approval.args !== null
711
- ? approval.args
712
- : {}),
713
- ...(approval.reason ? { __approvalReason: approval.reason } : {}),
714
- },
715
- }));
716
- const content = [];
717
- content.push(...buildContentFromAgentResult(json.result));
718
- content.push(...toolCalls);
719
- yield {
720
- content,
721
- status: {
722
- type: 'requires-action',
723
- reason: 'tool-calls',
724
- },
725
- };
726
- return;
727
- }
728
- // No approvals — yield complete content
729
- onFinishRef.current?.();
730
- const content = buildContentFromAgentResult(json.result);
731
- if (content.length > 0) {
732
- yield { content };
733
- }
734
- },
735
- };
736
- }
737
- export function usePikkuAgentNonStreamingRuntime(options) {
738
- const [pendingApprovals, setPendingApprovals] = useState([]);
739
- const optionsRef = useRef(options);
740
- optionsRef.current = options;
741
- const onFinishRef = useRef(options.onFinish);
742
- onFinishRef.current = options.onFinish;
743
- const pendingApprovalsRef = useRef([]);
744
- const approvalDecisionsRef = useRef([]);
745
- const setPendingApprovalsRef = useRef(setPendingApprovals);
746
- setPendingApprovalsRef.current = setPendingApprovals;
747
- const adapter = useMemo(() => createPikkuNonStreamingAdapter(optionsRef, pendingApprovalsRef, approvalDecisionsRef, setPendingApprovalsRef, onFinishRef), []);
748
- const initialMessages = useMemo(() => options.initialMessages
749
- ? convertDbMessages(options.initialMessages)
750
- : undefined, [options.initialMessages]);
751
- const runtime = useLocalRuntime(adapter, { initialMessages });
752
- const handleApproval = useCallback((toolCallId, approved) => {
753
- approvalDecisionsRef.current.push({ toolCallId, approved });
754
- }, []);
755
- const isAwaitingApproval = pendingApprovals.length > 0;
335
+ await resumeChainRef.current;
336
+ return true;
337
+ }
338
+ await resumeChainRef.current;
339
+ agent.queueResume(resume);
340
+ return true;
341
+ }, [agent, commitPending]);
756
342
  return {
757
343
  runtime,
758
344
  pendingApprovals,
759
- isAwaitingApproval,
345
+ isAwaitingApproval: pendingApprovals.length > 0,
760
346
  handleApproval,
761
347
  };
762
348
  }