@truefoundry/assistant-ui-runtime 0.1.1 → 0.1.3-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.
package/src/hooks.ts CHANGED
@@ -104,6 +104,24 @@ export const useTrueFoundryReload = () => {
104
104
  return () => trueFoundryExtras.get(aui).reload();
105
105
  };
106
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
+
107
125
  /** Returns a function to reset (re-submit) a user turn from any render context. */
108
126
  export const useTrueFoundryResetFromTurn = () => {
109
127
  const aui = useAui();
package/src/index.ts CHANGED
@@ -16,8 +16,11 @@ export type { UseTrueFoundryAgentRuntimeOptions, NamedAgentConfig, DraftAgentCon
16
16
  export type { AgentSpec, AgentSpecUpdate, DraftSession } from "./private/agentSpec.js";
17
17
  export { mergeAgentSpec, draftSessionTitle } from "./private/agentSpec.js";
18
18
  export { createTrueFoundryDraftThreadListAdapter } from "./private/truefoundryDraftThreadListAdapter.js";
19
+ export { createTrueFoundryOwnedSessionsThreadListAdapter } from "./truefoundryOwnedSessionsThreadListAdapter.js";
19
20
  export { createDraftSessionBridge } from "./private/draftSessionBridge.js";
20
21
  export type { DraftSessionBridge } from "./private/draftSessionBridge.js";
22
+ export { PrivateAgentSessionClient } from "truefoundry-gateway-sdk/agents/private";
23
+ export type { AgentDraftSession } from "truefoundry-gateway-sdk/agents/private";
21
24
  export {
22
25
  useTrueFoundryAgentSpec,
23
26
  useTrueFoundryUpdateAgentSpec,
@@ -48,6 +51,7 @@ export {
48
51
  useTrueFoundryCancel,
49
52
  useTrueFoundryResetFromTurn,
50
53
  useTrueFoundryReload,
54
+ useTrueFoundryHistoryPagination,
51
55
  } from "./hooks.js";
52
56
  export {
53
57
  collectApprovalInputs,
@@ -21,7 +21,7 @@ export function loadSessionSnapshot(
21
21
  onProgress?: (snap: SessionSnapshot) => void,
22
22
  ): Promise<SessionSnapshot> {
23
23
  const cacheKey =
24
- sessionOptions?.draftGateway != null ? `draft:${sessionId}` : sessionId;
24
+ sessionOptions?.privateClient != null ? `draft:${sessionId}` : sessionId;
25
25
  let inflight = inflightBySessionId.get(cacheKey);
26
26
  if (inflight == null) {
27
27
  inflight = getSession(client, sessionId, sessionOptions)
@@ -1,11 +1,11 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
 
3
- import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
4
- import type { AgentSessionClient } from "truefoundry-gateway-sdk/agents";
3
+ import type { PrivateAgentSessionClient } from "truefoundry-gateway-sdk/agents/private";
5
4
 
6
5
  import { bindDraftAgentSession } from "./bindDraftAgentSession.js";
7
6
 
8
7
  const draft = {
8
+ type: "session/draft" as const,
9
9
  id: "draft-1",
10
10
  agentSpec: { model: { name: "anthropic/claude-sonnet-4-6" } },
11
11
  createdBySubject: { type: "user" as const, id: "u1" },
@@ -15,41 +15,40 @@ const draft = {
15
15
 
16
16
  describe("bindDraftAgentSession", () => {
17
17
  it("validates the draft and binds turns to the draft session id", async () => {
18
- const get = vi.fn().mockResolvedValue({ data: draft });
19
- const gateway = {
20
- agents: { private: { draftSessions: { get } } },
21
- } as unknown as TrueFoundryGateway;
22
- const client = {
23
- client: gateway,
24
- } as unknown as AgentSessionClient;
18
+ const getDraftSession = vi.fn().mockResolvedValue(draft);
19
+ const privateClient = {
20
+ getDraftSession,
21
+ } as unknown as PrivateAgentSessionClient;
25
22
 
26
- const session = await bindDraftAgentSession(client, gateway, "draft-1");
23
+ const session = await bindDraftAgentSession(privateClient, "draft-1");
27
24
 
28
- expect(get).toHaveBeenCalledWith("draft-1");
25
+ expect(getDraftSession).toHaveBeenCalledWith({ draftSessionId: "draft-1" });
29
26
  expect(session.id).toBe("draft-1");
30
27
  });
31
28
  });
32
29
 
33
30
  describe("createDraftSessionBridge", () => {
34
- it("syncs agent spec via draftSessions.update", async () => {
31
+ it("syncs agent spec via draftSessions.update and returns updatedAt", async () => {
35
32
  const { createDraftSessionBridge } = await import("./draftSessionBridge.js");
36
33
  const update = vi.fn().mockResolvedValue({ data: draft });
37
- const gateway = {
38
- agents: {
39
- private: {
40
- draftSessions: {
41
- get: vi.fn().mockResolvedValue({ data: draft }),
42
- update,
34
+ const getDraftSession = vi.fn().mockResolvedValue(draft);
35
+ const privateClient = {
36
+ getDraftSession,
37
+ client: {
38
+ agents: {
39
+ private: {
40
+ draftSessions: { update },
43
41
  },
44
42
  },
45
43
  },
46
- } as unknown as TrueFoundryGateway;
44
+ } as unknown as PrivateAgentSessionClient;
47
45
 
48
- const bridge = createDraftSessionBridge(gateway);
49
- await bridge.syncAgentSpec("draft-1", draft.agentSpec);
46
+ const bridge = createDraftSessionBridge(privateClient);
47
+ const updatedAt = await bridge.syncAgentSpec("draft-1", draft.agentSpec);
50
48
 
51
49
  expect(update).toHaveBeenCalledWith("draft-1", {
52
50
  agentSpec: draft.agentSpec,
53
51
  });
52
+ expect(updatedAt).toBe(draft.updatedAt);
54
53
  });
55
54
  });
@@ -1,65 +1,27 @@
1
- import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
2
- import type { AgentSession, AgentSessionClient } from "truefoundry-gateway-sdk/agents";
3
- // SDK does not publicly export the AgentSession class; bind draft ids for turn routes.
4
- import { AgentSession as AgentSessionClass } from "truefoundry-gateway-sdk/dist/esm/agents/AgentSession.mjs";
5
-
6
- import type { DraftSession } from "./agentSpec.js";
7
-
8
- type SessionRecord = {
9
- id: string;
10
- agentName: string;
11
- title?: string;
12
- createdBySubject: DraftSession["createdBySubject"];
13
- createdAt: string;
14
- updatedAt: string;
15
- };
1
+ import type { AgentSession } from "truefoundry-gateway-sdk/agents";
2
+ import type { PrivateAgentSessionClient } from "truefoundry-gateway-sdk/agents/private";
16
3
 
17
4
  const inflightByDraftId = new Map<string, Promise<AgentSession>>();
18
5
 
19
- export function getGatewayFromSessionClient(
20
- client: AgentSessionClient,
21
- ): TrueFoundryGateway {
22
- const internal = client as unknown as { client: TrueFoundryGateway };
23
- if (internal.client == null) {
24
- throw new Error("AgentSessionClient is missing an internal gateway client.");
25
- }
26
- return internal.client;
27
- }
28
-
29
- function draftToSessionRecord(draft: DraftSession): SessionRecord {
30
- return {
31
- id: draft.id,
32
- agentName: draft.agentName ?? "",
33
- title: draft.title,
34
- createdBySubject: draft.createdBySubject,
35
- createdAt: draft.createdAt,
36
- updatedAt: draft.updatedAt,
37
- };
38
- }
39
-
40
6
  /**
41
- * Binds an `AgentSession` for turn APIs at `/agents/sessions/{draftSessionId}/turns`
42
- * after validating the draft via `GET draft-sessions/{draftSessionId}`.
43
- * Does not call `GET` or `POST` on `/agents/sessions` root.
7
+ * Binds turn APIs for a draft session via `PrivateAgentSessionClient.getDraftSession`.
8
+ * Returns an `AgentDraftSession` (identical turn surface to `AgentSession`).
44
9
  */
45
10
  export async function bindDraftAgentSession(
46
- client: AgentSessionClient,
47
- gateway: TrueFoundryGateway,
11
+ privateClient: PrivateAgentSessionClient,
48
12
  draftSessionId: string,
49
13
  ): Promise<AgentSession> {
50
14
  let inflight = inflightByDraftId.get(draftSessionId);
51
15
  if (inflight == null) {
52
- inflight = (async () => {
53
- const response = await gateway.agents.private.draftSessions.get(draftSessionId);
54
- return new AgentSessionClass(
55
- draftToSessionRecord(response.data),
56
- getGatewayFromSessionClient(client),
57
- ) as AgentSession;
58
- })().finally(() => {
59
- if (inflightByDraftId.get(draftSessionId) === inflight) {
60
- inflightByDraftId.delete(draftSessionId);
61
- }
62
- });
16
+ inflight = privateClient
17
+ .getDraftSession({ draftSessionId })
18
+ // AgentDraftSession shares the turn API with AgentSession.
19
+ .then((draft) => draft as unknown as AgentSession)
20
+ .finally(() => {
21
+ if (inflightByDraftId.get(draftSessionId) === inflight) {
22
+ inflightByDraftId.delete(draftSessionId);
23
+ }
24
+ });
63
25
  inflightByDraftId.set(draftSessionId, inflight);
64
26
  }
65
27
  return inflight;
@@ -1,28 +1,32 @@
1
- import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
1
+ import type { PrivateAgentSessionClient } from "truefoundry-gateway-sdk/agents/private";
2
2
 
3
3
  import type { AgentSpec } from "./agentSpec.js";
4
+ import { getGatewayFromPrivateClient } from "./getGatewayFromPrivateClient.js";
5
+
6
+ export const DRAFT_SESSION_LAST_UPDATED_AT_HEADER = "x-tfy-session-last-updated-at";
4
7
 
5
8
  export type DraftSessionBridge = {
6
- syncAgentSpec: (draftSessionId: string, agentSpec: AgentSpec) => Promise<void>;
9
+ syncAgentSpec: (draftSessionId: string, agentSpec: AgentSpec) => Promise<string>;
7
10
  getDraftAgentSpec: (draftSessionId: string) => Promise<AgentSpec>;
8
11
  };
9
12
 
10
13
  export function createDraftSessionBridge(
11
- gateway: TrueFoundryGateway,
14
+ privateClient: PrivateAgentSessionClient,
12
15
  ): DraftSessionBridge {
13
- async function getDraft(draftSessionId: string) {
14
- const response = await gateway.agents.private.draftSessions.get(draftSessionId);
15
- return response.data;
16
- }
17
-
18
16
  return {
19
17
  async getDraftAgentSpec(draftSessionId) {
20
- const draft = await getDraft(draftSessionId);
18
+ const draft = await privateClient.getDraftSession({ draftSessionId });
21
19
  return draft.agentSpec;
22
20
  },
23
21
 
24
22
  async syncAgentSpec(draftSessionId, agentSpec) {
25
- await gateway.agents.private.draftSessions.update(draftSessionId, { agentSpec });
23
+ // PrivateAgentSessionClient does not wrap update yet use the low-level client.
24
+ const gateway = getGatewayFromPrivateClient(privateClient);
25
+ const response = await gateway.agents.private.draftSessions.update(
26
+ draftSessionId,
27
+ { agentSpec },
28
+ );
29
+ return response.data.updatedAt;
26
30
  },
27
31
  };
28
32
  }
@@ -0,0 +1,13 @@
1
+ import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
2
+ import type { PrivateAgentSessionClient } from "truefoundry-gateway-sdk/agents/private";
3
+
4
+ /** Reach the underlying gateway for APIs not yet wrapped on PrivateAgentSessionClient (update, sandbox download). */
5
+ export function getGatewayFromPrivateClient(
6
+ privateClient: PrivateAgentSessionClient,
7
+ ): TrueFoundryGateway {
8
+ const internal = privateClient as unknown as { client: TrueFoundryGateway };
9
+ if (internal.client == null) {
10
+ throw new Error("PrivateAgentSessionClient is missing an internal gateway client.");
11
+ }
12
+ return internal.client;
13
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
 
3
- import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
3
+ import type { PrivateAgentSessionClient } from "truefoundry-gateway-sdk/agents/private";
4
4
 
5
5
  import { createTrueFoundryDraftThreadListAdapter } from "./truefoundryDraftThreadListAdapter.js";
6
6
  import type { AgentSpec } from "./agentSpec.js";
@@ -12,6 +12,7 @@ const defaultAgentSpec: AgentSpec = {
12
12
 
13
13
  function mockDraft(id: string, title: string | undefined, updatedAt: string) {
14
14
  return {
15
+ type: "session/draft" as const,
15
16
  id,
16
17
  agentSpec: defaultAgentSpec,
17
18
  title,
@@ -38,31 +39,33 @@ function mockDraftListPage(
38
39
 
39
40
  describe("createTrueFoundryDraftThreadListAdapter", () => {
40
41
  it("lists draft sessions with pagination cursor", async () => {
41
- const list = vi.fn().mockResolvedValue(
42
+ const listDraftSessions = vi.fn().mockResolvedValue(
42
43
  mockDraftListPage(
43
44
  [mockDraft("d1", "My draft", "2026-06-30T10:00:00.000Z")],
44
45
  "page-2",
45
46
  ),
46
47
  );
47
- const gateway = {
48
- agents: { private: { draftSessions: { list, create: vi.fn(), get: vi.fn() } } },
49
- } as unknown as TrueFoundryGateway;
48
+ const privateClient = {
49
+ listDraftSessions,
50
+ createDraftSession: vi.fn(),
51
+ getDraftSession: vi.fn(),
52
+ } as unknown as PrivateAgentSessionClient;
50
53
 
51
54
  const adapter = createTrueFoundryDraftThreadListAdapter({
52
- gateway,
55
+ privateClient,
53
56
  defaultAgentSpec,
54
57
  });
55
58
 
56
59
  const result = await adapter.list();
57
60
 
58
- expect(list).toHaveBeenCalledWith(
61
+ expect(listDraftSessions).toHaveBeenCalledWith(
59
62
  expect.objectContaining({
60
63
  limit: 20,
61
64
  pageToken: undefined,
62
65
  startTimestamp: expect.any(String),
63
66
  }),
64
67
  );
65
- expect(list).toHaveBeenCalledWith(
68
+ expect(listDraftSessions).toHaveBeenCalledWith(
66
69
  expect.not.objectContaining({ agentName: expect.anything() }),
67
70
  );
68
71
  expect(result.threads).toEqual([
@@ -77,21 +80,23 @@ describe("createTrueFoundryDraftThreadListAdapter", () => {
77
80
  });
78
81
 
79
82
  it("creates a draft session on initialize", async () => {
80
- const create = vi.fn().mockResolvedValue({
81
- data: mockDraft("d-new", undefined, "2026-06-30T12:00:00.000Z"),
82
- });
83
- const gateway = {
84
- agents: { private: { draftSessions: { list: vi.fn(), create, get: vi.fn() } } },
85
- } as unknown as TrueFoundryGateway;
83
+ const createDraftSession = vi.fn().mockResolvedValue(
84
+ mockDraft("d-new", undefined, "2026-06-30T12:00:00.000Z"),
85
+ );
86
+ const privateClient = {
87
+ listDraftSessions: vi.fn(),
88
+ createDraftSession,
89
+ getDraftSession: vi.fn(),
90
+ } as unknown as PrivateAgentSessionClient;
86
91
 
87
92
  const adapter = createTrueFoundryDraftThreadListAdapter({
88
- gateway,
93
+ privateClient,
89
94
  defaultAgentSpec,
90
95
  });
91
96
 
92
97
  const result = await adapter.initialize("local-thread-id");
93
98
 
94
- expect(create).toHaveBeenCalledWith({ agentSpec: defaultAgentSpec });
99
+ expect(createDraftSession).toHaveBeenCalledWith({ agentSpec: defaultAgentSpec });
95
100
  expect(result).toEqual({ remoteId: "d-new", externalId: undefined });
96
101
  });
97
102
 
@@ -102,39 +107,44 @@ describe("createTrueFoundryDraftThreadListAdapter", () => {
102
107
  mcpServers: [{ name: "github", enableTools: ["@all"] }],
103
108
  skills: [{ fqn: "acme/skill-a:1", preload: false }],
104
109
  };
105
- const create = vi.fn().mockResolvedValue({
106
- data: mockDraft("d-new", undefined, "2026-06-30T12:00:00.000Z"),
107
- });
108
- const gateway = {
109
- agents: { private: { draftSessions: { list: vi.fn(), create, get: vi.fn() } } },
110
- } as unknown as TrueFoundryGateway;
110
+ const createDraftSession = vi.fn().mockResolvedValue(
111
+ mockDraft("d-new", undefined, "2026-06-30T12:00:00.000Z"),
112
+ );
113
+ const privateClient = {
114
+ listDraftSessions: vi.fn(),
115
+ createDraftSession,
116
+ getDraftSession: vi.fn(),
117
+ } as unknown as PrivateAgentSessionClient;
111
118
 
112
119
  const adapter = createTrueFoundryDraftThreadListAdapter({
113
- gateway,
120
+ privateClient,
114
121
  defaultAgentSpec,
115
122
  getAgentSpec: () => liveAgentSpec,
116
123
  });
117
124
 
118
125
  await adapter.initialize("local-thread-id");
119
126
 
120
- expect(create).toHaveBeenCalledWith({ agentSpec: liveAgentSpec });
127
+ expect(createDraftSession).toHaveBeenCalledWith({ agentSpec: liveAgentSpec });
121
128
  });
122
129
 
123
130
  it("falls back to model name for title when draft has no title", async () => {
124
- const get = vi.fn().mockResolvedValue({
125
- data: mockDraft("d1", undefined, "2026-06-30T10:00:00.000Z"),
126
- });
127
- const gateway = {
128
- agents: { private: { draftSessions: { list: vi.fn(), create: vi.fn(), get } } },
129
- } as unknown as TrueFoundryGateway;
131
+ const getDraftSession = vi.fn().mockResolvedValue(
132
+ mockDraft("d1", undefined, "2026-06-30T10:00:00.000Z"),
133
+ );
134
+ const privateClient = {
135
+ listDraftSessions: vi.fn(),
136
+ createDraftSession: vi.fn(),
137
+ getDraftSession,
138
+ } as unknown as PrivateAgentSessionClient;
130
139
 
131
140
  const adapter = createTrueFoundryDraftThreadListAdapter({
132
- gateway,
141
+ privateClient,
133
142
  defaultAgentSpec,
134
143
  });
135
144
 
136
145
  const result = await adapter.fetch("d1");
137
146
 
147
+ expect(getDraftSession).toHaveBeenCalledWith({ draftSessionId: "d1" });
138
148
  expect(result.title).toBe("anthropic/claude-sonnet-4-6");
139
149
  });
140
150
  });
@@ -1,5 +1,5 @@
1
1
  import type { RemoteThreadListAdapter } from "@assistant-ui/core";
2
- import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
2
+ import type { PrivateAgentSessionClient } from "truefoundry-gateway-sdk/agents/private";
3
3
 
4
4
  import { draftSessionTitle, type AgentSpec } from "./agentSpec.js";
5
5
  import { sessionListStartTimestamp } from "../sessionListStartTimestamp.js";
@@ -7,15 +7,15 @@ import { sessionListStartTimestamp } from "../sessionListStartTimestamp.js";
7
7
  const THREAD_LIST_PAGE_SIZE = 20;
8
8
 
9
9
  export function createTrueFoundryDraftThreadListAdapter(options: {
10
- gateway: TrueFoundryGateway;
10
+ privateClient: PrivateAgentSessionClient;
11
11
  defaultAgentSpec: AgentSpec;
12
12
  getAgentSpec?: () => AgentSpec;
13
13
  }): RemoteThreadListAdapter {
14
- const { gateway, defaultAgentSpec, getAgentSpec } = options;
14
+ const { privateClient, defaultAgentSpec, getAgentSpec } = options;
15
15
 
16
16
  return {
17
17
  async list({ after } = {}) {
18
- const page = await gateway.agents.private.draftSessions.list({
18
+ const page = await privateClient.listDraftSessions({
19
19
  limit: THREAD_LIST_PAGE_SIZE,
20
20
  pageToken: after,
21
21
  startTimestamp: sessionListStartTimestamp(),
@@ -33,16 +33,16 @@ export function createTrueFoundryDraftThreadListAdapter(options: {
33
33
  },
34
34
 
35
35
  async initialize(_threadId: string) {
36
- const response = await gateway.agents.private.draftSessions.create({
36
+ const draft = await privateClient.createDraftSession({
37
37
  agentSpec: getAgentSpec?.() ?? defaultAgentSpec,
38
38
  });
39
- const draft = response.data;
40
39
  return { remoteId: draft.id, externalId: undefined };
41
40
  },
42
41
 
43
42
  async fetch(remoteId) {
44
- const response = await gateway.agents.private.draftSessions.get(remoteId);
45
- const draft = response.data;
43
+ const draft = await privateClient.getDraftSession({
44
+ draftSessionId: remoteId,
45
+ });
46
46
  return {
47
47
  status: "regular" as const,
48
48
  remoteId: draft.id,
@@ -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
+ });