@truefoundry/assistant-ui-runtime 0.1.1 → 0.1.3-rc.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/README.md +31 -0
- package/dist/index.d.ts +31 -9
- package/dist/index.js +711 -1306
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/src/convertTurnMessages.test.ts +146 -7
- package/src/convertTurnMessages.ts +442 -164
- package/src/draftAgentConfig.test.ts +30 -1
- package/src/hooks.ts +18 -0
- package/src/index.ts +4 -0
- package/src/loadSessionSnapshot.ts +1 -1
- package/src/private/bindDraftAgentSession.test.ts +20 -21
- package/src/private/bindDraftAgentSession.ts +14 -52
- package/src/private/draftSessionBridge.ts +14 -10
- package/src/private/getGatewayFromPrivateClient.ts +13 -0
- package/src/private/truefoundryDraftThreadListAdapter.test.ts +41 -31
- package/src/private/truefoundryDraftThreadListAdapter.ts +8 -8
- package/src/private/useDraftAgentSpec.test.tsx +153 -0
- package/src/private/useDraftAgentSpec.ts +80 -3
- package/src/sessionSnapshot.ts +21 -1
- package/src/sessions.ts +5 -5
- package/src/streamTurn.test.ts +34 -0
- package/src/streamTurn.ts +9 -1
- package/src/truefoundryExtras.ts +3 -0
- package/src/truefoundryOwnedSessionsThreadListAdapter.test.ts +100 -0
- package/src/truefoundryOwnedSessionsThreadListAdapter.ts +77 -0
- package/src/types.ts +10 -7
- package/src/useTrueFoundryAgentMessages.test.tsx +55 -5
- package/src/useTrueFoundryAgentMessages.ts +91 -16
- package/src/useTrueFoundryAgentRuntime.ts +40 -13
- package/src/agentSessionModule.d.ts +0 -37
|
@@ -19,13 +19,22 @@ export type UseDraftAgentSpecOptions = {
|
|
|
19
19
|
onError?: ((error: unknown) => void) | undefined;
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
+
export type UseDraftAgentSpecResult = {
|
|
23
|
+
agentSpec: AgentSpec | null;
|
|
24
|
+
draftSessionId: string | undefined;
|
|
25
|
+
isSpecSyncing: boolean;
|
|
26
|
+
specError: unknown | null;
|
|
27
|
+
updateAgentSpec: (update: AgentSpecUpdate) => void;
|
|
28
|
+
takeTurnHeaderTimestamp: () => Promise<string | undefined>;
|
|
29
|
+
};
|
|
30
|
+
|
|
22
31
|
export function useDraftAgentSpec({
|
|
23
32
|
draftSessionId,
|
|
24
33
|
draftBridge,
|
|
25
34
|
defaultAgentSpec,
|
|
26
35
|
onAgentSpecChange,
|
|
27
36
|
onError,
|
|
28
|
-
}: UseDraftAgentSpecOptions) {
|
|
37
|
+
}: UseDraftAgentSpecOptions): UseDraftAgentSpecResult {
|
|
29
38
|
const enabled = draftBridge != null;
|
|
30
39
|
const [agentSpec, setAgentSpec] = useState<AgentSpec>(defaultAgentSpec);
|
|
31
40
|
const [isSpecSyncing, setIsSpecSyncing] = useState(false);
|
|
@@ -38,6 +47,35 @@ export function useDraftAgentSpec({
|
|
|
38
47
|
const syncGenerationRef = useRef(0);
|
|
39
48
|
const loadedDraftIdRef = useRef<string | undefined>(undefined);
|
|
40
49
|
const localDirtyRef = useRef(false);
|
|
50
|
+
const lastUpdatedAtRef = useRef<string | undefined>(undefined);
|
|
51
|
+
const pendingFlushRef = useRef<(() => Promise<void>) | undefined>(undefined);
|
|
52
|
+
const inFlightFlushRef = useRef<Promise<void> | undefined>(undefined);
|
|
53
|
+
const activeDraftIdRef = useRef(draftSessionId);
|
|
54
|
+
|
|
55
|
+
// Invalidate sync state from the previous draft so a stale pending flush or
|
|
56
|
+
// stored updatedAt can't update the wrong draft or leak into the next
|
|
57
|
+
// turn's header. Bumping the generation makes any in-flight sync a no-op.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const previousDraftId = activeDraftIdRef.current;
|
|
60
|
+
if (previousDraftId === draftSessionId) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
activeDraftIdRef.current = draftSessionId;
|
|
64
|
+
syncGenerationRef.current++;
|
|
65
|
+
if (syncTimeoutRef.current != null) {
|
|
66
|
+
clearTimeout(syncTimeoutRef.current);
|
|
67
|
+
syncTimeoutRef.current = undefined;
|
|
68
|
+
}
|
|
69
|
+
pendingFlushRef.current = undefined;
|
|
70
|
+
inFlightFlushRef.current = undefined;
|
|
71
|
+
lastUpdatedAtRef.current = undefined;
|
|
72
|
+
setIsSpecSyncing(false);
|
|
73
|
+
// Dirty edits made against a previous draft must not be replayed onto
|
|
74
|
+
// the new one. Keep them only for lazy creation (undefined -> id).
|
|
75
|
+
if (previousDraftId != null) {
|
|
76
|
+
localDirtyRef.current = false;
|
|
77
|
+
}
|
|
78
|
+
}, [draftSessionId]);
|
|
41
79
|
|
|
42
80
|
useEffect(() => {
|
|
43
81
|
if (!enabled || draftBridge == null) {
|
|
@@ -93,10 +131,12 @@ export function useDraftAgentSpec({
|
|
|
93
131
|
}
|
|
94
132
|
setIsSpecSyncing(true);
|
|
95
133
|
try {
|
|
96
|
-
await draftBridge.syncAgentSpec(draftId, spec);
|
|
134
|
+
const updatedAt = await draftBridge.syncAgentSpec(draftId, spec);
|
|
97
135
|
if (generation !== syncGenerationRef.current) {
|
|
98
136
|
return;
|
|
99
137
|
}
|
|
138
|
+
lastUpdatedAtRef.current =
|
|
139
|
+
updatedAt || new Date().toISOString();
|
|
100
140
|
setSpecError(null);
|
|
101
141
|
onAgentSpecChange?.(spec);
|
|
102
142
|
} catch (error) {
|
|
@@ -119,8 +159,20 @@ export function useDraftAgentSpec({
|
|
|
119
159
|
clearTimeout(syncTimeoutRef.current);
|
|
120
160
|
}
|
|
121
161
|
const generation = ++syncGenerationRef.current;
|
|
162
|
+
const flush = () => {
|
|
163
|
+
pendingFlushRef.current = undefined;
|
|
164
|
+
syncTimeoutRef.current = undefined;
|
|
165
|
+
const promise = flushSpecSync(draftId, spec, generation).finally(() => {
|
|
166
|
+
if (inFlightFlushRef.current === promise) {
|
|
167
|
+
inFlightFlushRef.current = undefined;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
inFlightFlushRef.current = promise;
|
|
171
|
+
return promise;
|
|
172
|
+
};
|
|
173
|
+
pendingFlushRef.current = flush;
|
|
122
174
|
syncTimeoutRef.current = setTimeout(() => {
|
|
123
|
-
void
|
|
175
|
+
void flush();
|
|
124
176
|
}, SPEC_SYNC_DEBOUNCE_MS);
|
|
125
177
|
},
|
|
126
178
|
[flushSpecSync],
|
|
@@ -129,6 +181,29 @@ export function useDraftAgentSpec({
|
|
|
129
181
|
const scheduleSpecSyncRef = useRef(scheduleSpecSync);
|
|
130
182
|
scheduleSpecSyncRef.current = scheduleSpecSync;
|
|
131
183
|
|
|
184
|
+
const flushPendingSpecSyncNow = useCallback(async () => {
|
|
185
|
+
if (syncTimeoutRef.current != null) {
|
|
186
|
+
clearTimeout(syncTimeoutRef.current);
|
|
187
|
+
syncTimeoutRef.current = undefined;
|
|
188
|
+
}
|
|
189
|
+
const pending = pendingFlushRef.current;
|
|
190
|
+
if (pending != null) {
|
|
191
|
+
pendingFlushRef.current = undefined;
|
|
192
|
+
await pending();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (inFlightFlushRef.current != null) {
|
|
196
|
+
await inFlightFlushRef.current;
|
|
197
|
+
}
|
|
198
|
+
}, []);
|
|
199
|
+
|
|
200
|
+
const takeTurnHeaderTimestamp = useCallback(async () => {
|
|
201
|
+
await flushPendingSpecSyncNow();
|
|
202
|
+
const updatedAt = lastUpdatedAtRef.current;
|
|
203
|
+
lastUpdatedAtRef.current = undefined;
|
|
204
|
+
return updatedAt;
|
|
205
|
+
}, [flushPendingSpecSyncNow]);
|
|
206
|
+
|
|
132
207
|
useEffect(
|
|
133
208
|
() => () => {
|
|
134
209
|
if (syncTimeoutRef.current != null) {
|
|
@@ -160,6 +235,7 @@ export function useDraftAgentSpec({
|
|
|
160
235
|
isSpecSyncing: enabled ? isSpecSyncing : false,
|
|
161
236
|
specError: enabled ? specError : null,
|
|
162
237
|
updateAgentSpec,
|
|
238
|
+
takeTurnHeaderTimestamp,
|
|
163
239
|
}),
|
|
164
240
|
[
|
|
165
241
|
agentSpec,
|
|
@@ -167,6 +243,7 @@ export function useDraftAgentSpec({
|
|
|
167
243
|
enabled,
|
|
168
244
|
isSpecSyncing,
|
|
169
245
|
specError,
|
|
246
|
+
takeTurnHeaderTimestamp,
|
|
170
247
|
updateAgentSpec,
|
|
171
248
|
],
|
|
172
249
|
);
|
package/src/sessionSnapshot.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
Turn,
|
|
4
4
|
TurnCreatedEvent,
|
|
5
5
|
TurnDoneEvent,
|
|
6
|
+
TurnEvent,
|
|
6
7
|
TurnInputItem,
|
|
7
8
|
} from "truefoundry-gateway-sdk/agents";
|
|
8
9
|
|
|
@@ -12,6 +13,19 @@ import type { StoredApprovalDecision } from "./toolApproval.js";
|
|
|
12
13
|
import type { StoredToolResponse } from "./toolResponse.js";
|
|
13
14
|
import type { TurnStreamUpdate } from "./turnStreamUpdate.js";
|
|
14
15
|
|
|
16
|
+
/** Session-level event item from `AgentSession.listEvents`. */
|
|
17
|
+
export type GatewaySessionEventItem = {
|
|
18
|
+
turnId: string;
|
|
19
|
+
event: TurnCreatedEvent | TurnDoneEvent | TurnEvent;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Cursor for fetching older `listEvents` pages (scroll-up history). */
|
|
23
|
+
export type SessionHistoryPagination = {
|
|
24
|
+
/** Token for the next older page; omit when exhausted. */
|
|
25
|
+
olderPageToken?: string;
|
|
26
|
+
hasOlder: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
15
29
|
/** Turn metadata retained for cross-turn projection and subsequent required-action replay. */
|
|
16
30
|
export type SessionTurnRecord = Pick<
|
|
17
31
|
Turn,
|
|
@@ -55,6 +69,12 @@ export type SessionSnapshot = {
|
|
|
55
69
|
requiredActions: RequiredActionsOverlay;
|
|
56
70
|
runningTurn?: Turn;
|
|
57
71
|
unstable_resume?: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Chronological `listEvents` items loaded so far (for prepend-on-scroll rebuild).
|
|
74
|
+
* Live stream commits are not appended here — they live in `turns` / `fold`.
|
|
75
|
+
*/
|
|
76
|
+
historyEvents?: readonly GatewaySessionEventItem[];
|
|
77
|
+
historyPagination?: SessionHistoryPagination;
|
|
58
78
|
};
|
|
59
79
|
|
|
60
80
|
export type ProjectSessionMessagesOptions = {
|
|
@@ -110,7 +130,7 @@ export function sessionEventsToSessionRecord(
|
|
|
110
130
|
/** Returns a new snapshot wrapper; fold maps may be mutated in place before calling. */
|
|
111
131
|
export function replaceSessionSnapshot(
|
|
112
132
|
snapshot: SessionSnapshot,
|
|
113
|
-
patch: Partial<Omit<SessionSnapshot, "
|
|
133
|
+
patch: Partial<Omit<SessionSnapshot, "requiredActions">> & {
|
|
114
134
|
requiredActions?: RequiredActionsOverlay;
|
|
115
135
|
},
|
|
116
136
|
): SessionSnapshot {
|
package/src/sessions.ts
CHANGED
|
@@ -2,15 +2,15 @@ import type {
|
|
|
2
2
|
AgentSession,
|
|
3
3
|
AgentSessionClient,
|
|
4
4
|
} from "truefoundry-gateway-sdk/agents";
|
|
5
|
-
import type {
|
|
5
|
+
import type { PrivateAgentSessionClient } from "truefoundry-gateway-sdk/agents/private";
|
|
6
6
|
|
|
7
7
|
import { bindDraftAgentSession } from "./private/bindDraftAgentSession.js";
|
|
8
8
|
|
|
9
9
|
const inflightBySessionId = new Map<string, Promise<AgentSession>>();
|
|
10
10
|
|
|
11
11
|
export type GetSessionOptions = {
|
|
12
|
-
/** When set, validates the draft and binds turns
|
|
13
|
-
|
|
12
|
+
/** When set, validates the draft and binds turns via PrivateAgentSessionClient. */
|
|
13
|
+
privateClient?: PrivateAgentSessionClient;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
/** `sessionId` is the assistant-ui thread `remoteId` from `RemoteThreadListAdapter.initialize`. */
|
|
@@ -19,8 +19,8 @@ export function getSession(
|
|
|
19
19
|
sessionId: string,
|
|
20
20
|
options?: GetSessionOptions,
|
|
21
21
|
): Promise<AgentSession> {
|
|
22
|
-
if (options?.
|
|
23
|
-
return bindDraftAgentSession(
|
|
22
|
+
if (options?.privateClient != null) {
|
|
23
|
+
return bindDraftAgentSession(options.privateClient, sessionId);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
let inflight = inflightBySessionId.get(sessionId);
|
package/src/streamTurn.test.ts
CHANGED
|
@@ -193,6 +193,40 @@ describe("streamTurn", () => {
|
|
|
193
193
|
expect(execute).not.toHaveBeenCalled();
|
|
194
194
|
expect(updates).toEqual([]);
|
|
195
195
|
});
|
|
196
|
+
|
|
197
|
+
it("forwards headers to turn.execute request options", async () => {
|
|
198
|
+
const execute = vi.fn(() => (async function* () {})());
|
|
199
|
+
const prepareTurn = vi.fn(() => ({ execute }));
|
|
200
|
+
const session = {
|
|
201
|
+
prepareTurn,
|
|
202
|
+
cancel: vi.fn().mockResolvedValue(undefined),
|
|
203
|
+
} as unknown as AgentSession;
|
|
204
|
+
const abortSignal = new AbortController().signal;
|
|
205
|
+
|
|
206
|
+
await collectUpdates(
|
|
207
|
+
streamTurnContent(
|
|
208
|
+
session,
|
|
209
|
+
new PeerThreadFoldState(),
|
|
210
|
+
{
|
|
211
|
+
userMessage: "hello",
|
|
212
|
+
headers: {
|
|
213
|
+
"x-tfy-session-last-updated-at": "2026-06-30T10:00:00.000Z",
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
abortSignal,
|
|
217
|
+
),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
expect(execute).toHaveBeenCalledWith(
|
|
221
|
+
{ stream: true },
|
|
222
|
+
{
|
|
223
|
+
abortSignal,
|
|
224
|
+
headers: {
|
|
225
|
+
"x-tfy-session-last-updated-at": "2026-06-30T10:00:00.000Z",
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
);
|
|
229
|
+
});
|
|
196
230
|
});
|
|
197
231
|
|
|
198
232
|
describe("resumeTurnStream", () => {
|
package/src/streamTurn.ts
CHANGED
|
@@ -21,6 +21,8 @@ export type StreamTurnOptions = {
|
|
|
21
21
|
* root turn (no `previousTurnId` field).
|
|
22
22
|
*/
|
|
23
23
|
previousTurnId?: string | null;
|
|
24
|
+
/** Extra headers for the createTurn request (`execute` request options). */
|
|
25
|
+
headers?: Record<string, string>;
|
|
24
26
|
};
|
|
25
27
|
|
|
26
28
|
function buildTurnInput(options: StreamTurnOptions): TurnInputItem[] {
|
|
@@ -68,7 +70,13 @@ export async function* streamTurnContent(
|
|
|
68
70
|
|
|
69
71
|
try {
|
|
70
72
|
yield* streamTurnEvents(
|
|
71
|
-
turn.execute(
|
|
73
|
+
turn.execute(
|
|
74
|
+
{ stream: true },
|
|
75
|
+
{
|
|
76
|
+
abortSignal,
|
|
77
|
+
...(options.headers != null ? { headers: options.headers } : {}),
|
|
78
|
+
},
|
|
79
|
+
),
|
|
72
80
|
foldState,
|
|
73
81
|
groupRootBaseline,
|
|
74
82
|
);
|
package/src/truefoundryExtras.ts
CHANGED
|
@@ -28,6 +28,9 @@ export type TrueFoundryRuntimeExtras = {
|
|
|
28
28
|
cancel: () => Promise<void>;
|
|
29
29
|
resetFromTurn: (turnId: string) => Promise<void>;
|
|
30
30
|
reload: () => void;
|
|
31
|
+
hasOlderHistory: boolean;
|
|
32
|
+
isLoadingOlderHistory: boolean;
|
|
33
|
+
loadOlderHistory: () => Promise<void>;
|
|
31
34
|
draft: TrueFoundryDraftRuntimeExtras | null;
|
|
32
35
|
};
|
|
33
36
|
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { PrivateAgentSessionClient } from "truefoundry-gateway-sdk/agents/private";
|
|
4
|
+
|
|
5
|
+
import { createTrueFoundryOwnedSessionsThreadListAdapter } from "./truefoundryOwnedSessionsThreadListAdapter.js";
|
|
6
|
+
|
|
7
|
+
function mockNamedSession(id: string, title: string, updatedAt: string) {
|
|
8
|
+
return {
|
|
9
|
+
type: "session" as const,
|
|
10
|
+
id,
|
|
11
|
+
agentName: "my-agent",
|
|
12
|
+
title,
|
|
13
|
+
createdBySubject: { type: "user" as const, id: "u1" },
|
|
14
|
+
createdAt: updatedAt,
|
|
15
|
+
updatedAt,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function mockDraftSession(id: string, title: string | undefined, updatedAt: string) {
|
|
20
|
+
return {
|
|
21
|
+
type: "session/draft" as const,
|
|
22
|
+
id,
|
|
23
|
+
agentSpec: { model: { name: "anthropic/claude-sonnet-4-6" } },
|
|
24
|
+
title,
|
|
25
|
+
createdBySubject: { type: "user" as const, id: "u1" },
|
|
26
|
+
createdAt: updatedAt,
|
|
27
|
+
updatedAt,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("createTrueFoundryOwnedSessionsThreadListAdapter", () => {
|
|
32
|
+
it("lists owned sessions (named + draft) with pagination cursor", async () => {
|
|
33
|
+
const listOwnedSessions = vi.fn().mockResolvedValue({
|
|
34
|
+
data: [
|
|
35
|
+
mockNamedSession("s1", "Named chat", "2026-06-30T12:00:00.000Z"),
|
|
36
|
+
mockDraftSession("d1", "Draft chat", "2026-06-30T11:00:00.000Z"),
|
|
37
|
+
],
|
|
38
|
+
response: {
|
|
39
|
+
pagination: { nextPageToken: "page-2", limit: 20 },
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
const privateClient = {
|
|
43
|
+
listOwnedSessions,
|
|
44
|
+
getDraftSession: vi.fn(),
|
|
45
|
+
} as unknown as PrivateAgentSessionClient;
|
|
46
|
+
|
|
47
|
+
const adapter = createTrueFoundryOwnedSessionsThreadListAdapter({ privateClient });
|
|
48
|
+
const result = await adapter.list();
|
|
49
|
+
|
|
50
|
+
expect(listOwnedSessions).toHaveBeenCalledWith(
|
|
51
|
+
expect.objectContaining({
|
|
52
|
+
limit: 20,
|
|
53
|
+
pageToken: undefined,
|
|
54
|
+
startTimestamp: expect.any(String),
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
expect(result.threads).toEqual([
|
|
58
|
+
{
|
|
59
|
+
status: "regular",
|
|
60
|
+
remoteId: "s1",
|
|
61
|
+
title: "Named chat",
|
|
62
|
+
lastMessageAt: new Date("2026-06-30T12:00:00.000Z"),
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
status: "regular",
|
|
66
|
+
remoteId: "d1",
|
|
67
|
+
title: "Draft chat",
|
|
68
|
+
lastMessageAt: new Date("2026-06-30T11:00:00.000Z"),
|
|
69
|
+
},
|
|
70
|
+
]);
|
|
71
|
+
expect(result.nextCursor).toBe("page-2");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("falls back to model name for untitled drafts", async () => {
|
|
75
|
+
const listOwnedSessions = vi.fn().mockResolvedValue({
|
|
76
|
+
data: [mockDraftSession("d1", undefined, "2026-06-30T11:00:00.000Z")],
|
|
77
|
+
response: { pagination: { limit: 20 } },
|
|
78
|
+
});
|
|
79
|
+
const privateClient = {
|
|
80
|
+
listOwnedSessions,
|
|
81
|
+
getDraftSession: vi.fn(),
|
|
82
|
+
} as unknown as PrivateAgentSessionClient;
|
|
83
|
+
|
|
84
|
+
const adapter = createTrueFoundryOwnedSessionsThreadListAdapter({ privateClient });
|
|
85
|
+
const result = await adapter.list();
|
|
86
|
+
|
|
87
|
+
expect(result.threads[0]?.title).toBe("anthropic/claude-sonnet-4-6");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("throws on initialize because the adapter is read-only", async () => {
|
|
91
|
+
const privateClient = {
|
|
92
|
+
listOwnedSessions: vi.fn(),
|
|
93
|
+
getDraftSession: vi.fn(),
|
|
94
|
+
} as unknown as PrivateAgentSessionClient;
|
|
95
|
+
|
|
96
|
+
const adapter = createTrueFoundryOwnedSessionsThreadListAdapter({ privateClient });
|
|
97
|
+
|
|
98
|
+
await expect(adapter.initialize("local")).rejects.toThrow(/read-only/);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { RemoteThreadListAdapter } from "@assistant-ui/core";
|
|
2
|
+
import type { AgentSession } from "truefoundry-gateway-sdk/agents";
|
|
3
|
+
import type {
|
|
4
|
+
AgentDraftSession,
|
|
5
|
+
PrivateAgentSessionClient,
|
|
6
|
+
} from "truefoundry-gateway-sdk/agents/private";
|
|
7
|
+
|
|
8
|
+
import { draftSessionTitle } from "./private/agentSpec.js";
|
|
9
|
+
import { sessionListStartTimestamp } from "./sessionListStartTimestamp.js";
|
|
10
|
+
|
|
11
|
+
const THREAD_LIST_PAGE_SIZE = 20;
|
|
12
|
+
|
|
13
|
+
function ownedSessionTitle(session: AgentSession | AgentDraftSession): string {
|
|
14
|
+
if (session.type === "session/draft") {
|
|
15
|
+
return draftSessionTitle(session);
|
|
16
|
+
}
|
|
17
|
+
return session.title ?? session.agentName;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Read-only thread-list adapter backed by `PrivateAgentSessionClient.listOwnedSessions`.
|
|
22
|
+
* Returns every session the caller owns (named + draft), newest first.
|
|
23
|
+
*/
|
|
24
|
+
export function createTrueFoundryOwnedSessionsThreadListAdapter(options: {
|
|
25
|
+
privateClient: PrivateAgentSessionClient;
|
|
26
|
+
}): RemoteThreadListAdapter {
|
|
27
|
+
const { privateClient } = options;
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
async list({ after } = {}) {
|
|
31
|
+
const page = await privateClient.listOwnedSessions({
|
|
32
|
+
limit: THREAD_LIST_PAGE_SIZE,
|
|
33
|
+
pageToken: after,
|
|
34
|
+
startTimestamp: sessionListStartTimestamp(),
|
|
35
|
+
});
|
|
36
|
+
const threads = page.data.map((session) => ({
|
|
37
|
+
status: "regular" as const,
|
|
38
|
+
remoteId: session.id,
|
|
39
|
+
title: ownedSessionTitle(session),
|
|
40
|
+
lastMessageAt: new Date(session.updatedAt),
|
|
41
|
+
}));
|
|
42
|
+
return {
|
|
43
|
+
threads,
|
|
44
|
+
nextCursor: page.response.pagination.nextPageToken ?? undefined,
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
async initialize() {
|
|
49
|
+
throw new Error(
|
|
50
|
+
"Owned sessions history adapter is read-only; create sessions via a named or draft runtime.",
|
|
51
|
+
);
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
async fetch(remoteId) {
|
|
55
|
+
// PrivateAgentSessionClient only exposes get for drafts. Named sessions
|
|
56
|
+
// surface through listOwnedSessions; fetch falls back to draft get.
|
|
57
|
+
const draft = await privateClient.getDraftSession({
|
|
58
|
+
draftSessionId: remoteId,
|
|
59
|
+
});
|
|
60
|
+
return {
|
|
61
|
+
status: "regular" as const,
|
|
62
|
+
remoteId: draft.id,
|
|
63
|
+
title: draftSessionTitle(draft),
|
|
64
|
+
lastMessageAt: new Date(draft.updatedAt),
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
async rename() {},
|
|
69
|
+
async archive() {},
|
|
70
|
+
async unarchive() {},
|
|
71
|
+
async delete() {},
|
|
72
|
+
|
|
73
|
+
async generateTitle() {
|
|
74
|
+
return new ReadableStream();
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
SpeechSynthesisAdapter,
|
|
8
8
|
} from "@assistant-ui/core";
|
|
9
9
|
import type { AgentSessionClient } from "truefoundry-gateway-sdk/agents";
|
|
10
|
-
import type {
|
|
10
|
+
import type { PrivateAgentSessionClient } from "truefoundry-gateway-sdk/agents/private";
|
|
11
11
|
|
|
12
12
|
import type { AgentSpec } from "./private/agentSpec.js";
|
|
13
13
|
|
|
@@ -47,13 +47,16 @@ export type UseTrueFoundryAgentRuntimeOptions = TrueFoundryAgentRuntimeBaseOptio
|
|
|
47
47
|
agent?: TrueFoundryAgentConfig | undefined;
|
|
48
48
|
/** Legacy named-agent shorthand. Prefer `agent: { mode: "named", agentName }`. */
|
|
49
49
|
agentName?: string | undefined;
|
|
50
|
-
/**
|
|
51
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Required when `agent.mode === "draft"`. Also used for sandbox file downloads
|
|
52
|
+
* (named or draft).
|
|
53
|
+
*/
|
|
54
|
+
privateClient?: PrivateAgentSessionClient | undefined;
|
|
52
55
|
};
|
|
53
56
|
|
|
54
57
|
export type ResolvedTrueFoundryAgentRuntimeOptions = TrueFoundryAgentRuntimeBaseOptions & {
|
|
55
58
|
agent: TrueFoundryAgentConfig;
|
|
56
|
-
|
|
59
|
+
privateClient?: PrivateAgentSessionClient | undefined;
|
|
57
60
|
};
|
|
58
61
|
|
|
59
62
|
export function resolveTrueFoundryAgentConfig(
|
|
@@ -78,15 +81,15 @@ export function resolveTrueFoundryAgentRuntimeOptions(
|
|
|
78
81
|
): ResolvedTrueFoundryAgentRuntimeOptions {
|
|
79
82
|
const agent = resolveTrueFoundryAgentConfig(options);
|
|
80
83
|
|
|
81
|
-
if (agent.mode === "draft" && options.
|
|
84
|
+
if (agent.mode === "draft" && options.privateClient == null) {
|
|
82
85
|
throw new Error(
|
|
83
|
-
"Draft agent mode requires a `
|
|
86
|
+
"Draft agent mode requires a `privateClient` PrivateAgentSessionClient.",
|
|
84
87
|
);
|
|
85
88
|
}
|
|
86
89
|
|
|
87
90
|
return {
|
|
88
91
|
...options,
|
|
89
92
|
agent,
|
|
90
|
-
|
|
93
|
+
privateClient: options.privateClient,
|
|
91
94
|
};
|
|
92
95
|
}
|
|
@@ -3,7 +3,7 @@ import type { ThreadMessage } from "@assistant-ui/core";
|
|
|
3
3
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import type { AgentSessionClient, Turn } from "truefoundry-gateway-sdk/agents";
|
|
6
|
-
import type {
|
|
6
|
+
import type { PrivateAgentSessionClient } from "truefoundry-gateway-sdk/agents/private";
|
|
7
7
|
|
|
8
8
|
import { ROOT_THREAD_ID } from "./constants.js";
|
|
9
9
|
import { collectPendingToolResponses } from "./collectPending.js";
|
|
@@ -329,6 +329,56 @@ describe("useTrueFoundryAgentMessages", () => {
|
|
|
329
329
|
expect(loadSessionSnapshot).not.toHaveBeenCalled();
|
|
330
330
|
});
|
|
331
331
|
|
|
332
|
+
it("sendTurn forwards getTurnHeaders only when they resolve to a value", async () => {
|
|
333
|
+
const getTurnHeaders = vi
|
|
334
|
+
.fn()
|
|
335
|
+
.mockResolvedValueOnce({
|
|
336
|
+
"x-tfy-session-last-updated-at": "2026-06-30T12:00:00.000Z",
|
|
337
|
+
})
|
|
338
|
+
.mockResolvedValueOnce(undefined);
|
|
339
|
+
|
|
340
|
+
const { result } = renderHook(() =>
|
|
341
|
+
useTrueFoundryAgentMessages({
|
|
342
|
+
client: mockClient,
|
|
343
|
+
sessionId: "session-1",
|
|
344
|
+
getTurnHeaders,
|
|
345
|
+
}),
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
349
|
+
|
|
350
|
+
await act(async () => {
|
|
351
|
+
await result.current.sendTurn({ userMessage: "first" });
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
expect(getTurnHeaders).toHaveBeenCalledTimes(1);
|
|
355
|
+
expect(streamTurnContent).toHaveBeenCalledWith(
|
|
356
|
+
expect.anything(),
|
|
357
|
+
expect.any(PeerThreadFoldState),
|
|
358
|
+
{
|
|
359
|
+
userMessage: "first",
|
|
360
|
+
headers: {
|
|
361
|
+
"x-tfy-session-last-updated-at": "2026-06-30T12:00:00.000Z",
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
expect.any(AbortSignal),
|
|
365
|
+
expect.any(Array),
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
await act(async () => {
|
|
369
|
+
await result.current.sendTurn({ userMessage: "second" });
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
expect(getTurnHeaders).toHaveBeenCalledTimes(2);
|
|
373
|
+
expect(streamTurnContent).toHaveBeenLastCalledWith(
|
|
374
|
+
expect.anything(),
|
|
375
|
+
expect.any(PeerThreadFoldState),
|
|
376
|
+
{ userMessage: "second" },
|
|
377
|
+
expect.any(AbortSignal),
|
|
378
|
+
expect.any(Array),
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
|
|
332
382
|
it("loads converted session history on mount", async () => {
|
|
333
383
|
vi.mocked(loadSessionSnapshot).mockResolvedValue(
|
|
334
384
|
snapshotWithUserTurn("hello"),
|
|
@@ -921,16 +971,16 @@ describe("useTrueFoundryAgentMessages", () => {
|
|
|
921
971
|
expect(loadSessionSnapshot).toHaveBeenCalledTimes(1);
|
|
922
972
|
});
|
|
923
973
|
|
|
924
|
-
it("cancel resolves the session through the
|
|
974
|
+
it("cancel resolves the session through the private client in draft mode", async () => {
|
|
925
975
|
const cancel = vi.fn().mockResolvedValue(undefined);
|
|
926
976
|
vi.mocked(getSession).mockResolvedValue({ cancel } as never);
|
|
927
|
-
const
|
|
977
|
+
const privateClient = {} as PrivateAgentSessionClient;
|
|
928
978
|
|
|
929
979
|
const { result } = renderHook(() =>
|
|
930
980
|
useTrueFoundryAgentMessages({
|
|
931
981
|
client: mockClient,
|
|
932
982
|
sessionId: "draft-session-1",
|
|
933
|
-
|
|
983
|
+
privateClient,
|
|
934
984
|
}),
|
|
935
985
|
);
|
|
936
986
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
@@ -941,7 +991,7 @@ describe("useTrueFoundryAgentMessages", () => {
|
|
|
941
991
|
|
|
942
992
|
expect(cancel).toHaveBeenCalled();
|
|
943
993
|
expect(getSession).toHaveBeenCalledWith(mockClient, "draft-session-1", {
|
|
944
|
-
|
|
994
|
+
privateClient,
|
|
945
995
|
});
|
|
946
996
|
});
|
|
947
997
|
});
|