@truefoundry/assistant-ui-runtime 0.1.15 → 0.1.16
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/index.d.ts +0 -1
- package/dist/index.js +10 -56
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/convertTurnMessages.test.ts +211 -43
- package/src/convertTurnMessages.ts +17 -72
- package/src/types.ts +0 -1
- package/src/useTrueFoundryAgentMessages.test.tsx +30 -26
- package/src/useTrueFoundryAgentMessages.ts +6 -13
- package/src/useTrueFoundryAgentRuntime.ts +0 -2
package/package.json
CHANGED
|
@@ -18,8 +18,8 @@ import type {
|
|
|
18
18
|
import { ROOT_THREAD_ID } from "./constants.js";
|
|
19
19
|
import { collectPendingToolResponses } from "./collectPending.js";
|
|
20
20
|
import {
|
|
21
|
-
buildSnapshotBeforeTurnIndex,
|
|
22
21
|
buildSnapshotFromSessionEvents,
|
|
22
|
+
buildSnapshotThroughTurn,
|
|
23
23
|
buildTurnAssistantContent,
|
|
24
24
|
buildUserMessageContent,
|
|
25
25
|
buildUserMessageFromTurnInput,
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
prependOlderSessionHistory,
|
|
29
29
|
projectSessionMessages,
|
|
30
30
|
repositoryItemsFromMessages,
|
|
31
|
+
resolveGatewayBranchPreviousTurnIdForTurn,
|
|
31
32
|
streamTurnEvents,
|
|
32
33
|
turnStreamUpdateToAssistantMessage,
|
|
33
34
|
} from "./convertTurnMessages.js";
|
|
@@ -135,22 +136,68 @@ function mockTurn(
|
|
|
135
136
|
};
|
|
136
137
|
}
|
|
137
138
|
|
|
139
|
+
/** Mirror runtime listTurns normalization: ASC stays, DESC (or tied) reverses. */
|
|
140
|
+
function toOldestFirstTurns(turns: TurnFixture[]): TurnFixture[] {
|
|
141
|
+
const chain = turns.filter((turn) => turn.state.status !== "running");
|
|
142
|
+
let newestFirst = chain.length > 1;
|
|
143
|
+
for (let i = 1; i < chain.length; i++) {
|
|
144
|
+
const newer = chain[i];
|
|
145
|
+
const older = chain[i - 1];
|
|
146
|
+
if (newer == null || older == null) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const delta = Date.parse(newer.createdAt) - Date.parse(older.createdAt);
|
|
150
|
+
if (delta < 0) {
|
|
151
|
+
newestFirst = true;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
if (delta > 0) {
|
|
155
|
+
newestFirst = false;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return newestFirst ? [...chain].reverse() : [...chain];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Mirrors the host's ancestor walk: parent pointers only, sibling branches excluded. */
|
|
163
|
+
function ancestorChainFromTurns(
|
|
164
|
+
turns: TurnFixture[],
|
|
165
|
+
anchorTurnId: string,
|
|
166
|
+
): TurnFixture[] {
|
|
167
|
+
const turnsById = new Map(turns.map((turn) => [turn.id, turn]));
|
|
168
|
+
const chain: TurnFixture[] = [];
|
|
169
|
+
const seen = new Set<string>();
|
|
170
|
+
let cursor: string | null | undefined = anchorTurnId;
|
|
171
|
+
while (cursor != null && !seen.has(cursor)) {
|
|
172
|
+
seen.add(cursor);
|
|
173
|
+
const turn = turnsById.get(cursor);
|
|
174
|
+
if (turn == null) {
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
if (turn.state.status !== "running") {
|
|
178
|
+
chain.unshift(turn);
|
|
179
|
+
}
|
|
180
|
+
cursor = turn.previousTurnId;
|
|
181
|
+
}
|
|
182
|
+
return chain;
|
|
183
|
+
}
|
|
184
|
+
|
|
138
185
|
/**
|
|
139
|
-
* Builds session-level event items from per-turn mocks —
|
|
140
|
-
* running turns excluded (matches session.listEvents
|
|
186
|
+
* Builds session-level event items from per-turn mocks — accepts ASC or DESC
|
|
187
|
+
* listTurns order; running turns excluded (matches session.listEvents). An
|
|
188
|
+
* anchor selects that turn's ancestor chain, not a chronological prefix.
|
|
141
189
|
*/
|
|
142
190
|
function sessionEventItemsFromTurns(
|
|
143
|
-
|
|
191
|
+
turns: TurnFixture[],
|
|
144
192
|
lastTurnId?: string,
|
|
145
193
|
): SessionEventItem[] {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
194
|
+
const chain =
|
|
195
|
+
lastTurnId == null
|
|
196
|
+
? toOldestFirstTurns(turns)
|
|
197
|
+
: ancestorChainFromTurns(turns, lastTurnId);
|
|
151
198
|
|
|
152
199
|
const items: SessionEventItem[] = [];
|
|
153
|
-
for (const turn of
|
|
200
|
+
for (const turn of chain) {
|
|
154
201
|
items.push({
|
|
155
202
|
turnId: turn.id,
|
|
156
203
|
event: {
|
|
@@ -160,6 +207,9 @@ function sessionEventItemsFromTurns(
|
|
|
160
207
|
input: turn.input,
|
|
161
208
|
state: { status: "running" },
|
|
162
209
|
createdAt: turn.createdAt,
|
|
210
|
+
...(turn.previousTurnId === undefined
|
|
211
|
+
? {}
|
|
212
|
+
: { previousTurnId: turn.previousTurnId }),
|
|
163
213
|
},
|
|
164
214
|
});
|
|
165
215
|
for (const event of turn.events) {
|
|
@@ -180,10 +230,18 @@ function sessionEventItemsFromTurns(
|
|
|
180
230
|
|
|
181
231
|
function mockServerWithTurns(turns: TurnFixture[]): AgentChatServer {
|
|
182
232
|
const eventsByTurnId = new Map(turns.map((turn) => [turn.id, turn.events]));
|
|
233
|
+
const turnsById = new Map(turns.map((turn) => [turn.id, turn]));
|
|
183
234
|
return {
|
|
184
235
|
listTurns: async ({ limit }: { limit?: number } = {}) => ({
|
|
185
236
|
data: limit != null ? turns.slice(0, limit) : turns,
|
|
186
237
|
}),
|
|
238
|
+
getTurn: async ({ turnId }: { turnId: string }) => {
|
|
239
|
+
const turn = turnsById.get(turnId);
|
|
240
|
+
if (turn == null) {
|
|
241
|
+
throw new Error(`Turn ${turnId} not found`);
|
|
242
|
+
}
|
|
243
|
+
return turn;
|
|
244
|
+
},
|
|
187
245
|
listEvents: async (opts: { lastTurnId?: string } = {}) => {
|
|
188
246
|
const items = sessionEventItemsFromTurns(turns, opts.lastTurnId);
|
|
189
247
|
const newestFirst = [...items].reverse();
|
|
@@ -2824,40 +2882,46 @@ describe("buildSnapshotFromSessionEvents", () => {
|
|
|
2824
2882
|
});
|
|
2825
2883
|
});
|
|
2826
2884
|
|
|
2827
|
-
describe("
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2885
|
+
describe("buildSnapshotThroughTurn", () => {
|
|
2886
|
+
/** Linear session t1 -> t2 -> t3, oldest-first. */
|
|
2887
|
+
function linearTurns(): TurnFixture[] {
|
|
2888
|
+
return [
|
|
2889
|
+
mockTurn({
|
|
2890
|
+
id: "t1",
|
|
2891
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
2892
|
+
previousTurnId: null,
|
|
2893
|
+
input: [{ type: "user.message", content: "first" }],
|
|
2894
|
+
events: [
|
|
2895
|
+
modelMessage({ id: "m1", threadId: ROOT_THREAD_ID, content: "reply 1" }),
|
|
2835
2896
|
],
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2897
|
+
}),
|
|
2898
|
+
mockTurn({
|
|
2899
|
+
id: "t2",
|
|
2900
|
+
createdAt: "2026-01-01T00:01:00.000Z",
|
|
2901
|
+
previousTurnId: "t1",
|
|
2902
|
+
input: [{ type: "user.message", content: "second" }],
|
|
2903
|
+
events: [
|
|
2904
|
+
modelMessage({ id: "m2", threadId: ROOT_THREAD_ID, content: "reply 2" }),
|
|
2843
2905
|
],
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2906
|
+
}),
|
|
2907
|
+
mockTurn({
|
|
2908
|
+
id: "t3",
|
|
2909
|
+
createdAt: "2026-01-01T00:02:00.000Z",
|
|
2910
|
+
previousTurnId: "t2",
|
|
2911
|
+
input: [{ type: "user.message", content: "third" }],
|
|
2912
|
+
events: [
|
|
2913
|
+
modelMessage({ id: "m3", threadId: ROOT_THREAD_ID, content: "reply 3" }),
|
|
2851
2914
|
],
|
|
2852
|
-
|
|
2915
|
+
}),
|
|
2916
|
+
];
|
|
2917
|
+
}
|
|
2853
2918
|
|
|
2854
|
-
|
|
2855
|
-
const server = mockServerWithTurns(
|
|
2856
|
-
const snapshot = await
|
|
2919
|
+
it("rewinds to the anchor's ancestor chain, inclusive", async () => {
|
|
2920
|
+
const server = mockServerWithTurns(linearTurns());
|
|
2921
|
+
const snapshot = await buildSnapshotThroughTurn(server, SESSION_ID, "t2");
|
|
2857
2922
|
|
|
2858
2923
|
expect(snapshot.turns.map((turn) => turn.id)).toEqual(["t1", "t2"]);
|
|
2859
|
-
|
|
2860
|
-
expect(messages.map((message) => message.id)).toEqual([
|
|
2924
|
+
expect(projectSessionMessages(snapshot).map((message) => message.id)).toEqual([
|
|
2861
2925
|
"t1-user",
|
|
2862
2926
|
"t1-assistant",
|
|
2863
2927
|
"t2-user",
|
|
@@ -2865,12 +2929,116 @@ describe("buildSnapshotBeforeTurnIndex", () => {
|
|
|
2865
2929
|
]);
|
|
2866
2930
|
});
|
|
2867
2931
|
|
|
2868
|
-
it("returns an empty snapshot
|
|
2869
|
-
const server = mockServerWithTurns(
|
|
2870
|
-
|
|
2871
|
-
]);
|
|
2872
|
-
const snapshot = await buildSnapshotBeforeTurnIndex(server, SESSION_ID, 0);
|
|
2932
|
+
it("returns an empty snapshot for a null anchor (branching from a root turn)", async () => {
|
|
2933
|
+
const server = mockServerWithTurns(linearTurns());
|
|
2934
|
+
const snapshot = await buildSnapshotThroughTurn(server, SESSION_ID, null);
|
|
2873
2935
|
expect(snapshot.turns).toHaveLength(0);
|
|
2874
2936
|
});
|
|
2937
|
+
|
|
2938
|
+
it("ignores listTurns page order", async () => {
|
|
2939
|
+
const asc = linearTurns();
|
|
2940
|
+
const ascSnapshot = await buildSnapshotThroughTurn(
|
|
2941
|
+
mockServerWithTurns(asc),
|
|
2942
|
+
SESSION_ID,
|
|
2943
|
+
"t2",
|
|
2944
|
+
);
|
|
2945
|
+
const descSnapshot = await buildSnapshotThroughTurn(
|
|
2946
|
+
mockServerWithTurns([...asc].reverse()),
|
|
2947
|
+
SESSION_ID,
|
|
2948
|
+
"t2",
|
|
2949
|
+
);
|
|
2950
|
+
|
|
2951
|
+
expect(descSnapshot.turns.map((turn) => turn.id)).toEqual(
|
|
2952
|
+
ascSnapshot.turns.map((turn) => turn.id),
|
|
2953
|
+
);
|
|
2954
|
+
expect(descSnapshot.turns.map((turn) => turn.id)).toEqual(["t1", "t2"]);
|
|
2955
|
+
});
|
|
2956
|
+
|
|
2957
|
+
it("drops sibling turns left behind by an earlier rerun", async () => {
|
|
2958
|
+
// m2/m3 were rerun once already: t2/t3 are orphans, t2p/t3p are live.
|
|
2959
|
+
const [t1, t2, t3] = linearTurns();
|
|
2960
|
+
const t2p = mockTurn({
|
|
2961
|
+
id: "t2p",
|
|
2962
|
+
createdAt: "2026-01-01T00:03:00.000Z",
|
|
2963
|
+
previousTurnId: "t1",
|
|
2964
|
+
input: [{ type: "user.message", content: "second again" }],
|
|
2965
|
+
events: [
|
|
2966
|
+
modelMessage({ id: "m2p", threadId: ROOT_THREAD_ID, content: "reply 2p" }),
|
|
2967
|
+
],
|
|
2968
|
+
});
|
|
2969
|
+
const t3p = mockTurn({
|
|
2970
|
+
id: "t3p",
|
|
2971
|
+
createdAt: "2026-01-01T00:04:00.000Z",
|
|
2972
|
+
previousTurnId: "t2p",
|
|
2973
|
+
input: [{ type: "user.message", content: "third again" }],
|
|
2974
|
+
events: [
|
|
2975
|
+
modelMessage({ id: "m3p", threadId: ROOT_THREAD_ID, content: "reply 3p" }),
|
|
2976
|
+
],
|
|
2977
|
+
});
|
|
2978
|
+
const server = mockServerWithTurns([t1!, t2!, t3!, t2p, t3p]);
|
|
2979
|
+
|
|
2980
|
+
// Rerunning the middle live turn rewinds to its parent, not to the
|
|
2981
|
+
// chronologically preceding turn (t3), whose chain still holds t2/t3.
|
|
2982
|
+
const previousTurnId = await resolveGatewayBranchPreviousTurnIdForTurn(
|
|
2983
|
+
server,
|
|
2984
|
+
SESSION_ID,
|
|
2985
|
+
"t2p",
|
|
2986
|
+
);
|
|
2987
|
+
expect(previousTurnId).toBe("t1");
|
|
2988
|
+
|
|
2989
|
+
const snapshot = await buildSnapshotThroughTurn(server, SESSION_ID, previousTurnId);
|
|
2990
|
+
expect(snapshot.turns.map((turn) => turn.id)).toEqual(["t1"]);
|
|
2991
|
+
|
|
2992
|
+
// The orphan chain is still reachable, so the assertion above is not
|
|
2993
|
+
// passing by accident.
|
|
2994
|
+
const orphanSnapshot = await buildSnapshotThroughTurn(server, SESSION_ID, "t3");
|
|
2995
|
+
expect(orphanSnapshot.turns.map((turn) => turn.id)).toEqual(["t1", "t2", "t3"]);
|
|
2996
|
+
});
|
|
2997
|
+
});
|
|
2998
|
+
|
|
2999
|
+
describe("resolveGatewayBranchPreviousTurnIdForTurn", () => {
|
|
3000
|
+
it("uses the turn parent for subsequent turns and none for roots (ASC listTurns)", async () => {
|
|
3001
|
+
const t1 = mockTurn({
|
|
3002
|
+
id: "t1",
|
|
3003
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
3004
|
+
previousTurnId: null,
|
|
3005
|
+
});
|
|
3006
|
+
const t2 = mockTurn({
|
|
3007
|
+
id: "t2",
|
|
3008
|
+
createdAt: "2026-01-01T00:01:00.000Z",
|
|
3009
|
+
previousTurnId: "t1",
|
|
3010
|
+
});
|
|
3011
|
+
const t3 = mockTurn({
|
|
3012
|
+
id: "t3",
|
|
3013
|
+
createdAt: "2026-01-01T00:02:00.000Z",
|
|
3014
|
+
previousTurnId: "t2",
|
|
3015
|
+
});
|
|
3016
|
+
const server = mockServerWithTurns([t1, t2, t3]);
|
|
3017
|
+
|
|
3018
|
+
await expect(
|
|
3019
|
+
resolveGatewayBranchPreviousTurnIdForTurn(server, SESSION_ID, "t1"),
|
|
3020
|
+
).resolves.toBe("none");
|
|
3021
|
+
await expect(
|
|
3022
|
+
resolveGatewayBranchPreviousTurnIdForTurn(server, SESSION_ID, "t3"),
|
|
3023
|
+
).resolves.toBe("t2");
|
|
3024
|
+
});
|
|
3025
|
+
|
|
3026
|
+
it("uses the turn parent when listTurns is newest-first", async () => {
|
|
3027
|
+
const t1 = mockTurn({
|
|
3028
|
+
id: "t1",
|
|
3029
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
3030
|
+
previousTurnId: null,
|
|
3031
|
+
});
|
|
3032
|
+
const t2 = mockTurn({
|
|
3033
|
+
id: "t2",
|
|
3034
|
+
createdAt: "2026-01-01T00:01:00.000Z",
|
|
3035
|
+
previousTurnId: "t1",
|
|
3036
|
+
});
|
|
3037
|
+
const server = mockServerWithTurns([t2, t1]);
|
|
3038
|
+
|
|
3039
|
+
await expect(
|
|
3040
|
+
resolveGatewayBranchPreviousTurnIdForTurn(server, SESSION_ID, "t2"),
|
|
3041
|
+
).resolves.toBe("t1");
|
|
3042
|
+
});
|
|
2875
3043
|
});
|
|
2876
3044
|
|
|
@@ -1379,93 +1379,38 @@ export async function buildSnapshotFromSession(
|
|
|
1379
1379
|
});
|
|
1380
1380
|
}
|
|
1381
1381
|
|
|
1382
|
-
/**
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
): Promise<SessionSnapshot> {
|
|
1389
|
-
const turns = await listSessionTurnsOrdered(server, sessionId);
|
|
1390
|
-
|
|
1391
|
-
const beforeIndex = turns.findIndex((turn) => turn.id === beforeTurnId);
|
|
1392
|
-
if (beforeIndex === -1) {
|
|
1393
|
-
throw new Error(`Turn ${beforeTurnId} not found in session`);
|
|
1394
|
-
}
|
|
1395
|
-
|
|
1396
|
-
return buildSnapshotBeforeTurnIndex(
|
|
1397
|
-
server,
|
|
1398
|
-
sessionId,
|
|
1399
|
-
beforeIndex,
|
|
1400
|
-
concurrency,
|
|
1401
|
-
turns,
|
|
1402
|
-
);
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
/** Rebuilds session state from the first `turnIndex` turns (excludes that turn). */
|
|
1406
|
-
export async function buildSnapshotBeforeTurnIndex(
|
|
1382
|
+
/**
|
|
1383
|
+
* Rebuilds the conversation through `anchorTurnId`, including that turn.
|
|
1384
|
+
* The server follows parent links from the anchor, so turns from abandoned
|
|
1385
|
+
* branches are excluded. A null anchor represents an empty conversation.
|
|
1386
|
+
*/
|
|
1387
|
+
export async function buildSnapshotThroughTurn(
|
|
1407
1388
|
server: AgentChatServer,
|
|
1408
1389
|
sessionId: string,
|
|
1409
|
-
|
|
1410
|
-
_concurrency: number = DEFAULT_LIST_EVENTS_CONCURRENCY,
|
|
1411
|
-
orderedTurns?: Turn[],
|
|
1390
|
+
anchorTurnId: string | null,
|
|
1412
1391
|
): Promise<SessionSnapshot> {
|
|
1413
|
-
if (
|
|
1392
|
+
if (anchorTurnId == null) {
|
|
1414
1393
|
return createEmptySessionSnapshot();
|
|
1415
1394
|
}
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
const lastTurnId = turnsToInclude.at(-1)?.id;
|
|
1420
|
-
if (lastTurnId == null) {
|
|
1421
|
-
return createEmptySessionSnapshot();
|
|
1422
|
-
}
|
|
1423
|
-
|
|
1424
|
-
// Anchor the session events window at the newest included turn so the
|
|
1425
|
-
// ancestor chain matches `[turns[0], …, turns[turnIndex - 1]]`.
|
|
1426
|
-
const items = await fetchAllSessionEvents(server, sessionId, { lastTurnId });
|
|
1395
|
+
const items = await fetchAllSessionEvents(server, sessionId, {
|
|
1396
|
+
lastTurnId: anchorTurnId,
|
|
1397
|
+
});
|
|
1427
1398
|
const snapshot = createEmptySessionSnapshot();
|
|
1428
1399
|
ingestSessionEventsIntoSnapshot(snapshot, items);
|
|
1429
1400
|
return snapshot;
|
|
1430
1401
|
}
|
|
1431
1402
|
|
|
1432
|
-
/**
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
turnIndex: number,
|
|
1437
|
-
orderedTurns?: Turn[],
|
|
1438
|
-
): Promise<string> {
|
|
1439
|
-
if (turnIndex <= 0) {
|
|
1440
|
-
return "none";
|
|
1441
|
-
}
|
|
1442
|
-
const turns = orderedTurns ?? (await listSessionTurnsOrdered(server, sessionId));
|
|
1443
|
-
return turns[turnIndex - 1]?.id ?? "none";
|
|
1444
|
-
}
|
|
1445
|
-
|
|
1446
|
-
/** Resolves `previousTurnId` by turn id so partial history windows stay correct. */
|
|
1403
|
+
/**
|
|
1404
|
+
* Resolves `previousTurnId` for edit/retry of `turnId` from the turn's own
|
|
1405
|
+
* parent pointer (`"none"` for roots). Independent of listTurns order.
|
|
1406
|
+
*/
|
|
1447
1407
|
export async function resolveGatewayBranchPreviousTurnIdForTurn(
|
|
1448
1408
|
server: AgentChatServer,
|
|
1449
1409
|
sessionId: string,
|
|
1450
1410
|
turnId: string,
|
|
1451
1411
|
): Promise<string> {
|
|
1452
|
-
const
|
|
1453
|
-
|
|
1454
|
-
return resolveGatewayBranchPreviousTurnId(server, sessionId, turnIndex, turns);
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
|
-
async function listSessionTurnsOrdered(
|
|
1458
|
-
server: AgentChatServer,
|
|
1459
|
-
sessionId: string,
|
|
1460
|
-
): Promise<Turn[]> {
|
|
1461
|
-
const turns = await drainListPages((pageToken) =>
|
|
1462
|
-
server.listTurns({
|
|
1463
|
-
sessionId,
|
|
1464
|
-
...(pageToken != null ? { pageToken } : {}),
|
|
1465
|
-
}),
|
|
1466
|
-
);
|
|
1467
|
-
turns.reverse();
|
|
1468
|
-
return turns;
|
|
1412
|
+
const turn = await server.getTurn({ sessionId, turnId });
|
|
1413
|
+
return turn.previousTurnId ?? "none";
|
|
1469
1414
|
}
|
|
1470
1415
|
|
|
1471
1416
|
export async function buildTurnAssistantContent(
|
package/src/types.ts
CHANGED
|
@@ -28,7 +28,6 @@ type TrueFoundryAgentRuntimeBaseOptions = ExternalStoreSharedOptions & {
|
|
|
28
28
|
threadId?: string | undefined;
|
|
29
29
|
onThreadIdChange?: ((threadId: string | undefined) => void) | undefined;
|
|
30
30
|
onError?: ((error: unknown) => void) | undefined;
|
|
31
|
-
listEventsConcurrency?: number | undefined;
|
|
32
31
|
/**
|
|
33
32
|
* Optional filter forwarded to `listSessions({ agentId })`.
|
|
34
33
|
* Omit for all chats; hosts that key agents by name pass that name as the id.
|
|
@@ -47,6 +47,7 @@ vi.mock("./convertTurnMessages.js", async (importOriginal) => {
|
|
|
47
47
|
const mockServer = {
|
|
48
48
|
cancelSession: vi.fn().mockResolvedValue(undefined),
|
|
49
49
|
listTurns: vi.fn(),
|
|
50
|
+
getTurn: vi.fn(),
|
|
50
51
|
// Present so resume-capable paths are exercised; resumeTurnStream is mocked.
|
|
51
52
|
subscribeToTurn: vi.fn(),
|
|
52
53
|
} as unknown as AgentChatServer;
|
|
@@ -429,6 +430,7 @@ describe("useTrueFoundryAgentMessages", () => {
|
|
|
429
430
|
expect.any(PeerThreadFoldState),
|
|
430
431
|
{
|
|
431
432
|
userMessage: "first",
|
|
433
|
+
previousTurnId: "none",
|
|
432
434
|
headers: {
|
|
433
435
|
"x-tfy-session-last-updated-at": "2026-06-30T12:00:00.000Z",
|
|
434
436
|
},
|
|
@@ -689,21 +691,22 @@ describe("useTrueFoundryAgentMessages", () => {
|
|
|
689
691
|
|
|
690
692
|
it("editFromTurn drops prior turns before showing the edited user message", async () => {
|
|
691
693
|
const createdAt = new Date().toISOString();
|
|
694
|
+
const rootTurn = {
|
|
695
|
+
id: "turn-1",
|
|
696
|
+
sessionId: "session-1",
|
|
697
|
+
createdAt,
|
|
698
|
+
previousTurnId: null,
|
|
699
|
+
state: {
|
|
700
|
+
status: "done" as const,
|
|
701
|
+
requiredActions: [],
|
|
702
|
+
completedAt: createdAt,
|
|
703
|
+
},
|
|
704
|
+
input: [{ type: "user.message" as const, content: "Hello" }],
|
|
705
|
+
} as Turn;
|
|
692
706
|
vi.mocked(mockServer.listTurns).mockResolvedValue({
|
|
693
|
-
data: [
|
|
694
|
-
{
|
|
695
|
-
id: "turn-1",
|
|
696
|
-
sessionId: "session-1",
|
|
697
|
-
createdAt,
|
|
698
|
-
state: {
|
|
699
|
-
status: "done",
|
|
700
|
-
requiredActions: [],
|
|
701
|
-
completedAt: createdAt,
|
|
702
|
-
},
|
|
703
|
-
input: [{ type: "user.message", content: "Hello" }],
|
|
704
|
-
} as Turn,
|
|
705
|
-
],
|
|
707
|
+
data: [rootTurn],
|
|
706
708
|
});
|
|
709
|
+
vi.mocked(mockServer.getTurn).mockResolvedValue(rootTurn);
|
|
707
710
|
const fold = new PeerThreadFoldState();
|
|
708
711
|
ingestTurnEvent(fold, {
|
|
709
712
|
type: "model.message",
|
|
@@ -805,21 +808,22 @@ describe("useTrueFoundryAgentMessages", () => {
|
|
|
805
808
|
const onError = vi.fn();
|
|
806
809
|
const original = snapshotWithUserTurn("Hello");
|
|
807
810
|
vi.mocked(loadSessionSnapshot).mockResolvedValue(original);
|
|
811
|
+
const rootTurn = {
|
|
812
|
+
id: "turn-1",
|
|
813
|
+
sessionId: "session-1",
|
|
814
|
+
createdAt,
|
|
815
|
+
previousTurnId: null,
|
|
816
|
+
state: {
|
|
817
|
+
status: "done" as const,
|
|
818
|
+
requiredActions: [],
|
|
819
|
+
completedAt: createdAt,
|
|
820
|
+
},
|
|
821
|
+
input: [{ type: "user.message" as const, content: "Hello" }],
|
|
822
|
+
} as Turn;
|
|
808
823
|
vi.mocked(mockServer.listTurns).mockResolvedValue({
|
|
809
|
-
data: [
|
|
810
|
-
{
|
|
811
|
-
id: "turn-1",
|
|
812
|
-
sessionId: "session-1",
|
|
813
|
-
createdAt,
|
|
814
|
-
state: {
|
|
815
|
-
status: "done",
|
|
816
|
-
requiredActions: [],
|
|
817
|
-
completedAt: createdAt,
|
|
818
|
-
},
|
|
819
|
-
input: [{ type: "user.message", content: "Hello" }],
|
|
820
|
-
} as Turn,
|
|
821
|
-
],
|
|
824
|
+
data: [rootTurn],
|
|
822
825
|
});
|
|
826
|
+
vi.mocked(mockServer.getTurn).mockResolvedValue(rootTurn);
|
|
823
827
|
vi.mocked(streamTurnContent).mockImplementation(async function* () {
|
|
824
828
|
throw new Error("Turn preparation failed");
|
|
825
829
|
});
|
|
@@ -15,7 +15,7 @@ import type { AgentChatServer } from "./server/types.js";
|
|
|
15
15
|
import { ROOT_THREAD_ID } from "./constants.js";
|
|
16
16
|
import {
|
|
17
17
|
buildEditedUserMessageContent,
|
|
18
|
-
|
|
18
|
+
buildSnapshotThroughTurn,
|
|
19
19
|
computeGroupRootBaseline,
|
|
20
20
|
extractTurnUserMessageContent,
|
|
21
21
|
prependOlderSessionHistory,
|
|
@@ -60,7 +60,6 @@ export type UseTrueFoundryAgentMessagesOptions = {
|
|
|
60
60
|
isMain?: boolean | undefined;
|
|
61
61
|
/** URL-selected session may load before the thread list marks it as main. */
|
|
62
62
|
isInitialSession?: boolean | undefined;
|
|
63
|
-
listEventsConcurrency?: number | undefined;
|
|
64
63
|
onError?: ((error: unknown) => void) | undefined;
|
|
65
64
|
initializeSession?: () => Promise<{
|
|
66
65
|
remoteId: string;
|
|
@@ -293,7 +292,6 @@ export function useTrueFoundryAgentMessages({
|
|
|
293
292
|
sessionId,
|
|
294
293
|
isMain,
|
|
295
294
|
isInitialSession,
|
|
296
|
-
listEventsConcurrency,
|
|
297
295
|
onError,
|
|
298
296
|
initializeSession,
|
|
299
297
|
resolveConversationSessionId,
|
|
@@ -975,11 +973,12 @@ export function useTrueFoundryAgentMessages({
|
|
|
975
973
|
conversationSessionId,
|
|
976
974
|
turnId,
|
|
977
975
|
);
|
|
978
|
-
|
|
976
|
+
// Rewind to the exact parent used for the new branch. Using the
|
|
977
|
+
// previous item from listTurns could select an abandoned branch.
|
|
978
|
+
rewound = await buildSnapshotThroughTurn(
|
|
979
979
|
server,
|
|
980
980
|
conversationSessionId,
|
|
981
|
-
|
|
982
|
-
listEventsConcurrency,
|
|
981
|
+
previousTurnId === "none" ? null : previousTurnId,
|
|
983
982
|
);
|
|
984
983
|
createdAtByMessageIdRef.current = new Map();
|
|
985
984
|
// Keep the ref aligned before awaiting sendTurn so any intermediate
|
|
@@ -1000,13 +999,7 @@ export function useTrueFoundryAgentMessages({
|
|
|
1000
999
|
branchRollbackSnapshot: committed,
|
|
1001
1000
|
});
|
|
1002
1001
|
},
|
|
1003
|
-
[
|
|
1004
|
-
cancel,
|
|
1005
|
-
server,
|
|
1006
|
-
listEventsConcurrency,
|
|
1007
|
-
sendTurn,
|
|
1008
|
-
sessionId,
|
|
1009
|
-
],
|
|
1002
|
+
[cancel, server, sendTurn, sessionId],
|
|
1010
1003
|
);
|
|
1011
1004
|
|
|
1012
1005
|
const resetFromTurn = useCallback(
|
|
@@ -49,7 +49,6 @@ function useTrueFoundryAgentRuntimeImpl(
|
|
|
49
49
|
agent,
|
|
50
50
|
adapters,
|
|
51
51
|
onError,
|
|
52
|
-
listEventsConcurrency,
|
|
53
52
|
...sharedOptions
|
|
54
53
|
} = options;
|
|
55
54
|
|
|
@@ -126,7 +125,6 @@ function useTrueFoundryAgentRuntimeImpl(
|
|
|
126
125
|
sessionId,
|
|
127
126
|
isMain,
|
|
128
127
|
isInitialSession,
|
|
129
|
-
listEventsConcurrency,
|
|
130
128
|
onError,
|
|
131
129
|
initializeSession,
|
|
132
130
|
getTurnHeaders: agent.mode === "draft" ? getTurnHeaders : undefined,
|