@truefoundry/assistant-ui-runtime 0.1.6 → 0.1.7
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 +3 -2
- package/dist/{chunk-Q2SHKMLM.js → chunk-SQDOTGP2.js} +32 -10
- package/dist/chunk-SQDOTGP2.js.map +1 -0
- package/dist/index.d.ts +19 -23
- package/dist/index.js +256 -162
- package/dist/index.js.map +1 -1
- package/dist/plugins/truefoundry-agent-server-adapter/index.d.ts +3 -6
- package/dist/plugins/truefoundry-agent-server-adapter/index.js +1 -1
- package/dist/server/index.d.ts +2 -2
- package/dist/{types-BfiFf8O1.d.ts → types-DbNsU075.d.ts} +196 -9
- package/package.json +1 -1
- package/src/{private → draft}/agentSpec.ts +14 -17
- package/src/{private → draft}/draftSessionBridge.ts +1 -2
- package/src/{private → draft}/truefoundryDraftThreadListAdapter.test.ts +1 -1
- package/src/{private → draft}/truefoundryDraftThreadListAdapter.ts +2 -1
- package/src/{private → draft}/useDraftAgentSpec.ts +16 -5
- package/src/draftAgentConfig.test.ts +2 -1
- package/src/index.ts +30 -7
- package/src/plugins/truefoundry-agent-server-adapter/index.ts +51 -11
- package/src/plugins/truefoundry-agent-server-adapter/types.ts +6 -4
- package/src/server/index.ts +23 -0
- package/src/server/types.ts +256 -11
- package/src/truefoundryExtras.ts +4 -1
- package/src/truefoundryOwnedSessionsThreadListAdapter.ts +1 -1
- package/src/types.ts +1 -2
- package/src/useTrueFoundryAgentMessages.test.tsx +261 -1
- package/src/useTrueFoundryAgentMessages.ts +284 -176
- package/src/useTrueFoundryAgentRuntime.ts +31 -21
- package/dist/chunk-Q2SHKMLM.js.map +0 -1
- /package/src/{private → draft}/useDraftAgentSpec.test.tsx +0 -0
package/src/server/index.ts
CHANGED
|
@@ -31,6 +31,29 @@ export type {
|
|
|
31
31
|
Turn,
|
|
32
32
|
AgentChatServer,
|
|
33
33
|
AgentBuilderServer,
|
|
34
|
+
ProviderType,
|
|
35
|
+
ModelEntry,
|
|
36
|
+
ModelProviderConfigBase,
|
|
37
|
+
ModelProviderBase,
|
|
38
|
+
ModelProviderCatalogEntry,
|
|
39
|
+
CreateModelProviderRequest,
|
|
40
|
+
UpdateModelProviderRequest,
|
|
41
|
+
ModelCatalogServer,
|
|
42
|
+
ToolBase,
|
|
43
|
+
ConnectorAuthType,
|
|
44
|
+
ConnectorAuth,
|
|
45
|
+
ConnectorAuthPublic,
|
|
46
|
+
ConnectorConfigBase,
|
|
47
|
+
ConnectorBase,
|
|
48
|
+
ConnectorCatalogEntry,
|
|
49
|
+
CreateConnectorRequest,
|
|
50
|
+
UpdateConnectorRequest,
|
|
51
|
+
ConnectorCatalogServer,
|
|
52
|
+
SkillBase,
|
|
53
|
+
CreateSkillRequest,
|
|
54
|
+
SkillCatalogServer,
|
|
55
|
+
CatalogServer,
|
|
56
|
+
AgentUIServerPort,
|
|
34
57
|
} from "./types.js";
|
|
35
58
|
|
|
36
59
|
export type {
|
package/src/server/types.ts
CHANGED
|
@@ -10,8 +10,7 @@ import type {
|
|
|
10
10
|
ActionRequiredEvent,
|
|
11
11
|
SessionEventItem,
|
|
12
12
|
TurnEvent,
|
|
13
|
-
TurnStreamData
|
|
14
|
-
TurnStreamingEvent,
|
|
13
|
+
TurnStreamData
|
|
15
14
|
} from "./events.js";
|
|
16
15
|
|
|
17
16
|
// ---------------------------------------------------------------------------
|
|
@@ -72,7 +71,7 @@ export type SkillMount = object;
|
|
|
72
71
|
export type McpServerMount = object;
|
|
73
72
|
|
|
74
73
|
// ---------------------------------------------------------------------------
|
|
75
|
-
// AgentSpec — model
|
|
74
|
+
// AgentSpec — model / skills / mcpServers are type params; host widens the rest
|
|
76
75
|
// ---------------------------------------------------------------------------
|
|
77
76
|
|
|
78
77
|
export interface ModelParams {
|
|
@@ -87,17 +86,19 @@ export interface Model {
|
|
|
87
86
|
|
|
88
87
|
/**
|
|
89
88
|
* SDK-owned agent definition — fields the FE reads/writes.
|
|
90
|
-
* Host
|
|
89
|
+
* Host widens `model` / `skills` / `mcpServers` via type params, and adds
|
|
90
|
+
* extra fields via `TSpec extends AgentSpec<...>`.
|
|
91
91
|
*/
|
|
92
|
-
export interface AgentSpec
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
92
|
+
export interface AgentSpec<
|
|
93
|
+
TModel extends Model = Model,
|
|
94
|
+
TSkill extends SkillMount = SkillMount,
|
|
95
|
+
TMcp extends McpServerMount = McpServerMount,
|
|
96
|
+
> {
|
|
97
|
+
model: TModel;
|
|
98
|
+
skills?: TSkill[];
|
|
99
|
+
mcpServers?: TMcp[];
|
|
96
100
|
instructions?: string;
|
|
97
|
-
messages?: unknown[];
|
|
98
101
|
variables?: Record<string, string>;
|
|
99
|
-
responseFormat?: unknown;
|
|
100
|
-
config?: unknown;
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
// ---------------------------------------------------------------------------
|
|
@@ -317,3 +318,247 @@ export interface AgentBuilderServer<
|
|
|
317
318
|
}): Promise<TSave>;
|
|
318
319
|
deleteAgent?(req: { agentName: string }): Promise<void>;
|
|
319
320
|
}
|
|
321
|
+
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// Catalog management — FE-minimal settings DTOs (host extends via generics)
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Provider type id. Reserved literal: `"custom"` for user-defined providers;
|
|
328
|
+
* any other string is a builtin (e.g. `"openai"`, `"anthropic"`).
|
|
329
|
+
*
|
|
330
|
+
* Note: `string | "custom"` is useless in TypeScript (`"custom"` ⊆ `string`),
|
|
331
|
+
* so this stays `string` and `"custom"` is a documented convention.
|
|
332
|
+
*/
|
|
333
|
+
export type ProviderType = string;
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Model row — form "Model ID" + "Display name".
|
|
337
|
+
* Host extends for properties, etc.
|
|
338
|
+
*/
|
|
339
|
+
export interface ModelEntry {
|
|
340
|
+
id: string;
|
|
341
|
+
name: string;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Write config for create/update (custom form + catalog "Save key").
|
|
346
|
+
* Host extends. `baseUrl` present iff `type === "custom"`.
|
|
347
|
+
*/
|
|
348
|
+
export interface ModelProviderConfigBase<TModel extends ModelEntry = ModelEntry> {
|
|
349
|
+
type: ProviderType;
|
|
350
|
+
name: string;
|
|
351
|
+
/** Present iff `type === "custom"`. */
|
|
352
|
+
baseUrl?: string;
|
|
353
|
+
apiKey: string;
|
|
354
|
+
models: TModel[];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Configured provider card (list/read). No raw `apiKey`.
|
|
359
|
+
* Host extends for apiKeySet, timestamps, etc.
|
|
360
|
+
*/
|
|
361
|
+
export interface ModelProviderBase<TModel extends ModelEntry = ModelEntry> {
|
|
362
|
+
id: string;
|
|
363
|
+
type: ProviderType;
|
|
364
|
+
name: string;
|
|
365
|
+
/** Present iff `type === "custom"`. */
|
|
366
|
+
baseUrl?: string;
|
|
367
|
+
models: TModel[];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Discovery-only catalog provider (AVAILABLE list).
|
|
372
|
+
* `type` must not be `"custom"` — custom providers use the custom form.
|
|
373
|
+
* Host extends for richer model rows.
|
|
374
|
+
*/
|
|
375
|
+
export interface ModelProviderCatalogEntry<TModel extends ModelEntry = ModelEntry> {
|
|
376
|
+
type: ProviderType;
|
|
377
|
+
name: string;
|
|
378
|
+
models: TModel[];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Create — no `id`; server assigns it. Catalog path = entry + apiKey. */
|
|
382
|
+
export type CreateModelProviderRequest<TModel extends ModelEntry = ModelEntry> =
|
|
383
|
+
ModelProviderConfigBase<TModel>;
|
|
384
|
+
|
|
385
|
+
/** Update — `id` required. */
|
|
386
|
+
export type UpdateModelProviderRequest<TModel extends ModelEntry = ModelEntry> =
|
|
387
|
+
ModelProviderConfigBase<TModel> & { id: string };
|
|
388
|
+
|
|
389
|
+
export interface ModelCatalogServer<
|
|
390
|
+
TModel extends ModelEntry = ModelEntry,
|
|
391
|
+
TProvider extends ModelProviderBase<TModel> = ModelProviderBase<TModel>,
|
|
392
|
+
TCatalogProvider extends ModelProviderCatalogEntry<TModel> = ModelProviderCatalogEntry<TModel>,
|
|
393
|
+
TCreate extends CreateModelProviderRequest<TModel> = CreateModelProviderRequest<TModel>,
|
|
394
|
+
TUpdate extends UpdateModelProviderRequest<TModel> = UpdateModelProviderRequest<TModel>,
|
|
395
|
+
> {
|
|
396
|
+
getModelProviderCatalog(): Promise<TCatalogProvider[]>;
|
|
397
|
+
listModelProviders(): Promise<TProvider[]>;
|
|
398
|
+
createModelProvider(req: TCreate): Promise<TProvider>;
|
|
399
|
+
/** Full replace update keyed by provider `id`. */
|
|
400
|
+
updateModelProvider(req: TUpdate): Promise<TProvider>;
|
|
401
|
+
deleteModelProvider?(req: { id: string }): Promise<void>;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Tool row on a connector detail. Host extends for schemas, etc. */
|
|
405
|
+
export interface ToolBase {
|
|
406
|
+
id: string;
|
|
407
|
+
name: string;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Auth type id. Reserved literals: `"None"`, `"OAuth"`, `"API Key"`.
|
|
412
|
+
* Stays `string` so hosts can widen (same pattern as `ProviderType`).
|
|
413
|
+
*/
|
|
414
|
+
export type ConnectorAuthType = string;
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Write-time connector auth. Host extends / narrows via `TType`.
|
|
418
|
+
* For `"API Key"`, pass `apiKey` (and optional `headerName`).
|
|
419
|
+
*/
|
|
420
|
+
export interface ConnectorAuth<TType extends ConnectorAuthType = ConnectorAuthType> {
|
|
421
|
+
type: TType;
|
|
422
|
+
apiKey?: string;
|
|
423
|
+
headerName?: string;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Catalog / list auth — no secrets. Host extends / narrows via `TType`.
|
|
428
|
+
*/
|
|
429
|
+
export interface ConnectorAuthPublic<
|
|
430
|
+
TType extends ConnectorAuthType = ConnectorAuthType,
|
|
431
|
+
> {
|
|
432
|
+
type: TType;
|
|
433
|
+
headerName?: string;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* MCP / connector create-edit config. Host extends for extra fields, etc.
|
|
438
|
+
*/
|
|
439
|
+
export interface ConnectorConfigBase<
|
|
440
|
+
TAuth extends ConnectorAuth = ConnectorAuth,
|
|
441
|
+
> {
|
|
442
|
+
name: string;
|
|
443
|
+
url: string;
|
|
444
|
+
auth: TAuth;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Connected connector row (settings/connectors). No raw `apiKey`.
|
|
449
|
+
* Host extends.
|
|
450
|
+
*/
|
|
451
|
+
export interface ConnectorBase<
|
|
452
|
+
TTool extends ToolBase = ToolBase,
|
|
453
|
+
TAuth extends ConnectorAuthPublic = ConnectorAuthPublic,
|
|
454
|
+
> {
|
|
455
|
+
id: string;
|
|
456
|
+
name: string;
|
|
457
|
+
description: string;
|
|
458
|
+
url: string;
|
|
459
|
+
auth: TAuth;
|
|
460
|
+
authenticated: boolean;
|
|
461
|
+
tools: TTool[];
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Discovery catalog entry for "+ Add MCP server". Host extends. */
|
|
465
|
+
export interface ConnectorCatalogEntry<
|
|
466
|
+
TAuth extends ConnectorAuthPublic = ConnectorAuthPublic,
|
|
467
|
+
> {
|
|
468
|
+
id: string;
|
|
469
|
+
name: string;
|
|
470
|
+
description?: string;
|
|
471
|
+
url: string;
|
|
472
|
+
auth: TAuth;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Create connector — no `id`; server assigns it. Host extends. */
|
|
476
|
+
export type CreateConnectorRequest<TAuth extends ConnectorAuth = ConnectorAuth> =
|
|
477
|
+
ConnectorConfigBase<TAuth>;
|
|
478
|
+
|
|
479
|
+
/** Update connector — `id` required. Host extends. */
|
|
480
|
+
export type UpdateConnectorRequest<TAuth extends ConnectorAuth = ConnectorAuth> =
|
|
481
|
+
ConnectorConfigBase<TAuth> & { id: string };
|
|
482
|
+
|
|
483
|
+
export interface ConnectorCatalogServer<
|
|
484
|
+
TTool extends ToolBase = ToolBase,
|
|
485
|
+
TAuthWrite extends ConnectorAuth = ConnectorAuth,
|
|
486
|
+
TAuthPublic extends ConnectorAuthPublic = ConnectorAuthPublic,
|
|
487
|
+
TConnector extends ConnectorBase<TTool, TAuthPublic> = ConnectorBase<
|
|
488
|
+
TTool,
|
|
489
|
+
TAuthPublic
|
|
490
|
+
>,
|
|
491
|
+
TCatalogEntry extends ConnectorCatalogEntry<TAuthPublic> =
|
|
492
|
+
ConnectorCatalogEntry<TAuthPublic>,
|
|
493
|
+
TCreate extends CreateConnectorRequest<TAuthWrite> =
|
|
494
|
+
CreateConnectorRequest<TAuthWrite>,
|
|
495
|
+
TUpdate extends UpdateConnectorRequest<TAuthWrite> =
|
|
496
|
+
UpdateConnectorRequest<TAuthWrite>,
|
|
497
|
+
> {
|
|
498
|
+
getConnectorCatalog(): Promise<TCatalogEntry[]>;
|
|
499
|
+
listConnectors(req?: { query?: string }): Promise<TConnector[]>;
|
|
500
|
+
createConnector(req: TCreate): Promise<TConnector>;
|
|
501
|
+
/** Full replace update keyed by connector `id`. */
|
|
502
|
+
updateConnector(req: TUpdate): Promise<TConnector>;
|
|
503
|
+
/** Start connector auth (e.g. OAuth). Host may widen return with `authUrl`. */
|
|
504
|
+
authenticateConnector(req: { id: string }): Promise<TConnector>;
|
|
505
|
+
/** Clear connector auth. */
|
|
506
|
+
disconnectConnector(req: { id: string }): Promise<TConnector>;
|
|
507
|
+
deleteConnector?(req: { id: string }): Promise<void>;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ---------------------------------------------------------------------------
|
|
511
|
+
// Skills catalog — FE-minimal settings DTOs (host extends via generics)
|
|
512
|
+
// ---------------------------------------------------------------------------
|
|
513
|
+
|
|
514
|
+
/** Skill row shown in settings/skills (list + delete). Host extends for fqn, etc. */
|
|
515
|
+
export interface SkillBase {
|
|
516
|
+
id: string;
|
|
517
|
+
name: string;
|
|
518
|
+
description: string;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Create-skill request. Host extends for branch, auth, etc. */
|
|
522
|
+
export interface CreateSkillRequest {
|
|
523
|
+
repo: string;
|
|
524
|
+
directory: string;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export interface SkillCatalogServer<
|
|
528
|
+
TSkill extends SkillBase = SkillBase,
|
|
529
|
+
TCreate extends CreateSkillRequest = CreateSkillRequest,
|
|
530
|
+
> {
|
|
531
|
+
listSkills(req?: { query?: string }): Promise<TSkill[]>;
|
|
532
|
+
createSkill(req: TCreate): Promise<TSkill>;
|
|
533
|
+
deleteSkill?(req: { id: string }): Promise<void>;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Settings management aggregate — modelCatalog + connectorCatalog + optional skillCatalog.
|
|
538
|
+
* Hosts may pass the whole object to an app shell, or a focused sub-port to a page.
|
|
539
|
+
*/
|
|
540
|
+
export interface CatalogServer<
|
|
541
|
+
TModelCatalog extends ModelCatalogServer = ModelCatalogServer,
|
|
542
|
+
TConnectorCatalog extends ConnectorCatalogServer = ConnectorCatalogServer,
|
|
543
|
+
TSkillCatalog extends SkillCatalogServer = SkillCatalogServer,
|
|
544
|
+
> {
|
|
545
|
+
modelCatalog: TModelCatalog;
|
|
546
|
+
connectorCatalog: TConnectorCatalog;
|
|
547
|
+
/** Optional — omit when the host has no skills settings surface. */
|
|
548
|
+
skillCatalog?: TSkillCatalog;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Composed host port: chat + builder + optional settings catalog.
|
|
553
|
+
* Agent-ui's `AgentUIServer` mirrors this shape; named differently here to
|
|
554
|
+
* avoid colliding with that package's local type name.
|
|
555
|
+
*
|
|
556
|
+
* `catalog` is optional — if the host passes it, settings UI can call
|
|
557
|
+
* `useCatalogServer()` / show modelCatalog, connectorCatalog, and skillCatalog;
|
|
558
|
+
* if omitted, those surfaces stay hidden.
|
|
559
|
+
*/
|
|
560
|
+
export type AgentUIServerPort<
|
|
561
|
+
TChat extends AgentChatServer = AgentChatServer,
|
|
562
|
+
TBuilder extends AgentBuilderServer = AgentBuilderServer,
|
|
563
|
+
TCatalog extends CatalogServer = CatalogServer,
|
|
564
|
+
> = TChat & TBuilder & { catalog?: TCatalog };
|
package/src/truefoundryExtras.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createRuntimeExtras } from "@assistant-ui/core/internal";
|
|
2
2
|
import type { McpAuthRequiredEvent } from "./server/index.js";
|
|
3
3
|
|
|
4
|
-
import type { AgentSpec
|
|
4
|
+
import type { AgentSpec } from "./server/types.js";
|
|
5
|
+
import type { AgentSpecUpdate } from "./draft/agentSpec.js";
|
|
5
6
|
import type { PendingApproval, PendingToolResponse } from "./collectPending.js";
|
|
6
7
|
import type { RespondToToolApprovalOptions } from "./toolApproval.js";
|
|
7
8
|
import type { RespondToToolResponseOptions } from "./toolResponse.js";
|
|
@@ -11,6 +12,7 @@ export type { PendingApproval, PendingToolResponse };
|
|
|
11
12
|
export type TrueFoundryDraftRuntimeExtras = {
|
|
12
13
|
agentSpec: AgentSpec | null;
|
|
13
14
|
draftSessionId: string | undefined;
|
|
15
|
+
isSpecLoading: boolean;
|
|
14
16
|
isSpecSyncing: boolean;
|
|
15
17
|
specError: unknown | null;
|
|
16
18
|
updateAgentSpec: (update: AgentSpecUpdate) => void;
|
|
@@ -41,6 +43,7 @@ export const trueFoundryExtras = createRuntimeExtras<TrueFoundryRuntimeExtras>(
|
|
|
41
43
|
export const EMPTY_DRAFT_EXTRAS: TrueFoundryDraftRuntimeExtras = {
|
|
42
44
|
agentSpec: null,
|
|
43
45
|
draftSessionId: undefined,
|
|
46
|
+
isSpecLoading: false,
|
|
44
47
|
isSpecSyncing: false,
|
|
45
48
|
specError: null,
|
|
46
49
|
updateAgentSpec: () => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { RemoteThreadListAdapter } from "@assistant-ui/core";
|
|
2
2
|
|
|
3
3
|
import type { AgentChatServer, Session } from "./server/types.js";
|
|
4
|
-
import { draftSessionTitle } from "./
|
|
4
|
+
import { draftSessionTitle } from "./draft/agentSpec.js";
|
|
5
5
|
import { sessionListStartTimestamp } from "./sessionListStartTimestamp.js";
|
|
6
6
|
|
|
7
7
|
const THREAD_LIST_PAGE_SIZE = 20;
|
package/src/types.ts
CHANGED
|
@@ -7,8 +7,7 @@ import type {
|
|
|
7
7
|
SpeechSynthesisAdapter,
|
|
8
8
|
} from "@assistant-ui/core";
|
|
9
9
|
|
|
10
|
-
import type { AgentSpec } from "./
|
|
11
|
-
import type { AgentChatServer } from "./server/types.js";
|
|
10
|
+
import type { AgentChatServer, AgentSpec } from "./server/types.js";
|
|
12
11
|
|
|
13
12
|
export type NamedAgentConfig = {
|
|
14
13
|
mode: "named";
|
|
@@ -46,6 +46,7 @@ vi.mock("./convertTurnMessages.js", async (importOriginal) => {
|
|
|
46
46
|
|
|
47
47
|
const mockServer = {
|
|
48
48
|
cancelSession: vi.fn().mockResolvedValue(undefined),
|
|
49
|
+
listTurns: vi.fn(),
|
|
49
50
|
} as unknown as AgentChatServer;
|
|
50
51
|
|
|
51
52
|
function snapshotWithAssistantMessage(
|
|
@@ -279,6 +280,7 @@ describe("useTrueFoundryAgentMessages", () => {
|
|
|
279
280
|
beforeEach(() => {
|
|
280
281
|
vi.clearAllMocks();
|
|
281
282
|
vi.mocked(mockServer.cancelSession).mockResolvedValue(undefined);
|
|
283
|
+
vi.mocked(mockServer.listTurns).mockResolvedValue({ data: [] });
|
|
282
284
|
vi.mocked(loadSessionSnapshot).mockResolvedValue(createEmptySessionSnapshot());
|
|
283
285
|
vi.mocked(streamTurnContent).mockReturnValue(singleUpdateStream());
|
|
284
286
|
vi.mocked(resumeTurnStream).mockReturnValue(singleUpdateStream());
|
|
@@ -298,6 +300,79 @@ describe("useTrueFoundryAgentMessages", () => {
|
|
|
298
300
|
expect(loadSessionSnapshot).not.toHaveBeenCalled();
|
|
299
301
|
});
|
|
300
302
|
|
|
303
|
+
it("loads the initial URL session before it is marked as the main thread", async () => {
|
|
304
|
+
const { rerender } = renderHook(
|
|
305
|
+
({ isMain }: { isMain: boolean }) =>
|
|
306
|
+
useTrueFoundryAgentMessages({
|
|
307
|
+
server: mockServer,
|
|
308
|
+
sessionId: "session-from-url",
|
|
309
|
+
isMain,
|
|
310
|
+
isInitialSession: true,
|
|
311
|
+
}),
|
|
312
|
+
{ initialProps: { isMain: false } },
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
await waitFor(() =>
|
|
316
|
+
expect(loadSessionSnapshot).toHaveBeenCalledWith(
|
|
317
|
+
mockServer,
|
|
318
|
+
"session-from-url",
|
|
319
|
+
expect.any(Function),
|
|
320
|
+
),
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
rerender({ isMain: true });
|
|
324
|
+
await act(async () => Promise.resolve());
|
|
325
|
+
expect(loadSessionSnapshot).toHaveBeenCalledTimes(1);
|
|
326
|
+
|
|
327
|
+
rerender({ isMain: false });
|
|
328
|
+
rerender({ isMain: true });
|
|
329
|
+
await waitFor(() => expect(loadSessionSnapshot).toHaveBeenCalledTimes(2));
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("does not load an inactive background thread", async () => {
|
|
333
|
+
const { result } = renderHook(() =>
|
|
334
|
+
useTrueFoundryAgentMessages({
|
|
335
|
+
server: mockServer,
|
|
336
|
+
sessionId: "background-session",
|
|
337
|
+
isMain: false,
|
|
338
|
+
isInitialSession: false,
|
|
339
|
+
}),
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
343
|
+
expect(loadSessionSnapshot).not.toHaveBeenCalled();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("retries a failed URL early load before the thread is promoted to main", async () => {
|
|
347
|
+
vi.mocked(loadSessionSnapshot)
|
|
348
|
+
.mockRejectedValueOnce(new Error("load failed"))
|
|
349
|
+
.mockResolvedValueOnce(snapshotWithUserTurn("Hello"));
|
|
350
|
+
|
|
351
|
+
const { result } = renderHook(() =>
|
|
352
|
+
useTrueFoundryAgentMessages({
|
|
353
|
+
server: mockServer,
|
|
354
|
+
sessionId: "session-from-url",
|
|
355
|
+
isMain: false,
|
|
356
|
+
isInitialSession: true,
|
|
357
|
+
}),
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
await waitFor(() => expect(loadSessionSnapshot).toHaveBeenCalledTimes(1));
|
|
361
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
362
|
+
|
|
363
|
+
await act(async () => {
|
|
364
|
+
result.current.retryLoad();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
await waitFor(() => expect(loadSessionSnapshot).toHaveBeenCalledTimes(2));
|
|
368
|
+
await waitFor(() =>
|
|
369
|
+
expect(result.current.messages[0]).toMatchObject({
|
|
370
|
+
role: "user",
|
|
371
|
+
content: [{ type: "text", text: "Hello" }],
|
|
372
|
+
}),
|
|
373
|
+
);
|
|
374
|
+
});
|
|
375
|
+
|
|
301
376
|
it("sendTurn lazily initializes a session when sessionId is undefined", async () => {
|
|
302
377
|
const initializeSession = vi.fn().mockResolvedValue({
|
|
303
378
|
remoteId: "session-new",
|
|
@@ -358,6 +433,7 @@ describe("useTrueFoundryAgentMessages", () => {
|
|
|
358
433
|
},
|
|
359
434
|
expect.any(AbortSignal),
|
|
360
435
|
expect.any(Array),
|
|
436
|
+
expect.any(Function),
|
|
361
437
|
);
|
|
362
438
|
|
|
363
439
|
await act(async () => {
|
|
@@ -372,6 +448,7 @@ describe("useTrueFoundryAgentMessages", () => {
|
|
|
372
448
|
{ userMessage: "second" },
|
|
373
449
|
expect.any(AbortSignal),
|
|
374
450
|
expect.any(Array),
|
|
451
|
+
expect.any(Function),
|
|
375
452
|
);
|
|
376
453
|
});
|
|
377
454
|
|
|
@@ -385,7 +462,11 @@ describe("useTrueFoundryAgentMessages", () => {
|
|
|
385
462
|
);
|
|
386
463
|
|
|
387
464
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
388
|
-
expect(loadSessionSnapshot).toHaveBeenCalledWith(
|
|
465
|
+
expect(loadSessionSnapshot).toHaveBeenCalledWith(
|
|
466
|
+
mockServer,
|
|
467
|
+
"session-1",
|
|
468
|
+
expect.any(Function),
|
|
469
|
+
);
|
|
389
470
|
expect(result.current.messages).toHaveLength(1);
|
|
390
471
|
expect(result.current.messages[0]?.role).toBe("user");
|
|
391
472
|
});
|
|
@@ -493,6 +574,21 @@ describe("useTrueFoundryAgentMessages", () => {
|
|
|
493
574
|
|
|
494
575
|
it("editFromTurn drops prior turns before showing the edited user message", async () => {
|
|
495
576
|
const createdAt = new Date().toISOString();
|
|
577
|
+
vi.mocked(mockServer.listTurns).mockResolvedValue({
|
|
578
|
+
data: [
|
|
579
|
+
{
|
|
580
|
+
id: "turn-1",
|
|
581
|
+
sessionId: "session-1",
|
|
582
|
+
createdAt,
|
|
583
|
+
state: {
|
|
584
|
+
status: "done",
|
|
585
|
+
requiredActions: [],
|
|
586
|
+
completedAt: createdAt,
|
|
587
|
+
},
|
|
588
|
+
input: [{ type: "user.message", content: "Hello" }],
|
|
589
|
+
} as Turn,
|
|
590
|
+
],
|
|
591
|
+
});
|
|
496
592
|
const fold = new PeerThreadFoldState();
|
|
497
593
|
ingestTurnEvent(fold, {
|
|
498
594
|
type: "model.message",
|
|
@@ -589,6 +685,54 @@ describe("useTrueFoundryAgentMessages", () => {
|
|
|
589
685
|
});
|
|
590
686
|
});
|
|
591
687
|
|
|
688
|
+
it("restores history when edit fails before turn.created", async () => {
|
|
689
|
+
const createdAt = new Date().toISOString();
|
|
690
|
+
const onError = vi.fn();
|
|
691
|
+
const original = snapshotWithUserTurn("Hello");
|
|
692
|
+
vi.mocked(loadSessionSnapshot).mockResolvedValue(original);
|
|
693
|
+
vi.mocked(mockServer.listTurns).mockResolvedValue({
|
|
694
|
+
data: [
|
|
695
|
+
{
|
|
696
|
+
id: "turn-1",
|
|
697
|
+
sessionId: "session-1",
|
|
698
|
+
createdAt,
|
|
699
|
+
state: {
|
|
700
|
+
status: "done",
|
|
701
|
+
requiredActions: [],
|
|
702
|
+
completedAt: createdAt,
|
|
703
|
+
},
|
|
704
|
+
input: [{ type: "user.message", content: "Hello" }],
|
|
705
|
+
} as Turn,
|
|
706
|
+
],
|
|
707
|
+
});
|
|
708
|
+
vi.mocked(streamTurnContent).mockImplementation(async function* () {
|
|
709
|
+
throw new Error("Turn preparation failed");
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
const { result } = renderHook(() =>
|
|
713
|
+
useTrueFoundryAgentMessages({
|
|
714
|
+
server: mockServer,
|
|
715
|
+
sessionId: "session-1",
|
|
716
|
+
onError,
|
|
717
|
+
}),
|
|
718
|
+
);
|
|
719
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
720
|
+
|
|
721
|
+
await act(async () => {
|
|
722
|
+
await expect(
|
|
723
|
+
result.current.editFromTurn("turn-1", "Edited"),
|
|
724
|
+
).rejects.toThrow("Turn preparation failed");
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
expect(result.current.messages).toHaveLength(1);
|
|
728
|
+
expect(result.current.messages[0]).toMatchObject({
|
|
729
|
+
role: "user",
|
|
730
|
+
content: [{ type: "text", text: "Hello" }],
|
|
731
|
+
});
|
|
732
|
+
// runStream reports once; callers must not double-toast.
|
|
733
|
+
expect(onError).toHaveBeenCalledOnce();
|
|
734
|
+
});
|
|
735
|
+
|
|
592
736
|
it("does not let a superseded stream complete the current stream", async () => {
|
|
593
737
|
let releaseFirstStream: (() => void) | undefined;
|
|
594
738
|
let releaseSecondStream: (() => void) | undefined;
|
|
@@ -962,4 +1106,120 @@ describe("useTrueFoundryAgentMessages", () => {
|
|
|
962
1106
|
// on mount and reconciles against the event log on the next page load.
|
|
963
1107
|
expect(loadSessionSnapshot).toHaveBeenCalledTimes(1);
|
|
964
1108
|
});
|
|
1109
|
+
|
|
1110
|
+
describe("pre-turn failure rollback", () => {
|
|
1111
|
+
it("reports and restores a user message when initializeSession fails", async () => {
|
|
1112
|
+
const onError = vi.fn();
|
|
1113
|
+
const onPreTurnFailure = vi.fn();
|
|
1114
|
+
const initializeSession = vi
|
|
1115
|
+
.fn()
|
|
1116
|
+
.mockRejectedValue(new Error("Draft session creation failed"));
|
|
1117
|
+
|
|
1118
|
+
const { result } = renderHook(() =>
|
|
1119
|
+
useTrueFoundryAgentMessages({
|
|
1120
|
+
server: mockServer,
|
|
1121
|
+
sessionId: undefined,
|
|
1122
|
+
initializeSession,
|
|
1123
|
+
onError,
|
|
1124
|
+
}),
|
|
1125
|
+
);
|
|
1126
|
+
|
|
1127
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
1128
|
+
expect(result.current.messages).toEqual([]);
|
|
1129
|
+
|
|
1130
|
+
await act(async () => {
|
|
1131
|
+
await expect(
|
|
1132
|
+
result.current.sendTurn({
|
|
1133
|
+
userMessage: "test message",
|
|
1134
|
+
onPreTurnFailure,
|
|
1135
|
+
}),
|
|
1136
|
+
).rejects.toThrow("Draft session creation failed");
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
expect(result.current.messages).toEqual([]);
|
|
1140
|
+
expect(onPreTurnFailure).toHaveBeenCalledOnce();
|
|
1141
|
+
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
|
1142
|
+
expect(initializeSession).toHaveBeenCalledOnce();
|
|
1143
|
+
expect(streamTurnContent).not.toHaveBeenCalled();
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
it("rolls back when the turns stream fails before turn.created", async () => {
|
|
1147
|
+
const onError = vi.fn();
|
|
1148
|
+
const onPreTurnFailure = vi.fn();
|
|
1149
|
+
vi.mocked(streamTurnContent).mockImplementation(async function* () {
|
|
1150
|
+
throw new Error("Turn preparation failed");
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
const { result } = renderHook(() =>
|
|
1154
|
+
useTrueFoundryAgentMessages({
|
|
1155
|
+
server: mockServer,
|
|
1156
|
+
sessionId: "session-1",
|
|
1157
|
+
onError,
|
|
1158
|
+
}),
|
|
1159
|
+
);
|
|
1160
|
+
|
|
1161
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
1162
|
+
|
|
1163
|
+
await act(async () => {
|
|
1164
|
+
await expect(
|
|
1165
|
+
result.current.sendTurn({
|
|
1166
|
+
userMessage: "test message",
|
|
1167
|
+
onPreTurnFailure,
|
|
1168
|
+
}),
|
|
1169
|
+
).rejects.toThrow("Turn preparation failed");
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
expect(result.current.messages).toEqual([]);
|
|
1173
|
+
expect(onPreTurnFailure).toHaveBeenCalledOnce();
|
|
1174
|
+
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
it("does not roll back after turn.created registers the user message", async () => {
|
|
1178
|
+
const onError = vi.fn();
|
|
1179
|
+
const onPreTurnFailure = vi.fn();
|
|
1180
|
+
vi.mocked(streamTurnContent).mockImplementation(
|
|
1181
|
+
async function* (
|
|
1182
|
+
_server,
|
|
1183
|
+
_sessionId,
|
|
1184
|
+
_fold,
|
|
1185
|
+
_options,
|
|
1186
|
+
_signal,
|
|
1187
|
+
_baseline,
|
|
1188
|
+
onTurnIdAvailable,
|
|
1189
|
+
) {
|
|
1190
|
+
onTurnIdAvailable?.("gateway-turn-123");
|
|
1191
|
+
yield { content: [{ type: "text" as const, text: "partial" }] };
|
|
1192
|
+
throw new Error("Mid-stream error");
|
|
1193
|
+
},
|
|
1194
|
+
);
|
|
1195
|
+
|
|
1196
|
+
const { result } = renderHook(() =>
|
|
1197
|
+
useTrueFoundryAgentMessages({
|
|
1198
|
+
server: mockServer,
|
|
1199
|
+
sessionId: "session-1",
|
|
1200
|
+
onError,
|
|
1201
|
+
}),
|
|
1202
|
+
);
|
|
1203
|
+
|
|
1204
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
1205
|
+
|
|
1206
|
+
await act(async () => {
|
|
1207
|
+
await expect(
|
|
1208
|
+
result.current.sendTurn({
|
|
1209
|
+
userMessage: "test message",
|
|
1210
|
+
onPreTurnFailure,
|
|
1211
|
+
}),
|
|
1212
|
+
).rejects.toThrow("Mid-stream error");
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
const userMessages = result.current.messages.filter((m) => m.role === "user");
|
|
1216
|
+
expect(userMessages).toHaveLength(1);
|
|
1217
|
+
expect(userMessages[0]?.content[0]).toMatchObject({
|
|
1218
|
+
type: "text",
|
|
1219
|
+
text: "test message",
|
|
1220
|
+
});
|
|
1221
|
+
expect(onPreTurnFailure).not.toHaveBeenCalled();
|
|
1222
|
+
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
|
1223
|
+
});
|
|
1224
|
+
});
|
|
965
1225
|
});
|