@assistant-ui/core 0.2.7 → 0.2.9
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/dist/adapters/attachment.d.ts.map +1 -1
- package/dist/adapters/speech.d.ts.map +1 -1
- package/dist/adapters/speech.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/duplicate-detection.d.ts.map +1 -1
- package/dist/internal.d.ts +2 -2
- package/dist/internal.js +2 -2
- package/dist/model-context/frame/host.d.ts.map +1 -1
- package/dist/model-context/frame/host.js.map +1 -1
- package/dist/model-context/frame/provider.d.ts.map +1 -1
- package/dist/model-context/frame/provider.js.map +1 -1
- package/dist/model-context/registry.d.ts.map +1 -1
- package/dist/model-context/tool.d.ts.map +1 -1
- package/dist/react/client/Interactables.js.map +1 -1
- package/dist/react/client/Tools.d.ts.map +1 -1
- package/dist/react/client/Tools.js +26 -15
- package/dist/react/client/Tools.js.map +1 -1
- package/dist/react/index.d.ts +6 -3
- package/dist/react/index.js +4 -1
- package/dist/react/model-context/define-toolkit.d.ts +20 -0
- package/dist/react/model-context/define-toolkit.d.ts.map +1 -0
- package/dist/react/model-context/define-toolkit.js +21 -0
- package/dist/react/model-context/define-toolkit.js.map +1 -0
- package/dist/react/model-context/hitl.d.ts +19 -0
- package/dist/react/model-context/hitl.d.ts.map +1 -0
- package/dist/react/model-context/hitl.js +22 -0
- package/dist/react/model-context/hitl.js.map +1 -0
- package/dist/react/model-context/toolbox.d.ts +29 -2
- package/dist/react/model-context/toolbox.d.ts.map +1 -1
- package/dist/react/model-context/toolbox.js +18 -0
- package/dist/react/model-context/toolbox.js.map +1 -0
- package/dist/react/model-context/useAssistantTool.d.ts.map +1 -1
- package/dist/react/model-context/useAssistantTool.js +6 -3
- package/dist/react/model-context/useAssistantTool.js.map +1 -1
- package/dist/react/model-context/useAssistantToolUI.d.ts +6 -0
- package/dist/react/model-context/useAssistantToolUI.d.ts.map +1 -1
- package/dist/react/model-context/useAssistantToolUI.js +4 -2
- package/dist/react/model-context/useAssistantToolUI.js.map +1 -1
- package/dist/react/model-context/useInlineRender.js.map +1 -1
- package/dist/react/primitives/message/MessageGroupedParts.d.ts +49 -7
- package/dist/react/primitives/message/MessageGroupedParts.d.ts.map +1 -1
- package/dist/react/primitives/message/MessageGroupedParts.js +28 -3
- package/dist/react/primitives/message/MessageGroupedParts.js.map +1 -1
- package/dist/react/primitives/message/MessageParts.d.ts.map +1 -1
- package/dist/react/primitives/message/MessageParts.js +2 -7
- package/dist/react/primitives/message/MessageParts.js.map +1 -1
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
- package/dist/react/runtimes/RuntimeAdapterProvider.d.ts.map +1 -1
- package/dist/react/runtimes/RuntimeAdapterProvider.js +6 -5
- package/dist/react/runtimes/RuntimeAdapterProvider.js.map +1 -1
- package/dist/react/runtimes/cloud/CloudFileAttachmentAdapter.d.ts.map +1 -1
- package/dist/react/runtimes/cloud/useCloudThreadListAdapter.d.ts.map +1 -1
- package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js.map +1 -1
- package/dist/react/runtimes/external-message-converter.d.ts.map +1 -1
- package/dist/react/runtimes/external-message-converter.js +1 -0
- package/dist/react/runtimes/external-message-converter.js.map +1 -1
- package/dist/react/runtimes/useExternalStoreSharedOptions.d.ts +7 -0
- package/dist/react/runtimes/useExternalStoreSharedOptions.d.ts.map +1 -0
- package/dist/react/runtimes/useExternalStoreSharedOptions.js +21 -0
- package/dist/react/runtimes/useExternalStoreSharedOptions.js.map +1 -0
- package/dist/react/runtimes/useLocalRuntime.d.ts.map +1 -1
- package/dist/react/runtimes/useLocalRuntime.js.map +1 -1
- package/dist/react/runtimes/useRemoteThreadListRuntime.d.ts.map +1 -1
- package/dist/react/runtimes/useRemoteThreadListRuntime.js.map +1 -1
- package/dist/react/types/scopes/tools.d.ts +19 -2
- package/dist/react/types/scopes/tools.d.ts.map +1 -1
- package/dist/react/utils/groupParts.d.ts +32 -11
- package/dist/react/utils/groupParts.d.ts.map +1 -1
- package/dist/react/utils/groupParts.js +13 -6
- package/dist/react/utils/groupParts.js.map +1 -1
- package/dist/runtime/api/assistant-runtime.d.ts.map +1 -1
- package/dist/runtime/api/attachment-runtime.d.ts.map +1 -1
- package/dist/runtime/api/composer-runtime.d.ts.map +1 -1
- package/dist/runtime/api/message-part-runtime.d.ts.map +1 -1
- package/dist/runtime/api/message-runtime.d.ts.map +1 -1
- package/dist/runtime/api/thread-list-item-runtime.d.ts.map +1 -1
- package/dist/runtime/api/thread-list-runtime.d.ts.map +1 -1
- package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
- package/dist/runtime/base/base-assistant-runtime-core.d.ts.map +1 -1
- package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
- package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtime/base/default-edit-composer-runtime-core.d.ts.map +1 -1
- package/dist/runtime/base/default-thread-composer-runtime-core.d.ts.map +1 -1
- package/dist/runtime/utils/message-repository.d.ts +9 -1
- package/dist/runtime/utils/message-repository.d.ts.map +1 -1
- package/dist/runtime/utils/message-repository.js +34 -14
- package/dist/runtime/utils/message-repository.js.map +1 -1
- package/dist/runtime/utils/thread-message-like.d.ts +1 -0
- package/dist/runtime/utils/thread-message-like.d.ts.map +1 -1
- package/dist/runtime/utils/thread-message-like.js +2 -1
- package/dist/runtime/utils/thread-message-like.js.map +1 -1
- package/dist/runtimes/external-store/external-store-shared-options.d.ts +8 -0
- package/dist/runtimes/external-store/external-store-shared-options.d.ts.map +1 -0
- package/dist/runtimes/external-store/external-store-shared-options.js +11 -0
- package/dist/runtimes/external-store/external-store-shared-options.js.map +1 -0
- package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +0 -2
- package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-runtime-core.js +12 -23
- package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
- package/dist/runtimes/external-store/thread-message-converter.d.ts.map +1 -1
- package/dist/runtimes/local/local-thread-list-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtimes/remote-thread-list/adapter/in-memory.d.ts.map +1 -1
- package/dist/runtimes/remote-thread-list/optimistic-state.d.ts.map +1 -1
- package/dist/runtimes/tool-invocations/ToolInvocationTracker.d.ts.map +1 -1
- package/dist/runtimes/tool-invocations/ToolInvocationTracker.js.map +1 -1
- package/dist/subscribable/subscribable.d.ts.map +1 -1
- package/dist/tests/remote-thread-list-test-helpers.d.ts.map +1 -1
- package/dist/types/message.d.ts +6 -0
- package/dist/types/message.d.ts.map +1 -1
- package/dist/types/message.js.map +1 -1
- package/dist/utils/composite-context-provider.d.ts.map +1 -1
- package/dist/utils/id.d.ts +1 -3
- package/dist/utils/id.d.ts.map +1 -1
- package/dist/utils/id.js +1 -4
- package/dist/utils/id.js.map +1 -1
- package/package.json +10 -10
- package/src/adapters/speech.ts +0 -1
- package/src/index.ts +2 -0
- package/src/internal.ts +0 -2
- package/src/model-context/frame/host.ts +0 -1
- package/src/model-context/frame/provider.ts +0 -1
- package/src/react/client/Interactables.ts +0 -1
- package/src/react/client/Tools.ts +50 -25
- package/src/react/index.ts +10 -2
- package/src/react/model-context/define-toolkit.test.ts +13 -0
- package/src/react/model-context/define-toolkit.ts +23 -0
- package/src/react/model-context/hitl.ts +22 -0
- package/src/react/model-context/toolbox.ts +46 -1
- package/src/react/model-context/useAssistantTool.ts +8 -3
- package/src/react/model-context/useAssistantToolUI.ts +9 -2
- package/src/react/model-context/useInlineRender.ts +0 -1
- package/src/react/primitives/message/MessageGroupedParts.tsx +101 -12
- package/src/react/primitives/message/MessageParts.tsx +4 -7
- package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +0 -3
- package/src/react/runtimes/RuntimeAdapterProvider.tsx +12 -7
- package/src/react/runtimes/cloud/useCloudThreadListAdapter.tsx +0 -3
- package/src/react/runtimes/external-message-converter.ts +4 -0
- package/src/react/runtimes/useExternalStoreSharedOptions.ts +23 -0
- package/src/react/runtimes/useLocalRuntime.ts +0 -10
- package/src/react/runtimes/useRemoteThreadListRuntime.ts +0 -6
- package/src/react/types/scopes/tools.ts +20 -1
- package/src/react/utils/groupParts.ts +49 -18
- package/src/runtime/utils/message-repository.ts +57 -16
- package/src/runtime/utils/thread-message-like.ts +2 -0
- package/src/runtimes/external-store/external-store-shared-options.ts +18 -0
- package/src/runtimes/external-store/external-store-thread-runtime-core.ts +18 -33
- package/src/runtimes/tool-invocations/ToolInvocationTracker.ts +0 -1
- package/src/tests/MessageRepository.test.ts +83 -52
- package/src/tests/OptimisticState-list-race.test.ts +0 -4
- package/src/tests/external-store-thread-runtime-core.test.ts +105 -73
- package/src/tests/groupParts.test.ts +70 -0
- package/src/tests/remote-thread-list-isLoading.test.ts +0 -5
- package/src/types/message.ts +6 -0
- package/src/utils/id.ts +0 -4
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { isMcpAppUri } from "../../types/message";
|
|
2
2
|
import type { PartState } from "../../store/scopes/part";
|
|
3
|
+
import type { ToolsState } from "../types/scopes/tools";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Registry context passed to a `groupBy` function as its second argument by
|
|
7
|
+
* `<MessagePrimitive.GroupedParts>`. Carries the live tool-UI registry so a
|
|
8
|
+
* `groupBy` can resolve registry-driven grouping (e.g. standalone tool calls)
|
|
9
|
+
* without the part itself having to carry that information.
|
|
10
|
+
*/
|
|
11
|
+
export type GroupByContext = {
|
|
12
|
+
/** Tool UIs registered in the tool-UI registry, keyed by tool name. */
|
|
13
|
+
readonly toolUIs?: ToolsState["toolUIs"];
|
|
14
|
+
};
|
|
3
15
|
|
|
4
16
|
/**
|
|
5
17
|
* Hierarchical adjacent-coalescing grouping for message parts.
|
|
@@ -25,12 +37,20 @@ export const GROUPBY_MEMO_KEY: unique symbol = Symbol.for(
|
|
|
25
37
|
);
|
|
26
38
|
|
|
27
39
|
/**
|
|
28
|
-
* Synthetic part-type
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
40
|
+
* Synthetic part-type keys recognized by {@link groupPartByType}, in
|
|
41
|
+
* addition to real {@link PartState} types:
|
|
42
|
+
*
|
|
43
|
+
* - `"standalone-tool-call"` — a tool-call whose UI should be presented on its
|
|
44
|
+
* own, outside the chain-of-thought grouping. Matches MCP-app tool calls plus
|
|
45
|
+
* any tool-call whose registered UI opts into standalone display (human
|
|
46
|
+
* tools, the built-in generative-UI tool, and tools that set
|
|
47
|
+
* `display: "standalone"`). Resolving the registry-driven cases reads the
|
|
48
|
+
* {@link GroupByContext} passed to the `groupBy` function. Takes precedence
|
|
49
|
+
* over the `"tool-call"` entry.
|
|
50
|
+
* - `"mcp-app"` — **deprecated**, kept for back-compat. Matches only MCP-app
|
|
51
|
+
* tool calls. Prefer `"standalone-tool-call"`, which is a superset.
|
|
32
52
|
*/
|
|
33
|
-
type GroupPartType = PartState["type"] | "mcp-app";
|
|
53
|
+
type GroupPartType = PartState["type"] | "standalone-tool-call" | "mcp-app";
|
|
34
54
|
|
|
35
55
|
/**
|
|
36
56
|
* Build a `groupBy` from a `part.type → group-key path` lookup.
|
|
@@ -38,9 +58,12 @@ type GroupPartType = PartState["type"] | "mcp-app";
|
|
|
38
58
|
* function carries a stable {@link GROUPBY_MEMO_KEY} fingerprint so
|
|
39
59
|
* `<MessagePrimitive.GroupedParts>` can memoize its tree across renders.
|
|
40
60
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* the
|
|
61
|
+
* The synthetic `"standalone-tool-call"` key matches tool calls that should
|
|
62
|
+
* render outside the chain-of-thought grouping. MCP-app calls are detected from
|
|
63
|
+
* the part alone; the registry-driven cases (human tools, the generative-UI
|
|
64
|
+
* tool, `display: "standalone"` opt-ins) are resolved from the
|
|
65
|
+
* {@link GroupByContext} that `<MessagePrimitive.GroupedParts>` passes to the
|
|
66
|
+
* `groupBy` function — the helper needs nothing threaded into it.
|
|
44
67
|
*
|
|
45
68
|
* @example
|
|
46
69
|
* ```tsx
|
|
@@ -48,7 +71,7 @@ type GroupPartType = PartState["type"] | "mcp-app";
|
|
|
48
71
|
* groupBy={groupPartByType({
|
|
49
72
|
* reasoning: ["group-thought", "group-reasoning"],
|
|
50
73
|
* "tool-call": ["group-thought", "group-tool"],
|
|
51
|
-
* "
|
|
74
|
+
* "standalone-tool-call": [],
|
|
52
75
|
* })}
|
|
53
76
|
* >
|
|
54
77
|
* {({ part, children }) => { ... }}
|
|
@@ -57,18 +80,26 @@ type GroupPartType = PartState["type"] | "mcp-app";
|
|
|
57
80
|
*/
|
|
58
81
|
export const groupPartByType = <TKey extends `group-${string}`>(
|
|
59
82
|
map: Partial<Readonly<Record<GroupPartType, readonly TKey[]>>>,
|
|
60
|
-
): ((part: PartState) => readonly TKey[]) => {
|
|
83
|
+
): ((part: PartState, context?: GroupByContext) => readonly TKey[]) => {
|
|
61
84
|
const lookup = map as Readonly<Record<string, readonly TKey[] | undefined>>;
|
|
62
|
-
const fn = ((part) => {
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
85
|
+
const fn = ((part, context) => {
|
|
86
|
+
if (part.type === "tool-call") {
|
|
87
|
+
const isMcpApp = isMcpAppUri(part.mcp?.app?.resourceUri);
|
|
88
|
+
// Read the first registration's flag — the same one `resolveToolRender`
|
|
89
|
+
// renders — so grouping and rendering never disagree for a tool name.
|
|
90
|
+
const isStandalone =
|
|
91
|
+
isMcpApp ||
|
|
92
|
+
(context?.toolUIs?.[part.toolName]?.[0]?.standalone ?? false);
|
|
93
|
+
if (isStandalone && lookup["standalone-tool-call"] !== undefined) {
|
|
94
|
+
return lookup["standalone-tool-call"]!;
|
|
95
|
+
}
|
|
96
|
+
// TODO(v0.15): drop the deprecated "mcp-app" key (superseded by "standalone-tool-call").
|
|
97
|
+
if (isMcpApp && lookup["mcp-app"] !== undefined) {
|
|
98
|
+
return lookup["mcp-app"]!;
|
|
99
|
+
}
|
|
69
100
|
}
|
|
70
101
|
return lookup[part.type] ?? [];
|
|
71
|
-
}) as ((part: PartState) => readonly TKey[]) & {
|
|
102
|
+
}) as ((part: PartState, context?: GroupByContext) => readonly TKey[]) & {
|
|
72
103
|
[GROUPBY_MEMO_KEY]?: string;
|
|
73
104
|
};
|
|
74
105
|
// Sort keys so the fingerprint is insensitive to map insertion order —
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ThreadMessage } from "../../types/message";
|
|
2
2
|
import type { RunConfig } from "../../types/message";
|
|
3
|
-
import { generateId
|
|
3
|
+
import { generateId } from "../../utils/id";
|
|
4
4
|
import type { ThreadMessageLike } from "./thread-message-like";
|
|
5
5
|
import { getAutoStatus } from "./auto-status";
|
|
6
6
|
import { fromThreadMessageLike } from "./thread-message-like";
|
|
@@ -259,20 +259,6 @@ export class MessageRepository {
|
|
|
259
259
|
};
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
-
appendOptimisticMessage(parentId: string | null, message: ThreadMessageLike) {
|
|
263
|
-
let optimisticId: string;
|
|
264
|
-
do {
|
|
265
|
-
optimisticId = generateOptimisticId();
|
|
266
|
-
} while (this.messages.has(optimisticId));
|
|
267
|
-
|
|
268
|
-
this.addOrUpdateMessage(
|
|
269
|
-
parentId,
|
|
270
|
-
fromThreadMessageLike(message, optimisticId, { type: "running" }),
|
|
271
|
-
);
|
|
272
|
-
|
|
273
|
-
return optimisticId;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
262
|
deleteMessage(messageId: string, replacementId?: string | null | undefined) {
|
|
277
263
|
const message = this.messages.get(messageId);
|
|
278
264
|
|
|
@@ -322,6 +308,45 @@ export class MessageRepository {
|
|
|
322
308
|
return children;
|
|
323
309
|
}
|
|
324
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Evicts optimistic messages (`metadata.isOptimistic`) the head just moved
|
|
313
|
+
* away from. Since eviction runs on every head move, the only optimistic
|
|
314
|
+
* messages in the repository live on the branch the head previously pointed
|
|
315
|
+
* at — so we walk just that branch rather than the whole repository. Keeps a
|
|
316
|
+
* client→server id swap from leaving a phantom sibling, and drops off-branch
|
|
317
|
+
* placeholders.
|
|
318
|
+
*/
|
|
319
|
+
private evictOffBranchOptimisticMessages(
|
|
320
|
+
previousHead: RepositoryMessage | null,
|
|
321
|
+
currentHead: RepositoryMessage | null,
|
|
322
|
+
) {
|
|
323
|
+
if (!previousHead) return;
|
|
324
|
+
|
|
325
|
+
const onHeadBranch = new Set<string>();
|
|
326
|
+
for (let current = currentHead; current; current = current.prev) {
|
|
327
|
+
onHeadBranch.add(current.current.id);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const stale: string[] = [];
|
|
331
|
+
for (
|
|
332
|
+
let current: RepositoryMessage | null = previousHead;
|
|
333
|
+
current;
|
|
334
|
+
current = current.prev
|
|
335
|
+
) {
|
|
336
|
+
// Stop at the first node shared with the current head branch: every
|
|
337
|
+
// ancestor above it is shared too, so nothing further can be off-branch.
|
|
338
|
+
if (onHeadBranch.has(current.current.id)) break;
|
|
339
|
+
if (current.current.metadata?.isOptimistic) {
|
|
340
|
+
stale.push(current.current.id);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
for (const id of stale) {
|
|
345
|
+
// A prior deletion may have already removed this node.
|
|
346
|
+
if (this.messages.has(id)) this.deleteMessage(id);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
325
350
|
switchToBranch(messageId: string) {
|
|
326
351
|
const message = this.messages.get(messageId);
|
|
327
352
|
if (!message)
|
|
@@ -329,11 +354,14 @@ export class MessageRepository {
|
|
|
329
354
|
"MessageRepository(switchToBranch): Branch not found. This is likely an internal bug in assistant-ui.",
|
|
330
355
|
);
|
|
331
356
|
|
|
357
|
+
const previousHead = this.head;
|
|
332
358
|
const prevOrRoot = message.prev ?? this.root;
|
|
333
359
|
prevOrRoot.next = message;
|
|
334
360
|
|
|
335
361
|
this.head = findHead(message);
|
|
336
362
|
|
|
363
|
+
this.evictOffBranchOptimisticMessages(previousHead, this.head);
|
|
364
|
+
|
|
337
365
|
this._messages.dirty();
|
|
338
366
|
}
|
|
339
367
|
|
|
@@ -349,6 +377,8 @@ export class MessageRepository {
|
|
|
349
377
|
"MessageRepository(resetHead): Branch not found. This is likely an internal bug in assistant-ui.",
|
|
350
378
|
);
|
|
351
379
|
|
|
380
|
+
const previousHead = this.head;
|
|
381
|
+
|
|
352
382
|
if (message.children.length > 0) {
|
|
353
383
|
const deleteDescendants = (msg: RepositoryMessage) => {
|
|
354
384
|
for (const childId of msg.children) {
|
|
@@ -378,6 +408,8 @@ export class MessageRepository {
|
|
|
378
408
|
}
|
|
379
409
|
}
|
|
380
410
|
|
|
411
|
+
this.evictOffBranchOptimisticMessages(previousHead, this.head);
|
|
412
|
+
|
|
381
413
|
this._messages.dirty();
|
|
382
414
|
}
|
|
383
415
|
|
|
@@ -394,15 +426,24 @@ export class MessageRepository {
|
|
|
394
426
|
export(): ExportedMessageRepository {
|
|
395
427
|
const exportItems: ExportedMessageRepository["messages"] = [];
|
|
396
428
|
|
|
429
|
+
// Optimistic messages are ephemeral and never persisted. They're always
|
|
430
|
+
// leaf nodes, so skipping them can't orphan a persisted child.
|
|
397
431
|
for (const [, message] of this.messages) {
|
|
432
|
+
if (message.current.metadata?.isOptimistic) continue;
|
|
398
433
|
exportItems.push({
|
|
399
434
|
message: message.current,
|
|
400
435
|
parentId: message.prev?.current.id ?? null,
|
|
401
436
|
});
|
|
402
437
|
}
|
|
403
438
|
|
|
439
|
+
// The head may itself be optimistic; walk up to the nearest persisted ancestor.
|
|
440
|
+
let head = this.head;
|
|
441
|
+
while (head?.current.metadata?.isOptimistic) {
|
|
442
|
+
head = head.prev;
|
|
443
|
+
}
|
|
444
|
+
|
|
404
445
|
return {
|
|
405
|
-
headId:
|
|
446
|
+
headId: head?.current.id ?? null,
|
|
406
447
|
messages: exportItems,
|
|
407
448
|
};
|
|
408
449
|
}
|
|
@@ -81,6 +81,7 @@ export type ThreadMessageLike = {
|
|
|
81
81
|
readonly steps?: readonly ThreadStep[] | undefined;
|
|
82
82
|
readonly timing?: MessageTiming | undefined;
|
|
83
83
|
readonly submittedFeedback?: { readonly type: "positive" | "negative" };
|
|
84
|
+
readonly isOptimistic?: boolean | undefined;
|
|
84
85
|
readonly custom?: Record<string, unknown> | undefined;
|
|
85
86
|
}
|
|
86
87
|
| undefined;
|
|
@@ -210,6 +211,7 @@ export const fromThreadMessageLike = (
|
|
|
210
211
|
...(metadata?.submittedFeedback && {
|
|
211
212
|
submittedFeedback: metadata.submittedFeedback,
|
|
212
213
|
}),
|
|
214
|
+
...(metadata?.isOptimistic && { isOptimistic: true }),
|
|
213
215
|
},
|
|
214
216
|
} satisfies ThreadAssistantMessage;
|
|
215
217
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ExternalStoreAdapter } from "./external-store-adapter";
|
|
2
|
+
|
|
3
|
+
export type ExternalStoreSharedOptions = Pick<
|
|
4
|
+
ExternalStoreAdapter,
|
|
5
|
+
"isDisabled" | "isSendDisabled" | "unstable_capabilities" | "suggestions"
|
|
6
|
+
>;
|
|
7
|
+
|
|
8
|
+
export const pickExternalStoreSharedOptions = (
|
|
9
|
+
options: ExternalStoreSharedOptions,
|
|
10
|
+
): ExternalStoreSharedOptions =>
|
|
11
|
+
({
|
|
12
|
+
isDisabled: options.isDisabled,
|
|
13
|
+
isSendDisabled: options.isSendDisabled,
|
|
14
|
+
unstable_capabilities: options.unstable_capabilities,
|
|
15
|
+
suggestions: options.suggestions,
|
|
16
|
+
}) satisfies {
|
|
17
|
+
[K in keyof Required<ExternalStoreSharedOptions>]: ExternalStoreSharedOptions[K];
|
|
18
|
+
};
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
ExportedMessageRepository,
|
|
31
31
|
MessageRepository,
|
|
32
32
|
} from "../../runtime/utils/message-repository";
|
|
33
|
+
import { generateId } from "../../utils/id";
|
|
33
34
|
import { ToolInvocationTracker } from "../tool-invocations/ToolInvocationTracker";
|
|
34
35
|
|
|
35
36
|
const EMPTY_ARRAY: readonly ThreadSuggestion[] = Object.freeze([]);
|
|
@@ -54,9 +55,6 @@ export class ExternalStoreThreadRuntimeCore
|
|
|
54
55
|
extends BaseThreadRuntimeCore
|
|
55
56
|
implements ThreadRuntimeCore
|
|
56
57
|
{
|
|
57
|
-
private _assistantOptimisticId: string | null = null;
|
|
58
|
-
private _lastSyncedMessageIds = new Set<string>();
|
|
59
|
-
|
|
60
58
|
private _capabilities: RuntimeCapabilities = {
|
|
61
59
|
switchToBranch: false,
|
|
62
60
|
switchBranchDuringRun: false,
|
|
@@ -176,10 +174,7 @@ export class ExternalStoreThreadRuntimeCore
|
|
|
176
174
|
return;
|
|
177
175
|
}
|
|
178
176
|
|
|
179
|
-
// Clear and import the message repository
|
|
180
177
|
this.repository.clear();
|
|
181
|
-
this._assistantOptimisticId = null;
|
|
182
|
-
this._lastSyncedMessageIds = new Set();
|
|
183
178
|
this.repository.import(store.messageRepository);
|
|
184
179
|
|
|
185
180
|
messages = this.repository.getMessages();
|
|
@@ -232,12 +227,6 @@ export class ExternalStoreThreadRuntimeCore
|
|
|
232
227
|
return newMessage;
|
|
233
228
|
});
|
|
234
229
|
|
|
235
|
-
const nextIds = new Set(messages.map((m) => m.id));
|
|
236
|
-
for (const prevId of this._lastSyncedMessageIds) {
|
|
237
|
-
if (!nextIds.has(prevId)) this.repository.deleteMessage(prevId);
|
|
238
|
-
}
|
|
239
|
-
this._lastSyncedMessageIds = nextIds;
|
|
240
|
-
|
|
241
230
|
for (let i = 0; i < messages.length; i++) {
|
|
242
231
|
const message = messages[i]!;
|
|
243
232
|
const parent = messages[i - 1];
|
|
@@ -260,24 +249,23 @@ export class ExternalStoreThreadRuntimeCore
|
|
|
260
249
|
}
|
|
261
250
|
}
|
|
262
251
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
252
|
+
// Append an optimistic placeholder while running but before a trailing
|
|
253
|
+
// assistant message exists. resetHead evicts off-branch optimistic messages
|
|
254
|
+
// (prior placeholders, mid-run id-swap siblings); export() never persists them.
|
|
255
|
+
let optimisticId: string | null = null;
|
|
268
256
|
if (hasUpcomingMessage(isRunning, messages)) {
|
|
269
|
-
|
|
257
|
+
optimisticId = generateId();
|
|
258
|
+
this.repository.addOrUpdateMessage(
|
|
270
259
|
messages.at(-1)?.id ?? null,
|
|
271
|
-
|
|
272
|
-
role: "assistant",
|
|
273
|
-
|
|
274
|
-
|
|
260
|
+
fromThreadMessageLike(
|
|
261
|
+
{ role: "assistant", content: [], metadata: { isOptimistic: true } },
|
|
262
|
+
optimisticId,
|
|
263
|
+
{ type: "running" },
|
|
264
|
+
),
|
|
275
265
|
);
|
|
276
266
|
}
|
|
277
267
|
|
|
278
|
-
this.repository.resetHead(
|
|
279
|
-
this._assistantOptimisticId ?? messages.at(-1)?.id ?? null,
|
|
280
|
-
);
|
|
268
|
+
this.repository.resetHead(optimisticId ?? messages.at(-1)?.id ?? null);
|
|
281
269
|
|
|
282
270
|
this._messages = this.repository.getMessages();
|
|
283
271
|
|
|
@@ -476,9 +464,11 @@ export class ExternalStoreThreadRuntimeCore
|
|
|
476
464
|
|
|
477
465
|
this._store.onCancel();
|
|
478
466
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
467
|
+
// Drop an empty optimistic head (placeholder or pre-stream message); a
|
|
468
|
+
// partially-streamed one is kept and re-supplied by the store on resync.
|
|
469
|
+
const head = this.repository.getMessages().at(-1);
|
|
470
|
+
if (head && head.metadata.isOptimistic && head.content.length === 0) {
|
|
471
|
+
this.repository.deleteMessage(head.id);
|
|
482
472
|
}
|
|
483
473
|
|
|
484
474
|
let messages = this.repository.getMessages();
|
|
@@ -488,7 +478,6 @@ export class ExternalStoreThreadRuntimeCore
|
|
|
488
478
|
previousMessage.id === messages.at(-1)?.id // ensure the previous message is a leaf node
|
|
489
479
|
) {
|
|
490
480
|
this.repository.deleteMessage(previousMessage.id);
|
|
491
|
-
this._lastSyncedMessageIds.delete(previousMessage.id);
|
|
492
481
|
if (!this.composer.text.trim()) {
|
|
493
482
|
this.composer.setText(getThreadMessageText(previousMessage));
|
|
494
483
|
}
|
|
@@ -537,16 +526,12 @@ export class ExternalStoreThreadRuntimeCore
|
|
|
537
526
|
}
|
|
538
527
|
|
|
539
528
|
public override reset(initialMessages?: readonly ThreadMessageLike[]) {
|
|
540
|
-
this._lastSyncedMessageIds = new Set();
|
|
541
529
|
const repo = new MessageRepository();
|
|
542
530
|
repo.import(ExportedMessageRepository.fromArray(initialMessages ?? []));
|
|
543
531
|
this.updateMessages(repo.getMessages());
|
|
544
532
|
}
|
|
545
533
|
|
|
546
534
|
public override import(data: ExportedMessageRepository) {
|
|
547
|
-
this._assistantOptimisticId = null;
|
|
548
|
-
this._lastSyncedMessageIds = new Set();
|
|
549
|
-
|
|
550
535
|
super.import(data);
|
|
551
536
|
|
|
552
537
|
if (this._store.onImport) {
|
|
@@ -446,7 +446,6 @@ export class ToolInvocationTracker {
|
|
|
446
446
|
|
|
447
447
|
if (this._executing.size === 0) {
|
|
448
448
|
const resolvers = this._settledResolvers.splice(0);
|
|
449
|
-
// biome-ignore lint/suspicious/useIterableCallbackReturn: forEach callback intentionally has no return
|
|
450
449
|
resolvers.forEach((resolve) => {
|
|
451
450
|
try {
|
|
452
451
|
resolve();
|
|
@@ -7,20 +7,14 @@ import type { ThreadMessage } from "../types/message";
|
|
|
7
7
|
import type { TextMessagePart } from "../types/message";
|
|
8
8
|
import type { ThreadMessageLike } from "../runtime/utils/thread-message-like";
|
|
9
9
|
|
|
10
|
-
// Mock generateId
|
|
10
|
+
// Mock generateId to make tests deterministic
|
|
11
11
|
const mockGenerateId = vi.fn();
|
|
12
|
-
const mockGenerateOptimisticId = vi.fn();
|
|
13
|
-
const mockIsOptimisticId = vi.fn((id: string) =>
|
|
14
|
-
id.startsWith("__optimistic__"),
|
|
15
|
-
);
|
|
16
12
|
|
|
17
13
|
vi.mock("../utils/id", async (importOriginal) => {
|
|
18
14
|
const original = await importOriginal<typeof import("../utils/id")>();
|
|
19
15
|
return {
|
|
20
16
|
...original,
|
|
21
17
|
generateId: () => mockGenerateId(),
|
|
22
|
-
generateOptimisticId: () => mockGenerateOptimisticId(),
|
|
23
|
-
isOptimisticId: (id: string) => mockIsOptimisticId(id),
|
|
24
18
|
};
|
|
25
19
|
});
|
|
26
20
|
|
|
@@ -58,23 +52,11 @@ describe("MessageRepository", () => {
|
|
|
58
52
|
...overrides,
|
|
59
53
|
});
|
|
60
54
|
|
|
61
|
-
/**
|
|
62
|
-
* Creates a test CoreMessage with the given overrides.
|
|
63
|
-
*/
|
|
64
|
-
const createThreadMessageLike = (overrides = {}): ThreadMessageLike => ({
|
|
65
|
-
role: "assistant",
|
|
66
|
-
content: [{ type: "text", text: "Test message" }],
|
|
67
|
-
...overrides,
|
|
68
|
-
});
|
|
69
|
-
|
|
70
55
|
beforeEach(() => {
|
|
71
56
|
repository = new MessageRepository();
|
|
72
57
|
// Reset mocks with predictable counter-based values
|
|
73
58
|
nextMockId = 1;
|
|
74
59
|
mockGenerateId.mockImplementation(() => `mock-id-${nextMockId++}`);
|
|
75
|
-
mockGenerateOptimisticId.mockImplementation(
|
|
76
|
-
() => `__optimistic__mock-id-${nextMockId++}`,
|
|
77
|
-
);
|
|
78
60
|
});
|
|
79
61
|
|
|
80
62
|
afterEach(() => {
|
|
@@ -297,53 +279,102 @@ describe("MessageRepository", () => {
|
|
|
297
279
|
});
|
|
298
280
|
|
|
299
281
|
describe("Optimistic messages", () => {
|
|
300
|
-
|
|
301
|
-
|
|
282
|
+
const optimistic = (overrides = {}) =>
|
|
283
|
+
createTestMessage({
|
|
284
|
+
status: { type: "running" },
|
|
285
|
+
metadata: {
|
|
286
|
+
unstable_state: null,
|
|
287
|
+
unstable_annotations: [],
|
|
288
|
+
unstable_data: [],
|
|
289
|
+
steps: [],
|
|
290
|
+
custom: {},
|
|
291
|
+
isOptimistic: true,
|
|
292
|
+
},
|
|
293
|
+
...overrides,
|
|
294
|
+
});
|
|
302
295
|
|
|
303
|
-
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
);
|
|
296
|
+
it("excludes optimistic messages from export()", () => {
|
|
297
|
+
const parent = createTestMessage({ id: "u" });
|
|
298
|
+
repository.addOrUpdateMessage(null, parent);
|
|
299
|
+
repository.addOrUpdateMessage("u", optimistic({ id: "placeholder" }));
|
|
300
|
+
repository.resetHead("placeholder");
|
|
308
301
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
302
|
+
const exported = repository.export();
|
|
303
|
+
|
|
304
|
+
expect(exported.messages.map((m) => m.message.id)).toEqual(["u"]);
|
|
305
|
+
// head was the optimistic placeholder; the exported head must fall back
|
|
306
|
+
// to the nearest persisted ancestor so it always resolves on import.
|
|
307
|
+
expect(exported.headId).toBe("u");
|
|
313
308
|
});
|
|
314
309
|
|
|
315
|
-
it("
|
|
316
|
-
const parent = createTestMessage({ id: "
|
|
310
|
+
it("round-trips through export/import without resurrecting the placeholder", () => {
|
|
311
|
+
const parent = createTestMessage({ id: "u" });
|
|
312
|
+
const real = createTestMessage({ id: "a" });
|
|
317
313
|
repository.addOrUpdateMessage(null, parent);
|
|
314
|
+
repository.addOrUpdateMessage("u", real);
|
|
315
|
+
repository.resetHead("a");
|
|
318
316
|
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
"parent-id",
|
|
322
|
-
coreMessage,
|
|
323
|
-
);
|
|
317
|
+
const restored = new MessageRepository();
|
|
318
|
+
restored.import(repository.export());
|
|
324
319
|
|
|
325
|
-
|
|
326
|
-
|
|
320
|
+
expect(restored.export().messages.map((m) => m.message.id)).toEqual([
|
|
321
|
+
"u",
|
|
322
|
+
"a",
|
|
323
|
+
]);
|
|
327
324
|
});
|
|
328
325
|
|
|
329
|
-
|
|
330
|
-
|
|
326
|
+
describe("HEAD-branch invariant", () => {
|
|
327
|
+
it("evicts an off-branch optimistic sibling when resetHead moves the head", () => {
|
|
328
|
+
// u -> { client_id (optimistic), server_id (optimistic) }. When the
|
|
329
|
+
// head moves to server_id, the dangling client_id sibling is evicted.
|
|
330
|
+
const parent = createTestMessage({ id: "u" });
|
|
331
|
+
repository.addOrUpdateMessage(null, parent);
|
|
332
|
+
repository.addOrUpdateMessage("u", optimistic({ id: "client_id" }));
|
|
333
|
+
repository.addOrUpdateMessage("u", optimistic({ id: "server_id" }));
|
|
334
|
+
|
|
335
|
+
repository.resetHead("server_id");
|
|
331
336
|
|
|
332
|
-
|
|
333
|
-
|
|
337
|
+
expect(repository.getBranches("server_id")).toEqual(["server_id"]);
|
|
338
|
+
expect(() => repository.getMessage("client_id")).toThrow();
|
|
334
339
|
});
|
|
335
|
-
repository.addOrUpdateMessage(null, existingMessage);
|
|
336
340
|
|
|
337
|
-
|
|
341
|
+
it("keeps the optimistic message that is on the head branch", () => {
|
|
342
|
+
const parent = createTestMessage({ id: "u" });
|
|
343
|
+
repository.addOrUpdateMessage(null, parent);
|
|
344
|
+
repository.addOrUpdateMessage("u", optimistic({ id: "a" }));
|
|
338
345
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
);
|
|
346
|
+
repository.resetHead("a");
|
|
347
|
+
|
|
348
|
+
expect(repository.getMessage("a").message.id).toBe("a");
|
|
349
|
+
});
|
|
344
350
|
|
|
345
|
-
|
|
346
|
-
|
|
351
|
+
it("never evicts real (non-optimistic) sibling branches", () => {
|
|
352
|
+
const parent = createTestMessage({ id: "u" });
|
|
353
|
+
repository.addOrUpdateMessage(null, parent);
|
|
354
|
+
repository.addOrUpdateMessage("u", createTestMessage({ id: "a1" }));
|
|
355
|
+
repository.addOrUpdateMessage("u", createTestMessage({ id: "a2" }));
|
|
356
|
+
|
|
357
|
+
repository.resetHead("a2");
|
|
358
|
+
|
|
359
|
+
// a1 is off the head branch but not optimistic, so it survives.
|
|
360
|
+
expect(repository.getBranches("a2")).toEqual(["a1", "a2"]);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("evicts optimistic messages from the previous branch on switchToBranch", () => {
|
|
364
|
+
// Two real branches under u; the head branch (a2) carries an optimistic
|
|
365
|
+
// child. Switching to a1 must drop the optimistic message left on a2.
|
|
366
|
+
const parent = createTestMessage({ id: "u" });
|
|
367
|
+
repository.addOrUpdateMessage(null, parent);
|
|
368
|
+
repository.addOrUpdateMessage("u", createTestMessage({ id: "a1" }));
|
|
369
|
+
repository.addOrUpdateMessage("u", createTestMessage({ id: "a2" }));
|
|
370
|
+
repository.addOrUpdateMessage("a2", optimistic({ id: "opt" }));
|
|
371
|
+
repository.resetHead("opt");
|
|
372
|
+
|
|
373
|
+
repository.switchToBranch("a1");
|
|
374
|
+
|
|
375
|
+
expect(() => repository.getMessage("opt")).toThrow();
|
|
376
|
+
expect(repository.getBranches("a1")).toEqual(["a1", "a2"]);
|
|
377
|
+
});
|
|
347
378
|
});
|
|
348
379
|
});
|
|
349
380
|
|
|
@@ -89,7 +89,6 @@ describe("list + delete race condition", () => {
|
|
|
89
89
|
const listPromise = state.optimisticUpdate({
|
|
90
90
|
execute: () => listDeferred.promise,
|
|
91
91
|
loading: (s) => ({ ...s, isLoading: true }),
|
|
92
|
-
// biome-ignore lint/suspicious/noThenProperty: OptimisticState reducer pattern
|
|
93
92
|
then: applyListResult,
|
|
94
93
|
});
|
|
95
94
|
|
|
@@ -147,7 +146,6 @@ describe("list + delete race condition", () => {
|
|
|
147
146
|
const listPromise = state.optimisticUpdate({
|
|
148
147
|
execute: () => listDeferred.promise,
|
|
149
148
|
loading: (s) => ({ ...s, isLoading: true }),
|
|
150
|
-
// biome-ignore lint/suspicious/noThenProperty: OptimisticState reducer pattern
|
|
151
149
|
then: applyListResult,
|
|
152
150
|
});
|
|
153
151
|
|
|
@@ -185,7 +183,6 @@ describe("list + delete race condition", () => {
|
|
|
185
183
|
const listPromise = state.optimisticUpdate({
|
|
186
184
|
execute: () => listDeferred.promise,
|
|
187
185
|
loading: (s) => ({ ...s, isLoading: true }),
|
|
188
|
-
// biome-ignore lint/suspicious/noThenProperty: OptimisticState reducer pattern
|
|
189
186
|
then: applyListResult,
|
|
190
187
|
});
|
|
191
188
|
|
|
@@ -223,7 +220,6 @@ describe("list + delete race condition", () => {
|
|
|
223
220
|
const listPromise = state.optimisticUpdate({
|
|
224
221
|
execute: () => listDeferred.promise,
|
|
225
222
|
loading: (s) => ({ ...s, isLoading: true }),
|
|
226
|
-
// biome-ignore lint/suspicious/noThenProperty: OptimisticState reducer pattern
|
|
227
223
|
then: applyListResult,
|
|
228
224
|
});
|
|
229
225
|
|