@truefoundry/assistant-ui-runtime 0.1.0-rc.1

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.
Files changed (58) hide show
  1. package/README.md +567 -0
  2. package/dist/index.d.ts +300 -0
  3. package/dist/index.js +4440 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +70 -0
  6. package/src/agentSessionModule.d.ts +25 -0
  7. package/src/agentSpec.ts +44 -0
  8. package/src/askUserQuestion.ts +37 -0
  9. package/src/attachmentAdapter.test.ts +56 -0
  10. package/src/attachmentAdapter.ts +58 -0
  11. package/src/bindDraftAgentSession.test.ts +55 -0
  12. package/src/bindDraftAgentSession.ts +66 -0
  13. package/src/buildEditedUserMessageContent.test.ts +61 -0
  14. package/src/collectPending.test.ts +69 -0
  15. package/src/collectPending.ts +165 -0
  16. package/src/constants.ts +2 -0
  17. package/src/convertTurnMessages.test.ts +1991 -0
  18. package/src/convertTurnMessages.ts +1251 -0
  19. package/src/createSubAgent.ts +8 -0
  20. package/src/draftAgentConfig.test.ts +88 -0
  21. package/src/draftSessionBridge.ts +28 -0
  22. package/src/extractTurnUserText.ts +21 -0
  23. package/src/foldPeerThreads.test.ts +386 -0
  24. package/src/foldPeerThreads.ts +587 -0
  25. package/src/hooks.ts +123 -0
  26. package/src/index.ts +68 -0
  27. package/src/lastUserMessageText.ts +19 -0
  28. package/src/loadSessionSnapshot.test.ts +57 -0
  29. package/src/loadSessionSnapshot.ts +35 -0
  30. package/src/mcpAuth.ts +44 -0
  31. package/src/messageCustomMetadata.ts +37 -0
  32. package/src/modelMessageContent.ts +135 -0
  33. package/src/modelMessageImageContent.test.ts +109 -0
  34. package/src/modelMessageImageContent.ts +193 -0
  35. package/src/requiredActionInputs.test.ts +394 -0
  36. package/src/requiredActionInputs.ts +65 -0
  37. package/src/requiredActionsFromActiveUpdate.test.ts +95 -0
  38. package/src/sessionListStartTimestamp.ts +6 -0
  39. package/src/sessionSnapshot.ts +107 -0
  40. package/src/sessions.ts +36 -0
  41. package/src/streamTurn.test.ts +243 -0
  42. package/src/streamTurn.ts +119 -0
  43. package/src/toolApproval.test.ts +137 -0
  44. package/src/toolApproval.ts +459 -0
  45. package/src/toolResponse.test.ts +136 -0
  46. package/src/toolResponse.ts +427 -0
  47. package/src/truefoundryDraftThreadListAdapter.test.ts +140 -0
  48. package/src/truefoundryDraftThreadListAdapter.ts +63 -0
  49. package/src/truefoundryExtras.ts +45 -0
  50. package/src/truefoundryThreadListAdapter.test.ts +103 -0
  51. package/src/truefoundryThreadListAdapter.ts +59 -0
  52. package/src/turnEventHelpers.ts +98 -0
  53. package/src/turnStreamUpdate.ts +11 -0
  54. package/src/types.ts +92 -0
  55. package/src/useDraftAgentSpec.ts +173 -0
  56. package/src/useTrueFoundryAgentMessages.test.tsx +723 -0
  57. package/src/useTrueFoundryAgentMessages.ts +757 -0
  58. package/src/useTrueFoundryAgentRuntime.ts +268 -0
@@ -0,0 +1,63 @@
1
+ import type { RemoteThreadListAdapter } from "@assistant-ui/core";
2
+ import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
3
+
4
+ import { draftSessionTitle, type AgentSpec } from "./agentSpec.js";
5
+ import { sessionListStartTimestamp } from "./sessionListStartTimestamp.js";
6
+
7
+ const THREAD_LIST_PAGE_SIZE = 20;
8
+
9
+ export function createTrueFoundryDraftThreadListAdapter(options: {
10
+ gateway: TrueFoundryGateway;
11
+ defaultAgentSpec: AgentSpec;
12
+ getAgentSpec?: () => AgentSpec;
13
+ }): RemoteThreadListAdapter {
14
+ const { gateway, defaultAgentSpec, getAgentSpec } = options;
15
+
16
+ return {
17
+ async list({ after } = {}) {
18
+ const page = await gateway.agents.private.draftSessions.list({
19
+ limit: THREAD_LIST_PAGE_SIZE,
20
+ pageToken: after,
21
+ startTimestamp: sessionListStartTimestamp(),
22
+ });
23
+ const threads = page.data.map((draft) => ({
24
+ status: "regular" as const,
25
+ remoteId: draft.id,
26
+ title: draftSessionTitle(draft),
27
+ lastMessageAt: new Date(draft.updatedAt),
28
+ }));
29
+ return {
30
+ threads,
31
+ nextCursor: page.response.pagination.nextPageToken ?? undefined,
32
+ };
33
+ },
34
+
35
+ async initialize(_threadId: string) {
36
+ const response = await gateway.agents.private.draftSessions.create({
37
+ agentSpec: getAgentSpec?.() ?? defaultAgentSpec,
38
+ });
39
+ const draft = response.data;
40
+ return { remoteId: draft.id, externalId: undefined };
41
+ },
42
+
43
+ async fetch(remoteId) {
44
+ const response = await gateway.agents.private.draftSessions.get(remoteId);
45
+ const draft = response.data;
46
+ return {
47
+ status: "regular" as const,
48
+ remoteId: draft.id,
49
+ title: draftSessionTitle(draft),
50
+ lastMessageAt: new Date(draft.updatedAt),
51
+ };
52
+ },
53
+
54
+ async rename() {},
55
+ async archive() {},
56
+ async unarchive() {},
57
+ async delete() {},
58
+
59
+ async generateTitle() {
60
+ return new ReadableStream();
61
+ },
62
+ };
63
+ }
@@ -0,0 +1,45 @@
1
+ import { createRuntimeExtras } from "@assistant-ui/core/internal";
2
+ import type { McpAuthRequiredEvent } from "truefoundry-gateway-sdk/agents";
3
+
4
+ import type { AgentSpec, AgentSpecUpdate } from "./agentSpec.js";
5
+ import type { PendingApproval, PendingToolResponse } from "./collectPending.js";
6
+ import type { RespondToToolApprovalOptions } from "./toolApproval.js";
7
+ import type { RespondToToolResponseOptions } from "./toolResponse.js";
8
+
9
+ export type { PendingApproval, PendingToolResponse };
10
+
11
+ export type TrueFoundryDraftRuntimeExtras = {
12
+ agentSpec: AgentSpec | null;
13
+ draftSessionId: string | undefined;
14
+ isSpecSyncing: boolean;
15
+ specError: unknown | null;
16
+ updateAgentSpec: (update: AgentSpecUpdate) => void;
17
+ };
18
+
19
+ export type TrueFoundryRuntimeExtras = {
20
+ pendingApprovals: PendingApproval[];
21
+ pendingToolResponses: PendingToolResponse[];
22
+ pendingMcpAuth: { mcpServers: McpAuthRequiredEvent["mcpServers"] } | null;
23
+ sandboxId: string | undefined;
24
+ respondToToolApproval: (response: RespondToToolApprovalOptions) => void;
25
+ respondToToolResponse: (response: RespondToToolResponseOptions) => void;
26
+ resumeMcpAuth: () => Promise<void>;
27
+ downloadSandboxFile: (path: string) => Promise<Blob>;
28
+ cancel: () => Promise<void>;
29
+ resetFromTurn: (turnId: string) => Promise<void>;
30
+ draft: TrueFoundryDraftRuntimeExtras | null;
31
+ };
32
+
33
+ export const trueFoundryExtras = createRuntimeExtras<TrueFoundryRuntimeExtras>(
34
+ "useTrueFoundryAgentRuntime",
35
+ );
36
+
37
+ export const EMPTY_DRAFT_EXTRAS: TrueFoundryDraftRuntimeExtras = {
38
+ agentSpec: null,
39
+ draftSessionId: undefined,
40
+ isSpecSyncing: false,
41
+ specError: null,
42
+ updateAgentSpec: () => {
43
+ throw new Error("Draft agent extras are only available in draft mode.");
44
+ },
45
+ };
@@ -0,0 +1,103 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import type { AgentSessionClient } from "truefoundry-gateway-sdk/agents";
4
+
5
+ import { createTrueFoundryThreadListAdapter } from "./truefoundryThreadListAdapter.js";
6
+
7
+ function mockSession(id: string, title: string, updatedAt: string) {
8
+ return {
9
+ id,
10
+ title,
11
+ updatedAt,
12
+ };
13
+ }
14
+
15
+ function mockListSessionsPage(
16
+ sessions: ReturnType<typeof mockSession>[],
17
+ nextPageToken?: string,
18
+ ) {
19
+ return {
20
+ data: sessions,
21
+ response: {
22
+ pagination: {
23
+ nextPageToken,
24
+ limit: 20,
25
+ },
26
+ },
27
+ };
28
+ }
29
+
30
+ describe("createTrueFoundryThreadListAdapter", () => {
31
+ it("lists the first page with limit and returns nextCursor", async () => {
32
+ const listSessions = vi.fn().mockResolvedValue(
33
+ mockListSessionsPage(
34
+ [mockSession("s1", "First", "2026-06-30T10:00:00.000Z")],
35
+ "page-2",
36
+ ),
37
+ );
38
+ const client = { listSessions } as unknown as AgentSessionClient;
39
+ const adapter = createTrueFoundryThreadListAdapter({
40
+ client,
41
+ agentName: "my-agent",
42
+ });
43
+
44
+ const result = await adapter.list();
45
+
46
+ expect(listSessions).toHaveBeenCalledWith(
47
+ expect.objectContaining({
48
+ agentName: "my-agent",
49
+ limit: 20,
50
+ pageToken: undefined,
51
+ startTimestamp: expect.any(String),
52
+ }),
53
+ );
54
+ expect(result.threads).toEqual([
55
+ {
56
+ status: "regular",
57
+ remoteId: "s1",
58
+ title: "First",
59
+ lastMessageAt: new Date("2026-06-30T10:00:00.000Z"),
60
+ },
61
+ ]);
62
+ expect(result.nextCursor).toBe("page-2");
63
+ });
64
+
65
+ it("forwards after as pageToken for subsequent pages", async () => {
66
+ const listSessions = vi.fn().mockResolvedValue(
67
+ mockListSessionsPage(
68
+ [mockSession("s2", "Second", "2026-06-29T10:00:00.000Z")],
69
+ ),
70
+ );
71
+ const client = { listSessions } as unknown as AgentSessionClient;
72
+ const adapter = createTrueFoundryThreadListAdapter({
73
+ client,
74
+ agentName: "my-agent",
75
+ });
76
+
77
+ const result = await adapter.list({ after: "page-2" });
78
+
79
+ expect(listSessions).toHaveBeenCalledWith(
80
+ expect.objectContaining({
81
+ agentName: "my-agent",
82
+ limit: 20,
83
+ pageToken: "page-2",
84
+ }),
85
+ );
86
+ expect(result.nextCursor).toBeUndefined();
87
+ });
88
+
89
+ it("omits nextCursor when the backend returns no next page token", async () => {
90
+ const listSessions = vi.fn().mockResolvedValue(
91
+ mockListSessionsPage([mockSession("s1", "Only", "2026-06-30T10:00:00.000Z")]),
92
+ );
93
+ const client = { listSessions } as unknown as AgentSessionClient;
94
+ const adapter = createTrueFoundryThreadListAdapter({
95
+ client,
96
+ agentName: "my-agent",
97
+ });
98
+
99
+ const result = await adapter.list();
100
+
101
+ expect(result.nextCursor).toBeUndefined();
102
+ });
103
+ });
@@ -0,0 +1,59 @@
1
+ import type { RemoteThreadListAdapter } from "@assistant-ui/core";
2
+ import type { AgentSessionClient } from "truefoundry-gateway-sdk/agents";
3
+
4
+ import { getSession } from "./sessions.js";
5
+ import { sessionListStartTimestamp } from "./sessionListStartTimestamp.js";
6
+
7
+ const THREAD_LIST_PAGE_SIZE = 20;
8
+
9
+ export function createTrueFoundryThreadListAdapter(options: {
10
+ client: AgentSessionClient;
11
+ agentName: string;
12
+ }): RemoteThreadListAdapter {
13
+ const { client, agentName } = options;
14
+
15
+ return {
16
+ async list({ after } = {}) {
17
+ const page = await client.listSessions({
18
+ agentName,
19
+ limit: THREAD_LIST_PAGE_SIZE,
20
+ pageToken: after,
21
+ startTimestamp: sessionListStartTimestamp(),
22
+ });
23
+ const threads = page.data.map((session) => ({
24
+ status: "regular" as const,
25
+ remoteId: session.id,
26
+ title: session.title,
27
+ lastMessageAt: new Date(session.updatedAt),
28
+ }));
29
+ return {
30
+ threads,
31
+ nextCursor: page.response.pagination.nextPageToken ?? undefined,
32
+ };
33
+ },
34
+
35
+ async initialize(_threadId: string) {
36
+ const session = await client.createSession({ agentName });
37
+ return { remoteId: session.id, externalId: undefined };
38
+ },
39
+
40
+ async fetch(remoteId) {
41
+ const session = await getSession(client, remoteId);
42
+ return {
43
+ status: "regular" as const,
44
+ remoteId: session.id,
45
+ title: session.title,
46
+ lastMessageAt: new Date(session.updatedAt),
47
+ };
48
+ },
49
+
50
+ async rename() {},
51
+ async archive() {},
52
+ async unarchive() {},
53
+ async delete() {},
54
+
55
+ async generateTitle() {
56
+ return new ReadableStream();
57
+ },
58
+ };
59
+ }
@@ -0,0 +1,98 @@
1
+ import type { Turn } from "truefoundry-gateway-sdk/agents";
2
+
3
+ import {
4
+ buildMcpAuthTextParts,
5
+ findMcpAuthRequired,
6
+ mcpAuthAssistantStatus,
7
+ mcpAuthMessageCustom,
8
+ } from "./mcpAuth.js";
9
+ import {
10
+ findApprovalRequiredInTurn,
11
+ toolApprovalMessageCustom,
12
+ toolApprovalStatus,
13
+ } from "./toolApproval.js";
14
+ import {
15
+ findResponseRequiredInTurn,
16
+ toolResponseMessageCustom,
17
+ toolResponseStatus,
18
+ } from "./toolResponse.js";
19
+ import type { AssistantContentPart } from "./modelMessageContent.js";
20
+ import type { TurnStreamUpdate } from "./turnStreamUpdate.js";
21
+
22
+ function buildToolApprovalUpdate(
23
+ content: AssistantContentPart[],
24
+ turn: Pick<Turn, "state">,
25
+ ): TurnStreamUpdate {
26
+ const pendingApproval = findApprovalRequiredInTurn(turn);
27
+ if (pendingApproval == null) {
28
+ return { content };
29
+ }
30
+ return {
31
+ content,
32
+ status: toolApprovalStatus(),
33
+ metadata: { custom: toolApprovalMessageCustom(pendingApproval.threadId) },
34
+ };
35
+ }
36
+
37
+ function buildToolResponseUpdate(
38
+ update: TurnStreamUpdate,
39
+ turn: Pick<Turn, "state">,
40
+ ): TurnStreamUpdate {
41
+ const pendingResponse = findResponseRequiredInTurn(turn);
42
+ if (pendingResponse == null) {
43
+ return update;
44
+ }
45
+ return {
46
+ ...update,
47
+ content: update.content,
48
+ status: toolResponseStatus(),
49
+ metadata: {
50
+ custom: {
51
+ ...update.metadata?.custom,
52
+ ...toolResponseMessageCustom(pendingResponse.threadId),
53
+ },
54
+ },
55
+ };
56
+ }
57
+
58
+ export function appendToolResponseToTurnContent(
59
+ update: TurnStreamUpdate,
60
+ turn: Pick<Turn, "state">,
61
+ ): TurnStreamUpdate {
62
+ if (update.status?.type === "requires-action") {
63
+ return update;
64
+ }
65
+ return buildToolResponseUpdate(update, turn);
66
+ }
67
+
68
+ export function appendToolApprovalToTurnContent(
69
+ update: TurnStreamUpdate,
70
+ turn: Pick<Turn, "state">,
71
+ ): TurnStreamUpdate {
72
+ if (update.status?.type === "requires-action") {
73
+ return update;
74
+ }
75
+ const approvalUpdate = buildToolApprovalUpdate(update.content, turn);
76
+ return appendToolResponseToTurnContent(approvalUpdate, turn);
77
+ }
78
+
79
+ export function appendMcpAuthToTurnContent(
80
+ content: AssistantContentPart[],
81
+ turn: Pick<Turn, "state">,
82
+ ): TurnStreamUpdate {
83
+ const pendingMcpAuth = findMcpAuthRequired(
84
+ turn.state.status === "done" ? turn.state.requiredActions : undefined,
85
+ );
86
+ if (pendingMcpAuth == null) {
87
+ return appendToolApprovalToTurnContent({ content }, turn);
88
+ }
89
+
90
+ return appendToolApprovalToTurnContent(
91
+ {
92
+ content: [...content, ...buildMcpAuthTextParts(pendingMcpAuth.mcpServers)],
93
+ status: mcpAuthAssistantStatus(),
94
+ metadata: { custom: mcpAuthMessageCustom(pendingMcpAuth.mcpServers) },
95
+ },
96
+ turn,
97
+ );
98
+ }
@@ -0,0 +1,11 @@
1
+ import type { MessageStatus } from "@assistant-ui/core";
2
+
3
+ import type { AssistantContentPart } from "./modelMessageContent.js";
4
+
5
+ export type TurnStreamUpdate = {
6
+ content: AssistantContentPart[];
7
+ status?: MessageStatus;
8
+ metadata?: {
9
+ custom?: Record<string, unknown>;
10
+ };
11
+ };
package/src/types.ts ADDED
@@ -0,0 +1,92 @@
1
+ import type {
2
+ AttachmentAdapter,
3
+ DictationAdapter,
4
+ ExternalStoreSharedOptions,
5
+ FeedbackAdapter,
6
+ RealtimeVoiceAdapter,
7
+ SpeechSynthesisAdapter,
8
+ } from "@assistant-ui/core";
9
+ import type { AgentSessionClient } from "truefoundry-gateway-sdk/agents";
10
+ import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
11
+
12
+ import type { AgentSpec } from "./agentSpec.js";
13
+
14
+ export type NamedAgentConfig = {
15
+ mode: "named";
16
+ agentName: string;
17
+ };
18
+
19
+ export type DraftAgentConfig = {
20
+ mode: "draft";
21
+ defaultAgentSpec: AgentSpec;
22
+ onAgentSpecChange?: ((spec: AgentSpec) => void) | undefined;
23
+ };
24
+
25
+ export type TrueFoundryAgentConfig = NamedAgentConfig | DraftAgentConfig;
26
+
27
+ type TrueFoundryAgentRuntimeBaseOptions = ExternalStoreSharedOptions & {
28
+ client: AgentSessionClient;
29
+ initialSessionId?: string | undefined;
30
+ threadId?: string | undefined;
31
+ onThreadIdChange?: ((threadId: string | undefined) => void) | undefined;
32
+ onError?: ((error: unknown) => void) | undefined;
33
+ listEventsConcurrency?: number | undefined;
34
+ adapters?:
35
+ | {
36
+ attachments?: AttachmentAdapter | undefined;
37
+ speech?: SpeechSynthesisAdapter | undefined;
38
+ dictation?: DictationAdapter | undefined;
39
+ voice?: RealtimeVoiceAdapter | undefined;
40
+ feedback?: FeedbackAdapter | undefined;
41
+ }
42
+ | undefined;
43
+ };
44
+
45
+ export type UseTrueFoundryAgentRuntimeOptions = TrueFoundryAgentRuntimeBaseOptions & {
46
+ /** Discriminated agent source. Omit when using legacy `agentName`. */
47
+ agent?: TrueFoundryAgentConfig | undefined;
48
+ /** Legacy named-agent shorthand. Prefer `agent: { mode: "named", agentName }`. */
49
+ agentName?: string | undefined;
50
+ /** Required when `agent.mode === "draft"`. */
51
+ gateway?: TrueFoundryGateway | undefined;
52
+ };
53
+
54
+ export type ResolvedTrueFoundryAgentRuntimeOptions = TrueFoundryAgentRuntimeBaseOptions & {
55
+ agent: TrueFoundryAgentConfig;
56
+ gateway?: TrueFoundryGateway | undefined;
57
+ };
58
+
59
+ export function resolveTrueFoundryAgentConfig(
60
+ options: Pick<UseTrueFoundryAgentRuntimeOptions, "agent" | "agentName">,
61
+ ): TrueFoundryAgentConfig {
62
+ if (options.agent != null) {
63
+ if (options.agent.mode === "named" && options.agentName != null) {
64
+ return { mode: "named", agentName: options.agentName };
65
+ }
66
+ return options.agent;
67
+ }
68
+ if (options.agentName != null) {
69
+ return { mode: "named", agentName: options.agentName };
70
+ }
71
+ throw new Error(
72
+ "useTrueFoundryAgentRuntime requires `agent` or legacy `agentName`.",
73
+ );
74
+ }
75
+
76
+ export function resolveTrueFoundryAgentRuntimeOptions(
77
+ options: UseTrueFoundryAgentRuntimeOptions,
78
+ ): ResolvedTrueFoundryAgentRuntimeOptions {
79
+ const agent = resolveTrueFoundryAgentConfig(options);
80
+
81
+ if (agent.mode === "draft" && options.gateway == null) {
82
+ throw new Error(
83
+ "Draft agent mode requires a `gateway` TrueFoundryGateway client.",
84
+ );
85
+ }
86
+
87
+ return {
88
+ ...options,
89
+ agent,
90
+ gateway: options.gateway,
91
+ };
92
+ }
@@ -0,0 +1,173 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+
5
+ import {
6
+ mergeAgentSpec,
7
+ type AgentSpec,
8
+ type AgentSpecUpdate,
9
+ } from "./agentSpec.js";
10
+ import type { DraftSessionBridge } from "./draftSessionBridge.js";
11
+
12
+ const SPEC_SYNC_DEBOUNCE_MS = 400;
13
+
14
+ export type UseDraftAgentSpecOptions = {
15
+ draftSessionId: string | undefined;
16
+ draftBridge: DraftSessionBridge | null;
17
+ defaultAgentSpec: AgentSpec;
18
+ onAgentSpecChange?: ((spec: AgentSpec) => void) | undefined;
19
+ onError?: ((error: unknown) => void) | undefined;
20
+ };
21
+
22
+ export function useDraftAgentSpec({
23
+ draftSessionId,
24
+ draftBridge,
25
+ defaultAgentSpec,
26
+ onAgentSpecChange,
27
+ onError,
28
+ }: UseDraftAgentSpecOptions) {
29
+ const enabled = draftBridge != null;
30
+ const [agentSpec, setAgentSpec] = useState<AgentSpec>(defaultAgentSpec);
31
+ const [isSpecSyncing, setIsSpecSyncing] = useState(false);
32
+ const [specError, setSpecError] = useState<unknown | null>(null);
33
+
34
+ const agentSpecRef = useRef(agentSpec);
35
+ agentSpecRef.current = agentSpec;
36
+
37
+ const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
38
+ const syncGenerationRef = useRef(0);
39
+ const loadedDraftIdRef = useRef<string | undefined>(undefined);
40
+ const localDirtyRef = useRef(false);
41
+
42
+ useEffect(() => {
43
+ if (!enabled || draftBridge == null) {
44
+ return;
45
+ }
46
+ if (draftSessionId == null) {
47
+ loadedDraftIdRef.current = undefined;
48
+ setAgentSpec(defaultAgentSpec);
49
+ localDirtyRef.current = false;
50
+ setSpecError(null);
51
+ return;
52
+ }
53
+
54
+ if (loadedDraftIdRef.current === draftSessionId) {
55
+ return;
56
+ }
57
+
58
+ let cancelled = false;
59
+ void (async () => {
60
+ try {
61
+ const loaded = await draftBridge.getDraftAgentSpec(draftSessionId);
62
+ if (cancelled) {
63
+ return;
64
+ }
65
+ loadedDraftIdRef.current = draftSessionId;
66
+
67
+ if (localDirtyRef.current) {
68
+ scheduleSpecSyncRef.current?.(draftSessionId, agentSpecRef.current);
69
+ localDirtyRef.current = false;
70
+ setSpecError(null);
71
+ return;
72
+ }
73
+
74
+ setAgentSpec(loaded);
75
+ setSpecError(null);
76
+ } catch (error) {
77
+ if (!cancelled) {
78
+ onError?.(error);
79
+ setSpecError(error);
80
+ }
81
+ }
82
+ })();
83
+
84
+ return () => {
85
+ cancelled = true;
86
+ };
87
+ }, [defaultAgentSpec, draftBridge, draftSessionId, enabled, onError]);
88
+
89
+ const flushSpecSync = useCallback(
90
+ async (draftId: string, spec: AgentSpec, generation: number) => {
91
+ if (draftBridge == null) {
92
+ return;
93
+ }
94
+ setIsSpecSyncing(true);
95
+ try {
96
+ await draftBridge.syncAgentSpec(draftId, spec);
97
+ if (generation !== syncGenerationRef.current) {
98
+ return;
99
+ }
100
+ setSpecError(null);
101
+ onAgentSpecChange?.(spec);
102
+ } catch (error) {
103
+ if (generation === syncGenerationRef.current) {
104
+ setSpecError(error);
105
+ onError?.(error);
106
+ }
107
+ } finally {
108
+ if (generation === syncGenerationRef.current) {
109
+ setIsSpecSyncing(false);
110
+ }
111
+ }
112
+ },
113
+ [draftBridge, onAgentSpecChange, onError],
114
+ );
115
+
116
+ const scheduleSpecSync = useCallback(
117
+ (draftId: string, spec: AgentSpec) => {
118
+ if (syncTimeoutRef.current != null) {
119
+ clearTimeout(syncTimeoutRef.current);
120
+ }
121
+ const generation = ++syncGenerationRef.current;
122
+ syncTimeoutRef.current = setTimeout(() => {
123
+ void flushSpecSync(draftId, spec, generation);
124
+ }, SPEC_SYNC_DEBOUNCE_MS);
125
+ },
126
+ [flushSpecSync],
127
+ );
128
+
129
+ const scheduleSpecSyncRef = useRef(scheduleSpecSync);
130
+ scheduleSpecSyncRef.current = scheduleSpecSync;
131
+
132
+ useEffect(
133
+ () => () => {
134
+ if (syncTimeoutRef.current != null) {
135
+ clearTimeout(syncTimeoutRef.current);
136
+ }
137
+ },
138
+ [],
139
+ );
140
+
141
+ const updateAgentSpec = useCallback(
142
+ (update: AgentSpecUpdate) => {
143
+ if (!enabled || draftBridge == null) {
144
+ return;
145
+ }
146
+ const next = mergeAgentSpec(agentSpecRef.current, update);
147
+ setAgentSpec(next);
148
+ localDirtyRef.current = true;
149
+ if (draftSessionId != null) {
150
+ scheduleSpecSync(draftSessionId, next);
151
+ }
152
+ },
153
+ [draftBridge, draftSessionId, enabled, scheduleSpecSync],
154
+ );
155
+
156
+ return useMemo(
157
+ () => ({
158
+ agentSpec: enabled ? agentSpec : null,
159
+ draftSessionId: enabled ? draftSessionId : undefined,
160
+ isSpecSyncing: enabled ? isSpecSyncing : false,
161
+ specError: enabled ? specError : null,
162
+ updateAgentSpec,
163
+ }),
164
+ [
165
+ agentSpec,
166
+ draftSessionId,
167
+ enabled,
168
+ isSpecSyncing,
169
+ specError,
170
+ updateAgentSpec,
171
+ ],
172
+ );
173
+ }