@ggui-ai/mcp-apps-react-native 0.0.1-placeholder → 0.10.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.
- package/LICENSE +201 -0
- package/README.md +58 -4
- package/package.json +84 -4
- package/src/McpAppIframe/McpAppIframe.tsx +368 -0
- package/src/McpAppIframe/dispatch.ts +418 -0
- package/src/McpAppIframe/index.ts +15 -0
- package/src/McpAppIframe/types.ts +227 -0
- package/src/chat-helpers/index.ts +12 -0
- package/src/chat-helpers/message-groups.ts +124 -0
- package/src/chat-helpers/render.ts +65 -0
- package/src/chat-helpers/useRafThrottled.ts +34 -0
- package/src/components/ErrorBoundary.tsx +96 -0
- package/src/components/GguiProvider.tsx +188 -0
- package/src/components/UiFeedback.tsx +233 -0
- package/src/components/mcp-apps-bridge.ts +388 -0
- package/src/context/GguiContext.ts +121 -0
- package/src/hooks/useAppState.ts +24 -0
- package/src/index.ts +150 -0
- package/src/invoke/index.ts +8 -0
- package/src/invoke/sse-parse.ts +79 -0
- package/src/invoke/useInvoke.ts +502 -0
- package/src/test-setup.ts +85 -0
- package/src/theme/ThemeProvider.tsx +129 -0
- package/src/theme/index.ts +29 -0
- package/src/theme/tokens.ts +201 -0
- package/src/theme/types.ts +94 -0
- package/src/types/react-test-renderer.d.ts +50 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useInvoke — React Native hook for the streamable invoke protocol (v1).
|
|
3
|
+
*
|
|
4
|
+
* Near-exact port of the web hook (`@ggui-ai/mcp-apps-react/src/invoke/useInvoke.ts`).
|
|
5
|
+
* Public API is identical — consumers should be able to migrate between web
|
|
6
|
+
* and RN by swapping imports only.
|
|
7
|
+
*
|
|
8
|
+
* ## Platform notes
|
|
9
|
+
*
|
|
10
|
+
* 1. **Fetch streaming.** React Native's `fetch` historically returned a null
|
|
11
|
+
* `response.body`: the runtime buffered the entire response before
|
|
12
|
+
* resolving. RN added an opt-in streaming mode — you must pass
|
|
13
|
+
* `reactNative: { textStreaming: true }` on `fetch()` init to get a real
|
|
14
|
+
* `ReadableStream`. This option is an RN-only extension to `RequestInit`
|
|
15
|
+
* and is ignored on web (so the same code path works under Expo Web).
|
|
16
|
+
*
|
|
17
|
+
* Tested on Expo SDK 54 / RN 0.81 (Hermes). On older RN (<0.75) the
|
|
18
|
+
* streaming path may fall through to the non-streaming fallback below.
|
|
19
|
+
*
|
|
20
|
+
* 2. **Non-streaming fallback.** If `response.body` is null (RN version
|
|
21
|
+
* without streaming support, Flipper interfering, etc.) the hook falls
|
|
22
|
+
* back to `response.text()` + single-shot SSE parse. The agent's turn
|
|
23
|
+
* completes atomically — no delta rendering, but correctness preserved.
|
|
24
|
+
* Consumers see content_block_start → content_block_delta → stop in one
|
|
25
|
+
* tick. Good enough for tiny turns; a noticeable UX regression for long
|
|
26
|
+
* ones. Document this to users targeting old RN.
|
|
27
|
+
*
|
|
28
|
+
* 3. **TextDecoder.** Ships natively in Hermes on RN 0.74+ (SDK 50+).
|
|
29
|
+
* If targeting older RN, polyfill it in the app entry file.
|
|
30
|
+
*
|
|
31
|
+
* 4. **AbortController.** Available in RN 0.60+ (all supported versions).
|
|
32
|
+
*
|
|
33
|
+
* 5. **crypto.randomUUID.** NOT available in Hermes. We degrade to
|
|
34
|
+
* `Math.random()` — ids are internal/ephemeral, not cryptographic.
|
|
35
|
+
*
|
|
36
|
+
* ## Runtime caveats (untested by the porting session)
|
|
37
|
+
*
|
|
38
|
+
* - Android pre-RN-0.75 may need the `react-native-fetch-api` polyfill to
|
|
39
|
+
* expose `response.body`. If a real device shows the non-streaming
|
|
40
|
+
* fallback always firing, that's the likely culprit.
|
|
41
|
+
* - iOS + Flipper has been known to intercept fetch and drop streaming.
|
|
42
|
+
* Disable Flipper network inspector when testing streaming locally.
|
|
43
|
+
* - Expo Web uses the browser's native fetch — behaves identically to the
|
|
44
|
+
* web hook.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```tsx
|
|
48
|
+
* const { messages, send, isStreaming, error } = useInvoke();
|
|
49
|
+
* <Pressable onPress={() => send('hi')}>
|
|
50
|
+
* <Text>Send</Text>
|
|
51
|
+
* </Pressable>
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
import { useCallback, useRef, useState } from 'react';
|
|
55
|
+
import type {
|
|
56
|
+
ContentBlock,
|
|
57
|
+
InvokeErrorCode,
|
|
58
|
+
InvokeTurn,
|
|
59
|
+
ToolUseBlock,
|
|
60
|
+
} from '@ggui-ai/protocol';
|
|
61
|
+
import { useGguiContext } from '../components/GguiProvider';
|
|
62
|
+
import { parseSseStream } from './sse-parse';
|
|
63
|
+
|
|
64
|
+
const PROTOCOL_VERSION = '1';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* RN-specific extension to `RequestInit`. React Native's fetch accepts
|
|
68
|
+
* `reactNative.textStreaming = true` to opt into a real streaming response
|
|
69
|
+
* body. The option is silently ignored on other platforms (web fetch just
|
|
70
|
+
* sees an unknown property), so the cast-free type below is safe across
|
|
71
|
+
* Expo Web + iOS + Android.
|
|
72
|
+
*/
|
|
73
|
+
type RNRequestInit = RequestInit & {
|
|
74
|
+
reactNative?: { textStreaming?: boolean };
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export interface UseInvokeOptions {
|
|
78
|
+
/** Override `appConfig.endpointUrl`. */
|
|
79
|
+
endpointUrl?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Continue an existing conversation. Absent → new session each call.
|
|
82
|
+
* Forwarded to the agent as the `X-Ggui-Host-Session-Id` header — this
|
|
83
|
+
* is the conversation envelope identity (the chat thread), distinct
|
|
84
|
+
* from any per-render `sessionId` carried on `_meta["ai.ggui/render"]`.
|
|
85
|
+
*/
|
|
86
|
+
hostSessionId?: string;
|
|
87
|
+
/** End-user JWT for authenticated apps. */
|
|
88
|
+
bearerToken?: string;
|
|
89
|
+
/**
|
|
90
|
+
* Seed the conversation on mount. Useful when reopening a persistent
|
|
91
|
+
* thread — the seed counts toward `history` on the next `send()` so the
|
|
92
|
+
* agent keeps context. Captured by `useState` once at mount; changing
|
|
93
|
+
* this prop later does NOT re-seed. The caller must gate hook mount
|
|
94
|
+
* until the seed is ready (ChatThreadProvider handles this in Chunk 2).
|
|
95
|
+
*/
|
|
96
|
+
initialMessages?: ConversationMessage[];
|
|
97
|
+
/**
|
|
98
|
+
* Fired for every `tool_use` content block the agent emits. Protocol v1.1
|
|
99
|
+
* emits two kinds of tool_use that clients care about:
|
|
100
|
+
* - `ggui_render` / `ggui_update` / `ggui_handshake` — paired by
|
|
101
|
+
* `tool_use_id` with an inline `tool_result` block on the same
|
|
102
|
+
* assistant turn. Consumers watch for the pair and mount their
|
|
103
|
+
* renderer (e.g. `<McpAppIframe>` on the web SDK) using the
|
|
104
|
+
* bootstrap metadata off the paired tool_result.
|
|
105
|
+
* - `ggui_render_blueprint` — a pure client tool (no server result to
|
|
106
|
+
* pair with); the consumer resolves the blueprint name locally.
|
|
107
|
+
*/
|
|
108
|
+
onToolUse?: (block: ToolUseBlock) => void;
|
|
109
|
+
/** Fired on terminal error frames or transport failures. */
|
|
110
|
+
onError?: (err: InvokeError) => void;
|
|
111
|
+
/**
|
|
112
|
+
* Dev-mode bridge routing — when set, `send()` POSTs to
|
|
113
|
+
* `{gatewayUrl}/{appId}` instead of `{endpointUrl}/invoke`. The pod
|
|
114
|
+
* routes by `appId`. Mirrors the web hook; see that JSDoc for the
|
|
115
|
+
* full description. When set, `endpointUrl` is optional — the gateway
|
|
116
|
+
* is the transport.
|
|
117
|
+
*/
|
|
118
|
+
devBridge?: {
|
|
119
|
+
/** Base URL of the bridge gateway (trailing slash optional). */
|
|
120
|
+
gatewayUrl: string;
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* Fires once per `send()` with the streaming mode that was used.
|
|
124
|
+
* Device testers on Android use this to confirm `response.body`
|
|
125
|
+
* actually returns a `ReadableStream` (mode `'streaming'`) vs
|
|
126
|
+
* silently degrading to the buffered fallback (mode `'buffered'`).
|
|
127
|
+
* Web hooks don't need this — ReadableStream is reliable there.
|
|
128
|
+
*/
|
|
129
|
+
onStreamMode?: (mode: 'streaming' | 'buffered') => void;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface InvokeError {
|
|
133
|
+
code: InvokeErrorCode | 'transport_error';
|
|
134
|
+
message: string;
|
|
135
|
+
retryAfterMs?: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface ConversationMessage {
|
|
139
|
+
/** Stable id — for assistants this is the agent's `message.id`. */
|
|
140
|
+
id: string;
|
|
141
|
+
role: 'user' | 'assistant';
|
|
142
|
+
/** Accumulated content blocks. Assistant blocks fill in over time. */
|
|
143
|
+
content: ContentBlock[];
|
|
144
|
+
/** True while assistant blocks are still receiving deltas. */
|
|
145
|
+
isStreaming: boolean;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface UseInvokeReturn {
|
|
149
|
+
messages: ConversationMessage[];
|
|
150
|
+
/**
|
|
151
|
+
* Send a user message; resolves when the assistant turn completes (or errors).
|
|
152
|
+
* Accepts an optional `clientMessageId` so callers with durable storage can
|
|
153
|
+
* own the user-message id and achieve outbox idempotency across retries.
|
|
154
|
+
*/
|
|
155
|
+
send: (message: string, opts?: { clientMessageId?: string }) => Promise<void>;
|
|
156
|
+
/** True between `send()` start and turn completion. */
|
|
157
|
+
isStreaming: boolean;
|
|
158
|
+
/** Most recent error; reset on next `send()`. */
|
|
159
|
+
error: InvokeError | null;
|
|
160
|
+
/** Abort the in-flight turn. Closes the fetch + drops streaming flags. */
|
|
161
|
+
abort: () => void;
|
|
162
|
+
/** Reset the local conversation. Does NOT affect server-side state. */
|
|
163
|
+
reset: () => void;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function useInvoke(options: UseInvokeOptions = {}): UseInvokeReturn {
|
|
167
|
+
const ctx = useGguiContext();
|
|
168
|
+
const endpointUrl = options.endpointUrl ?? ctx.appConfig?.endpointUrl;
|
|
169
|
+
|
|
170
|
+
const [messages, setMessages] = useState<ConversationMessage[]>(
|
|
171
|
+
options.initialMessages ?? [],
|
|
172
|
+
);
|
|
173
|
+
const [isStreaming, setIsStreaming] = useState(false);
|
|
174
|
+
const [error, setError] = useState<InvokeError | null>(null);
|
|
175
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
176
|
+
// Always-current snapshot for building `history` without re-creating `send`.
|
|
177
|
+
const messagesRef = useRef<ConversationMessage[]>(messages);
|
|
178
|
+
messagesRef.current = messages;
|
|
179
|
+
// Tracks the hostSessionId the agent surfaces via `tool_result` on turn 1
|
|
180
|
+
// so subsequent `send()` calls can carry `X-Ggui-Host-Session-Id` —
|
|
181
|
+
// without this the agent mints a new session per POST and turn-2 render
|
|
182
|
+
// events never reach the already-mounted render host. Mirrors the web
|
|
183
|
+
// hook's fix. Names the conversation envelope (the chat thread),
|
|
184
|
+
// distinct from any per-render `sessionId`.
|
|
185
|
+
const hostSessionIdRef = useRef<string | null>(options.hostSessionId ?? null);
|
|
186
|
+
|
|
187
|
+
const send = useCallback(
|
|
188
|
+
async (message: string, opts?: { clientMessageId?: string }): Promise<void> => {
|
|
189
|
+
// devBridge short-circuits the prod endpoint — gateway is the transport.
|
|
190
|
+
if (!endpointUrl && !options.devBridge) {
|
|
191
|
+
const err: InvokeError = {
|
|
192
|
+
code: 'invalid_request',
|
|
193
|
+
message: 'Cannot invoke: neither app.endpointUrl nor devBridge is configured.',
|
|
194
|
+
};
|
|
195
|
+
setError(err);
|
|
196
|
+
options.onError?.(err);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (abortRef.current) {
|
|
200
|
+
// Refuse overlapping sends — caller decides whether to abort first.
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
setError(null);
|
|
205
|
+
const controller = new AbortController();
|
|
206
|
+
abortRef.current = controller;
|
|
207
|
+
|
|
208
|
+
// Append user message + placeholder assistant message in one update.
|
|
209
|
+
// `clientMessageId` lets durable callers own the user id so retries
|
|
210
|
+
// dedupe at the outbox level instead of creating duplicates.
|
|
211
|
+
const userId = opts?.clientMessageId ?? `user_${cryptoRandom()}`;
|
|
212
|
+
const assistantId = `asst_${cryptoRandom()}`;
|
|
213
|
+
setMessages((prev) => [
|
|
214
|
+
...prev,
|
|
215
|
+
{ id: userId, role: 'user', content: [{ type: 'text', text: message }], isStreaming: false },
|
|
216
|
+
{ id: assistantId, role: 'assistant', content: [], isStreaming: true },
|
|
217
|
+
]);
|
|
218
|
+
setIsStreaming(true);
|
|
219
|
+
|
|
220
|
+
// Build history from the snapshot BEFORE this turn (excludes the user
|
|
221
|
+
// message we just appended, since `message` carries it explicitly).
|
|
222
|
+
const history = toInvokeHistory(messagesRef.current);
|
|
223
|
+
|
|
224
|
+
// Track the assistant message id across the turn — message_start may
|
|
225
|
+
// rename it from `assistantId` to the agent-supplied `message.id`,
|
|
226
|
+
// and the catch/finally block needs to find it whichever id it has.
|
|
227
|
+
let assistantMsgId = assistantId;
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
const headers: Record<string, string> = {
|
|
231
|
+
'Content-Type': 'application/json',
|
|
232
|
+
'Accept': 'text/event-stream',
|
|
233
|
+
'X-Ggui-Protocol-Version': PROTOCOL_VERSION,
|
|
234
|
+
'X-Ggui-App-Id': ctx.appId,
|
|
235
|
+
};
|
|
236
|
+
// options.hostSessionId wins for explicit-resume callers; otherwise
|
|
237
|
+
// fall back to the hostSessionId surfaced on a prior turn's
|
|
238
|
+
// tool_result.
|
|
239
|
+
const effectiveHostSessionId = options.hostSessionId ?? hostSessionIdRef.current;
|
|
240
|
+
if (effectiveHostSessionId) headers['X-Ggui-Host-Session-Id'] = effectiveHostSessionId;
|
|
241
|
+
if (options.bearerToken) headers['Authorization'] = `Bearer ${options.bearerToken}`;
|
|
242
|
+
|
|
243
|
+
const init: RNRequestInit = {
|
|
244
|
+
method: 'POST',
|
|
245
|
+
headers,
|
|
246
|
+
body: JSON.stringify({ message, history }),
|
|
247
|
+
signal: controller.signal,
|
|
248
|
+
// RN-only: opt into real streaming body. Silently ignored on web.
|
|
249
|
+
reactNative: { textStreaming: true },
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// Dev-mode bridge: gateway receives `{gatewayUrl}/{appId}` POSTs and
|
|
253
|
+
// streams the SSE response from the local `ggui dev` CLI (routes by appId).
|
|
254
|
+
const targetUrl = options.devBridge
|
|
255
|
+
? `${options.devBridge.gatewayUrl.replace(/\/$/, '')}/${encodeURIComponent(ctx.appId)}`
|
|
256
|
+
: `${(endpointUrl as string).replace(/\/$/, '')}/invoke`;
|
|
257
|
+
const response = await fetch(targetUrl, init);
|
|
258
|
+
|
|
259
|
+
if (!response.ok) {
|
|
260
|
+
let payload: { error?: { code?: InvokeErrorCode; message?: string; retryAfterMs?: number } } = {};
|
|
261
|
+
try {
|
|
262
|
+
payload = (await response.json()) as typeof payload;
|
|
263
|
+
} catch {
|
|
264
|
+
// non-JSON error body
|
|
265
|
+
}
|
|
266
|
+
const err: InvokeError = {
|
|
267
|
+
code: payload.error?.code ?? 'transport_error',
|
|
268
|
+
message: payload.error?.message ?? `HTTP ${response.status}`,
|
|
269
|
+
...(payload.error?.retryAfterMs !== undefined ? { retryAfterMs: payload.error.retryAfterMs } : {}),
|
|
270
|
+
};
|
|
271
|
+
throw err;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Stream events. If response.body is null (older RN, Flipper, no
|
|
275
|
+
// streaming support) fall back to buffered text parse — same frames,
|
|
276
|
+
// all arrive at once. Correctness preserved, streaming UX lost.
|
|
277
|
+
// Fire `onStreamMode` so device testers can verify which path the
|
|
278
|
+
// platform actually took (the buffered path is indistinguishable
|
|
279
|
+
// from a fast stream without explicit signalling).
|
|
280
|
+
const usingStreaming = response.body != null;
|
|
281
|
+
options.onStreamMode?.(usingStreaming ? 'streaming' : 'buffered');
|
|
282
|
+
const events = usingStreaming
|
|
283
|
+
? parseSseStream(response.body as unknown as ReadableStream<Uint8Array>, controller.signal)
|
|
284
|
+
: bufferedFallback(await response.text());
|
|
285
|
+
|
|
286
|
+
for await (const event of events) {
|
|
287
|
+
if (controller.signal.aborted) break;
|
|
288
|
+
if (event.type === 'message_start') {
|
|
289
|
+
// Adopt the agent's message id so client + server line up.
|
|
290
|
+
const newId = event.message.id;
|
|
291
|
+
setMessages((prev) =>
|
|
292
|
+
prev.map((m) => (m.id === assistantId ? { ...m, id: newId } : m)),
|
|
293
|
+
);
|
|
294
|
+
assistantMsgId = newId;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (event.type === 'content_block_start') {
|
|
298
|
+
const block = event.content_block;
|
|
299
|
+
setMessages((prev) =>
|
|
300
|
+
mutateAssistant(prev, assistantMsgId, (m) => ({
|
|
301
|
+
...m,
|
|
302
|
+
content: setBlockAt(m.content, event.index, block),
|
|
303
|
+
})),
|
|
304
|
+
);
|
|
305
|
+
if (block.type === 'tool_use') {
|
|
306
|
+
options.onToolUse?.(block);
|
|
307
|
+
}
|
|
308
|
+
// Snap hostSessionId off the first tool_result that surfaces
|
|
309
|
+
// one — agent-side tools like `ggui_render` / `ggui_handshake`
|
|
310
|
+
// inline their result on the same assistant turn with a
|
|
311
|
+
// sessionId payload (the conversation envelope identity, not
|
|
312
|
+
// a per-render id). Subsequent sends reuse this so the server
|
|
313
|
+
// threads user messages to the same session instead of minting
|
|
314
|
+
// a new one per POST.
|
|
315
|
+
if (block.type === 'tool_result' && !hostSessionIdRef.current) {
|
|
316
|
+
const maybe = extractHostSessionIdFromContent(block.content);
|
|
317
|
+
if (maybe) hostSessionIdRef.current = maybe;
|
|
318
|
+
}
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (event.type === 'content_block_delta') {
|
|
322
|
+
const { index, delta } = event;
|
|
323
|
+
setMessages((prev) =>
|
|
324
|
+
mutateAssistant(prev, assistantMsgId, (m) => ({
|
|
325
|
+
...m,
|
|
326
|
+
content: applyDelta(m.content, index, delta),
|
|
327
|
+
})),
|
|
328
|
+
);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (event.type === 'error') {
|
|
332
|
+
const err: InvokeError = {
|
|
333
|
+
code: event.error.code,
|
|
334
|
+
message: event.error.message,
|
|
335
|
+
...(event.error.retryAfterMs !== undefined ? { retryAfterMs: event.error.retryAfterMs } : {}),
|
|
336
|
+
};
|
|
337
|
+
throw err;
|
|
338
|
+
}
|
|
339
|
+
// content_block_stop, message_delta, ping, message_stop — no UI change.
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Stream ended cleanly.
|
|
343
|
+
setMessages((prev) =>
|
|
344
|
+
mutateAssistant(prev, assistantMsgId, (m) => ({ ...m, isStreaming: false })),
|
|
345
|
+
);
|
|
346
|
+
} catch (raw) {
|
|
347
|
+
const err: InvokeError = isInvokeError(raw)
|
|
348
|
+
? raw
|
|
349
|
+
: makeTransportError(raw instanceof Error ? raw.message : 'Invoke failed');
|
|
350
|
+
setError(err);
|
|
351
|
+
options.onError?.(err);
|
|
352
|
+
// Mark assistant message done (even if empty) so UI doesn't spin.
|
|
353
|
+
setMessages((prev) =>
|
|
354
|
+
mutateAssistant(prev, assistantMsgId, (m) => ({ ...m, isStreaming: false })),
|
|
355
|
+
);
|
|
356
|
+
} finally {
|
|
357
|
+
abortRef.current = null;
|
|
358
|
+
setIsStreaming(false);
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
[endpointUrl, ctx.appId, options],
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const abort = useCallback(() => {
|
|
365
|
+
abortRef.current?.abort();
|
|
366
|
+
abortRef.current = null;
|
|
367
|
+
setIsStreaming(false);
|
|
368
|
+
}, []);
|
|
369
|
+
|
|
370
|
+
const reset = useCallback(() => {
|
|
371
|
+
abortRef.current?.abort();
|
|
372
|
+
abortRef.current = null;
|
|
373
|
+
setMessages([]);
|
|
374
|
+
setError(null);
|
|
375
|
+
setIsStreaming(false);
|
|
376
|
+
// Clear the derived hostSessionId — reset() implies a fresh conversation.
|
|
377
|
+
hostSessionIdRef.current = options.hostSessionId ?? null;
|
|
378
|
+
}, [options.hostSessionId]);
|
|
379
|
+
|
|
380
|
+
return { messages, send, isStreaming, error, abort, reset };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ── Helpers ───────────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
function cryptoRandom(): string {
|
|
386
|
+
// Hermes ships `crypto.getRandomValues` but NOT `crypto.randomUUID` as of
|
|
387
|
+
// RN 0.81. We only need a short unique id for client-side message keying,
|
|
388
|
+
// so Math.random is acceptable (not cryptographic).
|
|
389
|
+
const g = globalThis as { crypto?: { randomUUID?: () => string } };
|
|
390
|
+
if (g.crypto?.randomUUID) return g.crypto.randomUUID().slice(0, 8);
|
|
391
|
+
return Math.random().toString(36).slice(2, 10);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function isInvokeError(value: unknown): value is InvokeError {
|
|
395
|
+
return (
|
|
396
|
+
typeof value === 'object' &&
|
|
397
|
+
value !== null &&
|
|
398
|
+
'code' in value &&
|
|
399
|
+
typeof (value as { code: unknown }).code === 'string'
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function makeTransportError(message: string): InvokeError {
|
|
404
|
+
return { code: 'transport_error', message };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Pull a `hostSessionId` string (the conversation envelope id) out of a
|
|
409
|
+
* tool_result's content payload if one is present. Tolerant of arbitrary
|
|
410
|
+
* nested shapes — agents may put the id directly on the result or under
|
|
411
|
+
* a wrapper like `{ result: { hostSessionId } }`. Wire field and
|
|
412
|
+
* SDK-side name are both `hostSessionId`.
|
|
413
|
+
*/
|
|
414
|
+
function extractHostSessionIdFromContent(content: unknown): string | null {
|
|
415
|
+
if (typeof content !== 'object' || content === null) return null;
|
|
416
|
+
const record = content as Record<string, unknown>;
|
|
417
|
+
if (typeof record.hostSessionId === 'string') return record.hostSessionId;
|
|
418
|
+
// One level of nesting — common when tools wrap their output in `{ result }`.
|
|
419
|
+
for (const value of Object.values(record)) {
|
|
420
|
+
if (typeof value === 'object' && value !== null) {
|
|
421
|
+
const inner = value as Record<string, unknown>;
|
|
422
|
+
if (typeof inner.hostSessionId === 'string') return inner.hostSessionId;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function mutateAssistant(
|
|
429
|
+
messages: ConversationMessage[],
|
|
430
|
+
id: string,
|
|
431
|
+
fn: (m: ConversationMessage) => ConversationMessage,
|
|
432
|
+
): ConversationMessage[] {
|
|
433
|
+
return messages.map((m) => (m.id === id && m.role === 'assistant' ? fn(m) : m));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function setBlockAt(blocks: ContentBlock[], index: number, block: ContentBlock): ContentBlock[] {
|
|
437
|
+
const next = blocks.slice();
|
|
438
|
+
// Pad with text placeholders if the agent skipped indices (defensive).
|
|
439
|
+
while (next.length < index) next.push({ type: 'text', text: '' });
|
|
440
|
+
next[index] = block;
|
|
441
|
+
return next;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function applyDelta(
|
|
445
|
+
blocks: ContentBlock[],
|
|
446
|
+
index: number,
|
|
447
|
+
delta: { type: 'text_delta'; text: string } | { type: 'input_json_delta'; partial_json: string },
|
|
448
|
+
): ContentBlock[] {
|
|
449
|
+
const target = blocks[index];
|
|
450
|
+
if (!target) return blocks;
|
|
451
|
+
if (delta.type === 'text_delta' && target.type === 'text') {
|
|
452
|
+
const next = blocks.slice();
|
|
453
|
+
next[index] = { ...target, text: target.text + delta.text };
|
|
454
|
+
return next;
|
|
455
|
+
}
|
|
456
|
+
// input_json_delta accumulation isn't surfaced to UI in v1 — agents that
|
|
457
|
+
// need this can opt in via a future hook. Drop silently.
|
|
458
|
+
return blocks;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Project the local conversation into the wire `InvokeTurn[]` shape the
|
|
463
|
+
* server expects on `history`. Strip in-flight assistant messages — they
|
|
464
|
+
* have no final stop_reason.
|
|
465
|
+
*/
|
|
466
|
+
function toInvokeHistory(messages: ConversationMessage[]): InvokeTurn[] {
|
|
467
|
+
return messages
|
|
468
|
+
.filter((m) => !(m.role === 'assistant' && m.isStreaming))
|
|
469
|
+
.map((m) => ({ role: m.role, content: m.content }));
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Fallback when `response.body` is null (no streaming support in this RN
|
|
474
|
+
* environment). Parses the entire buffered SSE payload and yields events
|
|
475
|
+
* one-by-one from memory. Consumers still see normal event-by-event state
|
|
476
|
+
* updates — just all in the same tick instead of interleaved with network.
|
|
477
|
+
*
|
|
478
|
+
* Splits frames on the SSE separator (`\n\n`), strips the `data: ` prefix,
|
|
479
|
+
* JSON-parses + validates each frame via the same schema the streaming path
|
|
480
|
+
* uses. Silently drops malformed frames — same policy as `parseSseStream`.
|
|
481
|
+
*/
|
|
482
|
+
async function* bufferedFallback(
|
|
483
|
+
payload: string,
|
|
484
|
+
): AsyncGenerator<import('@ggui-ai/protocol').InvokeEvent> {
|
|
485
|
+
const { invokeEventSchema } = await import('@ggui-ai/protocol');
|
|
486
|
+
const FRAME_SEP = '\n\n';
|
|
487
|
+
const frames = payload.split(FRAME_SEP);
|
|
488
|
+
for (const frame of frames) {
|
|
489
|
+
const dataIdx = frame.indexOf('data: ');
|
|
490
|
+
if (dataIdx === -1) continue;
|
|
491
|
+
const json = frame.slice(dataIdx + 'data: '.length).trim();
|
|
492
|
+
if (!json) continue;
|
|
493
|
+
let parsed: unknown;
|
|
494
|
+
try {
|
|
495
|
+
parsed = JSON.parse(json);
|
|
496
|
+
} catch {
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
const result = invokeEventSchema.safeParse(parsed);
|
|
500
|
+
if (result.success) yield result.data;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any -- global native-module mocks (react-native / WebView) for plain-Node test runs; test-only, never ships */
|
|
2
|
+
/**
|
|
3
|
+
* Global test setup for @ggui-ai/mcp-apps-react-native
|
|
4
|
+
*
|
|
5
|
+
* Mocks react-native and WebView so tests can run in a plain Node
|
|
6
|
+
* environment.
|
|
7
|
+
*/
|
|
8
|
+
import { vi } from 'vitest';
|
|
9
|
+
import React from 'react';
|
|
10
|
+
|
|
11
|
+
// --- react-native mock ---
|
|
12
|
+
|
|
13
|
+
const appStateListeners: ((state: string) => void)[] = [];
|
|
14
|
+
|
|
15
|
+
vi.mock('react-native', async () => {
|
|
16
|
+
const R = (await vi.importActual<typeof import('react')>('react'));
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
AppState: {
|
|
20
|
+
currentState: 'active',
|
|
21
|
+
addEventListener: vi.fn((_event: string, handler: (state: string) => void) => {
|
|
22
|
+
appStateListeners.push(handler);
|
|
23
|
+
return { remove: () => { const i = appStateListeners.indexOf(handler); if (i >= 0) appStateListeners.splice(i, 1); } };
|
|
24
|
+
}),
|
|
25
|
+
},
|
|
26
|
+
Platform: { OS: 'ios', Version: '17.0', select: (obj: Record<string, unknown>) => obj.ios ?? obj.default },
|
|
27
|
+
PixelRatio: { get: () => 3 },
|
|
28
|
+
Dimensions: {
|
|
29
|
+
get: () => ({ width: 390, height: 844, scale: 3, fontScale: 1 }),
|
|
30
|
+
addEventListener: vi.fn((_event: string, _handler: () => void) => ({ remove: vi.fn() })),
|
|
31
|
+
},
|
|
32
|
+
Easing: {
|
|
33
|
+
bezier: (x1: number, y1: number, x2: number, y2: number) => ({ _bezier: [x1, y1, x2, y2] }),
|
|
34
|
+
linear: { _type: 'linear' },
|
|
35
|
+
},
|
|
36
|
+
Animated: {
|
|
37
|
+
Value: class { constructor(public _value: number) {} },
|
|
38
|
+
timing: vi.fn(() => ({ start: vi.fn() })),
|
|
39
|
+
spring: vi.fn(() => ({ start: vi.fn() })),
|
|
40
|
+
},
|
|
41
|
+
AccessibilityInfo: {
|
|
42
|
+
isReduceMotionEnabled: vi.fn(async () => false),
|
|
43
|
+
addEventListener: vi.fn((_event: string, _handler: (enabled: boolean) => void) => ({
|
|
44
|
+
remove: vi.fn(),
|
|
45
|
+
})),
|
|
46
|
+
},
|
|
47
|
+
Linking: {
|
|
48
|
+
openURL: vi.fn(async (_url: string) => undefined),
|
|
49
|
+
canOpenURL: vi.fn(async (_url: string) => true),
|
|
50
|
+
addEventListener: vi.fn((_event: string, _handler: (state: unknown) => void) => ({
|
|
51
|
+
remove: vi.fn(),
|
|
52
|
+
})),
|
|
53
|
+
},
|
|
54
|
+
StyleSheet: { create: <T extends Record<string, unknown>>(styles: T): T => styles },
|
|
55
|
+
View: R.forwardRef(({ children, ...props }: any, ref: any) => R.createElement('View', { ...props, ref }, children)),
|
|
56
|
+
Text: ({ children, ...props }: any) => R.createElement('Text', props, children),
|
|
57
|
+
TextInput: (props: any) => R.createElement('TextInput', props),
|
|
58
|
+
Image: (props: any) => R.createElement('Image', props),
|
|
59
|
+
ScrollView: ({ children, ...props }: any) => R.createElement('ScrollView', props, children),
|
|
60
|
+
Switch: (props: any) => R.createElement('Switch', props),
|
|
61
|
+
Pressable: ({ children, ...props }: any) => R.createElement('Pressable', props, typeof children === 'function' ? children({ pressed: false }) : children),
|
|
62
|
+
ActivityIndicator: (props: any) => R.createElement('ActivityIndicator', props),
|
|
63
|
+
useColorScheme: () => 'light',
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// --- react-native-webview mock ---
|
|
68
|
+
|
|
69
|
+
vi.mock('react-native-webview', async () => {
|
|
70
|
+
const R = (await vi.importActual<typeof import('react')>('react'));
|
|
71
|
+
return {
|
|
72
|
+
default: R.forwardRef((props: any, ref: any) => R.createElement('WebView', { ...props, ref })),
|
|
73
|
+
WebView: R.forwardRef((props: any, ref: any) => R.createElement('WebView', { ...props, ref })),
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Suppress unused import warning — React is needed for JSX in vi.mock factories
|
|
78
|
+
void React;
|
|
79
|
+
|
|
80
|
+
// Helpers to simulate state changes in tests
|
|
81
|
+
export function simulateAppStateChange(state: string): void {
|
|
82
|
+
for (const listener of appStateListeners) {
|
|
83
|
+
listener(state);
|
|
84
|
+
}
|
|
85
|
+
}
|