@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,346 @@
1
+ import type {
2
+ MessageStatus,
3
+ ThreadAssistantMessage,
4
+ ThreadAssistantMessagePart,
5
+ ThreadMessage,
6
+ } from '@assistant-ui/core';
7
+ import type { ToolResponseRequiredEvent, Turn, TurnInputItem, UserToolResponseEvent } from './server/index.js';
8
+
9
+ import { ROOT_THREAD_ID } from './constants.js';
10
+ import { recordToolApprovalInFold, recordToolResponseInFold, type PeerThreadFoldState } from './foldPeerThreads.js';
11
+ import type { ToolResponseMessageCustomMetadata } from './messageCustomMetadata.js';
12
+ import type { TurnStreamUpdate } from './turnStreamUpdate.js';
13
+
14
+ export { ROOT_THREAD_ID } from './constants.js';
15
+
16
+ export const TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY = 'toolResponseThreadId';
17
+
18
+ export interface AskUserQuestionInterruptPayload {
19
+ question?: string;
20
+ options?: string[];
21
+ }
22
+
23
+ export type StoredToolResponse = Pick<UserToolResponseEvent, 'content'>;
24
+
25
+ export type RespondToToolResponseOptions = Pick<UserToolResponseEvent, 'toolCallId' | 'content'>;
26
+
27
+ type ToolCallPart = Extract<ThreadMessage['content'][number], { type: 'tool-call' }>;
28
+
29
+ type AssistantToolCallPart = Extract<ThreadAssistantMessagePart, { type: 'tool-call' }>;
30
+
31
+ export function hasPendingToolResponse(part: Pick<ToolCallPart, 'interrupt' | 'result'>): boolean {
32
+ return part.interrupt != null && part.result === undefined;
33
+ }
34
+
35
+ function isStagedResponseAwaitingSdk(part: ToolCallPart): boolean {
36
+ return part.interrupt != null && part.result !== undefined;
37
+ }
38
+
39
+ export function toolResponseStatus(): MessageStatus {
40
+ return { type: 'requires-action', reason: 'tool-calls' };
41
+ }
42
+
43
+ export function toolResponseMessageCustom(threadId: string): ToolResponseMessageCustomMetadata {
44
+ return {
45
+ [TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY]: threadId === ROOT_THREAD_ID ? ROOT_THREAD_ID : threadId,
46
+ };
47
+ }
48
+
49
+ export function getToolResponseThreadId(message: ThreadMessage | undefined): string | undefined {
50
+ if (message?.role !== 'assistant') {
51
+ return undefined;
52
+ }
53
+ const threadId = message.metadata.custom[TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY];
54
+ return typeof threadId === 'string' ? threadId : undefined;
55
+ }
56
+
57
+ export function findResponseRequiredInTurn(turn: Pick<Turn, 'state'>): ToolResponseRequiredEvent | undefined {
58
+ if (turn.state.status !== 'done') {
59
+ return undefined;
60
+ }
61
+ const found = turn.state.requiredActions?.find(action => action.type === 'tool.response_required');
62
+ return found?.type === 'tool.response_required' ? found : undefined;
63
+ }
64
+
65
+ function applyToolResponseToToolCall(part: AssistantToolCallPart, content: string): AssistantToolCallPart {
66
+ return { ...part, result: content };
67
+ }
68
+
69
+ function updateToolResponseInContent(
70
+ content: readonly ThreadAssistantMessagePart[],
71
+ options: RespondToToolResponseOptions,
72
+ ): { content: readonly ThreadAssistantMessagePart[]; found: boolean } {
73
+ let found = false;
74
+ const newContent = content.map(part => {
75
+ if (part.type !== 'tool-call') {
76
+ return part;
77
+ }
78
+
79
+ if (part.toolCallId === options.toolCallId && hasPendingToolResponse(part)) {
80
+ found = true;
81
+ return applyToolResponseToToolCall(part, options.content);
82
+ }
83
+
84
+ if (part.messages == null) {
85
+ return part;
86
+ }
87
+
88
+ const messages = part.messages.map(message => {
89
+ if (message.role !== 'assistant') {
90
+ return message;
91
+ }
92
+ const nested = updateToolResponseInContent(message.content, options);
93
+ if (!nested.found) {
94
+ return message;
95
+ }
96
+ found = true;
97
+ return { ...message, content: nested.content };
98
+ });
99
+ return { ...part, messages };
100
+ });
101
+
102
+ return { content: newContent, found };
103
+ }
104
+
105
+ export function applyToolResponseToMessage(
106
+ message: ThreadAssistantMessage,
107
+ options: RespondToToolResponseOptions,
108
+ ): ThreadAssistantMessage {
109
+ const { content } = updateToolResponseInContent(message.content, options);
110
+ return { ...message, content: [...content] };
111
+ }
112
+
113
+ function nestedMessagesHavePendingResponses(messages: readonly ThreadMessage[]): boolean {
114
+ for (const message of messages) {
115
+ if (messageHasPendingResponses(message)) {
116
+ return true;
117
+ }
118
+ }
119
+ return false;
120
+ }
121
+
122
+ export function messageHasPendingResponses(message: ThreadMessage | undefined): boolean {
123
+ if (message?.role !== 'assistant') {
124
+ return false;
125
+ }
126
+ for (const part of message.content) {
127
+ if (part.type !== 'tool-call') {
128
+ continue;
129
+ }
130
+ if (hasPendingToolResponse(part)) {
131
+ return true;
132
+ }
133
+ if (part.messages != null && nestedMessagesHavePendingResponses(part.messages)) {
134
+ return true;
135
+ }
136
+ }
137
+ return false;
138
+ }
139
+
140
+ function collectResponseInputsFromMessages(
141
+ messages: readonly ThreadMessage[],
142
+ defaultThreadId: string,
143
+ ): UserToolResponseEvent[] {
144
+ const events: UserToolResponseEvent[] = [];
145
+ for (const message of messages) {
146
+ events.push(...collectResponseInputs(message, defaultThreadId));
147
+ }
148
+ return events;
149
+ }
150
+
151
+ export function collectResponseInputs(message: ThreadMessage, threadId: string): UserToolResponseEvent[] {
152
+ if (message.role !== 'assistant' || !threadId) {
153
+ return [];
154
+ }
155
+ if (messageHasPendingResponses(message)) {
156
+ return [];
157
+ }
158
+
159
+ const scopedThreadId = getToolResponseThreadId(message) ?? threadId;
160
+ const events: UserToolResponseEvent[] = [];
161
+
162
+ for (const part of message.content) {
163
+ if (part.type !== 'tool-call') {
164
+ continue;
165
+ }
166
+ if (isStagedResponseAwaitingSdk(part)) {
167
+ events.push({
168
+ type: 'user.tool_response',
169
+ threadId: scopedThreadId,
170
+ toolCallId: part.toolCallId,
171
+ content: String(part.result),
172
+ });
173
+ }
174
+ if (part.messages != null) {
175
+ events.push(...collectResponseInputsFromMessages(part.messages, scopedThreadId));
176
+ }
177
+ }
178
+ return events;
179
+ }
180
+
181
+ function contentHasPendingResponses(content: readonly ThreadAssistantMessagePart[]): boolean {
182
+ return messageHasPendingResponses({
183
+ id: 'pending-check',
184
+ role: 'assistant',
185
+ content,
186
+ status: { type: 'complete', reason: 'stop' },
187
+ createdAt: new Date(),
188
+ metadata: {
189
+ unstable_state: null,
190
+ unstable_annotations: [],
191
+ unstable_data: [],
192
+ steps: [],
193
+ custom: {},
194
+ },
195
+ });
196
+ }
197
+
198
+ export function resolveToolResponseUpdate(update: TurnStreamUpdate): TurnStreamUpdate {
199
+ if (
200
+ contentHasPendingResponses(update.content) ||
201
+ update.status?.type !== 'requires-action' ||
202
+ update.status.reason !== 'tool-calls'
203
+ ) {
204
+ return update;
205
+ }
206
+
207
+ return { content: update.content };
208
+ }
209
+
210
+ function walkAssistantToolCallParts(
211
+ content: readonly ThreadAssistantMessagePart[],
212
+ visit: (part: AssistantToolCallPart) => void,
213
+ ): void {
214
+ for (const part of content) {
215
+ if (part.type !== 'tool-call') {
216
+ continue;
217
+ }
218
+ visit(part);
219
+ if (part.messages == null) {
220
+ continue;
221
+ }
222
+ for (const message of part.messages) {
223
+ if (message.role === 'assistant') {
224
+ walkAssistantToolCallParts(message.content, visit);
225
+ }
226
+ }
227
+ }
228
+ }
229
+
230
+ function collectStagedResponsesFromContent(content: readonly ThreadAssistantMessagePart[]): Map<string, string> {
231
+ const staged = new Map<string, string>();
232
+ walkAssistantToolCallParts(content, part => {
233
+ if (isStagedResponseAwaitingSdk(part)) {
234
+ staged.set(part.toolCallId, String(part.result));
235
+ }
236
+ });
237
+ return staged;
238
+ }
239
+
240
+ function applyStagedResponsesToContentMap(
241
+ content: readonly ThreadAssistantMessagePart[],
242
+ staged: ReadonlyMap<string, string>,
243
+ ): ThreadAssistantMessagePart[] {
244
+ return content.map(part => {
245
+ if (part.type !== 'tool-call') {
246
+ return part;
247
+ }
248
+
249
+ const contentValue = staged.get(part.toolCallId);
250
+ let nextPart: AssistantToolCallPart = part;
251
+ if (contentValue != null && hasPendingToolResponse(part)) {
252
+ nextPart = applyToolResponseToToolCall(part, contentValue);
253
+ }
254
+
255
+ if (nextPart.messages == null) {
256
+ return nextPart;
257
+ }
258
+
259
+ return {
260
+ ...nextPart,
261
+ messages: nextPart.messages.map(message => {
262
+ if (message.role !== 'assistant') {
263
+ return message;
264
+ }
265
+ return {
266
+ ...message,
267
+ content: applyStagedResponsesToContentMap(message.content, staged),
268
+ };
269
+ }),
270
+ };
271
+ });
272
+ }
273
+
274
+ export function mergeStagedResponsesIntoContent(
275
+ incoming: readonly ThreadAssistantMessagePart[],
276
+ existing: readonly ThreadAssistantMessagePart[],
277
+ ): ThreadAssistantMessagePart[] {
278
+ const staged = collectStagedResponsesFromContent(existing);
279
+ if (staged.size === 0) {
280
+ return [...incoming];
281
+ }
282
+ return applyStagedResponsesToContentMap(incoming, staged);
283
+ }
284
+
285
+ export function extractToolResponsesFromTurnInput(input: Turn['input'] | undefined): UserToolResponseEvent[] {
286
+ const events: UserToolResponseEvent[] = [];
287
+ for (const item of input ?? []) {
288
+ if (item.type === 'user.tool_response') {
289
+ events.push(item);
290
+ }
291
+ }
292
+ return events;
293
+ }
294
+
295
+ export function applyUserToolResponsesToFold(fold: PeerThreadFoldState, inputs: readonly TurnInputItem[]): void {
296
+ for (const item of inputs) {
297
+ if (item.type === 'user.tool_response') {
298
+ recordToolResponseInFold(fold, {
299
+ toolCallId: item.toolCallId,
300
+ content: item.content,
301
+ });
302
+ } else if (item.type === 'user.tool_approval') {
303
+ recordToolApprovalInFold(fold, {
304
+ toolCallId: item.toolCallId,
305
+ approved: item.approval.status === 'allow',
306
+ ...(item.approval.status === 'deny' && item.approval.reason != null ? { reason: item.approval.reason } : {}),
307
+ });
308
+ }
309
+ }
310
+ }
311
+
312
+ export function collectSubsequentToolResponses(
313
+ turns: readonly Pick<Turn, 'input'>[],
314
+ fromIndex: number,
315
+ ): Map<string, StoredToolResponse> {
316
+ const responses = new Map<string, StoredToolResponse>();
317
+
318
+ for (let index = fromIndex + 1; index < turns.length; index++) {
319
+ const input = turns[index]?.input ?? [];
320
+ if (input.some(item => item.type === 'user.message')) {
321
+ break;
322
+ }
323
+
324
+ for (const event of extractToolResponsesFromTurnInput(input)) {
325
+ responses.set(event.toolCallId, { content: event.content });
326
+ }
327
+ }
328
+
329
+ return responses;
330
+ }
331
+
332
+ export function collectToolResponsesFromTurnInput(input: Turn['input'] | undefined): Map<string, StoredToolResponse> {
333
+ const responses = new Map<string, StoredToolResponse>();
334
+ for (const event of extractToolResponsesFromTurnInput(input)) {
335
+ responses.set(event.toolCallId, { content: event.content });
336
+ }
337
+ return responses;
338
+ }
339
+
340
+ export function applyStagedResponsesToContent(
341
+ content: readonly ThreadAssistantMessagePart[],
342
+ responses: ReadonlyMap<string, StoredToolResponse>,
343
+ ): ThreadAssistantMessagePart[] {
344
+ const staged = new Map([...responses.entries()].map(([toolCallId, value]) => [toolCallId, value.content]));
345
+ return applyStagedResponsesToContentMap(content, staged);
346
+ }
@@ -0,0 +1,223 @@
1
+ 'use client';
2
+
3
+ import type { AssistantClient } from '@assistant-ui/store';
4
+ import { useAui } from '@assistant-ui/store';
5
+ import { useCallback, useSyncExternalStore } from 'react';
6
+ import type { McpAuthRequiredEvent } from './server/index.js';
7
+
8
+ import type { PendingApproval, PendingToolResponse } from './collectPending.js';
9
+ import type { AgentSpecUpdate } from './draft/agentSpec.js';
10
+ import type { AgentSpec } from './server/types.js';
11
+ import type { RespondToToolApprovalOptions } from './toolApproval.js';
12
+ import type { RespondToToolResponseOptions } from './toolResponse.js';
13
+
14
+ export type { PendingApproval, PendingToolResponse };
15
+
16
+ export interface TrueForgeDraftRuntimeExtras {
17
+ agentSpec: AgentSpec | null;
18
+ draftSessionId: string | undefined;
19
+ isSpecLoading: boolean;
20
+ isSpecSyncing: boolean;
21
+ specError: unknown;
22
+ updateAgentSpec: (update: AgentSpecUpdate) => void;
23
+ flushAgentSpec: () => Promise<void>;
24
+ adoptAgentSpec: (request: { agentSpec: AgentSpec; updatedAt?: string }) => void;
25
+ }
26
+
27
+ export interface TrueForgeRuntimeExtras {
28
+ pendingApprovals: PendingApproval[];
29
+ pendingToolResponses: PendingToolResponse[];
30
+ pendingMcpAuth: { mcpServers: McpAuthRequiredEvent['mcpServers'] } | null;
31
+ resumeUnavailable: boolean;
32
+ sandboxId: string | undefined;
33
+ respondToToolApproval: (response: RespondToToolApprovalOptions) => void;
34
+ respondToToolResponse: (response: RespondToToolResponseOptions) => void;
35
+ resumeMcpAuth: () => Promise<void>;
36
+ downloadSandboxFile: (req: { turnId: string; path: string }) => Promise<Blob>;
37
+ cancel: () => Promise<void>;
38
+ resetFromTurn: (turnId: string) => Promise<void>;
39
+ reload: () => void;
40
+ hasOlderHistory: boolean;
41
+ isLoadingOlderHistory: boolean;
42
+ loadOlderHistory: () => Promise<void>;
43
+ draft: TrueForgeDraftRuntimeExtras | null;
44
+ }
45
+
46
+ const extrasBrandSymbol = Symbol('useTrueForgeAgentRuntime extras');
47
+
48
+ const isTrueForgeExtras = (extras: unknown): extras is TrueForgeRuntimeExtras =>
49
+ typeof extras === 'object' && extras !== null && extrasBrandSymbol in extras;
50
+
51
+ const extrasBrand = {
52
+ provide: (value: TrueForgeRuntimeExtras): TrueForgeRuntimeExtras => {
53
+ Object.defineProperty(value, extrasBrandSymbol, {
54
+ value: true,
55
+ enumerable: false,
56
+ configurable: true,
57
+ });
58
+ return value;
59
+ },
60
+ is: isTrueForgeExtras,
61
+ tryGet: (extras: unknown): TrueForgeRuntimeExtras | undefined => (isTrueForgeExtras(extras) ? extras : undefined),
62
+ };
63
+
64
+ export const EMPTY_DRAFT_EXTRAS: TrueForgeDraftRuntimeExtras = {
65
+ agentSpec: null,
66
+ draftSessionId: undefined,
67
+ isSpecLoading: false,
68
+ isSpecSyncing: false,
69
+ specError: null,
70
+ updateAgentSpec: () => {
71
+ throw new Error('Draft agent extras are only available in draft mode.');
72
+ },
73
+ flushAgentSpec: () => Promise.reject(new Error('Draft agent extras are only available in draft mode.')),
74
+ adoptAgentSpec: () => {
75
+ throw new Error('Draft agent extras are only available in draft mode.');
76
+ },
77
+ };
78
+
79
+ type SubscribeFn = (onStoreChange: () => void) => () => void;
80
+
81
+ function isThreadAccessor(value: unknown): value is (this: object) => unknown {
82
+ return typeof value === 'function';
83
+ }
84
+
85
+ function isGetState(value: unknown): value is (this: object) => unknown {
86
+ return typeof value === 'function';
87
+ }
88
+
89
+ function isSubscribe(value: unknown): value is (this: object, onStoreChange: () => void) => unknown {
90
+ return typeof value === 'function';
91
+ }
92
+
93
+ /**
94
+ * Walk `Object.create(parent)` AUI clients. Stops before leaving the chain.
95
+ * Callers must try/catch RootAssistantClient proxy gets (it throws on any
96
+ * missing accessor such as `subscribe` / `thread`).
97
+ */
98
+ function walkAssistantClientAncestors(client: AssistantClient, visit: (current: object) => 'continue' | 'stop'): void {
99
+ let current: object | null = client;
100
+ const seen = new Set<object>();
101
+ while (!seen.has(current)) {
102
+ seen.add(current);
103
+ if (visit(current) === 'stop') {
104
+ return;
105
+ }
106
+ const parent = Reflect.getPrototypeOf(current);
107
+ if (parent == null || parent === Object.prototype) {
108
+ return;
109
+ }
110
+ current = parent;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * `PartPrimitive.Messages` wraps sub-agent threads in `ReadonlyThreadProvider`,
116
+ * which shadows `thread` (and therefore `thread.extras`) with a readonly client
117
+ * that has no TrueForge extras. Nested AUI clients are `Object.create(parent)`,
118
+ * so walk the prototype chain to reach the root runtime extras.
119
+ */
120
+ export function tryGetTrueForgeExtras(client: AssistantClient): TrueForgeRuntimeExtras | undefined {
121
+ let found: TrueForgeRuntimeExtras | undefined;
122
+ walkAssistantClientAncestors(client, current => {
123
+ try {
124
+ const thread: unknown = Reflect.get(current, 'thread');
125
+ if (isThreadAccessor(thread)) {
126
+ const threadClient: unknown = Reflect.apply(thread, current, []);
127
+ if (threadClient == null || typeof threadClient !== 'object') {
128
+ return 'continue';
129
+ }
130
+ const getState: unknown = Reflect.get(threadClient, 'getState');
131
+ if (!isGetState(getState)) {
132
+ return 'continue';
133
+ }
134
+ const state: unknown = Reflect.apply(getState, threadClient, []);
135
+ const extras = extrasBrand.tryGet(
136
+ state != null && typeof state === 'object' ? Reflect.get(state, 'extras') : undefined,
137
+ );
138
+ if (extras != null) {
139
+ found = extras;
140
+ return 'stop';
141
+ }
142
+ }
143
+ } catch {
144
+ // Nested/readonly clients may lack thread; RootAssistantClient proxy
145
+ // throws on missing scope accessors ("thread" / "subscribe").
146
+ }
147
+ return 'continue';
148
+ });
149
+ return found;
150
+ }
151
+
152
+ export function getTrueForgeExtras(client: AssistantClient): TrueForgeRuntimeExtras {
153
+ const extras = tryGetTrueForgeExtras(client);
154
+ if (extras == null) {
155
+ throw new Error('The current thread is not backed by the useTrueForgeAgentRuntime runtime.');
156
+ }
157
+ return extras;
158
+ }
159
+
160
+ function subscribeClientChain(client: AssistantClient): SubscribeFn {
161
+ return onStoreChange => {
162
+ const unsubs: (() => void)[] = [];
163
+ walkAssistantClientAncestors(client, current => {
164
+ try {
165
+ const subscribe: unknown = Reflect.get(current, 'subscribe');
166
+ if (isSubscribe(subscribe)) {
167
+ const unsubscribe: unknown = Reflect.apply(subscribe, current, [onStoreChange]);
168
+ if (typeof unsubscribe === 'function') {
169
+ unsubs.push(() => {
170
+ Reflect.apply(unsubscribe, undefined, []);
171
+ });
172
+ }
173
+ }
174
+ return 'continue';
175
+ } catch {
176
+ // RootAssistantClient proxy — no further usable ancestors.
177
+ return 'stop';
178
+ }
179
+ });
180
+ return () => {
181
+ for (const unsub of unsubs) {
182
+ unsub();
183
+ }
184
+ };
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Resolves TrueForge extras from the nearest ancestor runtime, including
190
+ * inside nested readonly sub-agent renderers (`PartPrimitive.Messages`).
191
+ */
192
+ export function useTrueForgeRuntimeExtras(): TrueForgeRuntimeExtras | undefined {
193
+ const aui = useAui();
194
+ const subscribe = useCallback(subscribeClientChain(aui), [aui]);
195
+ const getSnapshot = useCallback(() => tryGetTrueForgeExtras(aui), [aui]);
196
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
197
+ }
198
+
199
+ function useTrueForgeExtrasApi(): TrueForgeRuntimeExtras;
200
+ function useTrueForgeExtrasApi<S>(select: (extras: TrueForgeRuntimeExtras) => S, fallback?: S): S;
201
+ function useTrueForgeExtrasApi<S>(select?: (extras: TrueForgeRuntimeExtras) => S, fallback?: S): unknown {
202
+ const extras = useTrueForgeRuntimeExtras();
203
+ const hasFallback = arguments.length >= 2;
204
+ if (extras == null) {
205
+ if (hasFallback) {
206
+ return fallback;
207
+ }
208
+ throw new Error('The current thread is not backed by the useTrueForgeAgentRuntime runtime.');
209
+ }
210
+ return select != null ? select(extras) : extras;
211
+ }
212
+
213
+ /**
214
+ * Brand + provide/tryGet from assistant-ui; get/use walk ancestor AUI clients so
215
+ * nested readonly sub-agent threads still resolve root TrueForge extras.
216
+ */
217
+ export const trueForgeExtras = {
218
+ provide: extrasBrand.provide,
219
+ is: extrasBrand.is,
220
+ tryGet: extrasBrand.tryGet,
221
+ get: getTrueForgeExtras,
222
+ use: useTrueForgeExtrasApi,
223
+ };
@@ -0,0 +1,71 @@
1
+ import type { RemoteThreadListAdapter } from '@assistant-ui/core';
2
+
3
+ import type { AgentChatServer } 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
+ /**
10
+ * Read-only thread-list adapter backed by `AgentChatServer.listSessions`.
11
+ */
12
+ export function createTrueForgeOwnedSessionsThreadListAdapter(options: {
13
+ server: AgentChatServer;
14
+ /** When set, filters `listSessions` by this agent id. Omit for all chats. */
15
+ listSessionsAgentId?: string;
16
+ /** Restrict history to sessions created by the authenticated subject. */
17
+ listSessionsCreatedByMe?: boolean;
18
+ }): RemoteThreadListAdapter {
19
+ const { server, listSessionsAgentId, listSessionsCreatedByMe = false } = options;
20
+
21
+ return {
22
+ async list({ after } = {}) {
23
+ const page = await server.listSessions({
24
+ ...(listSessionsAgentId != null ? { agentId: listSessionsAgentId } : {}),
25
+ createdByMe: listSessionsCreatedByMe,
26
+ limit: THREAD_LIST_PAGE_SIZE,
27
+ ...(after == null ? {} : { pageToken: after }),
28
+ startTimestamp: sessionListStartTimestamp(),
29
+ });
30
+ const threads = page.data.map(session => sessionToThreadMetadata(session, sessionDisplayTitle(session)));
31
+ return {
32
+ threads,
33
+ nextCursor: page.nextPageToken ?? undefined,
34
+ };
35
+ },
36
+
37
+ initialize() {
38
+ return Promise.reject(
39
+ new Error('Owned sessions history adapter is read-only; create sessions via a named or draft runtime.'),
40
+ );
41
+ },
42
+
43
+ async fetch(remoteId) {
44
+ const session = await server.getSession({ sessionId: remoteId });
45
+ return sessionToThreadMetadata(session, sessionDisplayTitle(session));
46
+ },
47
+
48
+ async rename(remoteId, newTitle) {
49
+ if (typeof server.renameSession !== 'function') {
50
+ return;
51
+ }
52
+ await server.renameSession({ sessionId: remoteId, title: newTitle });
53
+ },
54
+ archive() {
55
+ return Promise.resolve();
56
+ },
57
+ unarchive() {
58
+ return Promise.resolve();
59
+ },
60
+ async delete(remoteId) {
61
+ if (typeof server.deleteSession !== 'function') {
62
+ return;
63
+ }
64
+ await server.deleteSession({ sessionId: remoteId });
65
+ },
66
+
67
+ generateTitle() {
68
+ return Promise.resolve(new ReadableStream());
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,69 @@
1
+ import type { RemoteThreadListAdapter } from '@assistant-ui/core';
2
+
3
+ import type { AgentChatServer } from './server/types.js';
4
+ import { sessionListStartTimestamp } from './sessionListStartTimestamp.js';
5
+ import { getSession } from './sessions.js';
6
+ import { sessionToThreadMetadata } from './sessionThreadMetadata.js';
7
+
8
+ const THREAD_LIST_PAGE_SIZE = 20;
9
+
10
+ export function createTrueForgeThreadListAdapter(options: {
11
+ server: AgentChatServer;
12
+ agentName: string;
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, agentName, 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 => sessionToThreadMetadata(session, session.title ?? undefined));
30
+ return {
31
+ threads,
32
+ nextCursor: page.nextPageToken ?? undefined,
33
+ };
34
+ },
35
+
36
+ async initialize() {
37
+ const session = await server.createSession({ agentName });
38
+ return { remoteId: session.id, externalId: undefined };
39
+ },
40
+
41
+ async fetch(remoteId) {
42
+ const session = await getSession(server, remoteId);
43
+ return sessionToThreadMetadata(session, session.title ?? undefined);
44
+ },
45
+
46
+ async rename(remoteId, newTitle) {
47
+ if (typeof server.renameSession !== 'function') {
48
+ return;
49
+ }
50
+ await server.renameSession({ sessionId: remoteId, title: newTitle });
51
+ },
52
+ archive() {
53
+ return Promise.resolve();
54
+ },
55
+ unarchive() {
56
+ return Promise.resolve();
57
+ },
58
+ async delete(remoteId) {
59
+ if (typeof server.deleteSession !== 'function') {
60
+ return;
61
+ }
62
+ await server.deleteSession({ sessionId: remoteId });
63
+ },
64
+
65
+ generateTitle() {
66
+ return Promise.resolve(new ReadableStream());
67
+ },
68
+ };
69
+ }