@truefoundry/assistant-ui-runtime 0.1.0-rc.1 → 0.1.2
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 +45 -77
- package/dist/index.d.ts +14 -2
- package/dist/index.js +697 -147
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/agentSessionModule.d.ts +14 -2
- package/src/convertTurnMessages.test.ts +502 -1
- package/src/convertTurnMessages.ts +471 -28
- package/src/createSubAgent.ts +13 -5
- package/src/draftAgentConfig.test.ts +1 -1
- package/src/foldPeerThreads.test.ts +56 -0
- package/src/foldPeerThreads.ts +7 -1
- package/src/hooks.ts +24 -0
- package/src/index.ts +7 -5
- package/src/loadSessionSnapshot.test.ts +21 -6
- package/src/loadSessionSnapshot.ts +9 -5
- package/src/{bindDraftAgentSession.test.ts → private/bindDraftAgentSession.test.ts} +3 -2
- package/src/{draftSessionBridge.ts → private/draftSessionBridge.ts} +8 -2
- package/src/{truefoundryDraftThreadListAdapter.ts → private/truefoundryDraftThreadListAdapter.ts} +1 -1
- package/src/private/useDraftAgentSpec.test.tsx +153 -0
- package/src/{useDraftAgentSpec.ts → private/useDraftAgentSpec.ts} +80 -3
- package/src/sessionSnapshot.ts +48 -2
- package/src/sessions.ts +1 -1
- package/src/streamTurn.test.ts +34 -0
- package/src/streamTurn.ts +9 -1
- package/src/truefoundryExtras.ts +5 -1
- package/src/types.ts +1 -1
- package/src/useTrueFoundryAgentMessages.test.tsx +275 -1
- package/src/useTrueFoundryAgentMessages.ts +263 -70
- package/src/useTrueFoundryAgentRuntime.ts +51 -13
- /package/src/{agentSpec.ts → private/agentSpec.ts} +0 -0
- /package/src/{bindDraftAgentSession.ts → private/bindDraftAgentSession.ts} +0 -0
- /package/src/{truefoundryDraftThreadListAdapter.test.ts → private/truefoundryDraftThreadListAdapter.test.ts} +0 -0
package/src/hooks.ts
CHANGED
|
@@ -98,6 +98,30 @@ export const useTrueFoundryCancel = () => {
|
|
|
98
98
|
return () => trueFoundryExtras.get(aui).cancel();
|
|
99
99
|
};
|
|
100
100
|
|
|
101
|
+
/** Returns a function to reload (retry) the current session from any render context. */
|
|
102
|
+
export const useTrueFoundryReload = () => {
|
|
103
|
+
const aui = useAui();
|
|
104
|
+
return () => trueFoundryExtras.get(aui).reload();
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** Older history pagination state plus a load-more action for scroll-up. */
|
|
108
|
+
export const useTrueFoundryHistoryPagination = () => {
|
|
109
|
+
const extras = trueFoundryExtras.use((e) => e, undefined);
|
|
110
|
+
|
|
111
|
+
return useMemo(
|
|
112
|
+
() => ({
|
|
113
|
+
hasOlderHistory: extras?.hasOlderHistory ?? false,
|
|
114
|
+
isLoadingOlderHistory: extras?.isLoadingOlderHistory ?? false,
|
|
115
|
+
loadOlderHistory:
|
|
116
|
+
extras?.loadOlderHistory ??
|
|
117
|
+
(async () => {
|
|
118
|
+
throw new Error("TrueFoundry runtime is not ready yet");
|
|
119
|
+
}),
|
|
120
|
+
}),
|
|
121
|
+
[extras],
|
|
122
|
+
);
|
|
123
|
+
};
|
|
124
|
+
|
|
101
125
|
/** Returns a function to reset (re-submit) a user turn from any render context. */
|
|
102
126
|
export const useTrueFoundryResetFromTurn = () => {
|
|
103
127
|
const aui = useAui();
|
package/src/index.ts
CHANGED
|
@@ -13,11 +13,11 @@ export {
|
|
|
13
13
|
export type { ConvertTurnsResult, UserMessageContent } from "./convertTurnMessages.js";
|
|
14
14
|
export { ROOT_THREAD_ID } from "./constants.js";
|
|
15
15
|
export type { UseTrueFoundryAgentRuntimeOptions, NamedAgentConfig, DraftAgentConfig, TrueFoundryAgentConfig } from "./types.js";
|
|
16
|
-
export type { AgentSpec, AgentSpecUpdate, DraftSession } from "./agentSpec.js";
|
|
17
|
-
export { mergeAgentSpec, draftSessionTitle } from "./agentSpec.js";
|
|
18
|
-
export { createTrueFoundryDraftThreadListAdapter } from "./truefoundryDraftThreadListAdapter.js";
|
|
19
|
-
export { createDraftSessionBridge } from "./draftSessionBridge.js";
|
|
20
|
-
export type { DraftSessionBridge } from "./draftSessionBridge.js";
|
|
16
|
+
export type { AgentSpec, AgentSpecUpdate, DraftSession } from "./private/agentSpec.js";
|
|
17
|
+
export { mergeAgentSpec, draftSessionTitle } from "./private/agentSpec.js";
|
|
18
|
+
export { createTrueFoundryDraftThreadListAdapter } from "./private/truefoundryDraftThreadListAdapter.js";
|
|
19
|
+
export { createDraftSessionBridge } from "./private/draftSessionBridge.js";
|
|
20
|
+
export type { DraftSessionBridge } from "./private/draftSessionBridge.js";
|
|
21
21
|
export {
|
|
22
22
|
useTrueFoundryAgentSpec,
|
|
23
23
|
useTrueFoundryUpdateAgentSpec,
|
|
@@ -47,6 +47,8 @@ export {
|
|
|
47
47
|
useTrueFoundryDownloadSandboxFile,
|
|
48
48
|
useTrueFoundryCancel,
|
|
49
49
|
useTrueFoundryResetFromTurn,
|
|
50
|
+
useTrueFoundryReload,
|
|
51
|
+
useTrueFoundryHistoryPagination,
|
|
50
52
|
} from "./hooks.js";
|
|
51
53
|
export {
|
|
52
54
|
collectApprovalInputs,
|
|
@@ -11,11 +11,11 @@ vi.mock("./convertTurnMessages.js", async (importOriginal) => {
|
|
|
11
11
|
const actual = await importOriginal<typeof import("./convertTurnMessages.js")>();
|
|
12
12
|
return {
|
|
13
13
|
...actual,
|
|
14
|
-
|
|
14
|
+
buildSnapshotFromSessionEvents: vi.fn(),
|
|
15
15
|
};
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import { buildSnapshotFromSessionEvents } from "./convertTurnMessages.js";
|
|
19
19
|
import { loadSessionSnapshot } from "./loadSessionSnapshot.js";
|
|
20
20
|
import { getSession } from "./sessions.js";
|
|
21
21
|
|
|
@@ -28,7 +28,7 @@ describe("loadSessionSnapshot", () => {
|
|
|
28
28
|
|
|
29
29
|
it("deduplicates concurrent loads for the same session", async () => {
|
|
30
30
|
vi.mocked(getSession).mockResolvedValue({} as never);
|
|
31
|
-
vi.mocked(
|
|
31
|
+
vi.mocked(buildSnapshotFromSessionEvents).mockResolvedValue(
|
|
32
32
|
createEmptySessionSnapshot(),
|
|
33
33
|
);
|
|
34
34
|
|
|
@@ -39,12 +39,12 @@ describe("loadSessionSnapshot", () => {
|
|
|
39
39
|
|
|
40
40
|
expect(first).toBe(second);
|
|
41
41
|
expect(getSession).toHaveBeenCalledTimes(1);
|
|
42
|
-
expect(
|
|
42
|
+
expect(buildSnapshotFromSessionEvents).toHaveBeenCalledTimes(1);
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
it("allows a new load after the previous one settles", async () => {
|
|
46
46
|
vi.mocked(getSession).mockResolvedValue({} as never);
|
|
47
|
-
vi.mocked(
|
|
47
|
+
vi.mocked(buildSnapshotFromSessionEvents).mockResolvedValue(
|
|
48
48
|
createEmptySessionSnapshot(),
|
|
49
49
|
);
|
|
50
50
|
|
|
@@ -52,6 +52,21 @@ describe("loadSessionSnapshot", () => {
|
|
|
52
52
|
await loadSessionSnapshot(mockClient, "session-1");
|
|
53
53
|
|
|
54
54
|
expect(getSession).toHaveBeenCalledTimes(2);
|
|
55
|
-
expect(
|
|
55
|
+
expect(buildSnapshotFromSessionEvents).toHaveBeenCalledTimes(2);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("forwards onProgress to buildSnapshotFromSessionEvents", async () => {
|
|
59
|
+
vi.mocked(getSession).mockResolvedValue({} as never);
|
|
60
|
+
vi.mocked(buildSnapshotFromSessionEvents).mockResolvedValue(
|
|
61
|
+
createEmptySessionSnapshot(),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const onProgress = vi.fn();
|
|
65
|
+
await loadSessionSnapshot(mockClient, "session-1", undefined, onProgress);
|
|
66
|
+
|
|
67
|
+
expect(buildSnapshotFromSessionEvents).toHaveBeenCalledWith(
|
|
68
|
+
{},
|
|
69
|
+
onProgress,
|
|
70
|
+
);
|
|
56
71
|
});
|
|
57
72
|
});
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { AgentSessionClient } from "truefoundry-gateway-sdk/agents";
|
|
2
|
-
import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
|
|
3
2
|
|
|
4
|
-
import {
|
|
3
|
+
import { buildSnapshotFromSessionEvents } from "./convertTurnMessages.js";
|
|
5
4
|
import { getSession, type GetSessionOptions } from "./sessions.js";
|
|
6
5
|
import type { SessionSnapshot } from "./sessionSnapshot.js";
|
|
7
6
|
|
|
@@ -10,20 +9,25 @@ const inflightBySessionId = new Map<string, Promise<SessionSnapshot>>();
|
|
|
10
9
|
/**
|
|
11
10
|
* Loads a session snapshot once per concurrent burst for a given session id.
|
|
12
11
|
* React Strict Mode and overlapping fetch/load paths share the same in-flight
|
|
13
|
-
* request instead of duplicating getSession +
|
|
12
|
+
* request instead of duplicating getSession + listEvents calls.
|
|
13
|
+
*
|
|
14
|
+
* `onProgress` is called after each complete turn is ingested so the caller
|
|
15
|
+
* can update the UI progressively while history is being processed.
|
|
14
16
|
*/
|
|
15
17
|
export function loadSessionSnapshot(
|
|
16
18
|
client: AgentSessionClient,
|
|
17
19
|
sessionId: string,
|
|
18
|
-
concurrency?: number,
|
|
19
20
|
sessionOptions?: GetSessionOptions,
|
|
21
|
+
onProgress?: (snap: SessionSnapshot) => void,
|
|
20
22
|
): Promise<SessionSnapshot> {
|
|
21
23
|
const cacheKey =
|
|
22
24
|
sessionOptions?.draftGateway != null ? `draft:${sessionId}` : sessionId;
|
|
23
25
|
let inflight = inflightBySessionId.get(cacheKey);
|
|
24
26
|
if (inflight == null) {
|
|
25
27
|
inflight = getSession(client, sessionId, sessionOptions)
|
|
26
|
-
.then((session) =>
|
|
28
|
+
.then((session) =>
|
|
29
|
+
buildSnapshotFromSessionEvents(session, onProgress),
|
|
30
|
+
)
|
|
27
31
|
.finally(() => {
|
|
28
32
|
if (inflightBySessionId.get(cacheKey) === inflight) {
|
|
29
33
|
inflightBySessionId.delete(cacheKey);
|
|
@@ -31,7 +31,7 @@ describe("bindDraftAgentSession", () => {
|
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
describe("createDraftSessionBridge", () => {
|
|
34
|
-
it("syncs agent spec via draftSessions.update", async () => {
|
|
34
|
+
it("syncs agent spec via draftSessions.update and returns updatedAt", async () => {
|
|
35
35
|
const { createDraftSessionBridge } = await import("./draftSessionBridge.js");
|
|
36
36
|
const update = vi.fn().mockResolvedValue({ data: draft });
|
|
37
37
|
const gateway = {
|
|
@@ -46,10 +46,11 @@ describe("createDraftSessionBridge", () => {
|
|
|
46
46
|
} as unknown as TrueFoundryGateway;
|
|
47
47
|
|
|
48
48
|
const bridge = createDraftSessionBridge(gateway);
|
|
49
|
-
await bridge.syncAgentSpec("draft-1", draft.agentSpec);
|
|
49
|
+
const updatedAt = await bridge.syncAgentSpec("draft-1", draft.agentSpec);
|
|
50
50
|
|
|
51
51
|
expect(update).toHaveBeenCalledWith("draft-1", {
|
|
52
52
|
agentSpec: draft.agentSpec,
|
|
53
53
|
});
|
|
54
|
+
expect(updatedAt).toBe(draft.updatedAt);
|
|
54
55
|
});
|
|
55
56
|
});
|
|
@@ -2,8 +2,10 @@ import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
|
|
|
2
2
|
|
|
3
3
|
import type { AgentSpec } from "./agentSpec.js";
|
|
4
4
|
|
|
5
|
+
export const DRAFT_SESSION_LAST_UPDATED_AT_HEADER = "x-tfy-session-last-updated-at";
|
|
6
|
+
|
|
5
7
|
export type DraftSessionBridge = {
|
|
6
|
-
syncAgentSpec: (draftSessionId: string, agentSpec: AgentSpec) => Promise<
|
|
8
|
+
syncAgentSpec: (draftSessionId: string, agentSpec: AgentSpec) => Promise<string>;
|
|
7
9
|
getDraftAgentSpec: (draftSessionId: string) => Promise<AgentSpec>;
|
|
8
10
|
};
|
|
9
11
|
|
|
@@ -22,7 +24,11 @@ export function createDraftSessionBridge(
|
|
|
22
24
|
},
|
|
23
25
|
|
|
24
26
|
async syncAgentSpec(draftSessionId, agentSpec) {
|
|
25
|
-
await gateway.agents.private.draftSessions.update(
|
|
27
|
+
const response = await gateway.agents.private.draftSessions.update(
|
|
28
|
+
draftSessionId,
|
|
29
|
+
{ agentSpec },
|
|
30
|
+
);
|
|
31
|
+
return response.data.updatedAt;
|
|
26
32
|
},
|
|
27
33
|
};
|
|
28
34
|
}
|
package/src/{truefoundryDraftThreadListAdapter.ts → private/truefoundryDraftThreadListAdapter.ts}
RENAMED
|
@@ -2,7 +2,7 @@ import type { RemoteThreadListAdapter } from "@assistant-ui/core";
|
|
|
2
2
|
import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
|
|
3
3
|
|
|
4
4
|
import { draftSessionTitle, type AgentSpec } from "./agentSpec.js";
|
|
5
|
-
import { sessionListStartTimestamp } from "
|
|
5
|
+
import { sessionListStartTimestamp } from "../sessionListStartTimestamp.js";
|
|
6
6
|
|
|
7
7
|
const THREAD_LIST_PAGE_SIZE = 20;
|
|
8
8
|
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, renderHook } from "@testing-library/react";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import type { DraftSessionBridge } from "./draftSessionBridge.js";
|
|
6
|
+
import { useDraftAgentSpec } from "./useDraftAgentSpec.js";
|
|
7
|
+
|
|
8
|
+
const defaultAgentSpec = { model: { name: "anthropic/claude-sonnet-4-6" } };
|
|
9
|
+
|
|
10
|
+
async function flushMicrotasks() {
|
|
11
|
+
await act(async () => {
|
|
12
|
+
await Promise.resolve();
|
|
13
|
+
await Promise.resolve();
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("useDraftAgentSpec", () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.useFakeTimers();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.useRealTimers();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("stores updatedAt from sync and returns it once from takeTurnHeaderTimestamp", async () => {
|
|
27
|
+
const syncAgentSpec = vi
|
|
28
|
+
.fn()
|
|
29
|
+
.mockResolvedValue("2026-06-30T12:00:00.000Z");
|
|
30
|
+
const draftBridge: DraftSessionBridge = {
|
|
31
|
+
getDraftAgentSpec: vi.fn().mockResolvedValue(defaultAgentSpec),
|
|
32
|
+
syncAgentSpec,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const { result } = renderHook(() =>
|
|
36
|
+
useDraftAgentSpec({
|
|
37
|
+
draftSessionId: "draft-1",
|
|
38
|
+
draftBridge,
|
|
39
|
+
defaultAgentSpec,
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
await flushMicrotasks();
|
|
44
|
+
expect(draftBridge.getDraftAgentSpec).toHaveBeenCalledWith("draft-1");
|
|
45
|
+
|
|
46
|
+
act(() => {
|
|
47
|
+
result.current.updateAgentSpec({ instructions: "be brief" });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
await act(async () => {
|
|
51
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
52
|
+
});
|
|
53
|
+
await flushMicrotasks();
|
|
54
|
+
|
|
55
|
+
expect(syncAgentSpec).toHaveBeenCalledOnce();
|
|
56
|
+
|
|
57
|
+
let first: string | undefined;
|
|
58
|
+
await act(async () => {
|
|
59
|
+
first = await result.current.takeTurnHeaderTimestamp();
|
|
60
|
+
});
|
|
61
|
+
expect(first).toBe("2026-06-30T12:00:00.000Z");
|
|
62
|
+
|
|
63
|
+
let second: string | undefined;
|
|
64
|
+
await act(async () => {
|
|
65
|
+
second = await result.current.takeTurnHeaderTimestamp();
|
|
66
|
+
});
|
|
67
|
+
expect(second).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("flushes a pending debounced sync before returning the timestamp", async () => {
|
|
71
|
+
const syncAgentSpec = vi
|
|
72
|
+
.fn()
|
|
73
|
+
.mockResolvedValue("2026-06-30T13:00:00.000Z");
|
|
74
|
+
const draftBridge: DraftSessionBridge = {
|
|
75
|
+
getDraftAgentSpec: vi.fn().mockResolvedValue(defaultAgentSpec),
|
|
76
|
+
syncAgentSpec,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const { result } = renderHook(() =>
|
|
80
|
+
useDraftAgentSpec({
|
|
81
|
+
draftSessionId: "draft-1",
|
|
82
|
+
draftBridge,
|
|
83
|
+
defaultAgentSpec,
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
await flushMicrotasks();
|
|
88
|
+
expect(draftBridge.getDraftAgentSpec).toHaveBeenCalledWith("draft-1");
|
|
89
|
+
|
|
90
|
+
act(() => {
|
|
91
|
+
result.current.updateAgentSpec({ instructions: "new" });
|
|
92
|
+
});
|
|
93
|
+
expect(syncAgentSpec).not.toHaveBeenCalled();
|
|
94
|
+
|
|
95
|
+
let timestamp: string | undefined;
|
|
96
|
+
await act(async () => {
|
|
97
|
+
timestamp = await result.current.takeTurnHeaderTimestamp();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(syncAgentSpec).toHaveBeenCalledOnce();
|
|
101
|
+
expect(timestamp).toBe("2026-06-30T13:00:00.000Z");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("drops pending sync state and stored timestamp when draftSessionId changes", async () => {
|
|
105
|
+
const syncAgentSpec = vi
|
|
106
|
+
.fn()
|
|
107
|
+
.mockResolvedValue("2026-06-30T14:00:00.000Z");
|
|
108
|
+
const draftBridge: DraftSessionBridge = {
|
|
109
|
+
getDraftAgentSpec: vi.fn().mockResolvedValue(defaultAgentSpec),
|
|
110
|
+
syncAgentSpec,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const { result, rerender } = renderHook(
|
|
114
|
+
({ draftSessionId }: { draftSessionId: string }) =>
|
|
115
|
+
useDraftAgentSpec({
|
|
116
|
+
draftSessionId,
|
|
117
|
+
draftBridge,
|
|
118
|
+
defaultAgentSpec,
|
|
119
|
+
}),
|
|
120
|
+
{ initialProps: { draftSessionId: "draft-1" } },
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
await flushMicrotasks();
|
|
124
|
+
|
|
125
|
+
// A completed sync stores a timestamp for draft-1...
|
|
126
|
+
act(() => {
|
|
127
|
+
result.current.updateAgentSpec({ instructions: "for draft-1" });
|
|
128
|
+
});
|
|
129
|
+
await act(async () => {
|
|
130
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
131
|
+
});
|
|
132
|
+
await flushMicrotasks();
|
|
133
|
+
expect(syncAgentSpec).toHaveBeenCalledWith("draft-1", expect.anything());
|
|
134
|
+
|
|
135
|
+
// ...and another edit leaves a pending debounced flush for draft-1.
|
|
136
|
+
act(() => {
|
|
137
|
+
result.current.updateAgentSpec({ instructions: "pending for draft-1" });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
rerender({ draftSessionId: "draft-2" });
|
|
141
|
+
await flushMicrotasks();
|
|
142
|
+
|
|
143
|
+
let timestamp: string | undefined;
|
|
144
|
+
await act(async () => {
|
|
145
|
+
timestamp = await result.current.takeTurnHeaderTimestamp();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Neither draft-1's stored timestamp nor its pending flush leaks into draft-2.
|
|
149
|
+
expect(timestamp).toBeUndefined();
|
|
150
|
+
expect(syncAgentSpec).toHaveBeenCalledOnce();
|
|
151
|
+
expect(result.current.isSpecSyncing).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -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
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
McpAuthRequiredEvent,
|
|
3
|
+
Turn,
|
|
4
|
+
TurnCreatedEvent,
|
|
5
|
+
TurnDoneEvent,
|
|
6
|
+
TurnEvent,
|
|
7
|
+
TurnInputItem,
|
|
8
|
+
} from "truefoundry-gateway-sdk/agents";
|
|
2
9
|
|
|
3
10
|
import { extractTurnUserText } from "./extractTurnUserText.js";
|
|
4
11
|
import { PeerThreadFoldState } from "./foldPeerThreads.js";
|
|
@@ -6,6 +13,19 @@ import type { StoredApprovalDecision } from "./toolApproval.js";
|
|
|
6
13
|
import type { StoredToolResponse } from "./toolResponse.js";
|
|
7
14
|
import type { TurnStreamUpdate } from "./turnStreamUpdate.js";
|
|
8
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
|
+
|
|
9
29
|
/** Turn metadata retained for cross-turn projection and subsequent required-action replay. */
|
|
10
30
|
export type SessionTurnRecord = Pick<
|
|
11
31
|
Turn,
|
|
@@ -49,6 +69,12 @@ export type SessionSnapshot = {
|
|
|
49
69
|
requiredActions: RequiredActionsOverlay;
|
|
50
70
|
runningTurn?: Turn;
|
|
51
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;
|
|
52
78
|
};
|
|
53
79
|
|
|
54
80
|
export type ProjectSessionMessagesOptions = {
|
|
@@ -81,10 +107,30 @@ export function turnToSessionRecord(turn: Turn): SessionTurnRecord {
|
|
|
81
107
|
};
|
|
82
108
|
}
|
|
83
109
|
|
|
110
|
+
/** Builds a SessionTurnRecord from session-level TurnCreatedEvent + TurnDoneEvent data. */
|
|
111
|
+
export function sessionEventsToSessionRecord(
|
|
112
|
+
turnId: string,
|
|
113
|
+
createdEvent: TurnCreatedEvent,
|
|
114
|
+
doneEvent: TurnDoneEvent,
|
|
115
|
+
rootModelMessageIds: readonly string[],
|
|
116
|
+
sandboxId?: string,
|
|
117
|
+
): SessionTurnRecord {
|
|
118
|
+
const userText = extractTurnUserText(createdEvent.input);
|
|
119
|
+
return {
|
|
120
|
+
id: turnId,
|
|
121
|
+
...(userText ? { userText } : {}),
|
|
122
|
+
createdAt: createdEvent.createdAt,
|
|
123
|
+
state: doneEvent.state,
|
|
124
|
+
input: createdEvent.input,
|
|
125
|
+
rootModelMessageIds,
|
|
126
|
+
...(sandboxId != null ? { sandboxId } : {}),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
84
130
|
/** Returns a new snapshot wrapper; fold maps may be mutated in place before calling. */
|
|
85
131
|
export function replaceSessionSnapshot(
|
|
86
132
|
snapshot: SessionSnapshot,
|
|
87
|
-
patch: Partial<Omit<SessionSnapshot, "
|
|
133
|
+
patch: Partial<Omit<SessionSnapshot, "requiredActions">> & {
|
|
88
134
|
requiredActions?: RequiredActionsOverlay;
|
|
89
135
|
},
|
|
90
136
|
): SessionSnapshot {
|
package/src/sessions.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type {
|
|
|
4
4
|
} from "truefoundry-gateway-sdk/agents";
|
|
5
5
|
import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
|
|
6
6
|
|
|
7
|
-
import { bindDraftAgentSession } from "./bindDraftAgentSession.js";
|
|
7
|
+
import { bindDraftAgentSession } from "./private/bindDraftAgentSession.js";
|
|
8
8
|
|
|
9
9
|
const inflightBySessionId = new Map<string, Promise<AgentSession>>();
|
|
10
10
|
|
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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRuntimeExtras } from "@assistant-ui/core/internal";
|
|
2
2
|
import type { McpAuthRequiredEvent } from "truefoundry-gateway-sdk/agents";
|
|
3
3
|
|
|
4
|
-
import type { AgentSpec, AgentSpecUpdate } from "./agentSpec.js";
|
|
4
|
+
import type { AgentSpec, AgentSpecUpdate } from "./private/agentSpec.js";
|
|
5
5
|
import type { PendingApproval, PendingToolResponse } from "./collectPending.js";
|
|
6
6
|
import type { RespondToToolApprovalOptions } from "./toolApproval.js";
|
|
7
7
|
import type { RespondToToolResponseOptions } from "./toolResponse.js";
|
|
@@ -27,6 +27,10 @@ export type TrueFoundryRuntimeExtras = {
|
|
|
27
27
|
downloadSandboxFile: (path: string) => Promise<Blob>;
|
|
28
28
|
cancel: () => Promise<void>;
|
|
29
29
|
resetFromTurn: (turnId: string) => Promise<void>;
|
|
30
|
+
reload: () => void;
|
|
31
|
+
hasOlderHistory: boolean;
|
|
32
|
+
isLoadingOlderHistory: boolean;
|
|
33
|
+
loadOlderHistory: () => Promise<void>;
|
|
30
34
|
draft: TrueFoundryDraftRuntimeExtras | null;
|
|
31
35
|
};
|
|
32
36
|
|