@truefoundry/trueforge-assistant-ui-runtime 0.0.0 → 0.2.0-rc.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 (55) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +201 -0
  3. package/README.md +146 -4
  4. package/dist/chunk-2SQK6TIO.js +104 -0
  5. package/dist/chunk-2SQK6TIO.js.map +1 -0
  6. package/dist/index.d.ts +378 -0
  7. package/dist/index.js +4391 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/server/index.d.ts +1210 -0
  10. package/dist/server/index.js +9 -0
  11. package/dist/server/index.js.map +1 -0
  12. package/package.json +79 -16
  13. package/src/askUserQuestion.ts +38 -0
  14. package/src/attachmentAdapter.ts +63 -0
  15. package/src/collectPending.ts +167 -0
  16. package/src/constants.ts +2 -0
  17. package/src/convertTurnMessages.ts +1679 -0
  18. package/src/createSubAgent.ts +11 -0
  19. package/src/draft/agentSpec.ts +34 -0
  20. package/src/draft/draftSessionBridge.ts +28 -0
  21. package/src/draft/trueforgeDraftThreadListAdapter.ts +73 -0
  22. package/src/draft/useDraftAgentSpec.ts +289 -0
  23. package/src/extractTurnUserText.ts +23 -0
  24. package/src/foldPeerThreads.ts +553 -0
  25. package/src/hooks.ts +176 -0
  26. package/src/index.ts +227 -0
  27. package/src/lastUserMessageText.ts +19 -0
  28. package/src/listPages.ts +19 -0
  29. package/src/loadSessionSnapshot.ts +34 -0
  30. package/src/mcpAuth.ts +35 -0
  31. package/src/messageCustomMetadata.ts +50 -0
  32. package/src/modelMessageContent.ts +149 -0
  33. package/src/modelMessageImageContent.ts +154 -0
  34. package/src/requiredActionInputs.ts +38 -0
  35. package/src/sandboxDownload.ts +33 -0
  36. package/src/server/eventUtils.ts +125 -0
  37. package/src/server/events.ts +232 -0
  38. package/src/server/index.ts +178 -0
  39. package/src/server/types.ts +1191 -0
  40. package/src/sessionListStartTimestamp.ts +6 -0
  41. package/src/sessionSnapshot.ts +146 -0
  42. package/src/sessionThreadMetadata.ts +36 -0
  43. package/src/sessions.ts +17 -0
  44. package/src/streamTurn.ts +118 -0
  45. package/src/toolApproval.ts +413 -0
  46. package/src/toolResponse.ts +346 -0
  47. package/src/trueforgeExtras.ts +223 -0
  48. package/src/trueforgeOwnedSessionsThreadListAdapter.ts +71 -0
  49. package/src/trueforgeThreadListAdapter.ts +69 -0
  50. package/src/turnEventHelpers.ts +71 -0
  51. package/src/turnStreamUpdate.ts +11 -0
  52. package/src/types.ts +84 -0
  53. package/src/useTrueForgeAgentMessages.ts +1138 -0
  54. package/src/useTrueForgeAgentRuntime.ts +308 -0
  55. package/index.js +0 -6
@@ -0,0 +1,11 @@
1
+ import type { ToolCall } from './server/index.js';
2
+
3
+ export function isCreateSubAgentToolCall(toolCall: Pick<ToolCall, 'toolInfo' | 'function'>): boolean {
4
+ // Gateway may attach trueforge-system toolInfo on persisted turns, but streamed
5
+ // model.message tool calls often only carry function.name. Without the fallback,
6
+ // foldPeerThreads never nests child threads under the spawning tool call.
7
+ if (toolCall.toolInfo?.type === 'trueforge-system' && toolCall.toolInfo.name === 'create_sub_agent') {
8
+ return true;
9
+ }
10
+ return toolCall.function.name === 'create_sub_agent';
11
+ }
@@ -0,0 +1,34 @@
1
+ import type { AgentSpec } from '../server/types.js';
2
+
3
+ export interface DraftSession {
4
+ title?: string | null;
5
+ agentSpec: AgentSpec;
6
+ }
7
+
8
+ /** Partial update — host fields flow through when `TSpec` is widened. */
9
+ export type AgentSpecUpdate<TSpec extends AgentSpec = AgentSpec> = {
10
+ [K in keyof TSpec]?: K extends 'model'
11
+ ? Omit<Partial<TSpec['model']>, 'params'> & {
12
+ params?: Partial<NonNullable<TSpec['model']['params']>>;
13
+ }
14
+ : TSpec[K];
15
+ };
16
+
17
+ export function mergeAgentSpec<TSpec extends AgentSpec>(base: TSpec, update: AgentSpecUpdate<TSpec>): TSpec {
18
+ const { model: modelUpdate, ...rest } = update;
19
+
20
+ const next: TSpec = Object.assign({}, base, rest);
21
+
22
+ if (modelUpdate != null) {
23
+ next.model = Object.assign({}, base.model, modelUpdate, {
24
+ name: modelUpdate.name ?? base.model.name,
25
+ params: modelUpdate.params != null ? { ...base.model.params, ...modelUpdate.params } : base.model.params,
26
+ });
27
+ }
28
+
29
+ return next;
30
+ }
31
+
32
+ export function draftSessionTitle(draft: DraftSession): string {
33
+ return draft.title ?? draft.agentSpec.model.name;
34
+ }
@@ -0,0 +1,28 @@
1
+ import type { AgentChatServer, AgentSpec } from '../server/types.js';
2
+
3
+ export const DRAFT_SESSION_LAST_UPDATED_AT_HEADER = 'x-tfy-session-last-updated-at';
4
+
5
+ export interface DraftSessionBridge {
6
+ syncAgentSpec: (draftSessionId: string, agentSpec: AgentSpec) => Promise<string>;
7
+ getDraftAgentSpec: (draftSessionId: string) => Promise<AgentSpec>;
8
+ }
9
+
10
+ export function createDraftSessionBridge(server: AgentChatServer): DraftSessionBridge {
11
+ return {
12
+ async getDraftAgentSpec(draftSessionId) {
13
+ const session = await server.getSession({ sessionId: draftSessionId });
14
+ if (session.agentSpec == null) {
15
+ throw new Error(`Session ${draftSessionId} has no agentSpec (isMutable=${String(session.isMutable)}).`);
16
+ }
17
+ return session.agentSpec;
18
+ },
19
+
20
+ async syncAgentSpec(draftSessionId, agentSpec) {
21
+ const updated = await server.updateSession({
22
+ sessionId: draftSessionId,
23
+ agentSpec,
24
+ });
25
+ return updated.updatedAt;
26
+ },
27
+ };
28
+ }
@@ -0,0 +1,73 @@
1
+ import type { RemoteThreadListAdapter } from '@assistant-ui/core';
2
+
3
+ import type { AgentChatServer, AgentSpec } from '../server/types.js';
4
+ import { sessionListStartTimestamp } from '../sessionListStartTimestamp.js';
5
+ import { sessionDisplayTitle, sessionToThreadMetadata } from '../sessionThreadMetadata.js';
6
+
7
+ const THREAD_LIST_PAGE_SIZE = 20;
8
+
9
+ export function createTrueForgeDraftThreadListAdapter(options: {
10
+ server: AgentChatServer;
11
+ defaultAgentSpec: AgentSpec;
12
+ getAgentSpec?: () => AgentSpec;
13
+ /** When set, filters `listSessions` by this agent id. Omit for all chats. */
14
+ listSessionsAgentId?: string;
15
+ /** Restrict history to sessions created by the authenticated subject. */
16
+ listSessionsCreatedByMe?: boolean;
17
+ }): RemoteThreadListAdapter {
18
+ const { server, defaultAgentSpec, getAgentSpec, listSessionsAgentId, listSessionsCreatedByMe = false } = options;
19
+
20
+ return {
21
+ async list({ after } = {}) {
22
+ const page = await server.listSessions({
23
+ ...(listSessionsAgentId != null ? { agentId: listSessionsAgentId } : {}),
24
+ createdByMe: listSessionsCreatedByMe,
25
+ limit: THREAD_LIST_PAGE_SIZE,
26
+ ...(after == null ? {} : { pageToken: after }),
27
+ startTimestamp: sessionListStartTimestamp(),
28
+ });
29
+ const threads = page.data.map(session =>
30
+ sessionToThreadMetadata(session, sessionDisplayTitle(session, defaultAgentSpec)),
31
+ );
32
+ return {
33
+ threads,
34
+ nextCursor: page.nextPageToken ?? undefined,
35
+ };
36
+ },
37
+
38
+ async initialize() {
39
+ const draft = await server.createSession({
40
+ agentSpec: getAgentSpec?.() ?? defaultAgentSpec,
41
+ });
42
+ return { remoteId: draft.id, externalId: undefined };
43
+ },
44
+
45
+ async fetch(remoteId) {
46
+ const draft = await server.getSession({ sessionId: remoteId });
47
+ return sessionToThreadMetadata(draft, sessionDisplayTitle(draft, defaultAgentSpec));
48
+ },
49
+
50
+ async rename(remoteId, newTitle) {
51
+ if (typeof server.renameSession !== 'function') {
52
+ return;
53
+ }
54
+ await server.renameSession({ sessionId: remoteId, title: newTitle });
55
+ },
56
+ archive() {
57
+ return Promise.resolve();
58
+ },
59
+ unarchive() {
60
+ return Promise.resolve();
61
+ },
62
+ async delete(remoteId) {
63
+ if (typeof server.deleteSession !== 'function') {
64
+ return;
65
+ }
66
+ await server.deleteSession({ sessionId: remoteId });
67
+ },
68
+
69
+ generateTitle() {
70
+ return Promise.resolve(new ReadableStream());
71
+ },
72
+ };
73
+ }
@@ -0,0 +1,289 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+
5
+ import type { AgentSpec } from '../server/types.js';
6
+ import { mergeAgentSpec, type AgentSpecUpdate } from './agentSpec.js';
7
+ import type { DraftSessionBridge } from './draftSessionBridge.js';
8
+
9
+ const SPEC_SYNC_DEBOUNCE_MS = 400;
10
+
11
+ export interface UseDraftAgentSpecOptions {
12
+ draftSessionId: string | undefined;
13
+ draftBridge: DraftSessionBridge | null;
14
+ defaultAgentSpec: AgentSpec;
15
+ onAgentSpecChange?: ((spec: AgentSpec) => void) | undefined;
16
+ onError?: ((error: unknown) => void) | undefined;
17
+ }
18
+
19
+ export interface UseDraftAgentSpecResult {
20
+ agentSpec: AgentSpec | null;
21
+ draftSessionId: string | undefined;
22
+ isSpecLoading: boolean;
23
+ isSpecSyncing: boolean;
24
+ specError: unknown;
25
+ updateAgentSpec: (update: AgentSpecUpdate) => void;
26
+ flushAgentSpec: () => Promise<void>;
27
+ adoptAgentSpec: (request: { agentSpec: AgentSpec; updatedAt?: string }) => void;
28
+ takeTurnHeaderTimestamp: () => Promise<string | undefined>;
29
+ }
30
+
31
+ export function useDraftAgentSpec({
32
+ draftSessionId,
33
+ draftBridge,
34
+ defaultAgentSpec,
35
+ onAgentSpecChange,
36
+ onError,
37
+ }: UseDraftAgentSpecOptions): UseDraftAgentSpecResult {
38
+ const enabled = draftBridge != null;
39
+ const [agentSpec, setAgentSpec] = useState<AgentSpec>(defaultAgentSpec);
40
+ const [isSpecLoading, setIsSpecLoading] = useState(false);
41
+ const [isSpecSyncing, setIsSpecSyncing] = useState(false);
42
+ const [specError, setSpecError] = useState<unknown>(null);
43
+
44
+ const agentSpecRef = useRef(agentSpec);
45
+ agentSpecRef.current = agentSpec;
46
+
47
+ const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
48
+ const syncGenerationRef = useRef(0);
49
+ const loadedDraftIdRef = useRef<string | undefined>(undefined);
50
+ const localDirtyRef = useRef(false);
51
+ const lastUpdatedAtRef = useRef<string | undefined>(undefined);
52
+ const pendingFlushRef = useRef<(() => Promise<void>) | undefined>(undefined);
53
+ const inFlightFlushRef = useRef<Promise<void> | undefined>(undefined);
54
+ const activeDraftIdRef = useRef(draftSessionId);
55
+
56
+ // Invalidate sync state from the previous draft so a stale pending flush or
57
+ // stored updatedAt can't update the wrong draft or leak into the next
58
+ // turn's header. Bumping the generation makes any in-flight sync a no-op.
59
+ useEffect(() => {
60
+ const previousDraftId = activeDraftIdRef.current;
61
+ if (previousDraftId === draftSessionId) {
62
+ return;
63
+ }
64
+ activeDraftIdRef.current = draftSessionId;
65
+ syncGenerationRef.current++;
66
+ if (syncTimeoutRef.current != null) {
67
+ clearTimeout(syncTimeoutRef.current);
68
+ syncTimeoutRef.current = undefined;
69
+ }
70
+ pendingFlushRef.current = undefined;
71
+ inFlightFlushRef.current = undefined;
72
+ lastUpdatedAtRef.current = undefined;
73
+ setIsSpecSyncing(false);
74
+ // Dirty edits made against a previous draft must not be replayed onto
75
+ // the new one. Keep them only for lazy creation (undefined -> id).
76
+ if (previousDraftId != null) {
77
+ localDirtyRef.current = false;
78
+ }
79
+ }, [draftSessionId]);
80
+
81
+ useEffect(() => {
82
+ if (draftBridge == null) {
83
+ return;
84
+ }
85
+ if (draftSessionId == null) {
86
+ loadedDraftIdRef.current = undefined;
87
+ setAgentSpec(defaultAgentSpec);
88
+ localDirtyRef.current = false;
89
+ setSpecError(null);
90
+ setIsSpecLoading(false);
91
+ return;
92
+ }
93
+
94
+ if (loadedDraftIdRef.current === draftSessionId) {
95
+ return;
96
+ }
97
+
98
+ const abortController = new AbortController();
99
+ setIsSpecLoading(true);
100
+ void (async () => {
101
+ try {
102
+ const loaded = await draftBridge.getDraftAgentSpec(draftSessionId);
103
+ if (abortController.signal.aborted) {
104
+ return;
105
+ }
106
+ loadedDraftIdRef.current = draftSessionId;
107
+
108
+ if (localDirtyRef.current) {
109
+ scheduleSpecSyncRef.current(draftSessionId, agentSpecRef.current);
110
+ localDirtyRef.current = false;
111
+ setSpecError(null);
112
+ setIsSpecLoading(false);
113
+ return;
114
+ }
115
+
116
+ setAgentSpec(loaded);
117
+ setSpecError(null);
118
+ setIsSpecLoading(false);
119
+ } catch (error) {
120
+ if (!abortController.signal.aborted) {
121
+ onError?.(error);
122
+ setSpecError(error);
123
+ setIsSpecLoading(false);
124
+ }
125
+ }
126
+ })();
127
+
128
+ return () => {
129
+ abortController.abort();
130
+ // The cancelled load can no longer clear the flag itself. Releasing it
131
+ // here keeps it from sticking when the next run early-returns on an
132
+ // already-loaded draft; a run that starts a fresh load re-raises it in
133
+ // the same commit.
134
+ setIsSpecLoading(false);
135
+ };
136
+ }, [defaultAgentSpec, draftBridge, draftSessionId, enabled, onError]);
137
+
138
+ const flushSpecSync = useCallback(
139
+ async (draftId: string, spec: AgentSpec, generation: number) => {
140
+ if (draftBridge == null) {
141
+ return;
142
+ }
143
+ setIsSpecSyncing(true);
144
+ try {
145
+ const updatedAt = await draftBridge.syncAgentSpec(draftId, spec);
146
+ if (generation !== syncGenerationRef.current) {
147
+ return;
148
+ }
149
+ lastUpdatedAtRef.current = updatedAt || new Date().toISOString();
150
+ setSpecError(null);
151
+ onAgentSpecChange?.(spec);
152
+ } catch (error) {
153
+ if (generation === syncGenerationRef.current) {
154
+ setSpecError(error);
155
+ onError?.(error);
156
+ }
157
+ } finally {
158
+ if (generation === syncGenerationRef.current) {
159
+ setIsSpecSyncing(false);
160
+ }
161
+ }
162
+ },
163
+ [draftBridge, onAgentSpecChange, onError],
164
+ );
165
+
166
+ const scheduleSpecSync = useCallback(
167
+ (draftId: string, spec: AgentSpec) => {
168
+ if (syncTimeoutRef.current != null) {
169
+ clearTimeout(syncTimeoutRef.current);
170
+ }
171
+ const generation = ++syncGenerationRef.current;
172
+ const flush = () => {
173
+ pendingFlushRef.current = undefined;
174
+ syncTimeoutRef.current = undefined;
175
+ const promise = flushSpecSync(draftId, spec, generation).finally(() => {
176
+ if (inFlightFlushRef.current === promise) {
177
+ inFlightFlushRef.current = undefined;
178
+ }
179
+ });
180
+ inFlightFlushRef.current = promise;
181
+ return promise;
182
+ };
183
+ pendingFlushRef.current = flush;
184
+ syncTimeoutRef.current = setTimeout(() => {
185
+ void flush();
186
+ }, SPEC_SYNC_DEBOUNCE_MS);
187
+ },
188
+ [flushSpecSync],
189
+ );
190
+
191
+ const scheduleSpecSyncRef = useRef(scheduleSpecSync);
192
+ scheduleSpecSyncRef.current = scheduleSpecSync;
193
+
194
+ const flushPendingSpecSyncNow = useCallback(async () => {
195
+ if (syncTimeoutRef.current != null) {
196
+ clearTimeout(syncTimeoutRef.current);
197
+ syncTimeoutRef.current = undefined;
198
+ }
199
+ const pending = pendingFlushRef.current;
200
+ if (pending != null) {
201
+ pendingFlushRef.current = undefined;
202
+ await pending();
203
+ return;
204
+ }
205
+ if (inFlightFlushRef.current != null) {
206
+ await inFlightFlushRef.current;
207
+ }
208
+ }, []);
209
+
210
+ const adoptAgentSpec = useCallback(
211
+ ({ agentSpec: persistedSpec, updatedAt }: { agentSpec: AgentSpec; updatedAt?: string }) => {
212
+ if (syncTimeoutRef.current != null) {
213
+ clearTimeout(syncTimeoutRef.current);
214
+ syncTimeoutRef.current = undefined;
215
+ }
216
+ syncGenerationRef.current++;
217
+ pendingFlushRef.current = undefined;
218
+ inFlightFlushRef.current = undefined;
219
+ localDirtyRef.current = false;
220
+ // Keep the last successful sync timestamp when the caller omits
221
+ // updatedAt (e.g. a save that did not return sessionUpdatedAt).
222
+ if (updatedAt !== undefined) {
223
+ lastUpdatedAtRef.current = updatedAt;
224
+ }
225
+ agentSpecRef.current = persistedSpec;
226
+ setAgentSpec(persistedSpec);
227
+ setSpecError(null);
228
+ setIsSpecSyncing(false);
229
+ },
230
+ [],
231
+ );
232
+
233
+ const takeTurnHeaderTimestamp = useCallback(async () => {
234
+ await flushPendingSpecSyncNow();
235
+ const updatedAt = lastUpdatedAtRef.current;
236
+ lastUpdatedAtRef.current = undefined;
237
+ return updatedAt;
238
+ }, [flushPendingSpecSyncNow]);
239
+
240
+ useEffect(
241
+ () => () => {
242
+ if (syncTimeoutRef.current != null) {
243
+ clearTimeout(syncTimeoutRef.current);
244
+ }
245
+ },
246
+ [],
247
+ );
248
+
249
+ const updateAgentSpec = useCallback(
250
+ (update: AgentSpecUpdate) => {
251
+ if (draftBridge == null) {
252
+ return;
253
+ }
254
+ const next = mergeAgentSpec(agentSpecRef.current, update);
255
+ setAgentSpec(next);
256
+ localDirtyRef.current = true;
257
+ if (draftSessionId != null) {
258
+ scheduleSpecSync(draftSessionId, next);
259
+ }
260
+ },
261
+ [draftBridge, draftSessionId, enabled, scheduleSpecSync],
262
+ );
263
+
264
+ return useMemo(
265
+ () => ({
266
+ agentSpec: enabled ? agentSpec : null,
267
+ draftSessionId: enabled ? draftSessionId : undefined,
268
+ isSpecLoading: enabled ? isSpecLoading : false,
269
+ isSpecSyncing: enabled ? isSpecSyncing : false,
270
+ specError: enabled ? specError : null,
271
+ updateAgentSpec,
272
+ flushAgentSpec: flushPendingSpecSyncNow,
273
+ adoptAgentSpec,
274
+ takeTurnHeaderTimestamp,
275
+ }),
276
+ [
277
+ agentSpec,
278
+ draftSessionId,
279
+ enabled,
280
+ isSpecLoading,
281
+ isSpecSyncing,
282
+ specError,
283
+ flushPendingSpecSyncNow,
284
+ adoptAgentSpec,
285
+ takeTurnHeaderTimestamp,
286
+ updateAgentSpec,
287
+ ],
288
+ );
289
+ }
@@ -0,0 +1,23 @@
1
+ import type { Turn } from './server/index.js';
2
+
3
+ export function extractTurnUserText(input: Turn['input']): string | undefined {
4
+ const parts: string[] = [];
5
+ let hasUserMessage = false;
6
+ for (const item of input ?? []) {
7
+ if (item.type !== 'user.message') {
8
+ continue;
9
+ }
10
+ hasUserMessage = true;
11
+ const { content } = item;
12
+ if (typeof content === 'string') {
13
+ parts.push(content);
14
+ continue;
15
+ }
16
+ for (const part of content) {
17
+ if (part.type === 'text') {
18
+ parts.push(part.text);
19
+ }
20
+ }
21
+ }
22
+ return hasUserMessage ? parts.join('\n').trim() : undefined;
23
+ }