@ouispec/agent-react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -0
  3. package/dist/annotations/AnnotationRegistry.d.ts +20 -0
  4. package/dist/annotations/AnnotationRegistry.d.ts.map +1 -0
  5. package/dist/annotations/AnnotationRegistry.js +100 -0
  6. package/dist/annotations/AnnotationRegistry.js.map +1 -0
  7. package/dist/annotations/singleton.d.ts +7 -0
  8. package/dist/annotations/singleton.d.ts.map +1 -0
  9. package/dist/annotations/singleton.js +7 -0
  10. package/dist/annotations/singleton.js.map +1 -0
  11. package/dist/annotations/types.d.ts +22 -0
  12. package/dist/annotations/types.d.ts.map +1 -0
  13. package/dist/annotations/types.js +2 -0
  14. package/dist/annotations/types.js.map +1 -0
  15. package/dist/annotations/useViewAnnotation.d.ts +15 -0
  16. package/dist/annotations/useViewAnnotation.d.ts.map +1 -0
  17. package/dist/annotations/useViewAnnotation.js +42 -0
  18. package/dist/annotations/useViewAnnotation.js.map +1 -0
  19. package/dist/approvals/ApprovalCard.d.ts +54 -0
  20. package/dist/approvals/ApprovalCard.d.ts.map +1 -0
  21. package/dist/approvals/ApprovalCard.js +65 -0
  22. package/dist/approvals/ApprovalCard.js.map +1 -0
  23. package/dist/approvals/decision.d.ts +17 -0
  24. package/dist/approvals/decision.d.ts.map +1 -0
  25. package/dist/approvals/decision.js +25 -0
  26. package/dist/approvals/decision.js.map +1 -0
  27. package/dist/entity/AgentEntity.d.ts +58 -0
  28. package/dist/entity/AgentEntity.d.ts.map +1 -0
  29. package/dist/entity/AgentEntity.js +97 -0
  30. package/dist/entity/AgentEntity.js.map +1 -0
  31. package/dist/entity/AgentEntityComponent.d.ts +73 -0
  32. package/dist/entity/AgentEntityComponent.d.ts.map +1 -0
  33. package/dist/entity/AgentEntityComponent.js +134 -0
  34. package/dist/entity/AgentEntityComponent.js.map +1 -0
  35. package/dist/entity/asAgentEntity.d.ts +45 -0
  36. package/dist/entity/asAgentEntity.d.ts.map +1 -0
  37. package/dist/entity/asAgentEntity.js +51 -0
  38. package/dist/entity/asAgentEntity.js.map +1 -0
  39. package/dist/entity/defineAgentEntity.d.ts +54 -0
  40. package/dist/entity/defineAgentEntity.d.ts.map +1 -0
  41. package/dist/entity/defineAgentEntity.js +133 -0
  42. package/dist/entity/defineAgentEntity.js.map +1 -0
  43. package/dist/entity/index.d.ts +10 -0
  44. package/dist/entity/index.d.ts.map +1 -0
  45. package/dist/entity/index.js +6 -0
  46. package/dist/entity/index.js.map +1 -0
  47. package/dist/entity/useViewAnnotation.d.ts +64 -0
  48. package/dist/entity/useViewAnnotation.d.ts.map +1 -0
  49. package/dist/entity/useViewAnnotation.js +126 -0
  50. package/dist/entity/useViewAnnotation.js.map +1 -0
  51. package/dist/hooks/useFeedback.d.ts +7 -0
  52. package/dist/hooks/useFeedback.d.ts.map +1 -0
  53. package/dist/hooks/useFeedback.js +38 -0
  54. package/dist/hooks/useFeedback.js.map +1 -0
  55. package/dist/index.d.ts +12 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +11 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/provider/AgentProvider.d.ts +25 -0
  60. package/dist/provider/AgentProvider.d.ts.map +1 -0
  61. package/dist/provider/AgentProvider.js +800 -0
  62. package/dist/provider/AgentProvider.js.map +1 -0
  63. package/dist/provider/socketio.d.ts +8 -0
  64. package/dist/provider/socketio.d.ts.map +1 -0
  65. package/dist/provider/socketio.js +19 -0
  66. package/dist/provider/socketio.js.map +1 -0
  67. package/dist/provider/stored-messages.d.ts +16 -0
  68. package/dist/provider/stored-messages.d.ts.map +1 -0
  69. package/dist/provider/stored-messages.js +57 -0
  70. package/dist/provider/stored-messages.js.map +1 -0
  71. package/dist/provider/types.d.ts +158 -0
  72. package/dist/provider/types.d.ts.map +1 -0
  73. package/dist/provider/types.js +2 -0
  74. package/dist/provider/types.js.map +1 -0
  75. package/package.json +62 -0
@@ -0,0 +1,800 @@
1
+ import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
2
+ import { APPROVAL_DECIDE_EVENT } from '@ouispec/agent-core';
3
+ import { ALL_AGENT_SOCKET_EVENTS, parseSocketEvent } from '@ouispec/agent-core';
4
+ import { createSocketIOSocket } from './socketio.js';
5
+ import { ApprovalDecisionContext, approvalRefusalText } from '../approvals/decision.js';
6
+ import { annotationRegistry } from '../annotations/singleton.js';
7
+ import { storedToAgentMessages } from './stored-messages.js';
8
+ const AgentContext = createContext(null);
9
+ const MAX_DEBUG_LOGS = 500;
10
+ const DEFAULT_ACTIVE_CONVERSATION_KEY = 'agent-sdk.activeConversation';
11
+ const HISTORY_PAGE_SIZE = 20;
12
+ /** How long the card waits for the approval store to answer a click. */
13
+ const APPROVAL_DECIDE_TIMEOUT_MS = 10_000;
14
+ /**
15
+ * The tab's remembered active conversation, in `sessionStorage` so a reload
16
+ * returns to it and a new tab starts fresh. Storage can be missing or throw
17
+ * (private windows, blocked site data): then nothing is remembered.
18
+ */
19
+ function sessionKeyOf(config) {
20
+ if (!config.getConversation || config.activeConversationKey === false)
21
+ return null;
22
+ return config.activeConversationKey ?? DEFAULT_ACTIVE_CONVERSATION_KEY;
23
+ }
24
+ function readRemembered(key) {
25
+ if (!key)
26
+ return null;
27
+ try {
28
+ return window.sessionStorage.getItem(key);
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ function writeRemembered(key, conversationId) {
35
+ if (!key)
36
+ return;
37
+ try {
38
+ if (conversationId)
39
+ window.sessionStorage.setItem(key, conversationId);
40
+ else
41
+ window.sessionStorage.removeItem(key);
42
+ }
43
+ catch {
44
+ // Nothing is remembered; the conversation still works.
45
+ }
46
+ }
47
+ /**
48
+ * Whether a conversation still belongs in a list with this filter after a
49
+ * change. Only what the SDK can see is checked: a search is the platform's.
50
+ */
51
+ function passesFilter(summary, filter) {
52
+ const status = filter.status ?? 'active';
53
+ if (status === 'active' && summary.archivedAt)
54
+ return false;
55
+ if (status === 'archived' && !summary.archivedAt)
56
+ return false;
57
+ if (filter.scopeId !== undefined && (summary.scope?.id ?? null) !== filter.scopeId)
58
+ return false;
59
+ return true;
60
+ }
61
+ function reasonOf(err) {
62
+ return err instanceof Error ? err.message : String(err);
63
+ }
64
+ const ALL_DEBUG_NAMESPACES = ['agent:socket', 'agent:protocol', 'agent:state', 'agent:api'];
65
+ let debugLogCounter = 0;
66
+ function createDebugEntry(level, ns, message, data) {
67
+ return {
68
+ id: `dbg_${++debugLogCounter}`,
69
+ timestamp: Date.now(),
70
+ level,
71
+ ns,
72
+ message,
73
+ data,
74
+ };
75
+ }
76
+ /**
77
+ * AgentProvider — turnkey provider that handles the complete agent lifecycle.
78
+ *
79
+ * The platform provides:
80
+ * - createConversation() / sendMessage() — API callbacks
81
+ * - realtime: { url, getToken } — where and how to connect
82
+ *
83
+ * The SDK owns:
84
+ * - Socket.IO connection lifecycle (connect, reconnect, auth)
85
+ * - Room subscription tied to active turns, kept across reconnects
86
+ * - All event parsing (agent:* protocol)
87
+ * - Streaming state, message history, keyboard shortcuts
88
+ *
89
+ * UI actions are not dispatched here: the client's OUI surface runtime answers
90
+ * them on the same socket (ADR-0209).
91
+ */
92
+ export function AgentProvider({ config, children }) {
93
+ const [isOpen, setIsOpen] = useState(false);
94
+ const [isStreaming, setIsStreaming] = useState(false);
95
+ const [currentTurnId, setCurrentTurnId] = useState(null);
96
+ const [conversationId, setConversationId] = useState(null);
97
+ // The same id, read by callbacks that must not wait for a re-render (a send
98
+ // that follows a restore, a switch during a restore).
99
+ const conversationIdRef = useRef(null);
100
+ const [isLoadingConversation, setIsLoadingConversation] = useState(false);
101
+ const pendingLoadRef = useRef(null);
102
+ const loadSeqRef = useRef(0);
103
+ const [historyConversations, setHistoryConversations] = useState([]);
104
+ const [historyTotal, setHistoryTotal] = useState(0);
105
+ const [historyLoading, setHistoryLoading] = useState(false);
106
+ const [historyError, setHistoryError] = useState(null);
107
+ // The listed conversations, read by changes that must not wait for a re-render.
108
+ const historyConversationsRef = useRef([]);
109
+ const [historyFilter, setHistoryFilter] = useState({});
110
+ const historyFilterRef = useRef({});
111
+ const [historyRevision, setHistoryRevision] = useState(0);
112
+ const isStreamingRef = useRef(false);
113
+ const [messages, setMessages] = useState([]);
114
+ const [connected, setConnected] = useState(false);
115
+ const [debugEnabled, setDebugEnabled] = useState(false);
116
+ const [debugLogs, setDebugLogs] = useState([]);
117
+ const [presentedOptions, setPresentedOptions] = useState(null);
118
+ // The call a turn stopped at for the user's approval (ADR-0228), until they decide.
119
+ const [pendingApproval, setPendingApproval] = useState(null);
120
+ const [approvalDeciding, setApprovalDeciding] = useState(false);
121
+ const [approvalError, setApprovalError] = useState(null);
122
+ const socketRef = useRef(null);
123
+ const unsubscribeRef = useRef(null);
124
+ // The room of the turn in progress and its token. A reconnect gives the
125
+ // server a new socket that is in no room, so the join is repeated on every
126
+ // connect while a turn is active; without it the turn streams to nobody and
127
+ // the panel waits forever.
128
+ const activeRoomRef = useRef(null);
129
+ const streamBufferRef = useRef('');
130
+ const roundPrefixRef = useRef('');
131
+ const configRef = useRef(config);
132
+ const debugEnabledRef = useRef(debugEnabled);
133
+ configRef.current = config;
134
+ debugEnabledRef.current = debugEnabled;
135
+ isStreamingRef.current = isStreaming;
136
+ const shortcutKey = config.shortcutKey ?? 'k';
137
+ const setActiveConversation = useCallback((id) => {
138
+ conversationIdRef.current = id;
139
+ setConversationId(id);
140
+ writeRemembered(sessionKeyOf(configRef.current), id);
141
+ }, []);
142
+ const addDebugLog = useCallback((level, ns, message, data) => {
143
+ if (!debugEnabledRef.current)
144
+ return;
145
+ const entry = createDebugEntry(level, ns, message, data);
146
+ setDebugLogs(prev => {
147
+ const next = [...prev, entry];
148
+ return next.length > MAX_DEBUG_LOGS ? next.slice(-MAX_DEBUG_LOGS) : next;
149
+ });
150
+ }, []);
151
+ const clearDebugLogs = useCallback(() => setDebugLogs([]), []);
152
+ // --- Socket connection lifecycle (SDK-owned; the socket is a seam) ---
153
+ useEffect(() => {
154
+ const { url, getToken, createSocket = createSocketIOSocket } = config.realtime;
155
+ if (!url)
156
+ return;
157
+ const socket = createSocket({ url, getToken });
158
+ socket.on('connect', () => {
159
+ setConnected(true);
160
+ addDebugLog('info', 'agent:socket', 'Connected', { socketId: socket.id, url });
161
+ const active = activeRoomRef.current;
162
+ if (active) {
163
+ addDebugLog('info', 'agent:socket', `Rejoining turn room after reconnect: ${active.room}`, { room: active.room });
164
+ joinRoom(socket, active.room, active.token);
165
+ }
166
+ });
167
+ socket.on('disconnect', (reason) => {
168
+ setConnected(false);
169
+ addDebugLog('warn', 'agent:socket', `Disconnected: ${reason}`, { reason });
170
+ });
171
+ socket.on('connect_error', (err) => {
172
+ addDebugLog('error', 'agent:socket', `Connection error: ${err.message}`, { error: err.message });
173
+ });
174
+ socket.on('reconnect_attempt', (attempt) => {
175
+ addDebugLog('info', 'agent:socket', `Reconnecting (attempt ${attempt})`, { attempt });
176
+ });
177
+ socket.connect();
178
+ addDebugLog('info', 'agent:socket', 'Connecting...', { url });
179
+ socketRef.current = socket;
180
+ return () => {
181
+ socket.disconnect();
182
+ socketRef.current = null;
183
+ setConnected(false);
184
+ };
185
+ }, [config.realtime.url]);
186
+ // Keyboard shortcut
187
+ useEffect(() => {
188
+ const handler = (e) => {
189
+ if ((e.metaKey || e.ctrlKey) && e.key === shortcutKey) {
190
+ e.preventDefault();
191
+ setIsOpen(prev => !prev);
192
+ }
193
+ };
194
+ document.addEventListener('keydown', handler);
195
+ return () => document.removeEventListener('keydown', handler);
196
+ }, [shortcutKey]);
197
+ // --- Protocol event handler ---
198
+ const handleProtocolEvent = useCallback((event) => {
199
+ addDebugLog('event', 'agent:protocol', `${event.type}`, event);
200
+ configRef.current.onEvent?.(event);
201
+ switch (event.type) {
202
+ case 'token': {
203
+ if (!event.content)
204
+ break;
205
+ streamBufferRef.current += event.content;
206
+ // Snapshot the accumulated content BEFORE enqueueing the state update.
207
+ // React batches updates and runs the setMessages callback later; if the
208
+ // 'done' event arrives and clears streamBufferRef.current before the
209
+ // batch flushes, the deferred callback would otherwise read '' and wipe
210
+ // the message. Capturing into a local makes the update self-contained.
211
+ const snapshot = streamBufferRef.current;
212
+ setMessages(prev => {
213
+ const msgId = `msg_${event.turnId}`;
214
+ const existingIdx = prev.findIndex(m => m.id === msgId);
215
+ if (existingIdx !== -1) {
216
+ // Update in place — never create a duplicate with the same id.
217
+ const updated = [...prev];
218
+ updated[existingIdx] = { ...updated[existingIdx], content: snapshot, isStreaming: true };
219
+ return updated;
220
+ }
221
+ // Defensive: drop any stale streaming messages for this turnId before adding a fresh one.
222
+ const cleaned = prev.filter(m => m.id !== msgId);
223
+ return [...cleaned, {
224
+ id: msgId,
225
+ role: 'assistant',
226
+ content: snapshot,
227
+ timestamp: Date.now(),
228
+ isStreaming: true,
229
+ }];
230
+ });
231
+ break;
232
+ }
233
+ case 'token_clear':
234
+ // A tool_use round ended. Preserve text from previous rounds so the
235
+ // user sees the full multi-round response. Only truly clear if no text
236
+ // has been accumulated yet (first round).
237
+ if (streamBufferRef.current) {
238
+ // Commit the current buffer as a completed round prefix.
239
+ roundPrefixRef.current = streamBufferRef.current;
240
+ // Add a separator — the next round's tokens will append after this.
241
+ streamBufferRef.current = roundPrefixRef.current + '\n\n';
242
+ }
243
+ // Don't update message content — leave the existing text visible.
244
+ break;
245
+ case 'intent_call':
246
+ setMessages(prev => [...prev, {
247
+ id: `tool_${event.id}`,
248
+ role: 'tool',
249
+ content: null,
250
+ timestamp: Date.now(),
251
+ toolCall: { id: event.id, name: event.intentId, status: 'running' },
252
+ }]);
253
+ break;
254
+ case 'intent_result':
255
+ // Detect present_options tool results and surface them as interactive UI state.
256
+ if (event.result && typeof event.result === 'object' && event.result.__present_options) {
257
+ const payload = event.result;
258
+ setPresentedOptions({
259
+ prompt: payload.prompt ?? '',
260
+ options: payload.options ?? [],
261
+ style: payload.style ?? undefined,
262
+ });
263
+ }
264
+ setMessages(prev => prev.map(m => m.toolCall?.id === event.callId
265
+ ? { ...m, toolCall: { ...m.toolCall, status: event.success ? 'complete' : 'error', result: event.result } }
266
+ : m));
267
+ break;
268
+ case 'approval_required': {
269
+ // The card renders it; only the user's click on it decides.
270
+ const { type: _type, ...request } = event;
271
+ setPendingApproval(request);
272
+ setApprovalError(null);
273
+ setApprovalDeciding(false);
274
+ break;
275
+ }
276
+ case 'done':
277
+ // Finalize all streaming messages. Drop any assistant streaming bubble
278
+ // that ended up truly empty (e.g. token_clear wiped it and no text was
279
+ // re-emitted). Preserve tool messages (which legitimately have null content).
280
+ streamBufferRef.current = '';
281
+ roundPrefixRef.current = '';
282
+ setMessages(prev => {
283
+ const finalized = prev.map(m => m.isStreaming ? { ...m, isStreaming: false } : m);
284
+ return finalized.filter(m => {
285
+ // Only drop assistant bubbles that are empty (null or empty string).
286
+ // Tool messages have null content by design — keep them.
287
+ if (m.role === 'assistant' && (m.content === null || m.content === '')) {
288
+ return false;
289
+ }
290
+ return true;
291
+ });
292
+ });
293
+ setIsStreaming(false);
294
+ setCurrentTurnId(null);
295
+ activeRoomRef.current = null;
296
+ if (unsubscribeRef.current) {
297
+ unsubscribeRef.current();
298
+ unsubscribeRef.current = null;
299
+ }
300
+ break;
301
+ case 'error':
302
+ streamBufferRef.current = '';
303
+ roundPrefixRef.current = '';
304
+ setMessages(prev => {
305
+ const last = prev[prev.length - 1];
306
+ if (last?.isStreaming) {
307
+ return [...prev.slice(0, -1), { ...last, isStreaming: false, content: last.content || 'An error occurred.' }];
308
+ }
309
+ return [...prev, {
310
+ id: `err_${Date.now()}`,
311
+ role: 'assistant',
312
+ content: event.message || 'An error occurred.',
313
+ timestamp: Date.now(),
314
+ }];
315
+ });
316
+ setIsStreaming(false);
317
+ setCurrentTurnId(null);
318
+ activeRoomRef.current = null;
319
+ if (unsubscribeRef.current) {
320
+ unsubscribeRef.current();
321
+ unsubscribeRef.current = null;
322
+ }
323
+ break;
324
+ }
325
+ }, []);
326
+ // --- Join a turn room ---
327
+ // The realtime server requires a signed room token for a turn room and
328
+ // denies the join without one — the socket stays connected, the
329
+ // turn completes server-side, and no event ever arrives. Send the
330
+ // token-bearing form when we have a token, and ACK the result so a denial
331
+ // is visible instead of looking like the agent simply never replied.
332
+ const joinRoom = useCallback((socket, room, roomToken) => {
333
+ if (roomToken) {
334
+ socket.emit('subscribe', { rooms: [room], tokens: { [room]: roomToken } }, (res) => {
335
+ if (res && (!res.ok || res.denied?.includes(room))) {
336
+ addDebugLog('error', 'agent:socket', `Room subscription DENIED: ${room}`, {
337
+ room,
338
+ denied: res.denied,
339
+ error: res.error,
340
+ });
341
+ }
342
+ else {
343
+ addDebugLog('info', 'agent:socket', `Room joined: ${room}`, { room, joined: res?.joined });
344
+ }
345
+ });
346
+ }
347
+ else {
348
+ addDebugLog('warn', 'agent:socket', `Subscribing to ${room} WITHOUT a room token`, {
349
+ room,
350
+ hint: 'agent:* rooms require a signed token; the server will deny this join',
351
+ });
352
+ socket.emit('subscribe', [room]);
353
+ }
354
+ }, [addDebugLog]);
355
+ // --- Subscribe to a socket room for agent events ---
356
+ const subscribeToRoom = useCallback((room, turnId, roomToken) => {
357
+ const socket = socketRef.current;
358
+ if (!socket) {
359
+ addDebugLog('error', 'agent:socket', 'Cannot subscribe — no socket (realtime URL not configured)', { room });
360
+ return () => { };
361
+ }
362
+ addDebugLog('info', 'agent:socket', `Subscribing to room: ${room}`, {
363
+ room,
364
+ turnId,
365
+ hasRoomToken: !!roomToken,
366
+ connected: socket.connected,
367
+ });
368
+ // Joined now if connected; otherwise on the next connect, which the
369
+ // connect handler does for the active room.
370
+ activeRoomRef.current = { room, token: roomToken };
371
+ if (socket.connected)
372
+ joinRoom(socket, room, roomToken);
373
+ const handlers = new Map();
374
+ for (const eventName of ALL_AGENT_SOCKET_EVENTS) {
375
+ const handler = (data) => {
376
+ addDebugLog('socket', 'agent:socket', `← ${eventName}`, { eventName, data });
377
+ const parsed = parseSocketEvent(eventName, data, turnId);
378
+ if (parsed)
379
+ handleProtocolEvent(parsed);
380
+ };
381
+ handlers.set(eventName, handler);
382
+ socket.on(eventName, handler);
383
+ }
384
+ return () => {
385
+ for (const [eventName, handler] of handlers) {
386
+ socket.off(eventName, handler);
387
+ }
388
+ socket.emit('unsubscribe', [room]);
389
+ addDebugLog('info', 'agent:socket', `Unsubscribed from room: ${room}`, { room });
390
+ };
391
+ }, [handleProtocolEvent, addDebugLog, joinRoom]);
392
+ // Collect context from visible annotations and current route
393
+ const collectContext = useCallback(() => {
394
+ const annotations = annotationRegistry.getVisibleAnnotations();
395
+ return {
396
+ currentPath: typeof window !== 'undefined' ? window.location.pathname : undefined,
397
+ visibleAnnotations: annotations,
398
+ };
399
+ }, []);
400
+ // --- Start a turn: the user's message, or the continuation after an approval decision ---
401
+ const startTurn = useCallback(async (content, attachments, approval) => {
402
+ // A message sent while a conversation loads belongs to that conversation.
403
+ if (pendingLoadRef.current)
404
+ await pendingLoadRef.current;
405
+ let convId = conversationIdRef.current;
406
+ if (!convId) {
407
+ addDebugLog('info', 'agent:api', 'Creating conversation...', {});
408
+ const result = await configRef.current.createConversation();
409
+ convId = result.conversationId;
410
+ setActiveConversation(convId);
411
+ addDebugLog('info', 'agent:api', `Conversation created: ${convId}`, { conversationId: convId });
412
+ }
413
+ // A click on the approval card continues the turn, but it is not a message.
414
+ if (!approval) {
415
+ const userMsg = {
416
+ id: `msg_user_${Date.now()}`,
417
+ role: 'user',
418
+ content,
419
+ timestamp: Date.now(),
420
+ };
421
+ setMessages(prev => [...prev, userMsg]);
422
+ // A new message moves on: a card still waiting belongs to the previous turn.
423
+ setPendingApproval(null);
424
+ }
425
+ // Any choice still on screen belongs to the previous turn.
426
+ setPresentedOptions(null);
427
+ setIsStreaming(true);
428
+ streamBufferRef.current = '';
429
+ try {
430
+ const context = collectContext();
431
+ addDebugLog('info', 'agent:api', approval ? 'Continuing after an approval decision...' : 'Sending message...', {
432
+ conversationId: convId,
433
+ contentLength: content.length,
434
+ context,
435
+ ...(approval ? { approvalId: approval.approvalId, decision: approval.decision } : {}),
436
+ });
437
+ const { turnId, socketRoom, roomToken } = await configRef.current.sendMessage({
438
+ conversationId: convId,
439
+ content,
440
+ context,
441
+ attachments,
442
+ ...(approval ? { approval } : {}),
443
+ });
444
+ addDebugLog('info', 'agent:api', `Turn started: ${turnId}`, {
445
+ turnId,
446
+ socketRoom,
447
+ hasRoomToken: !!roomToken,
448
+ });
449
+ addDebugLog('info', 'agent:state', 'Streaming started', { turnId });
450
+ setCurrentTurnId(turnId);
451
+ if (unsubscribeRef.current)
452
+ unsubscribeRef.current();
453
+ unsubscribeRef.current = subscribeToRoom(socketRoom, turnId, roomToken);
454
+ return { turnId };
455
+ }
456
+ catch (err) {
457
+ addDebugLog('error', 'agent:api', `Send failed: ${err instanceof Error ? err.message : String(err)}`, { error: err });
458
+ setIsStreaming(false);
459
+ setMessages(prev => [...prev, {
460
+ id: `err_${Date.now()}`,
461
+ role: 'assistant',
462
+ content: err instanceof Error ? err.message : 'Failed to send message.',
463
+ timestamp: Date.now(),
464
+ }]);
465
+ return { turnId: '' };
466
+ }
467
+ }, [collectContext, subscribeToRoom, addDebugLog, setActiveConversation]);
468
+ const sendMessage = useCallback((content, attachments) => startTurn(content, attachments), [startTurn]);
469
+ // --- The user's decision on an approval card (ADR-0228 §2.2.3) ---
470
+ // Only the card calls this: it is not on the context `useAgent` returns, so
471
+ // nothing an app binds for the assistant can reach it.
472
+ const pendingApprovalRef = useRef(null);
473
+ pendingApprovalRef.current = pendingApproval;
474
+ const decideApproval = useCallback(async (decision) => {
475
+ const request = pendingApprovalRef.current;
476
+ const socket = socketRef.current;
477
+ if (!request)
478
+ return;
479
+ if (!socket) {
480
+ setApprovalError('Not connected: try again once the assistant reconnects.');
481
+ return;
482
+ }
483
+ setApprovalDeciding(true);
484
+ setApprovalError(null);
485
+ addDebugLog('info', 'agent:api', `Approval ${decision}`, { approvalId: request.approvalId, tool: request.tool });
486
+ const result = await new Promise(resolve => {
487
+ const timer = setTimeout(() => resolve({ ok: false, reason: 'invalid', error: 'the server did not answer' }), APPROVAL_DECIDE_TIMEOUT_MS);
488
+ socket.emit(APPROVAL_DECIDE_EVENT, { approvalId: request.approvalId, decision }, (answer) => {
489
+ clearTimeout(timer);
490
+ resolve(answer);
491
+ });
492
+ });
493
+ if (!result.ok) {
494
+ addDebugLog('warn', 'agent:api', `Approval refused: ${result.reason}`, { approvalId: request.approvalId, reason: result.reason });
495
+ setApprovalError(approvalRefusalText(result));
496
+ setApprovalDeciding(false);
497
+ return;
498
+ }
499
+ // The grant goes to this tab's OUI runtime before the turn that dispatches the call starts.
500
+ if (result.decision === 'approve') {
501
+ configRef.current.grantApproval?.({ approvalId: result.approvalId, argsHash: result.argsHash, expiresAt: result.expiresAt });
502
+ }
503
+ setPendingApproval(null);
504
+ setApprovalDeciding(false);
505
+ await startTurn('', undefined, result.decision === 'approve'
506
+ ? { approvalId: result.approvalId, decision: 'approve', token: result.token }
507
+ : { approvalId: result.approvalId, decision: 'decline' });
508
+ }, [startTurn, addDebugLog]);
509
+ const dismissApproval = useCallback(() => {
510
+ setPendingApproval(null);
511
+ setApprovalError(null);
512
+ setApprovalDeciding(false);
513
+ }, []);
514
+ // --- Conversation history ---
515
+ /** Stop listening to the turn in progress, if any. */
516
+ const leaveTurn = useCallback(() => {
517
+ if (unsubscribeRef.current) {
518
+ unsubscribeRef.current();
519
+ unsubscribeRef.current = null;
520
+ }
521
+ activeRoomRef.current = null;
522
+ streamBufferRef.current = '';
523
+ roundPrefixRef.current = '';
524
+ setCurrentTurnId(null);
525
+ }, []);
526
+ /**
527
+ * Show a stored conversation. The latest request wins: a load that finishes
528
+ * after another began is discarded. A restore that fails (the conversation
529
+ * was deleted, or belongs to someone else) forgets it and starts fresh.
530
+ */
531
+ const loadConversation = useCallback((id, { restoring }) => {
532
+ const getConversation = configRef.current.getConversation;
533
+ if (!getConversation)
534
+ return Promise.resolve();
535
+ const seq = ++loadSeqRef.current;
536
+ setIsLoadingConversation(true);
537
+ addDebugLog('info', 'agent:api', `${restoring ? 'Restoring' : 'Loading'} conversation ${id}`, { conversationId: id });
538
+ const load = (async () => {
539
+ try {
540
+ const stored = await getConversation(id);
541
+ if (seq !== loadSeqRef.current)
542
+ return;
543
+ setActiveConversation(stored.conversationId);
544
+ setMessages(storedToAgentMessages(stored.messages));
545
+ setPresentedOptions(null);
546
+ setPendingApproval(null);
547
+ setHistoryError(null);
548
+ }
549
+ catch (err) {
550
+ if (seq !== loadSeqRef.current)
551
+ return;
552
+ const message = err instanceof Error ? err.message : String(err);
553
+ addDebugLog('warn', 'agent:api', `Could not load conversation ${id}: ${message}`, { conversationId: id });
554
+ if (restoring) {
555
+ setActiveConversation(null);
556
+ }
557
+ else {
558
+ setHistoryError('That conversation could not be opened.');
559
+ }
560
+ }
561
+ finally {
562
+ if (seq === loadSeqRef.current) {
563
+ setIsLoadingConversation(false);
564
+ pendingLoadRef.current = null;
565
+ }
566
+ }
567
+ })();
568
+ pendingLoadRef.current = load;
569
+ return load;
570
+ }, [addDebugLog, setActiveConversation]);
571
+ // Return to the tab's conversation after a reload.
572
+ useEffect(() => {
573
+ const remembered = readRemembered(sessionKeyOf(configRef.current));
574
+ if (remembered)
575
+ void loadConversation(remembered, { restoring: true });
576
+ }, [loadConversation]);
577
+ const refreshHistory = useCallback(async (filter) => {
578
+ const listConversations = configRef.current.listConversations;
579
+ if (!listConversations)
580
+ return;
581
+ if (filter) {
582
+ historyFilterRef.current = filter;
583
+ setHistoryFilter(filter);
584
+ }
585
+ const asked = historyFilterRef.current;
586
+ setHistoryLoading(true);
587
+ try {
588
+ const page = await listConversations({ limit: HISTORY_PAGE_SIZE, offset: 0, ...asked });
589
+ // A refresh with another filter started since: its answer wins.
590
+ if (asked !== historyFilterRef.current)
591
+ return;
592
+ historyConversationsRef.current = page.conversations;
593
+ setHistoryConversations(page.conversations);
594
+ setHistoryTotal(page.total);
595
+ setHistoryError(null);
596
+ }
597
+ catch (err) {
598
+ addDebugLog('warn', 'agent:api', `Could not list conversations: ${reasonOf(err)}`);
599
+ setHistoryError('Your conversations could not be loaded.');
600
+ }
601
+ finally {
602
+ setHistoryLoading(false);
603
+ }
604
+ }, [addDebugLog]);
605
+ const queryConversations = useCallback(async (params) => {
606
+ const listConversations = configRef.current.listConversations;
607
+ if (!listConversations)
608
+ throw new Error('This app cannot list conversations.');
609
+ return listConversations(params);
610
+ }, []);
611
+ /** Replace the listed conversations after a change, and count the change. */
612
+ const setListed = useCallback((next) => {
613
+ const dropped = historyConversationsRef.current.length - next.length;
614
+ historyConversationsRef.current = next;
615
+ setHistoryConversations(next);
616
+ if (dropped > 0)
617
+ setHistoryTotal(total => Math.max(0, total - dropped));
618
+ setHistoryRevision(r => r + 1);
619
+ }, []);
620
+ /** Put changed conversations into the listed ones, dropping those the filter no longer lets in. */
621
+ const applyToHistory = useCallback((changed) => {
622
+ const byId = new Map(changed.map(c => [c.conversationId, c]));
623
+ const filter = historyFilterRef.current;
624
+ setListed(historyConversationsRef.current.flatMap(c => {
625
+ const updated = byId.get(c.conversationId);
626
+ if (!updated)
627
+ return [c];
628
+ return passesFilter(updated, filter) ? [updated] : [];
629
+ }));
630
+ }, [setListed]);
631
+ const renameConversation = useCallback(async (id, title) => {
632
+ const rename = configRef.current.renameConversation;
633
+ if (!rename)
634
+ throw new Error('This app cannot rename conversations.');
635
+ addDebugLog('info', 'agent:api', `Renaming conversation ${id}`, { conversationId: id, automatic: title === null });
636
+ const updated = await rename(id, title);
637
+ applyToHistory([updated]);
638
+ return updated;
639
+ }, [addDebugLog, applyToHistory]);
640
+ const updateConversations = useCallback(async (ids, changes) => {
641
+ const update = configRef.current.updateConversations;
642
+ if (!update)
643
+ throw new Error('This app cannot file or archive conversations.');
644
+ addDebugLog('info', 'agent:api', `Updating ${ids.length} conversation(s)`, { conversationIds: ids, changes });
645
+ const updated = await update(ids, changes);
646
+ applyToHistory(updated);
647
+ return updated;
648
+ }, [addDebugLog, applyToHistory]);
649
+ const switchConversation = useCallback(async (id) => {
650
+ if (isStreaming || id === conversationIdRef.current)
651
+ return;
652
+ leaveTurn();
653
+ await loadConversation(id, { restoring: false });
654
+ }, [isStreaming, leaveTurn, loadConversation]);
655
+ /** Leave the open conversation for a new one. Callers check that no turn is answering. */
656
+ const resetToNewConversation = useCallback(() => {
657
+ loadSeqRef.current++;
658
+ pendingLoadRef.current = null;
659
+ setIsLoadingConversation(false);
660
+ leaveTurn();
661
+ setActiveConversation(null);
662
+ setMessages([]);
663
+ setPresentedOptions(null);
664
+ setPendingApproval(null);
665
+ addDebugLog('info', 'agent:state', 'New conversation');
666
+ }, [leaveTurn, setActiveConversation, addDebugLog]);
667
+ const startNewConversation = useCallback(() => {
668
+ if (isStreaming)
669
+ return;
670
+ resetToNewConversation();
671
+ }, [isStreaming, resetToNewConversation]);
672
+ const removeConversations = useCallback(async (ids) => {
673
+ const remove = configRef.current.deleteConversations;
674
+ if (!remove)
675
+ throw new Error('This app cannot delete conversations.');
676
+ const open = conversationIdRef.current;
677
+ const removingOpen = open !== null && ids.includes(open);
678
+ if (removingOpen && isStreamingRef.current) {
679
+ throw new Error('That conversation is answering. Wait for it to finish, then delete it.');
680
+ }
681
+ addDebugLog('info', 'agent:api', `Deleting ${ids.length} conversation(s)`, { conversationIds: ids });
682
+ await remove(ids);
683
+ const gone = new Set(ids);
684
+ setListed(historyConversationsRef.current.filter(c => !gone.has(c.conversationId)));
685
+ if (removingOpen && conversationIdRef.current === open)
686
+ resetToNewConversation();
687
+ }, [addDebugLog, resetToNewConversation, setListed]);
688
+ const exportSession = useCallback(() => {
689
+ const session = {
690
+ exportedAt: new Date().toISOString(),
691
+ sdkVersion: '0.2.14',
692
+ session: {
693
+ conversationId,
694
+ currentTurnId,
695
+ connected,
696
+ isStreaming,
697
+ messageCount: messages.length,
698
+ messages: messages.map(m => ({
699
+ id: m.id,
700
+ role: m.role,
701
+ content: m.content,
702
+ timestamp: new Date(m.timestamp).toISOString(),
703
+ isStreaming: m.isStreaming,
704
+ toolCall: m.toolCall,
705
+ })),
706
+ },
707
+ debug: {
708
+ enabled: debugEnabled,
709
+ logCount: debugLogs.length,
710
+ logs: debugLogs,
711
+ },
712
+ environment: {
713
+ userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown',
714
+ url: typeof window !== 'undefined' ? window.location.href : 'unknown',
715
+ realtimeUrl: config.realtime.url,
716
+ },
717
+ };
718
+ const blob = new Blob([JSON.stringify(session, null, 2)], { type: 'application/json' });
719
+ const url = URL.createObjectURL(blob);
720
+ const a = document.createElement('a');
721
+ a.href = url;
722
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
723
+ a.download = `agent-session-${conversationId ?? 'no-conv'}-${stamp}.json`;
724
+ document.body.appendChild(a);
725
+ a.click();
726
+ document.body.removeChild(a);
727
+ URL.revokeObjectURL(url);
728
+ }, [conversationId, currentTurnId, connected, isStreaming, messages, debugEnabled, debugLogs, config.realtime.url]);
729
+ const debugState = {
730
+ enabled: debugEnabled,
731
+ setEnabled: setDebugEnabled,
732
+ logs: debugLogs,
733
+ clearLogs: clearDebugLogs,
734
+ namespaces: ALL_DEBUG_NAMESPACES,
735
+ exportSession,
736
+ };
737
+ const selectOption = useCallback((value) => {
738
+ setPresentedOptions(null);
739
+ sendMessage(value);
740
+ }, [sendMessage]);
741
+ const dismissOptions = useCallback(() => {
742
+ setPresentedOptions(null);
743
+ }, []);
744
+ const contextValue = {
745
+ isOpen,
746
+ toggle: useCallback(() => setIsOpen(p => !p), []),
747
+ open: useCallback(() => setIsOpen(true), []),
748
+ close: useCallback(() => setIsOpen(false), []),
749
+ sendMessage,
750
+ messages,
751
+ isStreaming,
752
+ isProcessing: isStreaming,
753
+ currentTurnId,
754
+ conversationId,
755
+ connected,
756
+ debug: debugState,
757
+ presentedOptions,
758
+ selectOption,
759
+ dismissOptions,
760
+ pendingApproval,
761
+ socket: socketRef.current,
762
+ history: {
763
+ available: Boolean(config.listConversations && config.getConversation),
764
+ manage: {
765
+ rename: Boolean(config.renameConversation),
766
+ update: Boolean(config.updateConversations),
767
+ remove: Boolean(config.deleteConversations),
768
+ },
769
+ conversations: historyConversations,
770
+ total: historyTotal,
771
+ filter: historyFilter,
772
+ loading: historyLoading,
773
+ error: historyError,
774
+ refresh: refreshHistory,
775
+ query: queryConversations,
776
+ rename: renameConversation,
777
+ update: updateConversations,
778
+ remove: removeConversations,
779
+ revision: historyRevision,
780
+ },
781
+ isLoadingConversation,
782
+ switchConversation,
783
+ startNewConversation,
784
+ };
785
+ const approvalDecision = {
786
+ pending: pendingApproval,
787
+ deciding: approvalDeciding,
788
+ error: approvalError,
789
+ decide: decideApproval,
790
+ dismiss: dismissApproval,
791
+ };
792
+ return React.createElement(AgentContext.Provider, { value: contextValue }, React.createElement(ApprovalDecisionContext.Provider, { value: approvalDecision }, children));
793
+ }
794
+ export function useAgent() {
795
+ const ctx = useContext(AgentContext);
796
+ if (!ctx)
797
+ throw new Error('useAgent must be used within an AgentProvider');
798
+ return ctx;
799
+ }
800
+ //# sourceMappingURL=AgentProvider.js.map