@guuey/agent-client 0.1.0 → 0.2.1
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/dist/block-ui.d.ts +6 -1
- package/dist/block-ui.d.ts.map +1 -1
- package/dist/block-ui.js +21 -3
- package/dist/card-mount.d.ts +69 -0
- package/dist/card-mount.d.ts.map +1 -0
- package/dist/card-mount.js +83 -0
- package/dist/ggui-render.d.ts +161 -0
- package/dist/ggui-render.d.ts.map +1 -0
- package/dist/ggui-render.js +164 -0
- package/dist/history.d.ts +12 -0
- package/dist/history.d.ts.map +1 -1
- package/dist/history.js +17 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -2
- package/dist/link-prompt.d.ts +22 -0
- package/dist/link-prompt.d.ts.map +1 -0
- package/dist/link-prompt.js +29 -0
- package/dist/sse.d.ts +19 -0
- package/dist/sse.d.ts.map +1 -1
- package/dist/sse.js +38 -0
- package/dist/types.d.ts +82 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +4 -1
- package/dist/useAgentInvoke.d.ts.map +1 -1
- package/dist/useAgentInvoke.js +99 -12
- package/dist/web-adapters.d.ts +78 -14
- package/dist/web-adapters.d.ts.map +1 -1
- package/dist/web-adapters.js +115 -21
- package/package.json +2 -2
- package/src/block-ui.ts +19 -3
- package/src/card-mount.ts +101 -0
- package/src/ggui-render.ts +270 -0
- package/src/history.ts +17 -0
- package/src/index.ts +27 -0
- package/src/link-prompt.ts +34 -0
- package/src/sse.ts +36 -0
- package/src/types.ts +85 -3
- package/src/useAgentInvoke.ts +106 -12
- package/src/web-adapters.ts +169 -24
package/src/index.ts
CHANGED
|
@@ -3,8 +3,11 @@ export {
|
|
|
3
3
|
extractAssistantText,
|
|
4
4
|
reduceAssistantText,
|
|
5
5
|
stringField,
|
|
6
|
+
parseConsentRequest,
|
|
7
|
+
parseLinkRequest,
|
|
6
8
|
type ParsedSseEvent,
|
|
7
9
|
} from "./sse";
|
|
10
|
+
export { dismissLinkPrompt } from "./link-prompt";
|
|
8
11
|
export {
|
|
9
12
|
createWebAdapters,
|
|
10
13
|
localStorageThreadStore,
|
|
@@ -17,6 +20,7 @@ export {
|
|
|
17
20
|
fetchThreadHistory,
|
|
18
21
|
threadHistoryRowsToMessages,
|
|
19
22
|
threadHistoryRowsToCards,
|
|
23
|
+
HistoryUnauthorizedError,
|
|
20
24
|
type ThreadHistoryRow,
|
|
21
25
|
type ThreadHistoryFetchOptions,
|
|
22
26
|
} from "./history";
|
|
@@ -36,6 +40,26 @@ export {
|
|
|
36
40
|
toolResultUiResource,
|
|
37
41
|
type McpUiResourcePayload,
|
|
38
42
|
} from "./block-ui";
|
|
43
|
+
// The ggui render channel (`uiData.resourceUri` + the `_meta["ai.ggui/render"]`
|
|
44
|
+
// bootstrap) and the dispatcher that mounts EITHER channel through the same
|
|
45
|
+
// second-origin sandbox path.
|
|
46
|
+
export {
|
|
47
|
+
asGguiRender,
|
|
48
|
+
asGguiRenderBootstrap,
|
|
49
|
+
blockGguiRender,
|
|
50
|
+
gguiRenderResource,
|
|
51
|
+
gguiShellHtml,
|
|
52
|
+
toolResultGguiRender,
|
|
53
|
+
GGUI_RENDER_META_KEY,
|
|
54
|
+
type GguiRenderBootstrap,
|
|
55
|
+
type GguiRenderDescriptor,
|
|
56
|
+
} from "./ggui-render";
|
|
57
|
+
export {
|
|
58
|
+
cardCardMount,
|
|
59
|
+
toolResultCardMount,
|
|
60
|
+
type CardMount,
|
|
61
|
+
type CardMountChannel,
|
|
62
|
+
} from "./card-mount";
|
|
39
63
|
// Re-export the AgJSON types the block-preserving transcript surfaces, so
|
|
40
64
|
// consumers can name `reduceResult` / block types without a direct
|
|
41
65
|
// `@silverprotocol/core` import.
|
|
@@ -43,12 +67,15 @@ export type { AgEvent, AgReduceResult, AgMessage, AgBlock } from "@silverprotoco
|
|
|
43
67
|
export type {
|
|
44
68
|
AgentMessage,
|
|
45
69
|
HistoryCard,
|
|
70
|
+
ProfileConsentRequest,
|
|
71
|
+
ProfileLinkRequest,
|
|
46
72
|
ThreadIdStore,
|
|
47
73
|
GenerateId,
|
|
48
74
|
InvokeRequest,
|
|
49
75
|
InvokeTransport,
|
|
50
76
|
AgentInvokeAdapters,
|
|
51
77
|
AgentInvokeHistoryAdapter,
|
|
78
|
+
AgentInvokeStatus,
|
|
52
79
|
HistoryLoadResult,
|
|
53
80
|
UseAgentInvokeOptions,
|
|
54
81
|
UseAgentInvokeReturn,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for the pod's `POST /v1/link-prompt/dismiss` route (nocode-runtime
|
|
3
|
+
* linkcoh T4) — the "don't ask again" action on the
|
|
4
|
+
* {@link ProfileLinkRequest} prompt (see `./sse`'s `parseLinkRequest` and
|
|
5
|
+
* `useAgentInvoke`'s `profileLinkRequest` state). Host-agnostic like
|
|
6
|
+
* {@link fetchThreadHistory} in `./history`: takes a bearer directly (the
|
|
7
|
+
* route authenticates ONLY off the verified bearer — the request body is
|
|
8
|
+
* never read server-side, so there is nothing else to send) and an
|
|
9
|
+
* injectable `fetchImpl` for tests, defaulting to the global `fetch`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Dismiss the pending cross-app profile link prompt for the calling byo
|
|
14
|
+
* end-user. `baseUrl` is the public read-plane base already ending in `/v1`
|
|
15
|
+
* (same convention as {@link fetchThreadHistory}'s `baseUrl`); `bearer` is the
|
|
16
|
+
* caller's byo access token, sent as `Authorization: Bearer <bearer>` — the
|
|
17
|
+
* ONLY thing the pod's route reads to determine the dismissal subject.
|
|
18
|
+
*
|
|
19
|
+
* Resolves on a 204 (the route's only success response); throws on any
|
|
20
|
+
* non-2xx status (401 unauthenticated, 403 non-byo caller, 500 write failure).
|
|
21
|
+
*/
|
|
22
|
+
export async function dismissLinkPrompt(
|
|
23
|
+
baseUrl: string,
|
|
24
|
+
bearer: string,
|
|
25
|
+
fetchImpl: typeof fetch = fetch,
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
const res = await fetchImpl(`${baseUrl}/link-prompt/dismiss`, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: { Authorization: `Bearer ${bearer}` },
|
|
30
|
+
});
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
throw new Error(`link prompt dismiss failed: ${res.status}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/sse.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* verbatim across web (Studio) and React-Native (Portal).
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { ProfileConsentRequest, ProfileLinkRequest } from "./types";
|
|
8
|
+
|
|
7
9
|
export interface ParsedSseEvent {
|
|
8
10
|
event: string;
|
|
9
11
|
data: unknown;
|
|
@@ -113,3 +115,37 @@ export function stringField(data: unknown, key: string): string | undefined {
|
|
|
113
115
|
const v = (data as Record<string, unknown>)[key];
|
|
114
116
|
return typeof v === "string" ? v : undefined;
|
|
115
117
|
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Parse a `profile-consent-needed` SSE payload into a typed
|
|
121
|
+
* {@link ProfileConsentRequest}, or `null` if it does not conform. `appId`
|
|
122
|
+
* must be a non-empty string and `requested` exactly `"read"` or
|
|
123
|
+
* `"read-write"`; extra keys are tolerated (ignored). Returns a fresh
|
|
124
|
+
* normalized object so callers get exactly the typed shape, never the raw
|
|
125
|
+
* wire payload with unknown extras.
|
|
126
|
+
*/
|
|
127
|
+
export function parseConsentRequest(data: unknown): ProfileConsentRequest | null {
|
|
128
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) return null;
|
|
129
|
+
const appId = (data as { appId?: unknown }).appId;
|
|
130
|
+
const requested = (data as { requested?: unknown }).requested;
|
|
131
|
+
if (typeof appId !== "string" || appId.length === 0) return null;
|
|
132
|
+
if (requested !== "read" && requested !== "read-write") return null;
|
|
133
|
+
return { appId, requested };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Parse a `profile-link-needed` SSE payload into a typed
|
|
138
|
+
* {@link ProfileLinkRequest}, or `null` if it does not conform. Same shape +
|
|
139
|
+
* validation as {@link parseConsentRequest} (`appId` non-empty string,
|
|
140
|
+
* `requested` exactly `"read"` or `"read-write"`, extra keys tolerated) — the
|
|
141
|
+
* pod emits an identically-shaped payload for both events; only the event
|
|
142
|
+
* NAME (and what it means to the consumer) differs.
|
|
143
|
+
*/
|
|
144
|
+
export function parseLinkRequest(data: unknown): ProfileLinkRequest | null {
|
|
145
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) return null;
|
|
146
|
+
const appId = (data as { appId?: unknown }).appId;
|
|
147
|
+
const requested = (data as { requested?: unknown }).requested;
|
|
148
|
+
if (typeof appId !== "string" || appId.length === 0) return null;
|
|
149
|
+
if (requested !== "read" && requested !== "read-write") return null;
|
|
150
|
+
return { appId, requested };
|
|
151
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
* (which also carries anonymous identity) — are INJECTED by the consumer via
|
|
7
7
|
* {@link AgentInvokeAdapters}. Web (Studio) passes localStorage / crypto /
|
|
8
8
|
* credentialed-cookie fetch; React-Native (Portal) passes AsyncStorage /
|
|
9
|
-
* getRandomValues / header-identity SSE fetch.
|
|
9
|
+
* getRandomValues / header-identity SSE fetch. Anonymous identity is per-host,
|
|
10
|
+
* not per-platform: a web host with no usable cookie jar (an embedded
|
|
11
|
+
* third-party iframe) carries its own guest secret in a header too — see
|
|
12
|
+
* `createWebAdapters`'s `getGuestSecret`. This mirrors the ggui
|
|
10
13
|
* `MessageStorageAdapter` injection pattern.
|
|
11
14
|
*/
|
|
12
15
|
|
|
@@ -18,6 +21,35 @@ export interface AgentMessage {
|
|
|
18
21
|
text: string;
|
|
19
22
|
}
|
|
20
23
|
|
|
24
|
+
/**
|
|
25
|
+
* A cross-app profile consent request surfaced mid-stream by the pod's
|
|
26
|
+
* `profile-consent-needed` SSE event (nocode-runtime T6). Emitted when the
|
|
27
|
+
* agent declares a profile intent the caller has NOT yet granted for this app,
|
|
28
|
+
* so the consumer UI can prompt the user to authorize `read` or `read-write`
|
|
29
|
+
* access. `requested` mirrors the pod's `ProfileAccess` posture verbatim; the
|
|
30
|
+
* literal union is inlined rather than imported to keep this client SDK free of
|
|
31
|
+
* any backend-package dependency.
|
|
32
|
+
*/
|
|
33
|
+
export interface ProfileConsentRequest {
|
|
34
|
+
appId: string;
|
|
35
|
+
requested: "read" | "read-write";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A cross-app profile LINK invite surfaced mid-stream by the pod's
|
|
40
|
+
* `profile-link-needed` SSE event (nocode-runtime linkcoh T3). Emitted when an
|
|
41
|
+
* unlinked byo end-user's declared profile posture booted, inviting them to
|
|
42
|
+
* link their guuey account (via the named `/link` ceremony) so they earn the
|
|
43
|
+
* guuey-wide cross-app profile. `requested` mirrors the pod's `ProfileAccess`
|
|
44
|
+
* posture verbatim (the builder's declared access, not a live ask) — the
|
|
45
|
+
* literal union is inlined rather than imported, same rationale as
|
|
46
|
+
* {@link ProfileConsentRequest}.
|
|
47
|
+
*/
|
|
48
|
+
export interface ProfileLinkRequest {
|
|
49
|
+
appId: string;
|
|
50
|
+
requested: "read" | "read-write";
|
|
51
|
+
}
|
|
52
|
+
|
|
21
53
|
/**
|
|
22
54
|
* A persisted generative-UI card rehydrated from thread history — the verbatim
|
|
23
55
|
* `AgArtifact` snapshot the pod stored on a `kind: "card"` row, tagged with its
|
|
@@ -58,7 +90,7 @@ export interface InvokeRequest {
|
|
|
58
90
|
* Opens an invoke request and yields decoded UTF-8 text chunks of the SSE
|
|
59
91
|
* stream (the hook accumulates + parses frames itself). MUST throw on a
|
|
60
92
|
* non-OK response or network failure. Owns headers + identity entirely, so
|
|
61
|
-
* the hook never sees cookies or
|
|
93
|
+
* the hook never sees cookies, bearer tokens, or guest secrets.
|
|
62
94
|
*/
|
|
63
95
|
export type InvokeTransport = (req: InvokeRequest) => AsyncIterable<string>;
|
|
64
96
|
|
|
@@ -111,10 +143,36 @@ export interface UseAgentInvokeOptions {
|
|
|
111
143
|
preserveBlocks?: boolean;
|
|
112
144
|
}
|
|
113
145
|
|
|
146
|
+
/**
|
|
147
|
+
* The per-turn lifecycle (guuey#91), derived ENTIRELY from frames the pod
|
|
148
|
+
* already emits — no protocol addition:
|
|
149
|
+
*
|
|
150
|
+
* - `ready` — no turn in flight (initial, after `done`/failure/abort).
|
|
151
|
+
* - `connecting` — `send()` fired, no `session` frame yet. With
|
|
152
|
+
* scale-to-zero pods this phase can span a cold start, so hosts typically
|
|
153
|
+
* swap to "waking your agent" copy after a few seconds.
|
|
154
|
+
* - `thinking` — the pod is awake and the turn is running, but no text is
|
|
155
|
+
* flowing and no tool is announced (between `session` and the first
|
|
156
|
+
* content, and between a `tool.done` and whatever follows it).
|
|
157
|
+
* - `using-tool` — a `tool.start` frame arrived; {@link UseAgentInvokeReturn.activeTool}
|
|
158
|
+
* carries the wire tool name until the matching `tool.done`.
|
|
159
|
+
* - `responding` — assistant text is arriving (`text.start`/`text.delta`
|
|
160
|
+
* silver frames, or bypass text/assistant frames).
|
|
161
|
+
*
|
|
162
|
+
* Failure keeps its own channel ({@link UseAgentInvokeReturn.error}) — there
|
|
163
|
+
* is deliberately no `error` status: after any terminal outcome the status
|
|
164
|
+
* returns to `ready` so the composer re-enables.
|
|
165
|
+
*/
|
|
166
|
+
export type AgentInvokeStatus = "ready" | "connecting" | "thinking" | "using-tool" | "responding";
|
|
167
|
+
|
|
114
168
|
export interface UseAgentInvokeReturn {
|
|
115
169
|
messages: AgentMessage[];
|
|
116
170
|
send: (input: string) => Promise<void>;
|
|
117
|
-
|
|
171
|
+
/** The per-turn lifecycle — see {@link AgentInvokeStatus}. Anything other
|
|
172
|
+
* than `ready` means a turn is in flight (the old `isStreaming === true`). */
|
|
173
|
+
status: AgentInvokeStatus;
|
|
174
|
+
/** The active tool's wire name while `status === 'using-tool'`, else null. */
|
|
175
|
+
activeTool: string | null;
|
|
118
176
|
error: string | null;
|
|
119
177
|
threadId: string | null;
|
|
120
178
|
/** Abort the in-flight turn (the stream stops; partial text is kept). */
|
|
@@ -150,4 +208,28 @@ export interface UseAgentInvokeReturn {
|
|
|
150
208
|
* `reset()` clears it back to `[]`.
|
|
151
209
|
*/
|
|
152
210
|
historyCards: HistoryCard[];
|
|
211
|
+
/**
|
|
212
|
+
* The latest cross-app profile consent request the pod asked for on THIS
|
|
213
|
+
* conversation, or `null`. Set from a well-formed `profile-consent-needed`
|
|
214
|
+
* SSE event (see {@link ProfileConsentRequest}); malformed payloads are
|
|
215
|
+
* dropped and leave the field untouched. `reset()` and an app switch clear
|
|
216
|
+
* it back to `null`. Consumers that never render a consent prompt (e.g.
|
|
217
|
+
* Studio) simply ignore this field.
|
|
218
|
+
*/
|
|
219
|
+
profileConsentRequest: ProfileConsentRequest | null;
|
|
220
|
+
/** Dismiss the pending {@link profileConsentRequest} (back to `null`). */
|
|
221
|
+
clearProfileConsentRequest: () => void;
|
|
222
|
+
/**
|
|
223
|
+
* The latest cross-app profile LINK invite the pod asked for on THIS
|
|
224
|
+
* conversation, or `null`. Set from a well-formed `profile-link-needed`
|
|
225
|
+
* SSE event (see {@link ProfileLinkRequest}); malformed payloads are
|
|
226
|
+
* dropped and leave the field untouched. `reset()` and an app switch clear
|
|
227
|
+
* it back to `null`. Consumers that never render a link prompt simply
|
|
228
|
+
* ignore this field. Distinct from {@link profileConsentRequest}: this one
|
|
229
|
+
* invites an UNLINKED byo user to link their account; consent asks an
|
|
230
|
+
* already-linked user to grant an app read/read-write access.
|
|
231
|
+
*/
|
|
232
|
+
profileLinkRequest: ProfileLinkRequest | null;
|
|
233
|
+
/** Dismiss the pending {@link profileLinkRequest} (back to `null`). */
|
|
234
|
+
clearProfileLinkRequest: () => void;
|
|
153
235
|
}
|
package/src/useAgentInvoke.ts
CHANGED
|
@@ -23,13 +23,22 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
25
25
|
import { Reducer, type AgReduceResult } from "@silverprotocol/core";
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
parseConsentRequest,
|
|
28
|
+
parseLinkRequest,
|
|
29
|
+
parseSseEvents,
|
|
30
|
+
reduceAssistantText,
|
|
31
|
+
stringField,
|
|
32
|
+
} from "./sse";
|
|
27
33
|
import { ingestMessageFrame } from "./blocks";
|
|
28
34
|
import type {
|
|
29
35
|
AgentInvokeAdapters,
|
|
36
|
+
AgentInvokeStatus,
|
|
30
37
|
AgentMessage,
|
|
31
38
|
HistoryCard,
|
|
32
39
|
HistoryLoadResult,
|
|
40
|
+
ProfileConsentRequest,
|
|
41
|
+
ProfileLinkRequest,
|
|
33
42
|
UseAgentInvokeOptions,
|
|
34
43
|
UseAgentInvokeReturn,
|
|
35
44
|
} from "./types";
|
|
@@ -63,7 +72,11 @@ export function applyHistoryResult(
|
|
|
63
72
|
export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeReturn {
|
|
64
73
|
const { endpointUrl, appId } = opts;
|
|
65
74
|
const [messages, setMessages] = useState<AgentMessage[]>([]);
|
|
66
|
-
|
|
75
|
+
// Per-turn lifecycle (guuey#91) — derived purely from the frames below; see
|
|
76
|
+
// the `AgentInvokeStatus` doc for the state meanings. `activeTool` carries
|
|
77
|
+
// the wire tool name only while status is 'using-tool'.
|
|
78
|
+
const [status, setStatus] = useState<AgentInvokeStatus>("ready");
|
|
79
|
+
const [activeTool, setActiveTool] = useState<string | null>(null);
|
|
67
80
|
const [error, setError] = useState<string | null>(null);
|
|
68
81
|
const [threadId, setThreadId] = useState<string | null>(null);
|
|
69
82
|
// Opt-in block-preserving transcript. `reduceResult` follows the
|
|
@@ -75,6 +88,15 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
|
|
|
75
88
|
// contract). Independent of the live `reduceResult` fold — populated only
|
|
76
89
|
// when a card-carrying history load seeds the transcript.
|
|
77
90
|
const [historyCards, setHistoryCards] = useState<HistoryCard[]>([]);
|
|
91
|
+
// The pod's latest cross-app profile consent ask on this conversation (T6's
|
|
92
|
+
// `profile-consent-needed` SSE event), or null. Cleared on app switch /
|
|
93
|
+
// reset / explicit dismiss. Consumers with no consent UI just ignore it.
|
|
94
|
+
const [profileConsentRequest, setProfileConsentRequest] = useState<ProfileConsentRequest | null>(null);
|
|
95
|
+
// The pod's latest cross-app profile LINK invite on this conversation (T3's
|
|
96
|
+
// `profile-link-needed` SSE event), or null. Cleared on app switch / reset /
|
|
97
|
+
// explicit dismiss, same lifecycle as `profileConsentRequest` — the two are
|
|
98
|
+
// independent (an unlinked-invite vs an already-linked consent ask).
|
|
99
|
+
const [profileLinkRequest, setProfileLinkRequest] = useState<ProfileLinkRequest | null>(null);
|
|
78
100
|
|
|
79
101
|
const abortRef = useRef<AbortController | null>(null);
|
|
80
102
|
// Mirror the latest threadId + adapters into refs so `send` reads fresh
|
|
@@ -83,9 +105,13 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
|
|
|
83
105
|
const threadIdRef = useRef<string | null>(null);
|
|
84
106
|
const adaptersRef = useRef<AgentInvokeAdapters>(opts.adapters);
|
|
85
107
|
adaptersRef.current = opts.adapters;
|
|
86
|
-
// The per-conversation AgJSON
|
|
108
|
+
// The per-conversation AgJSON fold (only built when `preserveBlocks`).
|
|
87
109
|
// Lazily (re)created on the first valid AgEvent after a fresh start / reset,
|
|
88
110
|
// so an off run never constructs one and a bypass run never allocates.
|
|
111
|
+
// The core Reducer carries `_meta` onto tool-result blocks as of
|
|
112
|
+
// `@silverprotocol/core` 0.4.1 (workspace#9), so BOTH generative-UI channels
|
|
113
|
+
// (MCP-Apps `_meta.ui`, ggui's render bootstrap) survive the fold natively —
|
|
114
|
+
// the old guuey-side `BlockFold` carriage wrapper is deleted.
|
|
89
115
|
const reducerRef = useRef<Reducer | null>(null);
|
|
90
116
|
const preserveBlocksRef = useRef<boolean>(opts.preserveBlocks ?? false);
|
|
91
117
|
preserveBlocksRef.current = opts.preserveBlocks ?? false;
|
|
@@ -105,12 +131,16 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
|
|
|
105
131
|
setThreadId(null);
|
|
106
132
|
setMessages([]);
|
|
107
133
|
setError(null);
|
|
108
|
-
|
|
134
|
+
setStatus("ready");
|
|
135
|
+
setActiveTool(null);
|
|
109
136
|
// Fresh conversation → drop the old fold; the reducer is rebuilt lazily on
|
|
110
137
|
// the next valid AgEvent. Persisted cards are re-seeded below from history.
|
|
111
138
|
reducerRef.current = null;
|
|
112
139
|
setReduceResult(null);
|
|
113
140
|
setHistoryCards([]);
|
|
141
|
+
// A prior app's consent ask must never leak into the new conversation.
|
|
142
|
+
setProfileConsentRequest(null);
|
|
143
|
+
setProfileLinkRequest(null);
|
|
114
144
|
|
|
115
145
|
let cancelled = false;
|
|
116
146
|
const key = threadStorageKey(appId);
|
|
@@ -191,19 +221,30 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
|
|
|
191
221
|
void adaptersRef.current.storage.save(threadStorageKey(appId), "");
|
|
192
222
|
setMessages([]);
|
|
193
223
|
setError(null);
|
|
194
|
-
|
|
224
|
+
setStatus("ready");
|
|
225
|
+
setActiveTool(null);
|
|
195
226
|
// Re-create the reducer for the new conversation (rebuilt lazily on the
|
|
196
227
|
// next valid AgEvent) and clear the exposed fold + any rehydrated cards.
|
|
197
228
|
reducerRef.current = null;
|
|
198
229
|
setReduceResult(null);
|
|
199
230
|
setHistoryCards([]);
|
|
231
|
+
setProfileConsentRequest(null);
|
|
232
|
+
setProfileLinkRequest(null);
|
|
200
233
|
}, [appId]);
|
|
201
234
|
|
|
235
|
+
const clearProfileConsentRequest = useCallback(() => {
|
|
236
|
+
setProfileConsentRequest(null);
|
|
237
|
+
}, []);
|
|
238
|
+
|
|
239
|
+
const clearProfileLinkRequest = useCallback(() => {
|
|
240
|
+
setProfileLinkRequest(null);
|
|
241
|
+
}, []);
|
|
242
|
+
|
|
202
243
|
const send = useCallback(
|
|
203
244
|
async (input: string) => {
|
|
204
|
-
if (!endpointUrl || !input.trim() ||
|
|
245
|
+
if (!endpointUrl || !input.trim() || status !== "ready") return;
|
|
205
246
|
setError(null);
|
|
206
|
-
|
|
247
|
+
setStatus("connecting");
|
|
207
248
|
setMessages((prev) => [...prev, { role: "user", text: input }, { role: "assistant", text: "" }]);
|
|
208
249
|
|
|
209
250
|
const controller = new AbortController();
|
|
@@ -216,7 +257,7 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
|
|
|
216
257
|
await hydrationRef.current;
|
|
217
258
|
}
|
|
218
259
|
if (controller.signal.aborted) {
|
|
219
|
-
|
|
260
|
+
setStatus("ready");
|
|
220
261
|
abortRef.current = null;
|
|
221
262
|
return;
|
|
222
263
|
}
|
|
@@ -250,6 +291,10 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
|
|
|
250
291
|
buffer = rest;
|
|
251
292
|
for (const ev of events) {
|
|
252
293
|
if (ev.event === "session") {
|
|
294
|
+
// The pod is awake and the turn is admitted — 'connecting' ends
|
|
295
|
+
// here (this frame arrives within ~1s of a warm pod; a cold
|
|
296
|
+
// scale-to-zero start is exactly the long 'connecting' phase).
|
|
297
|
+
setStatus("thinking");
|
|
253
298
|
const tid = stringField(ev.data, "threadId");
|
|
254
299
|
if (tid) {
|
|
255
300
|
threadIdRef.current = tid;
|
|
@@ -257,6 +302,26 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
|
|
|
257
302
|
void adapters.storage.save(threadStorageKey(appId), tid);
|
|
258
303
|
}
|
|
259
304
|
} else if (ev.event === "message") {
|
|
305
|
+
// Status derivation (guuey#91) — read the frame's `type` before
|
|
306
|
+
// the text fold. Silver frames announce tools + text explicitly;
|
|
307
|
+
// bypass frames ('text' / 'assistant' SDKMessages) only ever
|
|
308
|
+
// carry assistant text, so they map to 'responding'. Unknown
|
|
309
|
+
// types deliberately leave the status untouched.
|
|
310
|
+
const frameType = stringField(ev.data, "type");
|
|
311
|
+
if (frameType === "tool.start") {
|
|
312
|
+
setStatus("using-tool");
|
|
313
|
+
setActiveTool(stringField(ev.data, "name") ?? null);
|
|
314
|
+
} else if (frameType === "tool.done") {
|
|
315
|
+
setStatus("thinking");
|
|
316
|
+
setActiveTool(null);
|
|
317
|
+
} else if (
|
|
318
|
+
frameType === "text.start" ||
|
|
319
|
+
frameType === "text.delta" ||
|
|
320
|
+
frameType === "text" ||
|
|
321
|
+
frameType === "assistant"
|
|
322
|
+
) {
|
|
323
|
+
setStatus("responding");
|
|
324
|
+
}
|
|
260
325
|
renderAssistant(reduceAssistantText(assistantText, ev.data));
|
|
261
326
|
// Additively fold the SAME frame into the AgJSON reducer when
|
|
262
327
|
// opted in. The text surface above is untouched; only VALID
|
|
@@ -272,8 +337,21 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
|
|
|
272
337
|
}
|
|
273
338
|
} else if (ev.event === "error") {
|
|
274
339
|
setError(stringField(ev.data, "message") ?? "agent error");
|
|
340
|
+
} else if (ev.event === "profile-consent-needed") {
|
|
341
|
+
// Cross-app profile consent ask (T6). Only a well-formed payload
|
|
342
|
+
// updates state; a malformed one is dropped, leaving any prior
|
|
343
|
+
// valid request untouched (never clobbered to null).
|
|
344
|
+
const parsed = parseConsentRequest(ev.data);
|
|
345
|
+
if (parsed) setProfileConsentRequest(parsed);
|
|
346
|
+
} else if (ev.event === "profile-link-needed") {
|
|
347
|
+
// Cross-app profile LINK invite (linkcoh T3) for an unlinked byo
|
|
348
|
+
// caller. Same drop-if-malformed contract as consent above.
|
|
349
|
+
const parsed = parseLinkRequest(ev.data);
|
|
350
|
+
if (parsed) setProfileLinkRequest(parsed);
|
|
275
351
|
}
|
|
276
|
-
// `done` needs no handling — the stream closes after it.
|
|
352
|
+
// `done` needs no handling — the stream closes after it. Any other
|
|
353
|
+
// (unknown) event falls through silently — there is no default
|
|
354
|
+
// branch, so a consumer that never renders a field is unaffected.
|
|
277
355
|
}
|
|
278
356
|
}
|
|
279
357
|
} catch (e) {
|
|
@@ -281,7 +359,8 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
|
|
|
281
359
|
setError(e instanceof Error ? e.message : "failed to reach agent");
|
|
282
360
|
}
|
|
283
361
|
} finally {
|
|
284
|
-
|
|
362
|
+
setStatus("ready");
|
|
363
|
+
setActiveTool(null);
|
|
285
364
|
abortRef.current = null;
|
|
286
365
|
// A turn aborted before any assistant text streamed leaves an empty
|
|
287
366
|
// placeholder bubble — drop it so a stopped turn doesn't linger as a
|
|
@@ -296,8 +375,23 @@ export function useAgentInvoke(opts: UseAgentInvokeOptions): UseAgentInvokeRetur
|
|
|
296
375
|
}
|
|
297
376
|
}
|
|
298
377
|
},
|
|
299
|
-
[endpointUrl, appId,
|
|
378
|
+
[endpointUrl, appId, status],
|
|
300
379
|
);
|
|
301
380
|
|
|
302
|
-
return {
|
|
381
|
+
return {
|
|
382
|
+
messages,
|
|
383
|
+
send,
|
|
384
|
+
status,
|
|
385
|
+
activeTool,
|
|
386
|
+
error,
|
|
387
|
+
threadId,
|
|
388
|
+
abort,
|
|
389
|
+
reset,
|
|
390
|
+
reduceResult,
|
|
391
|
+
historyCards,
|
|
392
|
+
profileConsentRequest,
|
|
393
|
+
clearProfileConsentRequest,
|
|
394
|
+
profileLinkRequest,
|
|
395
|
+
clearProfileLinkRequest,
|
|
396
|
+
};
|
|
303
397
|
}
|