@truefoundry/assistant-ui-runtime 0.1.14 → 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 +73 -61
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/convertTurnMessages.test.ts +506 -44
- package/src/convertTurnMessages.ts +133 -85
- 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();
|
|
@@ -2434,7 +2492,7 @@ describe("buildSnapshotFromSessionEvents", () => {
|
|
|
2434
2492
|
expect(progressSnapshots).toEqual([1, 2]);
|
|
2435
2493
|
});
|
|
2436
2494
|
|
|
2437
|
-
it("
|
|
2495
|
+
it("falls back to listTurns({ limit: 1 }) when listEvents omits the running tip", async () => {
|
|
2438
2496
|
const listTurns = vi.fn(async () => ({
|
|
2439
2497
|
data: [
|
|
2440
2498
|
{
|
|
@@ -2460,6 +2518,300 @@ describe("buildSnapshotFromSessionEvents", () => {
|
|
|
2460
2518
|
expect(snapshot.pendingUser?.content).toBe("now");
|
|
2461
2519
|
});
|
|
2462
2520
|
|
|
2521
|
+
it("attaches an open tip from events when listTurns is oldest-first and clears answered ask-user", async () => {
|
|
2522
|
+
// Trueforge listTurns is ASC: limit:1 returns the oldest done turn even
|
|
2523
|
+
// while a later tip is still running. Tip detection must use the open
|
|
2524
|
+
// turn.created in listEvents (which includes running turns).
|
|
2525
|
+
const oldestDoneTurn = {
|
|
2526
|
+
id: "t-pause",
|
|
2527
|
+
sessionId: SESSION_ID,
|
|
2528
|
+
state: {
|
|
2529
|
+
status: "done",
|
|
2530
|
+
requiredActions: [
|
|
2531
|
+
{
|
|
2532
|
+
id: "resp-req-1",
|
|
2533
|
+
type: "tool.response_required",
|
|
2534
|
+
threadId: ROOT_THREAD_ID,
|
|
2535
|
+
createdAt,
|
|
2536
|
+
toolCalls: [{ id: "question-1", sourceEventId: "model-1" }],
|
|
2537
|
+
},
|
|
2538
|
+
],
|
|
2539
|
+
completedAt: createdAt,
|
|
2540
|
+
},
|
|
2541
|
+
input: [{ type: "user.message", content: "please research" }],
|
|
2542
|
+
createdAt,
|
|
2543
|
+
} as unknown as Turn;
|
|
2544
|
+
|
|
2545
|
+
const runningTip = {
|
|
2546
|
+
id: "t-running",
|
|
2547
|
+
sessionId: SESSION_ID,
|
|
2548
|
+
state: { status: "running" },
|
|
2549
|
+
input: [
|
|
2550
|
+
{
|
|
2551
|
+
type: "user.tool_response",
|
|
2552
|
+
threadId: ROOT_THREAD_ID,
|
|
2553
|
+
toolCallId: "question-1",
|
|
2554
|
+
content: "AI-native / LLM-powered SaaS",
|
|
2555
|
+
},
|
|
2556
|
+
],
|
|
2557
|
+
createdAt,
|
|
2558
|
+
} as unknown as Turn;
|
|
2559
|
+
|
|
2560
|
+
const items: SessionEventItem[] = [
|
|
2561
|
+
{
|
|
2562
|
+
turnId: "t-pause",
|
|
2563
|
+
event: {
|
|
2564
|
+
type: "turn.created",
|
|
2565
|
+
id: "evt-c-pause",
|
|
2566
|
+
turnId: "t-pause",
|
|
2567
|
+
input: oldestDoneTurn.input,
|
|
2568
|
+
state: { status: "running" },
|
|
2569
|
+
createdAt,
|
|
2570
|
+
},
|
|
2571
|
+
},
|
|
2572
|
+
{
|
|
2573
|
+
turnId: "t-pause",
|
|
2574
|
+
event: modelMessage({
|
|
2575
|
+
id: "model-1",
|
|
2576
|
+
threadId: ROOT_THREAD_ID,
|
|
2577
|
+
content: "One calibration question:",
|
|
2578
|
+
toolCalls: [
|
|
2579
|
+
{
|
|
2580
|
+
id: "question-1",
|
|
2581
|
+
type: "function",
|
|
2582
|
+
function: {
|
|
2583
|
+
name: "ask_user_question",
|
|
2584
|
+
arguments: JSON.stringify({
|
|
2585
|
+
question: "Which direction should I focus on?",
|
|
2586
|
+
options: [
|
|
2587
|
+
"General B2B SaaS",
|
|
2588
|
+
"AI-native / LLM-powered SaaS",
|
|
2589
|
+
],
|
|
2590
|
+
}),
|
|
2591
|
+
},
|
|
2592
|
+
toolInfo: {
|
|
2593
|
+
type: "truefoundry-system",
|
|
2594
|
+
name: "ask_user_question",
|
|
2595
|
+
},
|
|
2596
|
+
},
|
|
2597
|
+
],
|
|
2598
|
+
}),
|
|
2599
|
+
},
|
|
2600
|
+
{
|
|
2601
|
+
turnId: "t-pause",
|
|
2602
|
+
event: responseRequired({
|
|
2603
|
+
id: "resp-req-1",
|
|
2604
|
+
threadId: ROOT_THREAD_ID,
|
|
2605
|
+
toolCalls: [{ id: "question-1", sourceEventId: "model-1" }],
|
|
2606
|
+
}),
|
|
2607
|
+
},
|
|
2608
|
+
{
|
|
2609
|
+
turnId: "t-pause",
|
|
2610
|
+
event: {
|
|
2611
|
+
type: "turn.done",
|
|
2612
|
+
id: "evt-d-pause",
|
|
2613
|
+
state: oldestDoneTurn.state as TurnDoneEvent["state"],
|
|
2614
|
+
createdAt,
|
|
2615
|
+
} as TurnDoneEvent,
|
|
2616
|
+
},
|
|
2617
|
+
{
|
|
2618
|
+
turnId: "t-running",
|
|
2619
|
+
event: {
|
|
2620
|
+
type: "turn.created",
|
|
2621
|
+
id: "evt-c-running",
|
|
2622
|
+
turnId: "t-running",
|
|
2623
|
+
previousTurnId: "t-pause",
|
|
2624
|
+
input: runningTip.input,
|
|
2625
|
+
state: { status: "running" },
|
|
2626
|
+
createdAt,
|
|
2627
|
+
},
|
|
2628
|
+
},
|
|
2629
|
+
{
|
|
2630
|
+
turnId: "t-running",
|
|
2631
|
+
event: modelMessage({
|
|
2632
|
+
id: "model-2",
|
|
2633
|
+
threadId: ROOT_THREAD_ID,
|
|
2634
|
+
content: "Locked in — starting deep research.",
|
|
2635
|
+
}),
|
|
2636
|
+
},
|
|
2637
|
+
];
|
|
2638
|
+
|
|
2639
|
+
const listTurns = vi.fn(async () => ({
|
|
2640
|
+
data: [oldestDoneTurn],
|
|
2641
|
+
nextPageToken: "eyJvZmZzZXQiOjF9",
|
|
2642
|
+
}));
|
|
2643
|
+
const getTurn = vi.fn(async () => runningTip);
|
|
2644
|
+
const server = {
|
|
2645
|
+
listTurns,
|
|
2646
|
+
getTurn,
|
|
2647
|
+
listEvents: sessionEventsPage(items),
|
|
2648
|
+
listTurnEvents: async () => ({ data: [] }),
|
|
2649
|
+
} as unknown as AgentChatServer;
|
|
2650
|
+
|
|
2651
|
+
const snapshot = await buildSnapshotFromSessionEvents(server, SESSION_ID);
|
|
2652
|
+
|
|
2653
|
+
expect(getTurn).toHaveBeenCalledWith({
|
|
2654
|
+
sessionId: SESSION_ID,
|
|
2655
|
+
turnId: "t-running",
|
|
2656
|
+
});
|
|
2657
|
+
expect(listTurns).not.toHaveBeenCalled();
|
|
2658
|
+
expect(snapshot.runningTurn?.id).toBe("t-running");
|
|
2659
|
+
expect(snapshot.unstable_resume).toBe(true);
|
|
2660
|
+
|
|
2661
|
+
const messages = projectSessionMessages(snapshot);
|
|
2662
|
+
expect(collectPendingToolResponses(messages)).toHaveLength(0);
|
|
2663
|
+
|
|
2664
|
+
const assistant = messages.find((message) => message.role === "assistant");
|
|
2665
|
+
expect(assistant).toBeDefined();
|
|
2666
|
+
const toolCall = assistant?.content.find(
|
|
2667
|
+
(part) => part.type === "tool-call" && part.toolCallId === "question-1",
|
|
2668
|
+
);
|
|
2669
|
+
expect(toolCall).toMatchObject({
|
|
2670
|
+
type: "tool-call",
|
|
2671
|
+
toolCallId: "question-1",
|
|
2672
|
+
result: "AI-native / LLM-powered SaaS",
|
|
2673
|
+
});
|
|
2674
|
+
if (toolCall?.type === "tool-call") {
|
|
2675
|
+
expect(toolCall.interrupt).toBeUndefined();
|
|
2676
|
+
}
|
|
2677
|
+
});
|
|
2678
|
+
|
|
2679
|
+
it("keeps an answered ask-user answered when the open tip finished before getTurn", async () => {
|
|
2680
|
+
// The tip can complete between listEvents and getTurn. There is nothing
|
|
2681
|
+
// left to resume, but its user.tool_response input never reached the
|
|
2682
|
+
// fold via ingestion (no turn.done in the window), so it must still be
|
|
2683
|
+
// applied — otherwise the answered prompt resurfaces as pending.
|
|
2684
|
+
const finishedTip = {
|
|
2685
|
+
id: "t-tip",
|
|
2686
|
+
sessionId: SESSION_ID,
|
|
2687
|
+
state: { status: "done", requiredActions: [], completedAt: createdAt },
|
|
2688
|
+
input: [
|
|
2689
|
+
{
|
|
2690
|
+
type: "user.tool_response",
|
|
2691
|
+
threadId: ROOT_THREAD_ID,
|
|
2692
|
+
toolCallId: "question-1",
|
|
2693
|
+
content: "AI-native / LLM-powered SaaS",
|
|
2694
|
+
},
|
|
2695
|
+
],
|
|
2696
|
+
createdAt,
|
|
2697
|
+
} as unknown as Turn;
|
|
2698
|
+
|
|
2699
|
+
const items: SessionEventItem[] = [
|
|
2700
|
+
{
|
|
2701
|
+
turnId: "t-pause",
|
|
2702
|
+
event: {
|
|
2703
|
+
type: "turn.created",
|
|
2704
|
+
id: "evt-c-pause",
|
|
2705
|
+
turnId: "t-pause",
|
|
2706
|
+
input: [{ type: "user.message", content: "please research" }],
|
|
2707
|
+
state: { status: "running" },
|
|
2708
|
+
createdAt,
|
|
2709
|
+
},
|
|
2710
|
+
},
|
|
2711
|
+
{
|
|
2712
|
+
turnId: "t-pause",
|
|
2713
|
+
event: modelMessage({
|
|
2714
|
+
id: "model-1",
|
|
2715
|
+
threadId: ROOT_THREAD_ID,
|
|
2716
|
+
content: "One calibration question:",
|
|
2717
|
+
toolCalls: [
|
|
2718
|
+
{
|
|
2719
|
+
id: "question-1",
|
|
2720
|
+
type: "function",
|
|
2721
|
+
function: {
|
|
2722
|
+
name: "ask_user_question",
|
|
2723
|
+
arguments: JSON.stringify({
|
|
2724
|
+
question: "Which direction should I focus on?",
|
|
2725
|
+
options: [
|
|
2726
|
+
"General B2B SaaS",
|
|
2727
|
+
"AI-native / LLM-powered SaaS",
|
|
2728
|
+
],
|
|
2729
|
+
}),
|
|
2730
|
+
},
|
|
2731
|
+
toolInfo: {
|
|
2732
|
+
type: "truefoundry-system",
|
|
2733
|
+
name: "ask_user_question",
|
|
2734
|
+
},
|
|
2735
|
+
},
|
|
2736
|
+
],
|
|
2737
|
+
}),
|
|
2738
|
+
},
|
|
2739
|
+
{
|
|
2740
|
+
turnId: "t-pause",
|
|
2741
|
+
event: responseRequired({
|
|
2742
|
+
id: "resp-req-1",
|
|
2743
|
+
threadId: ROOT_THREAD_ID,
|
|
2744
|
+
toolCalls: [{ id: "question-1", sourceEventId: "model-1" }],
|
|
2745
|
+
}),
|
|
2746
|
+
},
|
|
2747
|
+
{
|
|
2748
|
+
turnId: "t-pause",
|
|
2749
|
+
event: {
|
|
2750
|
+
type: "turn.done",
|
|
2751
|
+
id: "evt-d-pause",
|
|
2752
|
+
state: {
|
|
2753
|
+
status: "done",
|
|
2754
|
+
requiredActions: [
|
|
2755
|
+
{
|
|
2756
|
+
id: "resp-req-1",
|
|
2757
|
+
type: "tool.response_required",
|
|
2758
|
+
threadId: ROOT_THREAD_ID,
|
|
2759
|
+
createdAt,
|
|
2760
|
+
toolCalls: [
|
|
2761
|
+
{ id: "question-1", sourceEventId: "model-1" },
|
|
2762
|
+
],
|
|
2763
|
+
},
|
|
2764
|
+
],
|
|
2765
|
+
completedAt: createdAt,
|
|
2766
|
+
},
|
|
2767
|
+
createdAt,
|
|
2768
|
+
} as unknown as TurnDoneEvent,
|
|
2769
|
+
},
|
|
2770
|
+
{
|
|
2771
|
+
turnId: "t-tip",
|
|
2772
|
+
event: {
|
|
2773
|
+
type: "turn.created",
|
|
2774
|
+
id: "evt-c-tip",
|
|
2775
|
+
turnId: "t-tip",
|
|
2776
|
+
previousTurnId: "t-pause",
|
|
2777
|
+
input: finishedTip.input,
|
|
2778
|
+
state: { status: "running" },
|
|
2779
|
+
createdAt,
|
|
2780
|
+
},
|
|
2781
|
+
},
|
|
2782
|
+
];
|
|
2783
|
+
|
|
2784
|
+
const listTurns = vi.fn(async () => ({ data: [], nextPageToken: undefined }));
|
|
2785
|
+
const getTurn = vi.fn(async () => finishedTip);
|
|
2786
|
+
const server = {
|
|
2787
|
+
listTurns,
|
|
2788
|
+
getTurn,
|
|
2789
|
+
listEvents: sessionEventsPage(items),
|
|
2790
|
+
listTurnEvents: async () => ({ data: [] }),
|
|
2791
|
+
} as unknown as AgentChatServer;
|
|
2792
|
+
|
|
2793
|
+
const snapshot = await buildSnapshotFromSessionEvents(server, SESSION_ID);
|
|
2794
|
+
|
|
2795
|
+
expect(snapshot.runningTurn).toBeUndefined();
|
|
2796
|
+
expect(snapshot.unstable_resume).toBeFalsy();
|
|
2797
|
+
|
|
2798
|
+
const messages = projectSessionMessages(snapshot);
|
|
2799
|
+
expect(collectPendingToolResponses(messages)).toHaveLength(0);
|
|
2800
|
+
|
|
2801
|
+
const assistant = messages.find((message) => message.role === "assistant");
|
|
2802
|
+
const toolCall = assistant?.content.find(
|
|
2803
|
+
(part) => part.type === "tool-call" && part.toolCallId === "question-1",
|
|
2804
|
+
);
|
|
2805
|
+
expect(toolCall).toMatchObject({
|
|
2806
|
+
type: "tool-call",
|
|
2807
|
+
toolCallId: "question-1",
|
|
2808
|
+
result: "AI-native / LLM-powered SaaS",
|
|
2809
|
+
});
|
|
2810
|
+
if (toolCall?.type === "tool-call") {
|
|
2811
|
+
expect(toolCall.interrupt).toBeUndefined();
|
|
2812
|
+
}
|
|
2813
|
+
});
|
|
2814
|
+
|
|
2463
2815
|
it("loads only the newest event page and exposes an older-history cursor", async () => {
|
|
2464
2816
|
const makeTurnItems = (id: string, text: string): SessionEventItem[] => [
|
|
2465
2817
|
{
|
|
@@ -2530,40 +2882,46 @@ describe("buildSnapshotFromSessionEvents", () => {
|
|
|
2530
2882
|
});
|
|
2531
2883
|
});
|
|
2532
2884
|
|
|
2533
|
-
describe("
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
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" }),
|
|
2541
2896
|
],
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
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" }),
|
|
2549
2905
|
],
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
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" }),
|
|
2557
2914
|
],
|
|
2558
|
-
|
|
2915
|
+
}),
|
|
2916
|
+
];
|
|
2917
|
+
}
|
|
2559
2918
|
|
|
2560
|
-
|
|
2561
|
-
const server = mockServerWithTurns(
|
|
2562
|
-
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");
|
|
2563
2922
|
|
|
2564
2923
|
expect(snapshot.turns.map((turn) => turn.id)).toEqual(["t1", "t2"]);
|
|
2565
|
-
|
|
2566
|
-
expect(messages.map((message) => message.id)).toEqual([
|
|
2924
|
+
expect(projectSessionMessages(snapshot).map((message) => message.id)).toEqual([
|
|
2567
2925
|
"t1-user",
|
|
2568
2926
|
"t1-assistant",
|
|
2569
2927
|
"t2-user",
|
|
@@ -2571,12 +2929,116 @@ describe("buildSnapshotBeforeTurnIndex", () => {
|
|
|
2571
2929
|
]);
|
|
2572
2930
|
});
|
|
2573
2931
|
|
|
2574
|
-
it("returns an empty snapshot
|
|
2575
|
-
const server = mockServerWithTurns(
|
|
2576
|
-
|
|
2577
|
-
]);
|
|
2578
|
-
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);
|
|
2579
2935
|
expect(snapshot.turns).toHaveLength(0);
|
|
2580
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
|
+
});
|
|
2581
3043
|
});
|
|
2582
3044
|
|