@truefoundry/assistant-ui-runtime 0.1.13 → 0.1.15
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 +23 -0
- package/dist/{chunk-XYGEKRA2.js → chunk-S4SH2KT2.js} +1 -1
- package/dist/chunk-S4SH2KT2.js.map +1 -0
- package/dist/index.d.ts +23 -3
- package/dist/index.js +138 -11
- package/dist/index.js.map +1 -1
- package/dist/plugins/truefoundry-agent-server-adapter/index.d.ts +2 -3
- package/dist/plugins/truefoundry-agent-server-adapter/index.js +1 -1
- package/dist/server/index.d.ts +2 -2
- package/dist/{types-BE-F3vCD.d.ts → types-VU3Fc17W.d.ts} +23 -5
- package/package.json +1 -1
- package/src/convertTurnMessages.test.ts +295 -1
- package/src/convertTurnMessages.ts +116 -13
- package/src/draft/useDraftAgentSpec.test.tsx +107 -0
- package/src/draft/useDraftAgentSpec.ts +38 -0
- package/src/hooks.ts +22 -0
- package/src/index.ts +6 -0
- package/src/plugins/truefoundry-agent-server-adapter/cp.ts +2 -1
- package/src/plugins/truefoundry-agent-server-adapter/types.ts +2 -2
- package/src/server/index.ts +3 -0
- package/src/server/types.ts +25 -3
- package/src/streamTurn.test.ts +18 -0
- package/src/streamTurn.ts +3 -3
- package/src/truefoundryExtras.ts +12 -0
- package/src/useTrueFoundryAgentMessages.test.tsx +115 -0
- package/src/useTrueFoundryAgentMessages.ts +38 -1
- package/src/useTrueFoundryAgentRuntime.ts +4 -0
- package/dist/chunk-XYGEKRA2.js.map +0 -1
|
@@ -150,4 +150,111 @@ describe("useDraftAgentSpec", () => {
|
|
|
150
150
|
expect(syncAgentSpec).toHaveBeenCalledOnce();
|
|
151
151
|
expect(result.current.isSpecSyncing).toBe(false);
|
|
152
152
|
});
|
|
153
|
+
|
|
154
|
+
it("flushes pending work before a coordinated save", async () => {
|
|
155
|
+
const syncAgentSpec = vi
|
|
156
|
+
.fn()
|
|
157
|
+
.mockResolvedValue("2026-06-30T15:00:00.000Z");
|
|
158
|
+
const draftBridge: DraftSessionBridge = {
|
|
159
|
+
getDraftAgentSpec: vi.fn().mockResolvedValue(defaultAgentSpec),
|
|
160
|
+
syncAgentSpec,
|
|
161
|
+
};
|
|
162
|
+
const { result } = renderHook(() =>
|
|
163
|
+
useDraftAgentSpec({
|
|
164
|
+
draftSessionId: "draft-1",
|
|
165
|
+
draftBridge,
|
|
166
|
+
defaultAgentSpec,
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
await flushMicrotasks();
|
|
170
|
+
|
|
171
|
+
act(() => {
|
|
172
|
+
result.current.updateAgentSpec({ instructions: "latest draft" });
|
|
173
|
+
});
|
|
174
|
+
await act(async () => {
|
|
175
|
+
await result.current.flushAgentSpec();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(syncAgentSpec).toHaveBeenCalledWith("draft-1", {
|
|
179
|
+
model: defaultAgentSpec.model,
|
|
180
|
+
instructions: "latest draft",
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("adopts an atomically persisted spec without scheduling another sync", async () => {
|
|
185
|
+
const syncAgentSpec = vi.fn().mockResolvedValue("unused");
|
|
186
|
+
const draftBridge: DraftSessionBridge = {
|
|
187
|
+
getDraftAgentSpec: vi.fn().mockResolvedValue(defaultAgentSpec),
|
|
188
|
+
syncAgentSpec,
|
|
189
|
+
};
|
|
190
|
+
const { result } = renderHook(() =>
|
|
191
|
+
useDraftAgentSpec({
|
|
192
|
+
draftSessionId: "draft-1",
|
|
193
|
+
draftBridge,
|
|
194
|
+
defaultAgentSpec,
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
await flushMicrotasks();
|
|
198
|
+
|
|
199
|
+
act(() => {
|
|
200
|
+
result.current.adoptAgentSpec({
|
|
201
|
+
agentSpec: {
|
|
202
|
+
model: { name: "openai/gpt-5" },
|
|
203
|
+
config: { generativeUi: { enabled: false } },
|
|
204
|
+
},
|
|
205
|
+
updatedAt: "2026-06-30T16:00:00.000Z",
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
await act(async () => {
|
|
209
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(result.current.agentSpec).toEqual({
|
|
213
|
+
model: { name: "openai/gpt-5" },
|
|
214
|
+
config: { generativeUi: { enabled: false } },
|
|
215
|
+
});
|
|
216
|
+
expect(syncAgentSpec).not.toHaveBeenCalled();
|
|
217
|
+
await expect(result.current.takeTurnHeaderTimestamp()).resolves.toBe(
|
|
218
|
+
"2026-06-30T16:00:00.000Z",
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("preserves the last sync timestamp when adopt omits updatedAt", async () => {
|
|
223
|
+
const syncAgentSpec = vi
|
|
224
|
+
.fn()
|
|
225
|
+
.mockResolvedValue("2026-06-30T17:00:00.000Z");
|
|
226
|
+
const draftBridge: DraftSessionBridge = {
|
|
227
|
+
getDraftAgentSpec: vi.fn().mockResolvedValue(defaultAgentSpec),
|
|
228
|
+
syncAgentSpec,
|
|
229
|
+
};
|
|
230
|
+
const { result } = renderHook(() =>
|
|
231
|
+
useDraftAgentSpec({
|
|
232
|
+
draftSessionId: "draft-1",
|
|
233
|
+
draftBridge,
|
|
234
|
+
defaultAgentSpec,
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
await flushMicrotasks();
|
|
238
|
+
|
|
239
|
+
act(() => {
|
|
240
|
+
result.current.updateAgentSpec({ instructions: "synced" });
|
|
241
|
+
});
|
|
242
|
+
await act(async () => {
|
|
243
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
244
|
+
});
|
|
245
|
+
await flushMicrotasks();
|
|
246
|
+
expect(syncAgentSpec).toHaveBeenCalledOnce();
|
|
247
|
+
|
|
248
|
+
act(() => {
|
|
249
|
+
result.current.adoptAgentSpec({
|
|
250
|
+
agentSpec: {
|
|
251
|
+
model: { name: "openai/gpt-5" },
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
await expect(result.current.takeTurnHeaderTimestamp()).resolves.toBe(
|
|
257
|
+
"2026-06-30T17:00:00.000Z",
|
|
258
|
+
);
|
|
259
|
+
});
|
|
153
260
|
});
|
|
@@ -23,6 +23,11 @@ export type UseDraftAgentSpecResult = {
|
|
|
23
23
|
isSpecSyncing: boolean;
|
|
24
24
|
specError: unknown | null;
|
|
25
25
|
updateAgentSpec: (update: AgentSpecUpdate) => void;
|
|
26
|
+
flushAgentSpec: () => Promise<void>;
|
|
27
|
+
adoptAgentSpec: (request: {
|
|
28
|
+
agentSpec: AgentSpec;
|
|
29
|
+
updatedAt?: string;
|
|
30
|
+
}) => void;
|
|
26
31
|
takeTurnHeaderTimestamp: () => Promise<string | undefined>;
|
|
27
32
|
};
|
|
28
33
|
|
|
@@ -206,6 +211,35 @@ export function useDraftAgentSpec({
|
|
|
206
211
|
}
|
|
207
212
|
}, []);
|
|
208
213
|
|
|
214
|
+
const adoptAgentSpec = useCallback(
|
|
215
|
+
({
|
|
216
|
+
agentSpec: persistedSpec,
|
|
217
|
+
updatedAt,
|
|
218
|
+
}: {
|
|
219
|
+
agentSpec: AgentSpec;
|
|
220
|
+
updatedAt?: string;
|
|
221
|
+
}) => {
|
|
222
|
+
if (syncTimeoutRef.current != null) {
|
|
223
|
+
clearTimeout(syncTimeoutRef.current);
|
|
224
|
+
syncTimeoutRef.current = undefined;
|
|
225
|
+
}
|
|
226
|
+
syncGenerationRef.current++;
|
|
227
|
+
pendingFlushRef.current = undefined;
|
|
228
|
+
inFlightFlushRef.current = undefined;
|
|
229
|
+
localDirtyRef.current = false;
|
|
230
|
+
// Keep the last successful sync timestamp when the caller omits
|
|
231
|
+
// updatedAt (e.g. a save that did not return sessionUpdatedAt).
|
|
232
|
+
if (updatedAt !== undefined) {
|
|
233
|
+
lastUpdatedAtRef.current = updatedAt;
|
|
234
|
+
}
|
|
235
|
+
agentSpecRef.current = persistedSpec;
|
|
236
|
+
setAgentSpec(persistedSpec);
|
|
237
|
+
setSpecError(null);
|
|
238
|
+
setIsSpecSyncing(false);
|
|
239
|
+
},
|
|
240
|
+
[],
|
|
241
|
+
);
|
|
242
|
+
|
|
209
243
|
const takeTurnHeaderTimestamp = useCallback(async () => {
|
|
210
244
|
await flushPendingSpecSyncNow();
|
|
211
245
|
const updatedAt = lastUpdatedAtRef.current;
|
|
@@ -245,6 +279,8 @@ export function useDraftAgentSpec({
|
|
|
245
279
|
isSpecSyncing: enabled ? isSpecSyncing : false,
|
|
246
280
|
specError: enabled ? specError : null,
|
|
247
281
|
updateAgentSpec,
|
|
282
|
+
flushAgentSpec: flushPendingSpecSyncNow,
|
|
283
|
+
adoptAgentSpec,
|
|
248
284
|
takeTurnHeaderTimestamp,
|
|
249
285
|
}),
|
|
250
286
|
[
|
|
@@ -254,6 +290,8 @@ export function useDraftAgentSpec({
|
|
|
254
290
|
isSpecLoading,
|
|
255
291
|
isSpecSyncing,
|
|
256
292
|
specError,
|
|
293
|
+
flushPendingSpecSyncNow,
|
|
294
|
+
adoptAgentSpec,
|
|
257
295
|
takeTurnHeaderTimestamp,
|
|
258
296
|
updateAgentSpec,
|
|
259
297
|
],
|
package/src/hooks.ts
CHANGED
|
@@ -142,6 +142,13 @@ export const useTrueFoundryHistoryPagination = () => {
|
|
|
142
142
|
);
|
|
143
143
|
};
|
|
144
144
|
|
|
145
|
+
/**
|
|
146
|
+
* True while a turn runs that this server cannot stream, so its result will not
|
|
147
|
+
* arrive in this client. Falls back to `false` on runtimes that predate the flag.
|
|
148
|
+
*/
|
|
149
|
+
export const useTrueFoundryResumeUnavailable = () =>
|
|
150
|
+
trueFoundryExtras.use((e) => e.resumeUnavailable, false) ?? false;
|
|
151
|
+
|
|
145
152
|
/** Returns a function to reset (re-submit) a user turn from any render context. */
|
|
146
153
|
export const useTrueFoundryResetFromTurn = () => {
|
|
147
154
|
const aui = useAui();
|
|
@@ -165,3 +172,18 @@ export const useTrueFoundryUpdateAgentSpec = () => {
|
|
|
165
172
|
trueFoundryExtras.get(aui).draft?.updateAgentSpec(update);
|
|
166
173
|
};
|
|
167
174
|
|
|
175
|
+
/** Flushes any pending draft-spec synchronization before a coordinated write. */
|
|
176
|
+
export const useTrueFoundryFlushAgentSpec = () => {
|
|
177
|
+
const aui = useAui();
|
|
178
|
+
return () =>
|
|
179
|
+
trueFoundryExtras.get(aui).draft?.flushAgentSpec() ?? Promise.resolve();
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** Adopts a spec already persisted by another server operation without syncing again. */
|
|
183
|
+
export const useTrueFoundryAdoptAgentSpec = () => {
|
|
184
|
+
const aui = useAui();
|
|
185
|
+
return (
|
|
186
|
+
request: Parameters<TrueFoundryDraftRuntimeExtras["adoptAgentSpec"]>[0],
|
|
187
|
+
) => trueFoundryExtras.get(aui).draft?.adoptAgentSpec(request);
|
|
188
|
+
};
|
|
189
|
+
|
package/src/index.ts
CHANGED
|
@@ -25,7 +25,9 @@ export { createTrueFoundryOwnedSessionsThreadListAdapter } from "./truefoundryOw
|
|
|
25
25
|
export { createDraftSessionBridge } from "./draft/draftSessionBridge.js";
|
|
26
26
|
export type { DraftSessionBridge } from "./draft/draftSessionBridge.js";
|
|
27
27
|
export {
|
|
28
|
+
useTrueFoundryAdoptAgentSpec,
|
|
28
29
|
useTrueFoundryAgentSpec,
|
|
30
|
+
useTrueFoundryFlushAgentSpec,
|
|
29
31
|
useTrueFoundryUpdateAgentSpec,
|
|
30
32
|
} from "./hooks.js";
|
|
31
33
|
export type { TrueFoundryDraftRuntimeExtras } from "./truefoundryExtras.js";
|
|
@@ -55,6 +57,7 @@ export {
|
|
|
55
57
|
useTrueFoundryResetFromTurn,
|
|
56
58
|
useTrueFoundryReload,
|
|
57
59
|
useTrueFoundryHistoryPagination,
|
|
60
|
+
useTrueFoundryResumeUnavailable,
|
|
58
61
|
} from "./hooks.js";
|
|
59
62
|
export {
|
|
60
63
|
collectApprovalInputs,
|
|
@@ -79,7 +82,10 @@ export type {
|
|
|
79
82
|
AgentChatServer,
|
|
80
83
|
AgentBuilderCapabilitiesResponse,
|
|
81
84
|
AgentBuilderServer,
|
|
85
|
+
AgentCapabilityConfig,
|
|
86
|
+
AgentRuntimeConfig,
|
|
82
87
|
SaveAgentRequest,
|
|
88
|
+
SaveAgentResult,
|
|
83
89
|
Session,
|
|
84
90
|
Turn,
|
|
85
91
|
TurnState,
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
AgentSelectorEntry,
|
|
10
10
|
ConnectorSelectorEntry,
|
|
11
11
|
ModelSelectorEntry,
|
|
12
|
+
SaveAgentRequest,
|
|
12
13
|
SearchAgentSelectorParams,
|
|
13
14
|
SkillSelectorEntry,
|
|
14
15
|
} from "../../server/types.js";
|
|
@@ -595,7 +596,7 @@ export function buildSaveAgentManifest(
|
|
|
595
596
|
*/
|
|
596
597
|
export async function saveAgent(
|
|
597
598
|
opts: CpCredentials,
|
|
598
|
-
req:
|
|
599
|
+
req: SaveAgentRequest<TfyAgentSpec>,
|
|
599
600
|
): Promise<unknown> {
|
|
600
601
|
const manifest = buildSaveAgentManifest(req.agentName, req.agentSpec);
|
|
601
602
|
return cpFetch(opts, "/api/svc/v1/agents", {
|
|
@@ -67,9 +67,9 @@ export interface TfyAgentSpec
|
|
|
67
67
|
extends AgentSpec<
|
|
68
68
|
TruefoundryGatewayApi.Model,
|
|
69
69
|
TfySkillMount,
|
|
70
|
-
TfyMcpServerMount
|
|
70
|
+
TfyMcpServerMount,
|
|
71
|
+
TruefoundryGatewayApi.RuntimeConfig
|
|
71
72
|
> {
|
|
72
|
-
config?: TruefoundryGatewayApi.RuntimeConfig;
|
|
73
73
|
responseFormat?: TruefoundryGatewayApi.ResponseFormat;
|
|
74
74
|
messages?: TruefoundryGatewayApi.AgentSpecUserMessage[];
|
|
75
75
|
}
|
package/src/server/index.ts
CHANGED
package/src/server/types.ts
CHANGED
|
@@ -105,19 +105,31 @@ export interface Model {
|
|
|
105
105
|
params?: ModelParams;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
export interface AgentCapabilityConfig {
|
|
109
|
+
enabled?: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface AgentRuntimeConfig {
|
|
113
|
+
generativeUi?: AgentCapabilityConfig;
|
|
114
|
+
dynamicSubAgents?: AgentCapabilityConfig;
|
|
115
|
+
askUserQuestions?: AgentCapabilityConfig;
|
|
116
|
+
}
|
|
117
|
+
|
|
108
118
|
/**
|
|
109
119
|
* SDK-owned agent definition — fields the FE reads/writes.
|
|
110
|
-
* Host widens `model` / `skills` / `mcpServers` via type params,
|
|
111
|
-
* extra fields via `TSpec extends AgentSpec<...>`.
|
|
120
|
+
* Host widens `model` / `skills` / `mcpServers` / `config` via type params,
|
|
121
|
+
* and adds extra fields via `TSpec extends AgentSpec<...>`.
|
|
112
122
|
*/
|
|
113
123
|
export interface AgentSpec<
|
|
114
124
|
TModel extends Model = Model,
|
|
115
125
|
TSkill extends SkillMount = SkillMount,
|
|
116
126
|
TMcp extends McpServerMount = McpServerMount,
|
|
127
|
+
TConfig extends AgentRuntimeConfig = AgentRuntimeConfig,
|
|
117
128
|
> {
|
|
118
129
|
model: TModel;
|
|
119
130
|
skills?: TSkill[];
|
|
120
131
|
mcpServers?: TMcp[];
|
|
132
|
+
config?: TConfig;
|
|
121
133
|
instructions?: string;
|
|
122
134
|
variables?: Record<string, string>;
|
|
123
135
|
}
|
|
@@ -329,6 +341,16 @@ export interface AgentChatServer<
|
|
|
329
341
|
export interface SaveAgentRequest<TSpec extends AgentSpec = AgentSpec> {
|
|
330
342
|
agentName: string;
|
|
331
343
|
agentSpec: TSpec;
|
|
344
|
+
intent: "create" | "update";
|
|
345
|
+
/** Current mutable session to update atomically with the named agent. */
|
|
346
|
+
sessionId?: string;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export interface SaveAgentResult {
|
|
350
|
+
/** Immutable id allocated by the host registry. */
|
|
351
|
+
agentId?: string;
|
|
352
|
+
/** Timestamp returned when the active mutable session was updated. */
|
|
353
|
+
sessionUpdatedAt?: string;
|
|
332
354
|
}
|
|
333
355
|
|
|
334
356
|
/**
|
|
@@ -352,7 +374,7 @@ export interface AgentBuilderServer<
|
|
|
352
374
|
TSkill extends SkillSelectorEntry = SkillSelectorEntry,
|
|
353
375
|
TMcp extends ConnectorSelectorEntry = ConnectorSelectorEntry,
|
|
354
376
|
TAgent extends AgentSelectorEntry = AgentSelectorEntry,
|
|
355
|
-
TSave =
|
|
377
|
+
TSave = SaveAgentResult,
|
|
356
378
|
TCapabilities extends AgentBuilderCapabilitiesResponse = AgentBuilderCapabilitiesResponse,
|
|
357
379
|
> {
|
|
358
380
|
getCapabilities(): Promise<TCapabilities>;
|
package/src/streamTurn.test.ts
CHANGED
|
@@ -357,6 +357,24 @@ describe("streamTurn", () => {
|
|
|
357
357
|
expect(updates).toEqual([{ content: [{ type: "text", text: "resumed" }] }]);
|
|
358
358
|
});
|
|
359
359
|
|
|
360
|
+
it("yields nothing when the server omits subscribeToTurn", async () => {
|
|
361
|
+
const cancelSession = vi.fn().mockResolvedValue(undefined);
|
|
362
|
+
const server = mockServer({ cancelSession });
|
|
363
|
+
|
|
364
|
+
const updates = await collectUpdates(
|
|
365
|
+
resumeTurnStream(
|
|
366
|
+
server,
|
|
367
|
+
SESSION_ID,
|
|
368
|
+
"turn-1",
|
|
369
|
+
new PeerThreadFoldState(),
|
|
370
|
+
new AbortController().signal,
|
|
371
|
+
),
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
expect(updates).toEqual([]);
|
|
375
|
+
expect(cancelSession).not.toHaveBeenCalled();
|
|
376
|
+
});
|
|
377
|
+
|
|
360
378
|
it("returns early when aborted before streaming starts", async () => {
|
|
361
379
|
const subscribeToTurn = vi.fn(async function* () {});
|
|
362
380
|
const cancelSession = vi.fn().mockResolvedValue(undefined);
|
package/src/streamTurn.ts
CHANGED
|
@@ -116,10 +116,10 @@ export async function* resumeTurnStream(
|
|
|
116
116
|
afterSequenceNumber?: number,
|
|
117
117
|
groupRootBaseline?: readonly string[],
|
|
118
118
|
): AsyncGenerator<TurnStreamUpdate> {
|
|
119
|
+
// Optional on custom backends. Callers detect the gap and report it, so an
|
|
120
|
+
// empty stream here is safer than throwing mid-render.
|
|
119
121
|
if (server.subscribeToTurn == null) {
|
|
120
|
-
|
|
121
|
-
"resumeTurnStream requires AgentChatServer.subscribeToTurn",
|
|
122
|
-
);
|
|
122
|
+
return;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
const onAbort = () => {
|
package/src/truefoundryExtras.ts
CHANGED
|
@@ -16,12 +16,18 @@ export type TrueFoundryDraftRuntimeExtras = {
|
|
|
16
16
|
isSpecSyncing: boolean;
|
|
17
17
|
specError: unknown | null;
|
|
18
18
|
updateAgentSpec: (update: AgentSpecUpdate) => void;
|
|
19
|
+
flushAgentSpec: () => Promise<void>;
|
|
20
|
+
adoptAgentSpec: (request: {
|
|
21
|
+
agentSpec: AgentSpec;
|
|
22
|
+
updatedAt?: string;
|
|
23
|
+
}) => void;
|
|
19
24
|
};
|
|
20
25
|
|
|
21
26
|
export type TrueFoundryRuntimeExtras = {
|
|
22
27
|
pendingApprovals: PendingApproval[];
|
|
23
28
|
pendingToolResponses: PendingToolResponse[];
|
|
24
29
|
pendingMcpAuth: { mcpServers: McpAuthRequiredEvent["mcpServers"] } | null;
|
|
30
|
+
resumeUnavailable: boolean;
|
|
25
31
|
sandboxId: string | undefined;
|
|
26
32
|
respondToToolApproval: (response: RespondToToolApprovalOptions) => void;
|
|
27
33
|
respondToToolResponse: (response: RespondToToolResponseOptions) => void;
|
|
@@ -49,4 +55,10 @@ export const EMPTY_DRAFT_EXTRAS: TrueFoundryDraftRuntimeExtras = {
|
|
|
49
55
|
updateAgentSpec: () => {
|
|
50
56
|
throw new Error("Draft agent extras are only available in draft mode.");
|
|
51
57
|
},
|
|
58
|
+
flushAgentSpec: async () => {
|
|
59
|
+
throw new Error("Draft agent extras are only available in draft mode.");
|
|
60
|
+
},
|
|
61
|
+
adoptAgentSpec: () => {
|
|
62
|
+
throw new Error("Draft agent extras are only available in draft mode.");
|
|
63
|
+
},
|
|
52
64
|
};
|
|
@@ -47,6 +47,8 @@ vi.mock("./convertTurnMessages.js", async (importOriginal) => {
|
|
|
47
47
|
const mockServer = {
|
|
48
48
|
cancelSession: vi.fn().mockResolvedValue(undefined),
|
|
49
49
|
listTurns: vi.fn(),
|
|
50
|
+
// Present so resume-capable paths are exercised; resumeTurnStream is mocked.
|
|
51
|
+
subscribeToTurn: vi.fn(),
|
|
50
52
|
} as unknown as AgentChatServer;
|
|
51
53
|
|
|
52
54
|
function snapshotWithAssistantMessage(
|
|
@@ -503,6 +505,119 @@ describe("useTrueFoundryAgentMessages", () => {
|
|
|
503
505
|
);
|
|
504
506
|
});
|
|
505
507
|
|
|
508
|
+
it("shows loaded history as running when the server cannot resume the turn", async () => {
|
|
509
|
+
const onError = vi.fn();
|
|
510
|
+
const runningTurn = {
|
|
511
|
+
id: "turn-running",
|
|
512
|
+
input: [{ type: "user.message", content: "keep going" }],
|
|
513
|
+
createdAt: new Date().toISOString(),
|
|
514
|
+
} as Turn;
|
|
515
|
+
vi.mocked(loadSessionSnapshot).mockResolvedValue(
|
|
516
|
+
replaceSessionSnapshot(createEmptySessionSnapshot(), {
|
|
517
|
+
runningTurn,
|
|
518
|
+
unstable_resume: true,
|
|
519
|
+
pendingUser: {
|
|
520
|
+
turnId: runningTurn.id,
|
|
521
|
+
content: "keep going",
|
|
522
|
+
createdAt: new Date(runningTurn.createdAt),
|
|
523
|
+
},
|
|
524
|
+
}),
|
|
525
|
+
);
|
|
526
|
+
const serverWithoutSubscribe = {
|
|
527
|
+
cancelSession: vi.fn().mockResolvedValue(undefined),
|
|
528
|
+
listTurns: vi.fn().mockResolvedValue({ data: [] }),
|
|
529
|
+
} as unknown as AgentChatServer;
|
|
530
|
+
|
|
531
|
+
const { result } = renderHook(() =>
|
|
532
|
+
useTrueFoundryAgentMessages({
|
|
533
|
+
server: serverWithoutSubscribe,
|
|
534
|
+
sessionId: "session-1",
|
|
535
|
+
onError,
|
|
536
|
+
}),
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
540
|
+
expect(resumeTurnStream).not.toHaveBeenCalled();
|
|
541
|
+
// The turn keeps running server-side, so history renders with a
|
|
542
|
+
// pending indicator rather than an endless skeleton.
|
|
543
|
+
await waitFor(() => expect(result.current.isRunning).toBe(true));
|
|
544
|
+
expect(result.current.messages[0]).toMatchObject({
|
|
545
|
+
role: "user",
|
|
546
|
+
content: [{ type: "text", text: "keep going" }],
|
|
547
|
+
});
|
|
548
|
+
// Waiting, not failing: hosts render this as state, not an error.
|
|
549
|
+
expect(result.current.resumeUnavailable).toBe(true);
|
|
550
|
+
expect(onError).not.toHaveBeenCalled();
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it("clears the running state when cancelling a turn it could not resume", async () => {
|
|
554
|
+
const runningTurn = { id: "turn-running" } as Turn;
|
|
555
|
+
vi.mocked(loadSessionSnapshot).mockResolvedValue(
|
|
556
|
+
replaceSessionSnapshot(createEmptySessionSnapshot(), {
|
|
557
|
+
runningTurn,
|
|
558
|
+
unstable_resume: true,
|
|
559
|
+
}),
|
|
560
|
+
);
|
|
561
|
+
const serverWithoutSubscribe = {
|
|
562
|
+
cancelSession: vi.fn().mockResolvedValue(undefined),
|
|
563
|
+
listTurns: vi.fn().mockResolvedValue({ data: [] }),
|
|
564
|
+
} as unknown as AgentChatServer;
|
|
565
|
+
|
|
566
|
+
const { result } = renderHook(() =>
|
|
567
|
+
useTrueFoundryAgentMessages({
|
|
568
|
+
server: serverWithoutSubscribe,
|
|
569
|
+
sessionId: "session-1",
|
|
570
|
+
onError: vi.fn(),
|
|
571
|
+
}),
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
await waitFor(() => expect(result.current.isRunning).toBe(true));
|
|
575
|
+
|
|
576
|
+
await act(async () => {
|
|
577
|
+
await result.current.cancel();
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
expect(serverWithoutSubscribe.cancelSession).toHaveBeenCalledWith({
|
|
581
|
+
sessionId: "session-1",
|
|
582
|
+
});
|
|
583
|
+
// No stream was attached, so nothing else would release the composer.
|
|
584
|
+
expect(result.current.isRunning).toBe(false);
|
|
585
|
+
expect(result.current.resumeUnavailable).toBe(false);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it("stays in the waiting state instead of resuming when resumeRun has no subscribeToTurn", async () => {
|
|
589
|
+
const onError = vi.fn();
|
|
590
|
+
const runningTurn = { id: "turn-running" } as Turn;
|
|
591
|
+
vi.mocked(loadSessionSnapshot).mockResolvedValue(
|
|
592
|
+
replaceSessionSnapshot(createEmptySessionSnapshot(), {
|
|
593
|
+
runningTurn,
|
|
594
|
+
unstable_resume: true,
|
|
595
|
+
}),
|
|
596
|
+
);
|
|
597
|
+
const serverWithoutSubscribe = {
|
|
598
|
+
cancelSession: vi.fn().mockResolvedValue(undefined),
|
|
599
|
+
listTurns: vi.fn().mockResolvedValue({ data: [] }),
|
|
600
|
+
} as unknown as AgentChatServer;
|
|
601
|
+
|
|
602
|
+
const { result } = renderHook(() =>
|
|
603
|
+
useTrueFoundryAgentMessages({
|
|
604
|
+
server: serverWithoutSubscribe,
|
|
605
|
+
sessionId: "session-1",
|
|
606
|
+
onError,
|
|
607
|
+
}),
|
|
608
|
+
);
|
|
609
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
610
|
+
onError.mockClear();
|
|
611
|
+
|
|
612
|
+
await act(async () => {
|
|
613
|
+
await result.current.resumeRun();
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
expect(resumeTurnStream).not.toHaveBeenCalled();
|
|
617
|
+
expect(result.current.resumeUnavailable).toBe(true);
|
|
618
|
+
expect(onError).not.toHaveBeenCalled();
|
|
619
|
+
});
|
|
620
|
+
|
|
506
621
|
it("clears isLoading while a resumed turn is still streaming", async () => {
|
|
507
622
|
let releaseStream: (() => void) | undefined;
|
|
508
623
|
vi.mocked(resumeTurnStream).mockReturnValue(
|
|
@@ -309,6 +309,7 @@ export function useTrueFoundryAgentMessages({
|
|
|
309
309
|
);
|
|
310
310
|
const [isLoadingOlderHistory, setIsLoadingOlderHistory] = useState(false);
|
|
311
311
|
const [loadRetryTrigger, setLoadRetryTrigger] = useState(0);
|
|
312
|
+
const [resumeUnavailable, setResumeUnavailable] = useState(false);
|
|
312
313
|
|
|
313
314
|
const snapshotRef = useRef(snapshot);
|
|
314
315
|
snapshotRef.current = snapshot;
|
|
@@ -326,6 +327,8 @@ export function useTrueFoundryAgentMessages({
|
|
|
326
327
|
const createdAtByMessageIdRef = useRef(new Map<string, Date>());
|
|
327
328
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
328
329
|
const activeRunRef = useRef<Promise<void> | null>(null);
|
|
330
|
+
// Mirrors `resumeUnavailable` for `cancel`, which reads it outside render.
|
|
331
|
+
const resumeUnavailableRef = useRef(false);
|
|
329
332
|
const runningTurnRef = useRef<Turn | undefined>(undefined);
|
|
330
333
|
const loadGenerationRef = useRef(0);
|
|
331
334
|
const streamGenerationRef = useRef(0);
|
|
@@ -333,6 +336,16 @@ export function useTrueFoundryAgentMessages({
|
|
|
333
336
|
const initialLoadStartedForRef = useRef<string | undefined>(undefined);
|
|
334
337
|
const skipInitialPromotionLoadForRef = useRef<string | undefined>(undefined);
|
|
335
338
|
|
|
339
|
+
/**
|
|
340
|
+
* A turn is running that this server cannot stream. Nothing will deliver its
|
|
341
|
+
* result to this client, so the UI shows a waiting state until the run is
|
|
342
|
+
* cancelled or the session is reloaded.
|
|
343
|
+
*/
|
|
344
|
+
const markResumeUnavailable = useCallback((value: boolean) => {
|
|
345
|
+
resumeUnavailableRef.current = value;
|
|
346
|
+
setResumeUnavailable(value);
|
|
347
|
+
}, []);
|
|
348
|
+
|
|
336
349
|
const projectOptions = useMemo(
|
|
337
350
|
() => ({
|
|
338
351
|
getCreatedAt: (messageId: string, fallback: Date) => {
|
|
@@ -371,6 +384,8 @@ export function useTrueFoundryAgentMessages({
|
|
|
371
384
|
const abortController = new AbortController();
|
|
372
385
|
abortControllerRef.current = abortController;
|
|
373
386
|
setIsRunning(true);
|
|
387
|
+
// This stream's `finally` owns the running flag from here on.
|
|
388
|
+
markResumeUnavailable(false);
|
|
374
389
|
|
|
375
390
|
const run = (async () => {
|
|
376
391
|
// Sub-agent turns can emit 100+ stream events per frame. Coalesce to one
|
|
@@ -516,6 +531,7 @@ export function useTrueFoundryAgentMessages({
|
|
|
516
531
|
const generation = ++loadGenerationRef.current;
|
|
517
532
|
++streamGenerationRef.current;
|
|
518
533
|
setIsRunning(false);
|
|
534
|
+
markResumeUnavailable(false);
|
|
519
535
|
abortControllerRef.current?.abort();
|
|
520
536
|
loadOlderInflightRef.current = null;
|
|
521
537
|
createdAtByMessageIdRef.current = new Map();
|
|
@@ -554,6 +570,16 @@ export function useTrueFoundryAgentMessages({
|
|
|
554
570
|
|
|
555
571
|
if (loadedSnapshot.runningTurn != null) {
|
|
556
572
|
const turn = loadedSnapshot.runningTurn;
|
|
573
|
+
|
|
574
|
+
// subscribeToTurn is optional, so a server can leave us without
|
|
575
|
+
// a reconnect path. The turn still runs on the backend: show the
|
|
576
|
+
// loaded history as running and let the host explain the gap.
|
|
577
|
+
if (server.subscribeToTurn == null) {
|
|
578
|
+
setIsRunning(true);
|
|
579
|
+
markResumeUnavailable(true);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
557
583
|
const isContinuation = !extractTurnUserText(turn.input);
|
|
558
584
|
// TODO: pass afterSequenceNumber once stream ingestion tracks sequence numbers.
|
|
559
585
|
// Use loadedSnapshot directly — snapshotRef.current still points at
|
|
@@ -831,7 +857,13 @@ export function useTrueFoundryAgentMessages({
|
|
|
831
857
|
// reconcile is needed here — the cancelled turn is terminal and local
|
|
832
858
|
// state reconciles against the event log on the next session load.
|
|
833
859
|
await activeRunRef.current?.catch(() => undefined);
|
|
834
|
-
|
|
860
|
+
// Nothing drained when the load could not attach a stream, so clear the
|
|
861
|
+
// running flag here or the composer stays blocked until a reload.
|
|
862
|
+
if (resumeUnavailableRef.current) {
|
|
863
|
+
markResumeUnavailable(false);
|
|
864
|
+
setIsRunning(false);
|
|
865
|
+
}
|
|
866
|
+
}, [server, sessionId, markResumeUnavailable]);
|
|
835
867
|
|
|
836
868
|
const isRunningRef = useRef(isRunning);
|
|
837
869
|
isRunningRef.current = isRunning;
|
|
@@ -897,6 +929,10 @@ export function useTrueFoundryAgentMessages({
|
|
|
897
929
|
if (turn == null) {
|
|
898
930
|
return;
|
|
899
931
|
}
|
|
932
|
+
if (server.subscribeToTurn == null) {
|
|
933
|
+
markResumeUnavailable(true);
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
900
936
|
// TODO: pass afterSequenceNumber once stream ingestion tracks sequence numbers.
|
|
901
937
|
await runStream(
|
|
902
938
|
(signal) =>
|
|
@@ -1066,6 +1102,7 @@ export function useTrueFoundryAgentMessages({
|
|
|
1066
1102
|
return {
|
|
1067
1103
|
messages,
|
|
1068
1104
|
isRunning,
|
|
1105
|
+
resumeUnavailable,
|
|
1069
1106
|
isLoading,
|
|
1070
1107
|
isLoadingOlderHistory,
|
|
1071
1108
|
hasOlderHistory,
|
|
@@ -108,6 +108,7 @@ function useTrueFoundryAgentRuntimeImpl(
|
|
|
108
108
|
const {
|
|
109
109
|
messages,
|
|
110
110
|
isRunning,
|
|
111
|
+
resumeUnavailable,
|
|
111
112
|
isLoading,
|
|
112
113
|
isLoadingOlderHistory,
|
|
113
114
|
hasOlderHistory,
|
|
@@ -187,6 +188,8 @@ function useTrueFoundryAgentRuntimeImpl(
|
|
|
187
188
|
isSpecSyncing: draftSpec.isSpecSyncing,
|
|
188
189
|
specError: draftSpec.specError,
|
|
189
190
|
updateAgentSpec: draftSpec.updateAgentSpec,
|
|
191
|
+
flushAgentSpec: draftSpec.flushAgentSpec,
|
|
192
|
+
adoptAgentSpec: draftSpec.adoptAgentSpec,
|
|
190
193
|
};
|
|
191
194
|
}, [agent.mode, draftSpec]);
|
|
192
195
|
|
|
@@ -199,6 +202,7 @@ function useTrueFoundryAgentRuntimeImpl(
|
|
|
199
202
|
pendingApprovals,
|
|
200
203
|
pendingToolResponses,
|
|
201
204
|
pendingMcpAuth,
|
|
205
|
+
resumeUnavailable,
|
|
202
206
|
sandboxId,
|
|
203
207
|
respondToToolApproval,
|
|
204
208
|
respondToToolResponse,
|