@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.
@@ -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: { agentName: string; agentSpec: TfyAgentSpec },
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
  }
@@ -15,8 +15,11 @@ export type {
15
15
  McpServerMount,
16
16
  ModelParams,
17
17
  Model,
18
+ AgentCapabilityConfig,
19
+ AgentRuntimeConfig,
18
20
  AgentSpec,
19
21
  SaveAgentRequest,
22
+ SaveAgentResult,
20
23
  Session,
21
24
  CreateSessionRequest,
22
25
  UpdateSessionRequest,
@@ -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, and adds
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 = unknown,
377
+ TSave = SaveAgentResult,
356
378
  TCapabilities extends AgentBuilderCapabilitiesResponse = AgentBuilderCapabilitiesResponse,
357
379
  > {
358
380
  getCapabilities(): Promise<TCapabilities>;
@@ -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
- throw new Error(
121
- "resumeTurnStream requires AgentChatServer.subscribeToTurn",
122
- );
122
+ return;
123
123
  }
124
124
 
125
125
  const onAbort = () => {
@@ -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
- }, [server, sessionId]);
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,