mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 21:08:34 +03:00
fix(webui): reconcile threads after browser resume
This commit is contained in:
@@ -738,6 +738,7 @@ export default function App() {
|
||||
} else {
|
||||
client.updateUrl(url);
|
||||
}
|
||||
client.updateMaxFrameBytes(boot.limits?.transport.max_frame_bytes);
|
||||
setState((current) =>
|
||||
current.status === "ready" && current.client === client
|
||||
? {
|
||||
@@ -769,6 +770,7 @@ export default function App() {
|
||||
const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities);
|
||||
const client = new NanobotClient({
|
||||
url,
|
||||
maxFrameBytes: boot.limits?.transport.max_frame_bytes,
|
||||
socketFactory: runtimeHost.socketFactory,
|
||||
onReauth: async () => {
|
||||
try {
|
||||
@@ -1206,6 +1208,7 @@ function Shell({
|
||||
useEffect(() => {
|
||||
return client.onError((error) => {
|
||||
if (error.kind !== "workspace_scope_rejected") return;
|
||||
if (error.chatId && error.chatId !== activeChatIdRef.current) return;
|
||||
setWorkspaceError(t("errors.workspaceScopeRejected.body"));
|
||||
void refreshWorkspaces();
|
||||
});
|
||||
|
||||
@@ -67,6 +67,11 @@ function resolveCopy(
|
||||
title: t("errors.workspaceScopeRejected.title"),
|
||||
body: t("errors.workspaceScopeRejected.body"),
|
||||
};
|
||||
case "turn_rejected":
|
||||
return {
|
||||
title: t("errors.turnRejected.title"),
|
||||
body: t("errors.turnRejected.body"),
|
||||
};
|
||||
default: {
|
||||
// Exhaustiveness guard: if a new StreamError kind is added, TS will
|
||||
// complain here until we add a corresponding i18n branch.
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
installedMcpPresetsFromPayload,
|
||||
isMcpPresetsPayload,
|
||||
} from "@/lib/mcp-preset-events";
|
||||
import type { CanonicalRunSnapshot } from "@/lib/nanobot-client";
|
||||
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
|
||||
import type {
|
||||
ChatSummary,
|
||||
@@ -44,13 +45,65 @@ import type {
|
||||
import { projectWebuiThreadMessages } from "@/lib/thread-display-compat";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
|
||||
type MessageShape = Pick<UIMessage, "role" | "kind" | "content">;
|
||||
type MessageShape = Pick<UIMessage, "role" | "kind" | "content" | "isStreaming" | "turnId">;
|
||||
|
||||
interface PendingCanonicalHydrate {
|
||||
historyLineage: number;
|
||||
historyVersion: number;
|
||||
runGeneration: number;
|
||||
uiBaseline: MessageShape[];
|
||||
uiLineage: number | null;
|
||||
uiRevision: number;
|
||||
}
|
||||
|
||||
interface PendingHistoryLineageCommit {
|
||||
lineage: number;
|
||||
messages: UIMessage[];
|
||||
}
|
||||
|
||||
interface PendingCanonicalCommit {
|
||||
canonicalSnapshot: CanonicalRunSnapshot;
|
||||
completedTurnIds: string[];
|
||||
expectedUiRevision: number;
|
||||
historyLineage: number;
|
||||
historyVersion: number;
|
||||
hydrate: PendingCanonicalHydrate;
|
||||
messages: UIMessage[];
|
||||
previousMessages: UIMessage[];
|
||||
}
|
||||
|
||||
function sameMessageShape(a: MessageShape, b: MessageShape): boolean {
|
||||
return (
|
||||
a.role === b.role
|
||||
&& (a.kind ?? "") === (b.kind ?? "")
|
||||
&& a.content === b.content
|
||||
&& (!a.turnId || !b.turnId || a.turnId === b.turnId)
|
||||
);
|
||||
}
|
||||
|
||||
function snapshotPreservesMessage(
|
||||
current: MessageShape,
|
||||
candidate: MessageShape,
|
||||
allowCompletedTurnReplacement: boolean,
|
||||
): boolean {
|
||||
if (sameMessageShape(current, candidate)) return true;
|
||||
if (
|
||||
allowCompletedTurnReplacement
|
||||
&& current.role === "assistant"
|
||||
&& candidate.role === current.role
|
||||
&& (candidate.kind ?? "") === (current.kind ?? "")
|
||||
&& !!current.turnId
|
||||
&& candidate.turnId === current.turnId
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
current.role === "assistant"
|
||||
&& current.isStreaming === true
|
||||
&& candidate.role === current.role
|
||||
&& (candidate.kind ?? "") === (current.kind ?? "")
|
||||
&& (!current.turnId || !candidate.turnId || candidate.turnId === current.turnId)
|
||||
&& candidate.content.startsWith(current.content)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,28 +117,44 @@ function durableMessageShape(message: UIMessage): MessageShape | null {
|
||||
role: message.role,
|
||||
kind: message.kind,
|
||||
content: message.content,
|
||||
isStreaming: message.isStreaming,
|
||||
turnId: message.turnId,
|
||||
};
|
||||
}
|
||||
|
||||
function preservesDurableMessages(current: UIMessage[], snapshot: UIMessage[]): boolean {
|
||||
// Canonical history refreshes can race with live websocket messages after fork/send.
|
||||
// Never accept a refreshed snapshot that drops a user/assistant message already shown.
|
||||
const expected = current
|
||||
.map(durableMessageShape)
|
||||
.filter((message): message is MessageShape => message !== null);
|
||||
if (expected.length === 0) return true;
|
||||
const candidates = snapshot
|
||||
function durableMessageShapes(messages: UIMessage[]): MessageShape[] {
|
||||
return messages
|
||||
.map(durableMessageShape)
|
||||
.filter((message): message is MessageShape => message !== null);
|
||||
}
|
||||
|
||||
function preservesMessageShapes(
|
||||
expected: MessageShape[],
|
||||
candidates: MessageShape[],
|
||||
allowCompletedTurnReplacement: boolean,
|
||||
): boolean {
|
||||
let cursor = 0;
|
||||
let previousCandidate: MessageShape | null = null;
|
||||
for (const message of expected) {
|
||||
if (
|
||||
allowCompletedTurnReplacement
|
||||
&& previousCandidate?.role === "assistant"
|
||||
&& message.role === "assistant"
|
||||
&& !!message.turnId
|
||||
&& message.turnId === previousCandidate.turnId
|
||||
) {
|
||||
// A delayed websocket delta can briefly create a second bubble after an
|
||||
// HTTP completion snapshot. The completed replay is authoritative for
|
||||
// that turn, so both local fragments may map to its single assistant row.
|
||||
continue;
|
||||
}
|
||||
let found = false;
|
||||
while (cursor < candidates.length) {
|
||||
const candidate = candidates[cursor];
|
||||
cursor += 1;
|
||||
if (sameMessageShape(message, candidate)) {
|
||||
if (snapshotPreservesMessage(message, candidate, allowCompletedTurnReplacement)) {
|
||||
found = true;
|
||||
previousCandidate = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -94,11 +163,55 @@ function preservesDurableMessages(current: UIMessage[], snapshot: UIMessage[]):
|
||||
return true;
|
||||
}
|
||||
|
||||
function isStaleThreadSnapshot(current: UIMessage[], snapshot: UIMessage[]): boolean {
|
||||
function preservesDurableMessages(
|
||||
current: UIMessage[],
|
||||
snapshot: UIMessage[],
|
||||
allowCompletedTurnReplacement = false,
|
||||
): boolean {
|
||||
// Canonical history refreshes can race with live websocket messages after fork/send.
|
||||
// Never accept a refreshed snapshot that drops a user/assistant message already shown.
|
||||
const expected = durableMessageShapes(current);
|
||||
if (expected.length === 0) return true;
|
||||
return preservesMessageShapes(
|
||||
expected,
|
||||
durableMessageShapes(snapshot),
|
||||
allowCompletedTurnReplacement,
|
||||
);
|
||||
}
|
||||
|
||||
function resetDropsPostRequestDurableTail(
|
||||
baseline: MessageShape[],
|
||||
current: UIMessage[],
|
||||
snapshot: UIMessage[],
|
||||
): boolean {
|
||||
const currentDurable = durableMessageShapes(current);
|
||||
let stablePrefixLength = 0;
|
||||
while (
|
||||
stablePrefixLength < baseline.length
|
||||
&& stablePrefixLength < currentDurable.length
|
||||
&& sameMessageShape(baseline[stablePrefixLength], currentDurable[stablePrefixLength])
|
||||
) {
|
||||
stablePrefixLength += 1;
|
||||
}
|
||||
const postRequestTail = currentDurable.slice(stablePrefixLength);
|
||||
if (postRequestTail.length === 0) return false;
|
||||
return !preservesMessageShapes(
|
||||
postRequestTail,
|
||||
durableMessageShapes(snapshot),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
function isStaleThreadSnapshot(
|
||||
current: UIMessage[],
|
||||
snapshot: UIMessage[],
|
||||
allowCompletedTurnReplacement = false,
|
||||
): boolean {
|
||||
if (current.length === 0) return false;
|
||||
if (snapshot.length === 0) return true;
|
||||
if (!preservesDurableMessages(current, snapshot)) return true;
|
||||
if (!preservesDurableMessages(current, snapshot, allowCompletedTurnReplacement)) return true;
|
||||
if (snapshot.length >= current.length) return false;
|
||||
if (allowCompletedTurnReplacement) return false;
|
||||
return snapshot.every((message, index) => sameMessageShape(current[index], message));
|
||||
}
|
||||
|
||||
@@ -114,6 +227,30 @@ function latestActiveTurnId(messages: UIMessage[]): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function completedAssistantTurnIds(messages: UIMessage[]): string[] {
|
||||
return Array.from(new Set(
|
||||
messages
|
||||
.filter((message) => message.role === "assistant" && !!message.turnId)
|
||||
.map((message) => message.turnId as string),
|
||||
));
|
||||
}
|
||||
|
||||
function canonicalRunSnapshot(
|
||||
messages: UIMessage[],
|
||||
hasPendingToolCalls: boolean,
|
||||
activeTurnId: string | null,
|
||||
): CanonicalRunSnapshot {
|
||||
return {
|
||||
observedTurnIds: Array.from(new Set(
|
||||
messages
|
||||
.filter((message) => message.role === "user" && !!message.turnId)
|
||||
.map((message) => message.turnId as string),
|
||||
)),
|
||||
hasPendingToolCalls,
|
||||
activeTurnId,
|
||||
};
|
||||
}
|
||||
|
||||
const FILE_PREVIEW_DEFAULT_WIDTH = 544;
|
||||
const FILE_PREVIEW_MIN_WIDTH = 360;
|
||||
const FILE_PREVIEW_MAX_WIDTH = 860;
|
||||
@@ -432,6 +569,10 @@ export function ThreadShell({
|
||||
hasMoreBefore,
|
||||
userMessageOffset,
|
||||
hasPendingToolCalls,
|
||||
completedTurnIds,
|
||||
continuity: historyContinuity,
|
||||
lineage: historyLineage,
|
||||
activeTurnId: historyActiveTurnId,
|
||||
refresh: refreshHistory,
|
||||
version: historyVersion,
|
||||
forkBoundaryMessageCount,
|
||||
@@ -474,9 +615,16 @@ export function ThreadShell({
|
||||
const prevChatIdForCacheRef = useRef<string | null>(null);
|
||||
/** Skip one message-cache write right after chatId changes (messages may not match yet). */
|
||||
const skipLayoutCacheRef = useRef(false);
|
||||
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
|
||||
const pendingCanonicalHydrateRef = useRef<Set<string>>(new Set());
|
||||
const pendingCanonicalHydrateRef = useRef<Map<string, PendingCanonicalHydrate>>(new Map());
|
||||
const pendingCanonicalCommitRef = useRef<Map<string, PendingCanonicalCommit>>(new Map());
|
||||
const pendingHistoryLineageCommitRef = useRef<Map<string, PendingHistoryLineageCommit>>(
|
||||
new Map(),
|
||||
);
|
||||
const completedCanonicalHydrateVersionRef = useRef<Map<string, number>>(new Map());
|
||||
const committedHistoryLineageRef = useRef<Map<string, number>>(new Map());
|
||||
const sessionKeyByChatIdRef = useRef<Map<string, string>>(new Map());
|
||||
const currentUiMessagesRef = useRef<UIMessage[] | null>(null);
|
||||
const uiRevisionRef = useRef(0);
|
||||
|
||||
const initial = useMemo(() => {
|
||||
if (!chatId) return historical;
|
||||
@@ -497,11 +645,25 @@ export function ThreadShell({
|
||||
send,
|
||||
transcribeAudio,
|
||||
stop,
|
||||
reconcileTurnComplete,
|
||||
setMessages,
|
||||
streamError,
|
||||
dismissStreamError,
|
||||
} = useNanobotStream(chatId, initial, hasPendingToolCalls, handleTurnEnd);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (currentUiMessagesRef.current === messages) return;
|
||||
currentUiMessagesRef.current = messages;
|
||||
uiRevisionRef.current += 1;
|
||||
if (!chatId) return;
|
||||
const lineageCommit = pendingHistoryLineageCommitRef.current.get(chatId);
|
||||
if (!lineageCommit) return;
|
||||
pendingHistoryLineageCommitRef.current.delete(chatId);
|
||||
if (lineageCommit.messages === messages) {
|
||||
committedHistoryLineageRef.current.set(chatId, lineageCommit.lineage);
|
||||
}
|
||||
}, [chatId, messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatId && historyKey) sessionKeyByChatIdRef.current.set(chatId, historyKey);
|
||||
}, [chatId, historyKey]);
|
||||
@@ -685,47 +847,196 @@ export function ThreadShell({
|
||||
useEffect(() => {
|
||||
if (!chatId || loading) return;
|
||||
const cached = messageCacheRef.current.get(chatId);
|
||||
const appliedVersion = appliedHistoryVersionRef.current.get(chatId) ?? 0;
|
||||
const hasPendingCanonicalHydrate = pendingCanonicalHydrateRef.current.has(chatId);
|
||||
const hasNewCanonicalHistory = hasPendingCanonicalHydrate && historyVersion > appliedVersion;
|
||||
const pendingCanonicalHydrate = pendingCanonicalHydrateRef.current.get(chatId);
|
||||
const hasNewCanonicalHistory = (
|
||||
pendingCanonicalHydrate !== undefined
|
||||
&& historyVersion > pendingCanonicalHydrate.historyVersion
|
||||
);
|
||||
// When the user switches away and back, keep the local in-memory thread
|
||||
// state (including not-yet-persisted messages) instead of replacing it with
|
||||
// whatever the history endpoint currently knows about. Once a fresh
|
||||
// canonical replay arrives (e.g. after ``session_updated`` refresh), prefer it
|
||||
// so rendering converges to the same shape as a manual refresh.
|
||||
setMessages((prev) => {
|
||||
const normalizedHistory = projectWebuiThreadMessages(historical);
|
||||
const keepLiveMessages = (messagesToKeep: UIMessage[]) => {
|
||||
const projected = projectWebuiThreadMessages(messagesToKeep);
|
||||
messageCacheRef.current.set(chatId, projected);
|
||||
return projected;
|
||||
};
|
||||
if (hasNewCanonicalHistory && historical.length > 0) {
|
||||
if (isStaleThreadSnapshot(prev, normalizedHistory)) return keepLiveMessages(prev);
|
||||
pendingCanonicalHydrateRef.current.delete(chatId);
|
||||
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
||||
messageCacheRef.current.set(chatId, normalizedHistory);
|
||||
return normalizedHistory;
|
||||
const normalizedHistory = projectWebuiThreadMessages(historical);
|
||||
const keepLiveMessages = (current: UIMessage[]) => projectWebuiThreadMessages(current);
|
||||
if (hasNewCanonicalHistory && pendingCanonicalHydrate) {
|
||||
// Transcript replay strips streaming metadata and uses persisted ids.
|
||||
// Never adopt it while the turn is active: even if no assistant delta
|
||||
// arrived locally yet, the next resumed delta must create/continue the
|
||||
// live cursor rather than append to an immutable replay row.
|
||||
if (hasPendingToolCalls) {
|
||||
setMessages((current) => keepLiveMessages(current));
|
||||
return;
|
||||
}
|
||||
const authoritativeReset = (
|
||||
pendingCanonicalHydrate.uiLineage !== null
|
||||
&& historyLineage !== pendingCanonicalHydrate.uiLineage
|
||||
&& (
|
||||
historyContinuity === "reset"
|
||||
|| (
|
||||
historyContinuity === "overlap"
|
||||
&& historyLineage === pendingCanonicalHydrate.historyLineage
|
||||
)
|
||||
)
|
||||
);
|
||||
const responseUiRevision = uiRevisionRef.current;
|
||||
const resetDropsRenderedTail = (
|
||||
authoritativeReset
|
||||
&& responseUiRevision !== pendingCanonicalHydrate.uiRevision
|
||||
&& resetDropsPostRequestDurableTail(
|
||||
pendingCanonicalHydrate.uiBaseline,
|
||||
messages,
|
||||
normalizedHistory,
|
||||
)
|
||||
);
|
||||
if (
|
||||
authoritativeReset
|
||||
? resetDropsRenderedTail
|
||||
: isStaleThreadSnapshot(messages, normalizedHistory, true)
|
||||
) {
|
||||
setMessages((current) => keepLiveMessages(current));
|
||||
return;
|
||||
}
|
||||
const canonicalCompletedTurnIds = Array.from(new Set([
|
||||
...completedTurnIds,
|
||||
...completedAssistantTurnIds(normalizedHistory),
|
||||
]));
|
||||
const canonicalSnapshot = canonicalRunSnapshot(
|
||||
normalizedHistory,
|
||||
hasPendingToolCalls,
|
||||
historyActiveTurnId,
|
||||
);
|
||||
if (!client.canReconcileCanonicalCompletion(
|
||||
chatId,
|
||||
pendingCanonicalHydrate.runGeneration,
|
||||
canonicalCompletedTurnIds,
|
||||
canonicalSnapshot,
|
||||
)) {
|
||||
setMessages((current) => keepLiveMessages(current));
|
||||
return;
|
||||
}
|
||||
pendingCanonicalCommitRef.current.set(chatId, {
|
||||
canonicalSnapshot,
|
||||
completedTurnIds: canonicalCompletedTurnIds,
|
||||
expectedUiRevision: responseUiRevision + 1,
|
||||
historyLineage,
|
||||
historyVersion,
|
||||
hydrate: pendingCanonicalHydrate,
|
||||
messages: normalizedHistory,
|
||||
previousMessages: messages,
|
||||
});
|
||||
setMessages((current) => {
|
||||
if (current !== messages) return current;
|
||||
if (
|
||||
authoritativeReset
|
||||
? resetDropsRenderedTail
|
||||
: isStaleThreadSnapshot(current, normalizedHistory, true)
|
||||
) {
|
||||
return keepLiveMessages(current);
|
||||
}
|
||||
return normalizedHistory;
|
||||
});
|
||||
return;
|
||||
}
|
||||
const adoptsNormalizedHistory = cached && cached.length > 0
|
||||
? (
|
||||
normalizedHistory.length > cached.length
|
||||
&& !isStaleThreadSnapshot(messages, normalizedHistory)
|
||||
)
|
||||
: !isStaleThreadSnapshot(messages, normalizedHistory);
|
||||
if (adoptsNormalizedHistory) {
|
||||
pendingHistoryLineageCommitRef.current.set(chatId, {
|
||||
lineage: historyLineage,
|
||||
messages: normalizedHistory,
|
||||
});
|
||||
}
|
||||
setMessages((current) => {
|
||||
if (cached && cached.length > 0) {
|
||||
if (
|
||||
normalizedHistory.length > cached.length
|
||||
&& !isStaleThreadSnapshot(prev, normalizedHistory)
|
||||
&& !isStaleThreadSnapshot(current, normalizedHistory)
|
||||
) {
|
||||
messageCacheRef.current.set(chatId, normalizedHistory);
|
||||
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
||||
return normalizedHistory;
|
||||
}
|
||||
if (isStaleThreadSnapshot(prev, cached)) return keepLiveMessages(prev);
|
||||
return cached;
|
||||
return isStaleThreadSnapshot(current, cached) ? keepLiveMessages(current) : cached;
|
||||
}
|
||||
if (isStaleThreadSnapshot(prev, normalizedHistory)) return keepLiveMessages(prev);
|
||||
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
||||
if (normalizedHistory.length > 0) messageCacheRef.current.set(chatId, normalizedHistory);
|
||||
return normalizedHistory;
|
||||
return isStaleThreadSnapshot(current, normalizedHistory)
|
||||
? keepLiveMessages(current)
|
||||
: normalizedHistory;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loading, chatId, historical, historyVersion]);
|
||||
}, [
|
||||
loading,
|
||||
chatId,
|
||||
client,
|
||||
completedTurnIds,
|
||||
historical,
|
||||
historyVersion,
|
||||
historyContinuity,
|
||||
historyLineage,
|
||||
historyActiveTurnId,
|
||||
hasPendingToolCalls,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!chatId) return;
|
||||
const commit = pendingCanonicalCommitRef.current.get(chatId);
|
||||
if (!commit) return;
|
||||
if (
|
||||
commit.historyVersion !== historyVersion
|
||||
|| commit.historyLineage !== historyLineage
|
||||
|| commit.messages !== messages
|
||||
) {
|
||||
pendingCanonicalCommitRef.current.delete(chatId);
|
||||
return;
|
||||
}
|
||||
if (pendingCanonicalHydrateRef.current.get(chatId) !== commit.hydrate) {
|
||||
pendingCanonicalCommitRef.current.delete(chatId);
|
||||
return;
|
||||
}
|
||||
if (uiRevisionRef.current !== commit.expectedUiRevision) {
|
||||
pendingCanonicalCommitRef.current.delete(chatId);
|
||||
const fallback = messageCacheRef.current.get(chatId) ?? commit.previousMessages;
|
||||
messageCacheRef.current.set(chatId, fallback);
|
||||
setMessages((current) => current === commit.messages ? fallback : current);
|
||||
return;
|
||||
}
|
||||
if (!client.reconcileCanonicalCompletion(
|
||||
chatId,
|
||||
commit.hydrate.runGeneration,
|
||||
commit.completedTurnIds,
|
||||
commit.canonicalSnapshot,
|
||||
)) {
|
||||
pendingCanonicalCommitRef.current.delete(chatId);
|
||||
const fallback = messageCacheRef.current.get(chatId) ?? commit.previousMessages;
|
||||
messageCacheRef.current.set(chatId, fallback);
|
||||
setMessages((current) => current === commit.messages ? fallback : current);
|
||||
return;
|
||||
}
|
||||
pendingCanonicalHydrateRef.current.delete(chatId);
|
||||
pendingCanonicalCommitRef.current.delete(chatId);
|
||||
committedHistoryLineageRef.current.set(chatId, historyLineage);
|
||||
completedCanonicalHydrateVersionRef.current.set(chatId, historyVersion);
|
||||
}, [chatId, client, historyLineage, historyVersion, messages, setMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId || hasPendingToolCalls) return;
|
||||
if (completedCanonicalHydrateVersionRef.current.get(chatId) !== historyVersion) return;
|
||||
completedCanonicalHydrateVersionRef.current.delete(chatId);
|
||||
reconcileTurnComplete();
|
||||
}, [chatId, hasPendingToolCalls, historyVersion, messages, reconcileTurnComplete]);
|
||||
|
||||
const refreshCanonicalHistory = useCallback(() => {
|
||||
if (!chatId) return;
|
||||
pendingCanonicalHydrateRef.current.set(chatId, {
|
||||
historyLineage,
|
||||
historyVersion,
|
||||
runGeneration: client.getRunGeneration(chatId),
|
||||
uiBaseline: durableMessageShapes(currentUiMessagesRef.current ?? []),
|
||||
uiLineage: committedHistoryLineageRef.current.get(chatId) ?? null,
|
||||
uiRevision: uiRevisionRef.current,
|
||||
});
|
||||
refreshHistory();
|
||||
}, [chatId, client, historyLineage, historyVersion, refreshHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId) return;
|
||||
@@ -735,10 +1046,30 @@ export function ThreadShell({
|
||||
// A turn-end thread refresh can arrive while the viewport is easing the
|
||||
// final layout change. User-driven scrolling already disables following,
|
||||
// so keep an active programmatic follow alive across canonical hydration.
|
||||
pendingCanonicalHydrateRef.current.add(chatId);
|
||||
refreshHistory();
|
||||
refreshCanonicalHistory();
|
||||
});
|
||||
}, [chatId, client, refreshHistory]);
|
||||
}, [chatId, client, refreshCanonicalHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
const refreshOnReturn = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
refreshCanonicalHistory();
|
||||
};
|
||||
document.addEventListener("visibilitychange", refreshOnReturn);
|
||||
return () => document.removeEventListener("visibilitychange", refreshOnReturn);
|
||||
}, [refreshCanonicalHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
let refreshOnNextOpen = client.status !== "open";
|
||||
return client.onStatus((status) => {
|
||||
if (status !== "open") {
|
||||
refreshOnNextOpen = true;
|
||||
return;
|
||||
}
|
||||
if (refreshOnNextOpen) refreshCanonicalHistory();
|
||||
refreshOnNextOpen = false;
|
||||
});
|
||||
}, [client, refreshCanonicalHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatId) return;
|
||||
@@ -944,8 +1275,8 @@ export function ThreadShell({
|
||||
const forkedChatId = await onForkChat(chatId, beforeUserIndex);
|
||||
if (!forkedChatId) return;
|
||||
messageCacheRef.current.delete(forkedChatId);
|
||||
appliedHistoryVersionRef.current.delete(forkedChatId);
|
||||
pendingCanonicalHydrateRef.current.add(forkedChatId);
|
||||
pendingCanonicalHydrateRef.current.delete(forkedChatId);
|
||||
completedCanonicalHydrateVersionRef.current.delete(forkedChatId);
|
||||
},
|
||||
[chatId, onForkChat],
|
||||
);
|
||||
|
||||
@@ -540,6 +540,8 @@ export function useNanobotStream(
|
||||
) => SubmittedTurn | null;
|
||||
transcribeAudio: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
|
||||
stop: () => void;
|
||||
/** Mark an accepted canonical snapshot as the definitive end of the active turn. */
|
||||
reconcileTurnComplete: () => void;
|
||||
setMessages: React.Dispatch<React.SetStateAction<UIMessage[]>>;
|
||||
/** Latest transport-level fault raised since the last ``dismissStreamError``.
|
||||
* ``null`` when there is nothing to show. */
|
||||
@@ -581,10 +583,6 @@ export function useNanobotStream(
|
||||
* backend changes. */
|
||||
const streamEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return client.onError((err) => setStreamError(err));
|
||||
}, [client]);
|
||||
|
||||
const dismissStreamError = useCallback(() => setStreamError(null), []);
|
||||
|
||||
const clearPendingStreamWork = useCallback(() => {
|
||||
@@ -654,6 +652,66 @@ export function useNanobotStream(
|
||||
return !!closedStreamId;
|
||||
}, []);
|
||||
|
||||
const applyStreamError = useCallback((err: StreamError) => {
|
||||
// One multiplexed client serves every thread. A correlated send fault
|
||||
// belongs only to its target chat. An uncorrelated transport close can
|
||||
// still be shown in the mounted thread, but cannot roll back any turn.
|
||||
if (!chatId || (err.chatId && err.chatId !== chatId)) return;
|
||||
setStreamError(err);
|
||||
if (!err.turnId) return;
|
||||
|
||||
const rejectedTurnId = err.turnId;
|
||||
pendingStreamEventsRef.current = pendingStreamEventsRef.current.filter(
|
||||
(event) => event.turn.turnId !== rejectedTurnId,
|
||||
);
|
||||
sideChannelTurnIdsRef.current.delete(rejectedTurnId);
|
||||
cancelStreamEndTimer();
|
||||
setMessages((prev) => {
|
||||
const rejectedRows = prev.filter((message) => message.turnId === rejectedTurnId);
|
||||
if (rejectedRows.length === 0) return prev;
|
||||
const rejectedIds = new Set(rejectedRows.map((message) => message.id));
|
||||
const rejectedSegments = new Set(
|
||||
rejectedRows
|
||||
.map((message) => message.activitySegmentId)
|
||||
.filter((segmentId): segmentId is string => typeof segmentId === "string"),
|
||||
);
|
||||
if (
|
||||
activeAssistantRef.current
|
||||
&& rejectedIds.has(activeAssistantRef.current.id)
|
||||
) {
|
||||
activeAssistantRef.current = null;
|
||||
}
|
||||
if (buffer.current && rejectedIds.has(buffer.current.messageId)) {
|
||||
buffer.current = null;
|
||||
}
|
||||
for (const id of rejectedIds) closedAssistantStreamIdsRef.current.delete(id);
|
||||
if (
|
||||
activitySegmentRef.current
|
||||
&& rejectedSegments.has(activitySegmentRef.current)
|
||||
) {
|
||||
activitySegmentRef.current = null;
|
||||
}
|
||||
if (
|
||||
fileEditSegmentRef.current
|
||||
&& rejectedSegments.has(fileEditSegmentRef.current)
|
||||
) {
|
||||
fileEditSegmentRef.current = null;
|
||||
}
|
||||
return prev.filter((message) => message.turnId !== rejectedTurnId);
|
||||
});
|
||||
|
||||
const remainingStartedAt = client.getRunStartedAt(chatId);
|
||||
const hasRemainingRun = (
|
||||
remainingStartedAt !== null
|
||||
|| client.hasUnsettledRun(chatId)
|
||||
);
|
||||
setRunStartedAt(remainingStartedAt);
|
||||
setIsStreaming(hasRemainingRun);
|
||||
if (!hasRemainingRun) suppressStreamUntilTurnEndRef.current = false;
|
||||
}, [cancelStreamEndTimer, chatId, client]);
|
||||
|
||||
useEffect(() => client.onError(applyStreamError), [applyStreamError, client]);
|
||||
|
||||
const resolveActiveAssistantIndex = useCallback((
|
||||
prev: UIMessage[],
|
||||
turn: UIMessageTurnFields = {},
|
||||
@@ -849,6 +907,15 @@ export function useNanobotStream(
|
||||
return () => document.removeEventListener("visibilitychange", flushOnReturn);
|
||||
}, [flushPendingStreamEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
return client.onStatus((status) => {
|
||||
if (status !== "reconnecting" && status !== "closed") return;
|
||||
// A transport drop does not prove the backend turn completed. Keep the
|
||||
// semantic running state intact so queued guidance is not flushed early.
|
||||
cancelStreamEndTimer();
|
||||
});
|
||||
}, [cancelStreamEndTimer, client]);
|
||||
|
||||
// Reset local state when switching chats. Do not reset on every
|
||||
// ``initialMessages`` update: a brand-new chat can receive an empty/404
|
||||
// history response after the optimistic first message has already rendered.
|
||||
@@ -883,6 +950,31 @@ export function useNanobotStream(
|
||||
if (!chatId) return;
|
||||
|
||||
const handle = (ev: InboundEvent) => {
|
||||
if (ev.event === "error") {
|
||||
if (ev.detail === "message_too_big") {
|
||||
applyStreamError({
|
||||
kind: "message_too_big",
|
||||
chatId,
|
||||
turnId: ev.turn_id,
|
||||
});
|
||||
} else if (ev.detail === "workspace_scope_rejected") {
|
||||
applyStreamError({
|
||||
kind: "workspace_scope_rejected",
|
||||
reason: ev.reason,
|
||||
chatId,
|
||||
turnId: ev.turn_id,
|
||||
});
|
||||
} else if (ev.turn_id) {
|
||||
applyStreamError({
|
||||
kind: "turn_rejected",
|
||||
detail: ev.detail,
|
||||
reason: ev.reason,
|
||||
chatId,
|
||||
turnId: ev.turn_id,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const sideChannelEvent = isSideChannelEvent(ev);
|
||||
if (
|
||||
streamEndTimerRef.current !== null
|
||||
@@ -1187,8 +1279,7 @@ export function useNanobotStream(
|
||||
});
|
||||
return;
|
||||
}
|
||||
// ``attached`` / ``error`` frames aren't actionable here; the client
|
||||
// shell handles them separately.
|
||||
// ``attached`` frames aren't actionable here.
|
||||
};
|
||||
|
||||
const unsub = client.onChat(chatId, handle);
|
||||
@@ -1202,6 +1293,7 @@ export function useNanobotStream(
|
||||
cancelStreamEndTimer();
|
||||
};
|
||||
}, [
|
||||
applyStreamError,
|
||||
cancelStreamEndTimer,
|
||||
chatId,
|
||||
client,
|
||||
@@ -1271,12 +1363,16 @@ export function useNanobotStream(
|
||||
});
|
||||
if (!sideChannel) setIsStreaming(true);
|
||||
const wireMedia = hasAttachments ? images!.map((i) => i.media) : undefined;
|
||||
const wireOptions = { ...options, turnId };
|
||||
delete wireOptions.quotedContext;
|
||||
delete wireOptions.sideChannel;
|
||||
delete wireOptions.finalizeActiveTurn;
|
||||
delete wireOptions.continueActiveTurn;
|
||||
client.sendMessage(chatId, outboundContent, wireMedia, wireOptions);
|
||||
const clientOptions = {
|
||||
...options,
|
||||
turnId,
|
||||
...((sideChannel || continueActiveTurn) ? { startsNewRun: false } : {}),
|
||||
};
|
||||
delete clientOptions.quotedContext;
|
||||
delete clientOptions.sideChannel;
|
||||
delete clientOptions.finalizeActiveTurn;
|
||||
delete clientOptions.continueActiveTurn;
|
||||
client.sendMessage(chatId, outboundContent, wireMedia, clientOptions);
|
||||
return { turnId, userMessageId, sideChannel };
|
||||
},
|
||||
[cancelStreamEndTimer, chatId, clearActivitySegment, client, flushPendingStreamEvents],
|
||||
@@ -1297,6 +1393,18 @@ export function useNanobotStream(
|
||||
client.sendMessage(chatId, "/stop");
|
||||
}, [chatId, clearActivitySegment, client, flushPendingStreamEvents]);
|
||||
|
||||
const reconcileTurnComplete = useCallback(() => {
|
||||
cancelStreamEndTimer();
|
||||
clearPendingStreamWork();
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
closedAssistantStreamIdsRef.current.clear();
|
||||
clearActivitySegment();
|
||||
suppressStreamUntilTurnEndRef.current = false;
|
||||
setRunStartedAt(null);
|
||||
setIsStreaming(false);
|
||||
}, [cancelStreamEndTimer, clearActivitySegment, clearPendingStreamWork]);
|
||||
|
||||
const transcribeAudio = useCallback(
|
||||
(dataUrl: string, options?: { durationMs?: number }) =>
|
||||
client.transcribeAudio(dataUrl, options),
|
||||
@@ -1312,6 +1420,7 @@ export function useNanobotStream(
|
||||
send,
|
||||
transcribeAudio,
|
||||
stop,
|
||||
reconcileTurnComplete,
|
||||
setMessages,
|
||||
streamError,
|
||||
dismissStreamError,
|
||||
|
||||
+201
-53
@@ -24,6 +24,8 @@ const INITIAL_HISTORY_PAGE_LIMIT = 160;
|
||||
const OLDER_HISTORY_PAGE_LIMIT = 120;
|
||||
const CHAT_CREATE_TIMEOUT_MS = 60_000;
|
||||
|
||||
export type SessionHistoryContinuity = "initial" | "overlap" | "reset";
|
||||
|
||||
function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] {
|
||||
return messages.map((m, idx) => ({
|
||||
...m,
|
||||
@@ -32,6 +34,63 @@ function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] {
|
||||
}));
|
||||
}
|
||||
|
||||
function sameSemanticMessage(a: UIMessage, b: UIMessage): boolean {
|
||||
return (
|
||||
a.role === b.role
|
||||
&& (a.kind ?? "") === (b.kind ?? "")
|
||||
&& a.content === b.content
|
||||
&& (!a.turnId || !b.turnId || a.turnId === b.turnId)
|
||||
);
|
||||
}
|
||||
|
||||
function longestSemanticOverlap(previous: UIMessage[], latest: UIMessage[]): number {
|
||||
const maxOverlap = Math.min(previous.length, latest.length);
|
||||
for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
|
||||
const previousStart = previous.length - overlap;
|
||||
let matches = true;
|
||||
for (let index = 0; index < overlap; index += 1) {
|
||||
if (!sameSemanticMessage(previous[previousStart + index], latest[index])) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matches) return overlap;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function mergeLatestHistory(
|
||||
previous: UIMessage[],
|
||||
latest: UIMessage[],
|
||||
initial: boolean,
|
||||
): {
|
||||
continuity: SessionHistoryContinuity;
|
||||
messages: UIMessage[];
|
||||
retainedPrefixLength: number;
|
||||
} {
|
||||
if (initial) {
|
||||
return {
|
||||
continuity: "initial",
|
||||
messages: latest,
|
||||
retainedPrefixLength: 0,
|
||||
};
|
||||
}
|
||||
const overlapLength = longestSemanticOverlap(previous, latest);
|
||||
if (overlapLength === 0) {
|
||||
return {
|
||||
continuity: "reset",
|
||||
messages: latest,
|
||||
retainedPrefixLength: 0,
|
||||
};
|
||||
}
|
||||
const retainedPrefixLength = previous.length - overlapLength;
|
||||
return {
|
||||
continuity: "overlap",
|
||||
messages: [...previous.slice(0, retainedPrefixLength), ...latest],
|
||||
retainedPrefixLength,
|
||||
};
|
||||
}
|
||||
|
||||
function hasPendingToolCallsFromThread(
|
||||
body: Awaited<ReturnType<typeof fetchWebuiThread>>,
|
||||
messages: UIMessage[],
|
||||
@@ -42,6 +101,17 @@ function hasPendingToolCallsFromThread(
|
||||
return hasPendingAgentActivity(messages);
|
||||
}
|
||||
|
||||
function completedTurnIdsFromThread(
|
||||
body: Awaited<ReturnType<typeof fetchWebuiThread>>,
|
||||
): string[] {
|
||||
if (!Array.isArray(body?.completed_turn_ids)) return [];
|
||||
return Array.from(new Set(
|
||||
body.completed_turn_ids.filter(
|
||||
(turnId): turnId is string => typeof turnId === "string" && turnId.length > 0,
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
/** Sidebar state: fetches the full session list and exposes create / delete actions. */
|
||||
export function useSessions(): {
|
||||
sessions: ChatSummary[];
|
||||
@@ -191,11 +261,20 @@ export function useSessionHistory(key: string | null): {
|
||||
userMessageOffset: number;
|
||||
version: number;
|
||||
forkBoundaryMessageCount: number | null;
|
||||
/** ``true`` when the replayed transcript ends with a trace row (turn still in flight). */
|
||||
/** ``true`` when the server reports that the turn is still in flight. */
|
||||
hasPendingToolCalls: boolean;
|
||||
/** Turn identities backed by explicit persisted completion events. */
|
||||
completedTurnIds: string[];
|
||||
/** Relationship between the latest canonical page and its predecessor. */
|
||||
continuity: SessionHistoryContinuity;
|
||||
/** Stable across overlapping latest pages; changes on initial load or reset. */
|
||||
lineage: number;
|
||||
/** Exact active turn when supplied by a current gateway. */
|
||||
activeTurnId: string | null;
|
||||
} {
|
||||
const { token } = useClient();
|
||||
const loadingOlderRef = useRef(false);
|
||||
const historyVersionRef = useRef(0);
|
||||
const [refreshSeq, setRefreshSeq] = useState(0);
|
||||
const refresh = useCallback(() => {
|
||||
setRefreshSeq((value) => value + 1);
|
||||
@@ -207,11 +286,15 @@ export function useSessionHistory(key: string | null): {
|
||||
loadingOlder: boolean;
|
||||
error: string | null;
|
||||
hasPendingToolCalls: boolean;
|
||||
completedTurnIds: string[];
|
||||
forkBoundaryMessageCount: number | null;
|
||||
beforeCursor: string | null;
|
||||
hasMoreBefore: boolean;
|
||||
userMessageOffset: number;
|
||||
version: number;
|
||||
continuity: SessionHistoryContinuity;
|
||||
lineage: number;
|
||||
activeTurnId: string | null;
|
||||
}>({
|
||||
key: null,
|
||||
messages: [],
|
||||
@@ -219,11 +302,15 @@ export function useSessionHistory(key: string | null): {
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: false,
|
||||
completedTurnIds: [],
|
||||
forkBoundaryMessageCount: null,
|
||||
beforeCursor: null,
|
||||
hasMoreBefore: false,
|
||||
userMessageOffset: 0,
|
||||
version: 0,
|
||||
continuity: "initial",
|
||||
lineage: 0,
|
||||
activeTurnId: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -235,11 +322,15 @@ export function useSessionHistory(key: string | null): {
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: false,
|
||||
completedTurnIds: [],
|
||||
forkBoundaryMessageCount: null,
|
||||
beforeCursor: null,
|
||||
hasMoreBefore: false,
|
||||
userMessageOffset: 0,
|
||||
version: 0,
|
||||
continuity: "initial",
|
||||
lineage: 0,
|
||||
activeTurnId: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -255,11 +346,15 @@ export function useSessionHistory(key: string | null): {
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: false,
|
||||
completedTurnIds: [],
|
||||
forkBoundaryMessageCount: null,
|
||||
beforeCursor: null,
|
||||
hasMoreBefore: false,
|
||||
userMessageOffset: 0,
|
||||
version: 0,
|
||||
continuity: "initial",
|
||||
lineage: 0,
|
||||
activeTurnId: null,
|
||||
});
|
||||
(async () => {
|
||||
try {
|
||||
@@ -268,56 +363,83 @@ export function useSessionHistory(key: string | null): {
|
||||
direction: "latest",
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (!body?.messages?.length) {
|
||||
setState((prev) => ({
|
||||
historyVersionRef.current += 1;
|
||||
const responseVersion = historyVersionRef.current;
|
||||
const completedTurnIds = completedTurnIdsFromThread(body);
|
||||
const ui = persistedMessagesToUi(body?.messages ?? []);
|
||||
const hasPending = hasPendingToolCallsFromThread(body, ui);
|
||||
const forkBoundary = typeof body?.fork_boundary_message_count === "number"
|
||||
? Math.max(0, Math.min(body.fork_boundary_message_count, ui.length))
|
||||
: null;
|
||||
setState((prev) => {
|
||||
const merged = prev.key === key
|
||||
? mergeLatestHistory(prev.messages, ui, prev.lineage === 0)
|
||||
: mergeLatestHistory([], ui, true);
|
||||
const retainedPrefix = merged.retainedPrefixLength > 0;
|
||||
const retainedForkBoundary = (
|
||||
retainedPrefix
|
||||
&& prev.forkBoundaryMessageCount !== null
|
||||
&& prev.forkBoundaryMessageCount <= merged.retainedPrefixLength
|
||||
)
|
||||
? prev.forkBoundaryMessageCount
|
||||
: null;
|
||||
return {
|
||||
key,
|
||||
messages: [],
|
||||
messages: merged.messages,
|
||||
loading: false,
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: false,
|
||||
forkBoundaryMessageCount: null,
|
||||
beforeCursor: null,
|
||||
hasMoreBefore: false,
|
||||
userMessageOffset: 0,
|
||||
version: prev.key === key ? prev.version + 1 : 1,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const ui = persistedMessagesToUi(body.messages);
|
||||
const hasPending = hasPendingToolCallsFromThread(body, ui);
|
||||
const forkBoundary = typeof body.fork_boundary_message_count === "number"
|
||||
? Math.max(0, Math.min(body.fork_boundary_message_count, ui.length))
|
||||
: null;
|
||||
setState((prev) => ({
|
||||
key,
|
||||
messages: ui,
|
||||
loading: false,
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: hasPending,
|
||||
forkBoundaryMessageCount: forkBoundary,
|
||||
beforeCursor: body.page?.before_cursor ?? null,
|
||||
hasMoreBefore: body.page?.has_more_before === true,
|
||||
userMessageOffset: Math.max(0, body.page?.user_message_offset ?? 0),
|
||||
version: prev.key === key ? prev.version + 1 : 1,
|
||||
}));
|
||||
hasPendingToolCalls: hasPending,
|
||||
completedTurnIds,
|
||||
forkBoundaryMessageCount: forkBoundary === null
|
||||
? retainedForkBoundary
|
||||
: forkBoundary + merged.retainedPrefixLength,
|
||||
beforeCursor: retainedPrefix
|
||||
? prev.beforeCursor
|
||||
: body?.page?.before_cursor ?? null,
|
||||
hasMoreBefore: retainedPrefix
|
||||
? prev.hasMoreBefore
|
||||
: body?.page?.has_more_before === true,
|
||||
userMessageOffset: retainedPrefix
|
||||
? prev.userMessageOffset
|
||||
: Math.max(0, body?.page?.user_message_offset ?? 0),
|
||||
version: responseVersion,
|
||||
continuity: merged.continuity,
|
||||
lineage: merged.continuity === "overlap"
|
||||
? prev.lineage
|
||||
: responseVersion,
|
||||
activeTurnId: typeof body?.active_turn_id === "string"
|
||||
? body.active_turn_id
|
||||
: null,
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
if (e instanceof ApiError && e.status === 404) {
|
||||
setState((prev) => ({
|
||||
key,
|
||||
messages: [],
|
||||
loading: false,
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: false,
|
||||
forkBoundaryMessageCount: null,
|
||||
beforeCursor: null,
|
||||
hasMoreBefore: false,
|
||||
userMessageOffset: 0,
|
||||
version: prev.key === key ? prev.version + 1 : 1,
|
||||
}));
|
||||
historyVersionRef.current += 1;
|
||||
const responseVersion = historyVersionRef.current;
|
||||
setState((prev) => {
|
||||
const continuity = prev.key === key && prev.lineage > 0
|
||||
? "reset"
|
||||
: "initial";
|
||||
return {
|
||||
key,
|
||||
messages: [],
|
||||
loading: false,
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: false,
|
||||
completedTurnIds: [],
|
||||
forkBoundaryMessageCount: null,
|
||||
beforeCursor: null,
|
||||
hasMoreBefore: false,
|
||||
userMessageOffset: 0,
|
||||
version: responseVersion,
|
||||
continuity,
|
||||
lineage: responseVersion,
|
||||
activeTurnId: null,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
setState((prev) => ({
|
||||
key,
|
||||
@@ -326,11 +448,15 @@ export function useSessionHistory(key: string | null): {
|
||||
loadingOlder: false,
|
||||
error: (e as Error).message,
|
||||
hasPendingToolCalls: false,
|
||||
completedTurnIds: [],
|
||||
forkBoundaryMessageCount: null,
|
||||
beforeCursor: null,
|
||||
hasMoreBefore: false,
|
||||
userMessageOffset: 0,
|
||||
version: prev.key === key ? prev.version : 0,
|
||||
continuity: prev.key === key ? prev.continuity : "initial",
|
||||
lineage: prev.key === key ? prev.lineage : 0,
|
||||
activeTurnId: prev.key === key ? prev.activeTurnId : null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -342,17 +468,26 @@ export function useSessionHistory(key: string | null): {
|
||||
|
||||
const loadOlder = useCallback(async () => {
|
||||
if (!key || loadingOlderRef.current) return;
|
||||
const before = state.key === key ? state.beforeCursor : null;
|
||||
if (!before || !state.hasMoreBefore) return;
|
||||
const requestKey = key;
|
||||
const requestLineage = state.key === requestKey ? state.lineage : 0;
|
||||
const beforeCursor = state.key === requestKey ? state.beforeCursor : null;
|
||||
if (!beforeCursor || !state.hasMoreBefore || requestLineage === 0) return;
|
||||
const matchesRequest = (candidate: typeof state) => (
|
||||
candidate.key === requestKey
|
||||
&& candidate.lineage === requestLineage
|
||||
&& candidate.beforeCursor === beforeCursor
|
||||
);
|
||||
loadingOlderRef.current = true;
|
||||
setState((prev) => prev.key === key ? { ...prev, loadingOlder: true, error: null } : prev);
|
||||
setState((prev) => matchesRequest(prev)
|
||||
? { ...prev, loadingOlder: true, error: null }
|
||||
: prev);
|
||||
try {
|
||||
const body = await fetchWebuiThread(token, key, {
|
||||
const body = await fetchWebuiThread(token, requestKey, {
|
||||
limit: OLDER_HISTORY_PAGE_LIMIT,
|
||||
before,
|
||||
before: beforeCursor,
|
||||
});
|
||||
setState((prev) => {
|
||||
if (prev.key !== key) return prev;
|
||||
if (!matchesRequest(prev)) return prev;
|
||||
if (!body?.messages?.length) {
|
||||
return {
|
||||
...prev,
|
||||
@@ -369,21 +504,21 @@ export function useSessionHistory(key: string | null): {
|
||||
? null
|
||||
: prev.forkBoundaryMessageCount + older.length;
|
||||
const nextMessages = [...older, ...prev.messages];
|
||||
// An older page cannot change the authoritative latest-turn lifecycle
|
||||
// state or masquerade as a completed latest-page refresh.
|
||||
return {
|
||||
...prev,
|
||||
messages: nextMessages,
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: hasPendingAgentActivity(nextMessages),
|
||||
forkBoundaryMessageCount: olderBoundary ?? shiftedBoundary,
|
||||
beforeCursor: body.page?.before_cursor ?? null,
|
||||
hasMoreBefore: body.page?.has_more_before === true,
|
||||
userMessageOffset: Math.max(0, body.page?.user_message_offset ?? 0),
|
||||
version: prev.version + 1,
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
setState((prev) => prev.key === key
|
||||
setState((prev) => matchesRequest(prev)
|
||||
? {
|
||||
...prev,
|
||||
loadingOlder: false,
|
||||
@@ -398,6 +533,7 @@ export function useSessionHistory(key: string | null): {
|
||||
state.beforeCursor,
|
||||
state.hasMoreBefore,
|
||||
state.key,
|
||||
state.lineage,
|
||||
token,
|
||||
]);
|
||||
|
||||
@@ -414,6 +550,10 @@ export function useSessionHistory(key: string | null): {
|
||||
version: 0,
|
||||
forkBoundaryMessageCount: null,
|
||||
hasPendingToolCalls: false,
|
||||
completedTurnIds: [],
|
||||
continuity: "initial",
|
||||
lineage: 0,
|
||||
activeTurnId: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -432,6 +572,10 @@ export function useSessionHistory(key: string | null): {
|
||||
version: 0,
|
||||
forkBoundaryMessageCount: null,
|
||||
hasPendingToolCalls: false,
|
||||
completedTurnIds: [],
|
||||
continuity: "initial",
|
||||
lineage: 0,
|
||||
activeTurnId: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -447,6 +591,10 @@ export function useSessionHistory(key: string | null): {
|
||||
version: state.version,
|
||||
forkBoundaryMessageCount: state.forkBoundaryMessageCount,
|
||||
hasPendingToolCalls: state.hasPendingToolCalls,
|
||||
completedTurnIds: state.completedTurnIds,
|
||||
continuity: state.continuity,
|
||||
lineage: state.lineage,
|
||||
activeTurnId: state.activeTurnId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1251,6 +1251,10 @@
|
||||
"workspaceScopeRejected": {
|
||||
"title": "Workspace was not changed",
|
||||
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "Message was not sent",
|
||||
"body": "The gateway rejected this message. Review its text or attachments, then try again."
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
|
||||
@@ -1238,6 +1238,10 @@
|
||||
"workspaceScopeRejected": {
|
||||
"title": "El espacio de trabajo no cambió",
|
||||
"body": "El gateway rechazó el proyecto o modo de acceso solicitado, así que Nanobot conservó el espacio de trabajo anterior."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "El mensaje no se envió",
|
||||
"body": "El gateway rechazó este mensaje. Revisa el texto o los archivos adjuntos e inténtalo de nuevo."
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
|
||||
@@ -1237,6 +1237,10 @@
|
||||
"workspaceScopeRejected": {
|
||||
"title": "L’espace de travail n’a pas changé",
|
||||
"body": "La passerelle a refusé le projet ou le mode d’accès demandé ; Nanobot a conservé l’espace de travail précédent."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "Le message n’a pas été envoyé",
|
||||
"body": "La passerelle a refusé ce message. Vérifiez le texte ou les pièces jointes, puis réessayez."
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
|
||||
@@ -1237,6 +1237,10 @@
|
||||
"workspaceScopeRejected": {
|
||||
"title": "Workspace tidak berubah",
|
||||
"body": "Gateway menolak proyek atau mode akses yang diminta, jadi Nanobot tetap memakai workspace sebelumnya."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "Pesan tidak terkirim",
|
||||
"body": "Gateway menolak pesan ini. Periksa teks atau lampiran, lalu coba lagi."
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
|
||||
@@ -1237,6 +1237,10 @@
|
||||
"workspaceScopeRejected": {
|
||||
"title": "ワークスペースは変更されませんでした",
|
||||
"body": "要求されたプロジェクトまたはアクセスモードがゲートウェイで拒否されたため、Nanobot は以前のワークスペースをそのまま使用しています。"
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "メッセージは送信されませんでした",
|
||||
"body": "ゲートウェイがこのメッセージを拒否しました。本文または添付ファイルを確認して、もう一度お試しください。"
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
|
||||
@@ -1237,6 +1237,10 @@
|
||||
"workspaceScopeRejected": {
|
||||
"title": "작업공간이 변경되지 않았습니다",
|
||||
"body": "요청한 프로젝트 또는 접근 모드가 게이트웨이에서 거부되어 Nanobot이 이전 작업공간을 계속 사용합니다."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "메시지가 전송되지 않았습니다",
|
||||
"body": "게이트웨이가 이 메시지를 거부했습니다. 텍스트나 첨부 파일을 확인한 후 다시 시도하세요."
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
|
||||
@@ -1251,6 +1251,10 @@
|
||||
"workspaceScopeRejected": {
|
||||
"title": "O workspace não foi alterado",
|
||||
"body": "O nanobot manteve o workspace anterior porque o projeto ou modo de acesso solicitado foi rejeitado pelo gateway."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "A mensagem não foi enviada",
|
||||
"body": "O gateway rejeitou esta mensagem. Revise o texto ou os anexos e tente novamente."
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
|
||||
@@ -1237,6 +1237,10 @@
|
||||
"workspaceScopeRejected": {
|
||||
"title": "Workspace không thay đổi",
|
||||
"body": "Gateway đã từ chối dự án hoặc chế độ truy cập được yêu cầu, nên Nanobot giữ workspace trước đó."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "Tin nhắn chưa được gửi",
|
||||
"body": "Gateway đã từ chối tin nhắn này. Hãy kiểm tra nội dung hoặc tệp đính kèm rồi thử lại."
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
|
||||
@@ -1251,6 +1251,10 @@
|
||||
"workspaceScopeRejected": {
|
||||
"title": "工作区未更改",
|
||||
"body": "网关拒绝了请求的项目或访问权限,Nanobot 已继续使用之前的工作区。"
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "消息未发送",
|
||||
"body": "网关拒绝了这条消息。请检查消息内容或附件后重试。"
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
|
||||
@@ -1237,6 +1237,10 @@
|
||||
"workspaceScopeRejected": {
|
||||
"title": "工作區未變更",
|
||||
"body": "閘道拒絕要求的專案或存取模式,因此 Nanobot 繼續使用先前的工作區。"
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "訊息未傳送",
|
||||
"body": "閘道拒絕了這則訊息。請檢查內容或附件後再試一次。"
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
|
||||
@@ -185,6 +185,7 @@ export async function fetchWebuiThread(
|
||||
const res = await fetchWithTimeout(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new ApiError(res.status, `HTTP ${res.status}`);
|
||||
|
||||
+554
-11
@@ -83,8 +83,20 @@ export type StreamError =
|
||||
/** Server rejected the inbound frame as too large (WS close code 1009).
|
||||
* This is the transport fallback after text and attachment policies have
|
||||
* already been checked independently. */
|
||||
| { kind: "message_too_big" }
|
||||
| { kind: "workspace_scope_rejected"; reason?: string; chatId?: string };
|
||||
| { kind: "message_too_big"; chatId?: string; turnId?: string }
|
||||
| {
|
||||
kind: "workspace_scope_rejected";
|
||||
reason?: string;
|
||||
chatId?: string;
|
||||
turnId?: string;
|
||||
}
|
||||
| {
|
||||
kind: "turn_rejected";
|
||||
detail?: string;
|
||||
reason?: string;
|
||||
chatId: string;
|
||||
turnId: string;
|
||||
};
|
||||
|
||||
type ErrorHandler = (error: StreamError) => void;
|
||||
|
||||
@@ -95,6 +107,13 @@ interface PendingRequest<T> {
|
||||
}
|
||||
|
||||
const SYSTEM_COMMAND_TURN_PREFIX = "webui-system:";
|
||||
const TURN_REJECTION_DETAILS = new Set([
|
||||
"access_denied",
|
||||
"attachment_rejected",
|
||||
"message_rejected",
|
||||
"missing content",
|
||||
"workspace_scope_rejected",
|
||||
]);
|
||||
|
||||
export function isSystemCommandTurnId(value: string | null | undefined): value is string {
|
||||
return typeof value === "string" && value.startsWith(SYSTEM_COMMAND_TURN_PREFIX);
|
||||
@@ -103,6 +122,8 @@ export function isSystemCommandTurnId(value: string | null | undefined): value i
|
||||
export interface NanobotClientOptions {
|
||||
url: string;
|
||||
reconnect?: boolean;
|
||||
/** Maximum UTF-8 bytes accepted for one websocket message. */
|
||||
maxFrameBytes?: number;
|
||||
/** Called when a connection drops so the app can refresh its token. */
|
||||
onReauth?: () => Promise<string | null>;
|
||||
/** Inject a custom WebSocket factory (used by unit tests). */
|
||||
@@ -111,6 +132,24 @@ export interface NanobotClientOptions {
|
||||
maxBackoffMs?: number;
|
||||
}
|
||||
|
||||
export interface CanonicalRunSnapshot {
|
||||
/** User turn ids present in the canonical transcript page. */
|
||||
observedTurnIds: readonly string[];
|
||||
/** Whether the server still considers the transcript tail active. */
|
||||
hasPendingToolCalls: boolean;
|
||||
/** Exact active turn when supplied by a current gateway. */
|
||||
activeTurnId?: string | null;
|
||||
}
|
||||
|
||||
type PendingMessageState = "queued" | "sent" | "unknown" | "accepted";
|
||||
|
||||
interface PendingMessageSend {
|
||||
chatId: string;
|
||||
turnId: string;
|
||||
startsNewRun: boolean;
|
||||
state: PendingMessageState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton WebSocket client that multiplexes chat streams.
|
||||
*
|
||||
@@ -134,6 +173,23 @@ export class NanobotClient {
|
||||
private knownChats = new Set<string>();
|
||||
/** Wall-clock run strip: updated from ``goal_status`` even with no ``onChat`` subscriber. */
|
||||
private runStartedAtByChatId = new Map<string, number>();
|
||||
/** Per-turn clocks let a rejected newer turn fall back without borrowing its timer. */
|
||||
private runStartedAtByTurnKey = new Map<string, number>();
|
||||
/** Monotonic per-chat generation for local sends and observed backend runs. */
|
||||
private runGenerationByChatId = new Map<string, number>();
|
||||
/** Turn associated with the latest generation, retained after idle for reconciliation. */
|
||||
private latestRunTurnIdByChatId = new Map<string, string>();
|
||||
/** Submitted or running turns not yet closed by lifecycle or canonical state. */
|
||||
private unsettledRunTurnIdsByChatId = new Map<string, Set<string>>();
|
||||
/** Correlated WebUI sends retained until protocol/canonical disposition. */
|
||||
private pendingMessageSends = new Map<string, PendingMessageSend>();
|
||||
/** Message sends written to the current socket but not yet acknowledged. */
|
||||
private socketPendingMessageSendKeys = new Set<string>();
|
||||
/** Last application frame written, used only for conservative 1009 attribution. */
|
||||
private lastSocketMessageSendKey: string | null = null;
|
||||
/** Canonically completed turns whose delayed websocket frames must be ignored. */
|
||||
private canonicalCompletedTurnIdsByChatId = new Map<string, Set<string>>();
|
||||
private static readonly COMPLETED_TURN_FENCE_MAX = 256;
|
||||
/** Latest ``goal_state`` snapshot per ``chat_id`` (multi-session isolation). */
|
||||
private goalStateByChatId = new Map<string, GoalStateWsPayload>();
|
||||
private pendingNewChat: PendingRequest<string> | null = null;
|
||||
@@ -145,6 +201,7 @@ export class NanobotClient {
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly shouldReconnect: boolean;
|
||||
private readonly maxBackoffMs: number;
|
||||
private maxFrameBytes: number | undefined;
|
||||
private socketFactory: (url: string) => WebSocket;
|
||||
private currentUrl: string;
|
||||
private status_: ConnectionStatus = "idle";
|
||||
@@ -156,6 +213,7 @@ export class NanobotClient {
|
||||
constructor(private options: NanobotClientOptions) {
|
||||
this.shouldReconnect = options.reconnect ?? true;
|
||||
this.maxBackoffMs = options.maxBackoffMs ?? 15_000;
|
||||
this.maxFrameBytes = this.normalizeMaxFrameBytes(options.maxFrameBytes);
|
||||
this.socketFactory = options.socketFactory ?? createDefaultSocket;
|
||||
this.currentUrl = options.url;
|
||||
}
|
||||
@@ -222,27 +280,386 @@ export class NanobotClient {
|
||||
return v === undefined ? null : v;
|
||||
}
|
||||
|
||||
/** Refresh transport policy after bootstrap token renewal. */
|
||||
updateMaxFrameBytes(maxFrameBytes?: number): void {
|
||||
this.maxFrameBytes = this.normalizeMaxFrameBytes(maxFrameBytes);
|
||||
}
|
||||
|
||||
/** Generation captured when an HTTP thread reconciliation starts. */
|
||||
getRunGeneration(chatId: string): number {
|
||||
return this.runGenerationByChatId.get(chatId) ?? 0;
|
||||
}
|
||||
|
||||
/** Whether a locally submitted lifecycle turn still lacks a terminal disposition. */
|
||||
hasUnsettledRun(chatId: string): boolean {
|
||||
return (this.unsettledRunTurnIdsByChatId.get(chatId)?.size ?? 0) > 0;
|
||||
}
|
||||
|
||||
private normalizeMaxFrameBytes(value: number | undefined): number | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
private canonicalTurnWillSettle(
|
||||
chatId: string,
|
||||
turnId: string,
|
||||
completed: ReadonlySet<string>,
|
||||
observed: ReadonlySet<string>,
|
||||
snapshot?: CanonicalRunSnapshot,
|
||||
): boolean {
|
||||
if (completed.has(turnId)) return true;
|
||||
if (!snapshot || snapshot.activeTurnId === turnId) return false;
|
||||
if (snapshot.hasPendingToolCalls) return false;
|
||||
if (observed.has(turnId)) return true;
|
||||
const pending = this.pendingMessageSends.get(this.runSendKey(chatId, turnId));
|
||||
return pending?.state === "unknown" || pending?.state === "accepted";
|
||||
}
|
||||
|
||||
private settleNonLifecycleCanonicalSends(
|
||||
chatId: string,
|
||||
completed: ReadonlySet<string>,
|
||||
observed: ReadonlySet<string>,
|
||||
snapshot?: CanonicalRunSnapshot,
|
||||
): void {
|
||||
for (const pending of [...this.pendingMessageSends.values()]) {
|
||||
if (pending.chatId !== chatId || pending.startsNewRun) continue;
|
||||
if (!this.canonicalTurnWillSettle(
|
||||
chatId,
|
||||
pending.turnId,
|
||||
completed,
|
||||
observed,
|
||||
snapshot,
|
||||
)) continue;
|
||||
this.clearPendingMessageSend(chatId, pending.turnId);
|
||||
}
|
||||
}
|
||||
|
||||
private prunePendingInboundTurn(chatId: string, turnId: string): void {
|
||||
const pending = this.pendingInboundByChat.get(chatId);
|
||||
if (!pending) return;
|
||||
const remaining = pending.filter((event) => (
|
||||
!("turn_id" in event)
|
||||
|| event.turn_id !== turnId
|
||||
));
|
||||
if (remaining.length > 0) this.pendingInboundByChat.set(chatId, remaining);
|
||||
else this.pendingInboundByChat.delete(chatId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure preflight for canonical reconciliation.
|
||||
*
|
||||
* Unlike ``reconcileCanonicalCompletion``, this does not add completion
|
||||
* fences, prune queued frames, settle turns, or emit run-status updates.
|
||||
*/
|
||||
canReconcileCanonicalCompletion(
|
||||
chatId: string,
|
||||
expectedRunGeneration: number,
|
||||
completedTurnIds: readonly string[],
|
||||
snapshot?: CanonicalRunSnapshot,
|
||||
): boolean {
|
||||
const completed = new Set(this.canonicalCompletedTurnIdsByChatId.get(chatId));
|
||||
for (const turnId of completedTurnIds) {
|
||||
if (turnId) completed.add(turnId);
|
||||
}
|
||||
const observed = new Set(
|
||||
snapshot?.observedTurnIds.filter((turnId) => turnId.length > 0) ?? [],
|
||||
);
|
||||
const willSettle = (turnId: string): boolean => this.canonicalTurnWillSettle(
|
||||
chatId,
|
||||
turnId,
|
||||
completed,
|
||||
observed,
|
||||
snapshot,
|
||||
);
|
||||
const latestRunTurnId = this.latestRunTurnIdByChatId.get(chatId);
|
||||
const latestRunIsRepresented = (
|
||||
typeof latestRunTurnId === "string"
|
||||
&& (
|
||||
completed.has(latestRunTurnId)
|
||||
|| (
|
||||
observed.has(latestRunTurnId)
|
||||
&& willSettle(latestRunTurnId)
|
||||
)
|
||||
)
|
||||
);
|
||||
const unsettledTurnIds = this.unsettledRunTurnIdsByChatId.get(chatId);
|
||||
const hasUnrepresentedTurn = (
|
||||
unsettledTurnIds !== undefined
|
||||
&& Array.from(unsettledTurnIds).some((turnId) => !willSettle(turnId))
|
||||
);
|
||||
const hasUnidentifiedActiveRun = (
|
||||
this.runStartedAtByChatId.has(chatId)
|
||||
&& latestRunTurnId === undefined
|
||||
&& (snapshot === undefined || snapshot.hasPendingToolCalls)
|
||||
);
|
||||
if (hasUnrepresentedTurn || hasUnidentifiedActiveRun) return false;
|
||||
return (
|
||||
this.getRunGeneration(chatId) === expectedRunGeneration
|
||||
|| latestRunIsRepresented
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically accept an HTTP snapshot as completed if no unrepresented run
|
||||
* started while the request was in flight.
|
||||
*
|
||||
* Completed turn ids are fenced even when the snapshot loses the generation
|
||||
* race: delayed websocket frames for older turns must never mutate newer UI.
|
||||
*/
|
||||
reconcileCanonicalCompletion(
|
||||
chatId: string,
|
||||
expectedRunGeneration: number,
|
||||
completedTurnIds: readonly string[],
|
||||
snapshot?: CanonicalRunSnapshot,
|
||||
): boolean {
|
||||
const fences = this.canonicalCompletedTurnIdsByChatId.get(chatId) ?? new Set<string>();
|
||||
for (const turnId of completedTurnIds) {
|
||||
if (!turnId) continue;
|
||||
fences.add(turnId);
|
||||
}
|
||||
while (fences.size > NanobotClient.COMPLETED_TURN_FENCE_MAX) {
|
||||
const oldest = fences.values().next().value;
|
||||
if (typeof oldest !== "string") break;
|
||||
fences.delete(oldest);
|
||||
}
|
||||
if (fences.size > 0) this.canonicalCompletedTurnIdsByChatId.set(chatId, fences);
|
||||
const pendingInbound = this.pendingInboundByChat.get(chatId);
|
||||
if (pendingInbound) {
|
||||
const remaining = pendingInbound.filter((event) => {
|
||||
const turnId = "turn_id" in event && typeof event.turn_id === "string"
|
||||
? event.turn_id
|
||||
: null;
|
||||
return turnId === null || !fences.has(turnId);
|
||||
});
|
||||
if (remaining.length > 0) this.pendingInboundByChat.set(chatId, remaining);
|
||||
else this.pendingInboundByChat.delete(chatId);
|
||||
}
|
||||
|
||||
if (!this.canReconcileCanonicalCompletion(
|
||||
chatId,
|
||||
expectedRunGeneration,
|
||||
[],
|
||||
snapshot,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const completed = new Set(fences);
|
||||
const observed = new Set(
|
||||
snapshot?.observedTurnIds.filter((turnId) => turnId.length > 0) ?? [],
|
||||
);
|
||||
const unsettledTurnIds = this.unsettledRunTurnIdsByChatId.get(chatId);
|
||||
if (unsettledTurnIds) {
|
||||
for (const turnId of [...unsettledTurnIds]) {
|
||||
if (!this.canonicalTurnWillSettle(
|
||||
chatId,
|
||||
turnId,
|
||||
completed,
|
||||
observed,
|
||||
snapshot,
|
||||
)) continue;
|
||||
unsettledTurnIds.delete(turnId);
|
||||
this.clearPendingMessageSend(chatId, turnId);
|
||||
this.runStartedAtByTurnKey.delete(this.runSendKey(chatId, turnId));
|
||||
}
|
||||
if (unsettledTurnIds.size === 0) this.unsettledRunTurnIdsByChatId.delete(chatId);
|
||||
}
|
||||
this.settleNonLifecycleCanonicalSends(chatId, completed, observed, snapshot);
|
||||
if (this.runStartedAtByChatId.delete(chatId)) {
|
||||
this.emitRunStatus(chatId, null);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Last ``goal_state`` payload for *chatId*, if any frame has arrived this connection. */
|
||||
getGoalState(chatId: string): GoalStateWsPayload | undefined {
|
||||
return this.goalStateByChatId.get(chatId);
|
||||
}
|
||||
|
||||
private advanceRunGeneration(chatId: string, turnId?: string): void {
|
||||
this.runGenerationByChatId.set(chatId, this.getRunGeneration(chatId) + 1);
|
||||
if (turnId) {
|
||||
this.latestRunTurnIdByChatId.set(chatId, turnId);
|
||||
const unsettled = this.unsettledRunTurnIdsByChatId.get(chatId) ?? new Set<string>();
|
||||
unsettled.add(turnId);
|
||||
this.unsettledRunTurnIdsByChatId.set(chatId, unsettled);
|
||||
} else {
|
||||
this.latestRunTurnIdByChatId.delete(chatId);
|
||||
}
|
||||
}
|
||||
|
||||
private settleRunTurn(chatId: string, turnId?: string): void {
|
||||
if (!turnId) return;
|
||||
this.clearPendingMessageSend(chatId, turnId);
|
||||
this.runStartedAtByTurnKey.delete(this.runSendKey(chatId, turnId));
|
||||
const unsettled = this.unsettledRunTurnIdsByChatId.get(chatId);
|
||||
if (!unsettled) return;
|
||||
unsettled.delete(turnId);
|
||||
if (unsettled.size === 0) this.unsettledRunTurnIdsByChatId.delete(chatId);
|
||||
}
|
||||
|
||||
private runSendKey(chatId: string, turnId: string): string {
|
||||
return `${chatId}\u0000${turnId}`;
|
||||
}
|
||||
|
||||
private trackPendingMessageSend(
|
||||
chatId: string,
|
||||
turnId: string,
|
||||
startsNewRun: boolean,
|
||||
): void {
|
||||
const key = this.runSendKey(chatId, turnId);
|
||||
this.pendingMessageSends.set(key, {
|
||||
chatId,
|
||||
turnId,
|
||||
startsNewRun,
|
||||
state: "queued",
|
||||
});
|
||||
}
|
||||
|
||||
private clearPendingMessageSend(chatId: string, turnId: string): void {
|
||||
const key = this.runSendKey(chatId, turnId);
|
||||
this.pendingMessageSends.delete(key);
|
||||
this.socketPendingMessageSendKeys.delete(key);
|
||||
this.sendQueue = this.sendQueue.filter((frame) => !(
|
||||
frame.type === "message"
|
||||
&& frame.chat_id === chatId
|
||||
&& frame.turn_id === turnId
|
||||
));
|
||||
}
|
||||
|
||||
private recordRunAcceptance(chatId: string, turnId?: string): void {
|
||||
if (!turnId) return;
|
||||
const key = this.runSendKey(chatId, turnId);
|
||||
const pending = this.pendingMessageSends.get(key);
|
||||
if (!pending) return;
|
||||
this.socketPendingMessageSendKeys.delete(key);
|
||||
if (!pending.startsNewRun) {
|
||||
this.pendingMessageSends.delete(key);
|
||||
return;
|
||||
}
|
||||
pending.state = "accepted";
|
||||
}
|
||||
|
||||
private recordRunRejection(chatId: string, turnId?: string): void {
|
||||
if (!turnId) return;
|
||||
const rejectedLatest = this.latestRunTurnIdByChatId.get(chatId) === turnId;
|
||||
this.settleRunTurn(chatId, turnId);
|
||||
this.prunePendingInboundTurn(chatId, turnId);
|
||||
if (!rejectedLatest) return;
|
||||
|
||||
const unsettled = this.unsettledRunTurnIdsByChatId.get(chatId);
|
||||
const previousTurnId = unsettled ? Array.from(unsettled).at(-1) : undefined;
|
||||
if (previousTurnId) {
|
||||
this.latestRunTurnIdByChatId.set(chatId, previousTurnId);
|
||||
const previousStartedAt = this.runStartedAtByTurnKey.get(
|
||||
this.runSendKey(chatId, previousTurnId),
|
||||
);
|
||||
const currentStartedAt = this.runStartedAtByChatId.get(chatId);
|
||||
if (previousStartedAt === undefined) {
|
||||
if (this.runStartedAtByChatId.delete(chatId)) {
|
||||
this.emitRunStatus(chatId, null);
|
||||
}
|
||||
} else {
|
||||
this.runStartedAtByChatId.set(chatId, previousStartedAt);
|
||||
if (currentStartedAt !== previousStartedAt) {
|
||||
this.emitRunStatus(chatId, previousStartedAt);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.latestRunTurnIdByChatId.delete(chatId);
|
||||
if (this.runStartedAtByChatId.delete(chatId)) {
|
||||
this.emitRunStatus(chatId, null);
|
||||
}
|
||||
}
|
||||
|
||||
private legacyRejectionTarget(ev: Extract<InboundEvent, { event: "error" }>): {
|
||||
chatId: string;
|
||||
turnId: string;
|
||||
} | null {
|
||||
if (!ev.detail || !TURN_REJECTION_DETAILS.has(ev.detail)) return null;
|
||||
if (
|
||||
ev.detail === "workspace_scope_rejected"
|
||||
&& ev.chat_id === undefined
|
||||
&& this.pendingNewChat
|
||||
) return null;
|
||||
const candidates = [...this.pendingMessageSends.values()].filter((pending) => (
|
||||
// A legacy error can only reject a frame currently awaiting its first
|
||||
// server disposition. Accepted or prior-connection unknown sends are
|
||||
// not safe candidates for an uncorrelated frame.
|
||||
pending.state === "sent"
|
||||
&& (ev.chat_id === undefined || pending.chatId === ev.chat_id)
|
||||
));
|
||||
if (candidates.length !== 1) return null;
|
||||
const [candidate] = candidates;
|
||||
if (
|
||||
this.lastSocketMessageSendKey
|
||||
!== this.runSendKey(candidate.chatId, candidate.turnId)
|
||||
) return null;
|
||||
return { chatId: candidate.chatId, turnId: candidate.turnId };
|
||||
}
|
||||
|
||||
private uniqueUnsettledTurnId(chatId: string): string | null {
|
||||
const unsettled = this.unsettledRunTurnIdsByChatId.get(chatId);
|
||||
if (!unsettled || unsettled.size !== 1) return null;
|
||||
return unsettled.values().next().value ?? null;
|
||||
}
|
||||
|
||||
private isCanonicalCompletedTurnEvent(chatId: string, ev: InboundEvent): boolean {
|
||||
const turnId = "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : null;
|
||||
return (
|
||||
turnId !== null
|
||||
&& this.canonicalCompletedTurnIdsByChatId.get(chatId)?.has(turnId) === true
|
||||
);
|
||||
}
|
||||
|
||||
private isSupersededRunCompletion(chatId: string, ev: InboundEvent): boolean {
|
||||
if (
|
||||
ev.event !== "turn_end"
|
||||
&& !(ev.event === "goal_status" && ev.status === "idle")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const turnId = "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : undefined;
|
||||
const latestRunTurnId = this.latestRunTurnIdByChatId.get(chatId);
|
||||
if (turnId === undefined && latestRunTurnId !== undefined) return true;
|
||||
return (
|
||||
turnId !== undefined
|
||||
&& latestRunTurnId !== undefined
|
||||
&& turnId !== latestRunTurnId
|
||||
);
|
||||
}
|
||||
|
||||
private recordRunCompletion(chatId: string, turnId?: string): void {
|
||||
this.settleRunTurn(chatId, turnId);
|
||||
const latestRunTurnId = this.latestRunTurnIdByChatId.get(chatId);
|
||||
const closesCurrentRun = latestRunTurnId === undefined || turnId === latestRunTurnId;
|
||||
if (closesCurrentRun && this.runStartedAtByChatId.delete(chatId)) {
|
||||
this.emitRunStatus(chatId, null);
|
||||
}
|
||||
}
|
||||
|
||||
private recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent): void {
|
||||
if (ev.event === "turn_end") {
|
||||
if (this.runStartedAtByChatId.has(chatId)) {
|
||||
this.runStartedAtByChatId.delete(chatId);
|
||||
this.emitRunStatus(chatId, null);
|
||||
}
|
||||
this.recordRunCompletion(chatId, ev.turn_id);
|
||||
return;
|
||||
}
|
||||
if (ev.event !== "goal_status") return;
|
||||
if (ev.status === "running" && typeof ev.started_at === "number") {
|
||||
this.advanceRunGeneration(chatId, ev.turn_id);
|
||||
if (ev.turn_id) {
|
||||
this.runStartedAtByTurnKey.set(
|
||||
this.runSendKey(chatId, ev.turn_id),
|
||||
ev.started_at,
|
||||
);
|
||||
}
|
||||
const previous = this.runStartedAtByChatId.get(chatId);
|
||||
this.runStartedAtByChatId.set(chatId, ev.started_at);
|
||||
if (previous !== ev.started_at) this.emitRunStatus(chatId, ev.started_at);
|
||||
} else if (this.runStartedAtByChatId.has(chatId)) {
|
||||
this.runStartedAtByChatId.delete(chatId);
|
||||
this.emitRunStatus(chatId, null);
|
||||
} else {
|
||||
this.recordRunCompletion(chatId, ev.turn_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +807,8 @@ export class NanobotClient {
|
||||
quotedContext?: string;
|
||||
workspaceScope?: WorkspaceScopePayload | null;
|
||||
turnId?: string;
|
||||
/** False for side-channel or injected messages that do not own a lifecycle. */
|
||||
startsNewRun?: boolean;
|
||||
},
|
||||
): void {
|
||||
this.knownChats.add(chatId);
|
||||
@@ -405,6 +824,22 @@ export class NanobotClient {
|
||||
...(options?.turnId ? { turn_id: options.turnId } : {}),
|
||||
webui: true,
|
||||
};
|
||||
if (!this.frameFitsTransport(frame)) {
|
||||
if (options?.turnId && isSystemCommandTurnId(options.turnId)) {
|
||||
this.rejectSystemCommand(options.turnId, "message_too_big");
|
||||
}
|
||||
this.emitError({
|
||||
kind: "message_too_big",
|
||||
chatId,
|
||||
...(options?.turnId ? { turnId: options.turnId } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (options?.turnId && !isSystemCommandTurnId(options.turnId)) {
|
||||
const startsNewRun = options.startsNewRun !== false;
|
||||
if (startsNewRun) this.advanceRunGeneration(chatId, options.turnId);
|
||||
this.trackPendingMessageSend(chatId, options.turnId, startsNewRun);
|
||||
}
|
||||
this.queueSend(frame);
|
||||
}
|
||||
|
||||
@@ -442,6 +877,7 @@ export class NanobotClient {
|
||||
if (this.runStartedAtByChatId.size === 0) return;
|
||||
const chatIds = [...this.runStartedAtByChatId.keys()];
|
||||
this.runStartedAtByChatId.clear();
|
||||
this.runStartedAtByTurnKey.clear();
|
||||
for (const chatId of chatIds) this.emitRunStatus(chatId, null);
|
||||
}
|
||||
|
||||
@@ -476,16 +912,61 @@ export class NanobotClient {
|
||||
console.log("[nanobot ws inbound]", summarizeInboundWsPayload(parsed));
|
||||
}
|
||||
|
||||
if (parsed.event === "error" && !parsed.turn_id) {
|
||||
const fallback = this.legacyRejectionTarget(parsed);
|
||||
if (fallback) {
|
||||
parsed = {
|
||||
...parsed,
|
||||
chat_id: parsed.chat_id ?? fallback.chatId,
|
||||
turn_id: fallback.turnId,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (
|
||||
(parsed.event === "goal_status" || parsed.event === "turn_end")
|
||||
&& !parsed.turn_id
|
||||
) {
|
||||
const fallbackTurnId = this.uniqueUnsettledTurnId(parsed.chat_id);
|
||||
if (fallbackTurnId) parsed = { ...parsed, turn_id: fallbackTurnId };
|
||||
}
|
||||
|
||||
const turnId = "turn_id" in parsed && typeof parsed.turn_id === "string"
|
||||
? parsed.turn_id
|
||||
: null;
|
||||
if (parsed.event === "message_accepted") {
|
||||
this.recordRunAcceptance(parsed.chat_id, parsed.turn_id);
|
||||
return;
|
||||
}
|
||||
if (isSystemCommandTurnId(turnId)) {
|
||||
if (parsed.event === "message" || parsed.event === "turn_end") {
|
||||
if (parsed.event === "error") {
|
||||
this.rejectSystemCommand(
|
||||
turnId,
|
||||
[parsed.detail, parsed.reason].filter(Boolean).join(":") || "server error",
|
||||
);
|
||||
} else if (parsed.event === "message" || parsed.event === "turn_end") {
|
||||
this.resolveSystemCommand(turnId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const correlatedChatId = (parsed as { chat_id?: string }).chat_id;
|
||||
if (parsed.event === "error" && correlatedChatId && turnId) {
|
||||
this.recordRunRejection(correlatedChatId, turnId);
|
||||
if (parsed.detail !== "workspace_scope_rejected") {
|
||||
this.emitError({
|
||||
kind: "turn_rejected",
|
||||
detail: parsed.detail,
|
||||
reason: parsed.reason,
|
||||
chatId: correlatedChatId,
|
||||
turnId,
|
||||
});
|
||||
}
|
||||
} else if (parsed.event !== "error" && correlatedChatId && turnId) {
|
||||
// Lifecycle traffic is also an implicit acceptance signal for clients
|
||||
// connected to an older gateway that doesn't emit message_accepted.
|
||||
this.recordRunAcceptance(correlatedChatId, turnId);
|
||||
}
|
||||
|
||||
if (parsed.event === "ready") {
|
||||
this.readyChatId = parsed.chat_id;
|
||||
this.knownChats.add(parsed.chat_id);
|
||||
@@ -528,6 +1009,7 @@ export class NanobotClient {
|
||||
kind: "workspace_scope_rejected",
|
||||
reason: parsed.reason,
|
||||
chatId: parsed.chat_id,
|
||||
turnId: parsed.turn_id,
|
||||
});
|
||||
if (this.pendingNewChat) {
|
||||
clearTimeout(this.pendingNewChat.timer);
|
||||
@@ -546,7 +1028,10 @@ export class NanobotClient {
|
||||
|
||||
const chatId = (parsed as { chat_id?: string }).chat_id;
|
||||
if (chatId) {
|
||||
if (this.isCanonicalCompletedTurnEvent(chatId, parsed)) return;
|
||||
const supersededRunCompletion = this.isSupersededRunCompletion(chatId, parsed);
|
||||
this.recordGoalStatusForRunStrip(chatId, parsed);
|
||||
if (supersededRunCompletion) return;
|
||||
this.recordGoalStateSnapshot(chatId, parsed);
|
||||
this.dispatch(chatId, parsed);
|
||||
}
|
||||
@@ -611,9 +1096,44 @@ export class NanobotClient {
|
||||
// display the error even while the client transparently reconnects.
|
||||
// Browsers populate ``CloseEvent.code`` with the wire-level close code;
|
||||
// 1009 = Message Too Big (server's max frame guard).
|
||||
const unacknowledged = Array.from(this.socketPendingMessageSendKeys)
|
||||
.map((key) => this.pendingMessageSends.get(key))
|
||||
.filter((pending): pending is PendingMessageSend => pending !== undefined);
|
||||
if (event?.code === 1009) {
|
||||
this.emitError({ kind: "message_too_big" });
|
||||
const soleKey = unacknowledged.length === 1
|
||||
? this.runSendKey(unacknowledged[0].chatId, unacknowledged[0].turnId)
|
||||
: null;
|
||||
if (
|
||||
unacknowledged.length === 1
|
||||
&& this.lastSocketMessageSendKey === soleKey
|
||||
) {
|
||||
const [rejected] = unacknowledged;
|
||||
this.recordRunRejection(rejected.chatId, rejected.turnId);
|
||||
this.emitError({
|
||||
kind: "message_too_big",
|
||||
chatId: rejected.chatId,
|
||||
turnId: rejected.turnId,
|
||||
});
|
||||
this.dispatch(rejected.chatId, {
|
||||
event: "error",
|
||||
detail: "message_too_big",
|
||||
chat_id: rejected.chatId,
|
||||
turn_id: rejected.turnId,
|
||||
});
|
||||
} else {
|
||||
// A close frame identifies no offending application message. Never
|
||||
// roll back multiple chats merely because they shared one socket.
|
||||
this.emitError({ kind: "message_too_big" });
|
||||
}
|
||||
}
|
||||
for (const pending of unacknowledged) {
|
||||
const current = this.pendingMessageSends.get(
|
||||
this.runSendKey(pending.chatId, pending.turnId),
|
||||
);
|
||||
if (current?.state === "sent") current.state = "unknown";
|
||||
}
|
||||
this.socketPendingMessageSendKeys.clear();
|
||||
this.lastSocketMessageSendKey = null;
|
||||
if (this.intentionallyClosed || !this.shouldReconnect) {
|
||||
this.setStatus("closed");
|
||||
return;
|
||||
@@ -671,6 +1191,14 @@ export class NanobotClient {
|
||||
pending.resolve();
|
||||
}
|
||||
|
||||
private rejectSystemCommand(turnId: string, detail: string): void {
|
||||
const pending = this.pendingSystemCommands.get(turnId);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingSystemCommands.delete(turnId);
|
||||
pending.reject(new Error(detail));
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
this.clearRunStatusesForReconnect();
|
||||
this.setStatus("reconnecting");
|
||||
@@ -699,10 +1227,25 @@ export class NanobotClient {
|
||||
}
|
||||
}
|
||||
|
||||
private frameFitsTransport(frame: Outbound): boolean {
|
||||
if (this.maxFrameBytes === undefined) return true;
|
||||
return new TextEncoder().encode(JSON.stringify(frame)).byteLength <= this.maxFrameBytes;
|
||||
}
|
||||
|
||||
private rawSend(frame: Outbound): void {
|
||||
if (!this.socket) return;
|
||||
try {
|
||||
this.socket.send(JSON.stringify(frame));
|
||||
this.lastSocketMessageSendKey = null;
|
||||
if (frame.type === "message" && frame.turn_id) {
|
||||
const key = this.runSendKey(frame.chat_id, frame.turn_id);
|
||||
const pending = this.pendingMessageSends.get(key);
|
||||
if (pending) {
|
||||
pending.state = "sent";
|
||||
this.socketPendingMessageSendKeys.add(key);
|
||||
this.lastSocketMessageSendKey = key;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Send failure will materialize as a close; queue the frame for retry.
|
||||
this.sendQueue.push(frame);
|
||||
|
||||
+15
-3
@@ -1082,6 +1082,7 @@ export interface InboundTurnMetadata {
|
||||
export type InboundEvent =
|
||||
| { event: "ready"; chat_id: string; client_id: string }
|
||||
| { event: "attached"; chat_id: string }
|
||||
| { event: "message_accepted"; chat_id: string; turn_id: string }
|
||||
| ({
|
||||
event: "message";
|
||||
chat_id: string;
|
||||
@@ -1149,14 +1150,14 @@ export type InboundEvent =
|
||||
/** Authoritative sustained-goal snapshot for this chat (same shape as ``goal_state`` events). */
|
||||
goal_state?: GoalStateWsPayload;
|
||||
} & InboundTurnMetadata)
|
||||
| {
|
||||
| ({
|
||||
event: "goal_status";
|
||||
chat_id: string;
|
||||
/** Turn executing (user message through agent loop). */
|
||||
status: "running" | "idle";
|
||||
/** Server ``time.time()`` when ``status`` is ``running``. */
|
||||
started_at?: number;
|
||||
}
|
||||
} & InboundTurnMetadata)
|
||||
| {
|
||||
event: "goal_state";
|
||||
chat_id: string;
|
||||
@@ -1175,7 +1176,14 @@ export type InboundEvent =
|
||||
detail?: string;
|
||||
provider?: string;
|
||||
}
|
||||
| { event: "error"; chat_id?: string; detail?: string; reason?: string };
|
||||
| {
|
||||
event: "error";
|
||||
chat_id?: string;
|
||||
detail?: string;
|
||||
reason?: string;
|
||||
/** Present when this error rejects a specific outbound WebUI turn. */
|
||||
turn_id?: string;
|
||||
};
|
||||
|
||||
/** Base64-encoded file attached to an outbound ``message`` envelope.
|
||||
*
|
||||
@@ -1224,7 +1232,11 @@ export interface WebuiThreadPersistedPayload {
|
||||
savedAt?: string;
|
||||
messages: UIMessage[];
|
||||
fork_boundary_message_count?: number;
|
||||
/** Turn ids backed by an explicit persisted ``turn_end`` event. */
|
||||
completed_turn_ids?: string[];
|
||||
has_pending_tool_calls?: boolean;
|
||||
/** Exact active turn when supplied by a current gateway. */
|
||||
active_turn_id?: string | null;
|
||||
page?: WebuiThreadPagePayload;
|
||||
workspace_scope?: WorkspaceScopePayload;
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ describe("webui API helpers", () => {
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -217,6 +217,7 @@ vi.mock("@/lib/nanobot-client", () => {
|
||||
attach = attachSpy;
|
||||
close = vi.fn();
|
||||
updateUrl = updateUrlSpy;
|
||||
updateMaxFrameBytes = vi.fn();
|
||||
}
|
||||
|
||||
return { NanobotClient: MockClient };
|
||||
|
||||
@@ -238,6 +238,891 @@ describe("NanobotClient", () => {
|
||||
expect(handler).toHaveBeenLastCalledWith("chat-strip", null);
|
||||
});
|
||||
|
||||
it("rejects a completed snapshot when a newer run is not represented", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
const requestGeneration = client.getRunGeneration("chat-race");
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-race",
|
||||
status: "running",
|
||||
started_at: 12_345,
|
||||
turn_id: "turn-new",
|
||||
});
|
||||
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion("chat-race", requestGeneration, ["turn-old"]),
|
||||
).toBe(false);
|
||||
expect(client.getRunStartedAt("chat-race")).toBe(12_345);
|
||||
});
|
||||
|
||||
it("rejects a user-only snapshot for a submitted turn that has not completed", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-submitted", "question", undefined, { turnId: "turn-submitted" });
|
||||
const requestGeneration = client.getRunGeneration("chat-submitted");
|
||||
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion("chat-submitted", requestGeneration, []),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not register injected guidance as an independently unsettled run", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-guidance",
|
||||
status: "running",
|
||||
started_at: 12_345,
|
||||
turn_id: "turn-active",
|
||||
});
|
||||
const requestGeneration = client.getRunGeneration("chat-guidance");
|
||||
|
||||
client.sendMessage("chat-guidance", "focus on sources", undefined, {
|
||||
turnId: "turn-guidance",
|
||||
startsNewRun: false,
|
||||
});
|
||||
|
||||
expect(client.getRunGeneration("chat-guidance")).toBe(requestGeneration);
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion(
|
||||
"chat-guidance",
|
||||
requestGeneration,
|
||||
["turn-active"],
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts an explicitly completed turn with no assistant row", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-empty-answer", "question", undefined, {
|
||||
turnId: "turn-empty-answer",
|
||||
});
|
||||
const requestGeneration = client.getRunGeneration("chat-empty-answer");
|
||||
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion(
|
||||
"chat-empty-answer",
|
||||
requestGeneration,
|
||||
["turn-empty-answer"],
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"message_rejected",
|
||||
"attachment_rejected",
|
||||
"workspace_scope_rejected",
|
||||
])("settles a specifically rejected outbound turn (%s)", (detail) => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-rejected", "question", undefined, {
|
||||
turnId: "turn-rejected",
|
||||
});
|
||||
const requestGeneration = client.getRunGeneration("chat-rejected");
|
||||
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion("chat-rejected", requestGeneration, []),
|
||||
).toBe(false);
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "error",
|
||||
chat_id: "chat-rejected",
|
||||
turn_id: "turn-rejected",
|
||||
detail,
|
||||
reason: "policy",
|
||||
});
|
||||
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion("chat-rejected", requestGeneration, []),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not let an older rejection settle or stop a newer run", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-rejection-race", "first", undefined, {
|
||||
turnId: "turn-old",
|
||||
});
|
||||
client.sendMessage("chat-rejection-race", "second", undefined, {
|
||||
turnId: "turn-new",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-rejection-race",
|
||||
status: "running",
|
||||
started_at: 2_000,
|
||||
turn_id: "turn-new",
|
||||
});
|
||||
const requestGeneration = client.getRunGeneration("chat-rejection-race");
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "error",
|
||||
chat_id: "chat-rejection-race",
|
||||
turn_id: "turn-old",
|
||||
detail: "message_rejected",
|
||||
reason: "text_too_large",
|
||||
});
|
||||
|
||||
expect(client.getRunStartedAt("chat-rejection-race")).toBe(2_000);
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion("chat-rejection-race", requestGeneration, []),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("restores the previous turn clock when the newer running turn is rejected", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-reject-newer-clock", "first", undefined, {
|
||||
turnId: "turn-clock-first",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-reject-newer-clock",
|
||||
status: "running",
|
||||
started_at: 1_000,
|
||||
turn_id: "turn-clock-first",
|
||||
});
|
||||
client.sendMessage("chat-reject-newer-clock", "second", undefined, {
|
||||
turnId: "turn-clock-second",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-reject-newer-clock",
|
||||
status: "running",
|
||||
started_at: 2_000,
|
||||
turn_id: "turn-clock-second",
|
||||
});
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "error",
|
||||
chat_id: "chat-reject-newer-clock",
|
||||
turn_id: "turn-clock-second",
|
||||
detail: "message_rejected",
|
||||
});
|
||||
|
||||
expect(client.getRunStartedAt("chat-reject-newer-clock")).toBe(1_000);
|
||||
expect(client.hasUnsettledRun("chat-reject-newer-clock")).toBe(true);
|
||||
});
|
||||
|
||||
it("rolls back lifecycle sends that close 1009 before server acceptance", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
|
||||
client.onError((error) => errors.push(error));
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-too-big", "oversized", undefined, {
|
||||
turnId: "turn-too-big",
|
||||
});
|
||||
const requestGeneration = client.getRunGeneration("chat-too-big");
|
||||
|
||||
lastSocket().fakeCloseWithCode(1009);
|
||||
|
||||
expect(errors).toEqual([{
|
||||
kind: "message_too_big",
|
||||
chatId: "chat-too-big",
|
||||
turnId: "turn-too-big",
|
||||
}]);
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion("chat-too-big", requestGeneration, []),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves an accepted older run when a newer send closes 1009", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
|
||||
client.onError((error) => errors.push(error));
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-too-big-race", "first", undefined, {
|
||||
turnId: "turn-accepted",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "message_accepted",
|
||||
chat_id: "chat-too-big-race",
|
||||
turn_id: "turn-accepted",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-too-big-race",
|
||||
status: "running",
|
||||
started_at: 1_000,
|
||||
turn_id: "turn-accepted",
|
||||
});
|
||||
client.sendMessage("chat-too-big-race", "oversized", undefined, {
|
||||
turnId: "turn-rejected",
|
||||
});
|
||||
const requestGeneration = client.getRunGeneration("chat-too-big-race");
|
||||
|
||||
lastSocket().fakeCloseWithCode(1009);
|
||||
|
||||
expect(errors).toEqual([{
|
||||
kind: "message_too_big",
|
||||
chatId: "chat-too-big-race",
|
||||
turnId: "turn-rejected",
|
||||
}]);
|
||||
expect(client.getRunStartedAt("chat-too-big-race")).toBe(1_000);
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion(
|
||||
"chat-too-big-race",
|
||||
requestGeneration,
|
||||
[],
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not roll back a lifecycle send after its acceptance ACK", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-accepted", "question", undefined, {
|
||||
turnId: "turn-accepted",
|
||||
});
|
||||
const requestGeneration = client.getRunGeneration("chat-accepted");
|
||||
lastSocket().fakeMessage({
|
||||
event: "message_accepted",
|
||||
chat_id: "chat-accepted",
|
||||
turn_id: "turn-accepted",
|
||||
});
|
||||
|
||||
lastSocket().fakeCloseWithCode(1009);
|
||||
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion("chat-accepted", requestGeneration, []),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("preflights exact websocket frame bytes and rejects only the oversized turn", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
maxFrameBytes: 180,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
|
||||
client.onError((error) => errors.push(error));
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
const sentBefore = lastSocket().sent.length;
|
||||
|
||||
client.sendMessage("chat-preflight-size", "x".repeat(500), undefined, {
|
||||
turnId: "turn-preflight-size",
|
||||
});
|
||||
|
||||
expect(lastSocket().sent).toHaveLength(sentBefore);
|
||||
expect(client.hasUnsettledRun("chat-preflight-size")).toBe(false);
|
||||
expect(errors).toEqual([{
|
||||
kind: "message_too_big",
|
||||
chatId: "chat-preflight-size",
|
||||
turnId: "turn-preflight-size",
|
||||
}]);
|
||||
});
|
||||
|
||||
it("does not attribute a fallback 1009 close across multiple unacknowledged chats", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
|
||||
client.onError((error) => errors.push(error));
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-size-a", "first", undefined, { turnId: "turn-size-a" });
|
||||
client.sendMessage("chat-size-b", "second", undefined, { turnId: "turn-size-b" });
|
||||
|
||||
lastSocket().fakeCloseWithCode(1009);
|
||||
|
||||
expect(errors).toEqual([{ kind: "message_too_big" }]);
|
||||
expect(client.hasUnsettledRun("chat-size-a")).toBe(true);
|
||||
expect(client.hasUnsettledRun("chat-size-b")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not attribute 1009 to an unacknowledged message when another frame followed it", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
|
||||
client.onError((error) => errors.push(error));
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-before-audio", "question", undefined, {
|
||||
turnId: "turn-before-audio",
|
||||
});
|
||||
const transcription = client.transcribeAudio("data:audio/webm;base64,AAAA");
|
||||
|
||||
lastSocket().fakeCloseWithCode(1009);
|
||||
|
||||
await expect(transcription).rejects.toThrow("socket closed");
|
||||
expect(errors).toEqual([{ kind: "message_too_big" }]);
|
||||
expect(client.hasUnsettledRun("chat-before-audio")).toBe(true);
|
||||
});
|
||||
|
||||
it("settles an unknown send absent from an idle canonical snapshot after disconnect", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-never-arrived", "question", undefined, {
|
||||
turnId: "turn-never-arrived",
|
||||
});
|
||||
const requestGeneration = client.getRunGeneration("chat-never-arrived");
|
||||
|
||||
lastSocket().close();
|
||||
|
||||
const snapshot = {
|
||||
observedTurnIds: [],
|
||||
hasPendingToolCalls: false,
|
||||
activeTurnId: null,
|
||||
};
|
||||
expect(
|
||||
client.canReconcileCanonicalCompletion(
|
||||
"chat-never-arrived",
|
||||
requestGeneration,
|
||||
[],
|
||||
snapshot,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion(
|
||||
"chat-never-arrived",
|
||||
requestGeneration,
|
||||
[],
|
||||
snapshot,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(client.hasUnsettledRun("chat-never-arrived")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps an ACK-lost observed turn active, then settles it from an idle snapshot", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-ack-lost", "question", undefined, {
|
||||
turnId: "turn-ack-lost",
|
||||
});
|
||||
const requestGeneration = client.getRunGeneration("chat-ack-lost");
|
||||
lastSocket().close();
|
||||
|
||||
expect(
|
||||
client.canReconcileCanonicalCompletion(
|
||||
"chat-ack-lost",
|
||||
requestGeneration,
|
||||
[],
|
||||
{
|
||||
observedTurnIds: ["turn-ack-lost"],
|
||||
hasPendingToolCalls: true,
|
||||
activeTurnId: "turn-ack-lost",
|
||||
},
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion(
|
||||
"chat-ack-lost",
|
||||
requestGeneration,
|
||||
[],
|
||||
{
|
||||
observedTurnIds: ["turn-ack-lost"],
|
||||
hasPendingToolCalls: false,
|
||||
activeTurnId: null,
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
expect(client.hasUnsettledRun("chat-ack-lost")).toBe(false);
|
||||
});
|
||||
|
||||
it("settles an accepted turn that never reached running from canonical idle", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-accepted-idle", "question", undefined, {
|
||||
turnId: "turn-accepted-idle",
|
||||
});
|
||||
const requestGeneration = client.getRunGeneration("chat-accepted-idle");
|
||||
lastSocket().fakeMessage({
|
||||
event: "message_accepted",
|
||||
chat_id: "chat-accepted-idle",
|
||||
turn_id: "turn-accepted-idle",
|
||||
});
|
||||
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion(
|
||||
"chat-accepted-idle",
|
||||
requestGeneration,
|
||||
[],
|
||||
{
|
||||
observedTurnIds: ["turn-accepted-idle"],
|
||||
hasPendingToolCalls: false,
|
||||
activeTurnId: null,
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
expect(client.hasUnsettledRun("chat-accepted-idle")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let a pre-send idle response erase a newly accepted turn", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
const requestGeneration = client.getRunGeneration("chat-stale-idle");
|
||||
client.sendMessage("chat-stale-idle", "question", undefined, {
|
||||
turnId: "turn-after-request",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "message_accepted",
|
||||
chat_id: "chat-stale-idle",
|
||||
turn_id: "turn-after-request",
|
||||
});
|
||||
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion(
|
||||
"chat-stale-idle",
|
||||
requestGeneration,
|
||||
[],
|
||||
{
|
||||
observedTurnIds: [],
|
||||
hasPendingToolCalls: false,
|
||||
activeTurnId: null,
|
||||
},
|
||||
),
|
||||
).toBe(false);
|
||||
expect(client.hasUnsettledRun("chat-stale-idle")).toBe(true);
|
||||
});
|
||||
|
||||
it("correlates a legacy rejection only to one currently sent turn", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
|
||||
client.onError((error) => errors.push(error));
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-legacy-reject", "question", undefined, {
|
||||
turnId: "turn-legacy-reject",
|
||||
});
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "error",
|
||||
chat_id: "chat-legacy-reject",
|
||||
detail: "message_rejected",
|
||||
reason: "text_too_large",
|
||||
});
|
||||
|
||||
expect(client.hasUnsettledRun("chat-legacy-reject")).toBe(false);
|
||||
expect(errors).toEqual([expect.objectContaining({
|
||||
kind: "turn_rejected",
|
||||
chatId: "chat-legacy-reject",
|
||||
turnId: "turn-legacy-reject",
|
||||
})]);
|
||||
});
|
||||
|
||||
it("correlates legacy lifecycle completion when exactly one turn is unsettled", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const handler = vi.fn();
|
||||
client.onChat("chat-legacy-idle", handler);
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-legacy-idle", "question", undefined, {
|
||||
turnId: "turn-legacy-idle",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-legacy-idle",
|
||||
status: "running",
|
||||
started_at: 4321,
|
||||
});
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-legacy-idle",
|
||||
status: "idle",
|
||||
});
|
||||
|
||||
expect(client.hasUnsettledRun("chat-legacy-idle")).toBe(false);
|
||||
expect(client.getRunStartedAt("chat-legacy-idle")).toBeNull();
|
||||
expect(handler).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
event: "goal_status",
|
||||
status: "idle",
|
||||
turn_id: "turn-legacy-idle",
|
||||
}));
|
||||
});
|
||||
|
||||
it("does not apply an uncorrelated legacy idle to multiple unsettled turns", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const handler = vi.fn();
|
||||
client.onChat("chat-legacy-ambiguous", handler);
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-legacy-ambiguous", "first", undefined, {
|
||||
turnId: "turn-legacy-first",
|
||||
});
|
||||
client.sendMessage("chat-legacy-ambiguous", "second", undefined, {
|
||||
turnId: "turn-legacy-second",
|
||||
});
|
||||
handler.mockClear();
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-legacy-ambiguous",
|
||||
status: "idle",
|
||||
});
|
||||
|
||||
expect(client.hasUnsettledRun("chat-legacy-ambiguous")).toBe(true);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not correlate a legacy scope error to an already accepted turn", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
|
||||
client.onError((error) => errors.push(error));
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-legacy-scope", "question", undefined, {
|
||||
turnId: "turn-already-accepted",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "message_accepted",
|
||||
chat_id: "chat-legacy-scope",
|
||||
turn_id: "turn-already-accepted",
|
||||
});
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "error",
|
||||
chat_id: "chat-legacy-scope",
|
||||
detail: "workspace_scope_rejected",
|
||||
reason: "chat_running",
|
||||
});
|
||||
|
||||
expect(client.hasUnsettledRun("chat-legacy-scope")).toBe(true);
|
||||
expect(errors).toEqual([{
|
||||
kind: "workspace_scope_rejected",
|
||||
reason: "chat_running",
|
||||
chatId: "chat-legacy-scope",
|
||||
turnId: undefined,
|
||||
}]);
|
||||
});
|
||||
|
||||
it("does not correlate a scope-control rejection to a preceding unacknowledged message", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-scope-control", "question", undefined, {
|
||||
turnId: "turn-before-scope-control",
|
||||
});
|
||||
client.setWorkspaceScope("chat-scope-control", {
|
||||
project_path: "/tmp/project",
|
||||
project_name: "project",
|
||||
access_mode: "restricted",
|
||||
});
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "error",
|
||||
chat_id: "chat-scope-control",
|
||||
detail: "workspace_scope_rejected",
|
||||
reason: "chat_running",
|
||||
});
|
||||
|
||||
expect(client.hasUnsettledRun("chat-scope-control")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not correlate a new-chat scope rejection to an unrelated sent turn", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-unrelated-scope", "question", undefined, {
|
||||
turnId: "turn-unrelated-scope",
|
||||
});
|
||||
const pendingChat = client.newChat(5_000, {
|
||||
project_path: "/missing",
|
||||
project_name: "missing",
|
||||
access_mode: "restricted",
|
||||
});
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "error",
|
||||
detail: "workspace_scope_rejected",
|
||||
reason: "project_path must be an existing directory",
|
||||
});
|
||||
|
||||
await expect(pendingChat).rejects.toThrow("workspace_scope_rejected");
|
||||
expect(client.hasUnsettledRun("chat-unrelated-scope")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a correlated system command instead of leaving it pending", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
const pending = client.sendSystemCommand("chat-system-reject", "/model invalid");
|
||||
const sent = JSON.parse(lastSocket().sent.at(-1) ?? "{}") as { turn_id?: string };
|
||||
expect(sent.turn_id).toMatch(/^webui-system:/);
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "error",
|
||||
chat_id: "chat-system-reject",
|
||||
turn_id: sent.turn_id,
|
||||
detail: "message_rejected",
|
||||
reason: "invalid_command",
|
||||
});
|
||||
|
||||
await expect(pending).rejects.toThrow("message_rejected:invalid_command");
|
||||
});
|
||||
|
||||
it("ignores a delayed idle event from an older turn after a new run starts", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const chatHandler = vi.fn();
|
||||
const runHandler = vi.fn();
|
||||
client.onChat("chat-delayed-idle", chatHandler);
|
||||
client.onRunStatus(runHandler);
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-delayed-idle",
|
||||
status: "running",
|
||||
started_at: 1_000,
|
||||
turn_id: "turn-old",
|
||||
});
|
||||
client.sendMessage("chat-delayed-idle", "next question", undefined, {
|
||||
turnId: "turn-new",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-delayed-idle",
|
||||
status: "running",
|
||||
started_at: 2_000,
|
||||
turn_id: "turn-new",
|
||||
});
|
||||
chatHandler.mockClear();
|
||||
runHandler.mockClear();
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-delayed-idle",
|
||||
status: "idle",
|
||||
turn_id: "turn-old",
|
||||
});
|
||||
|
||||
expect(client.getRunStartedAt("chat-delayed-idle")).toBe(2_000);
|
||||
expect(runHandler).not.toHaveBeenCalled();
|
||||
expect(chatHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a completed snapshot that represents a delayed running frame", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
const requestGeneration = client.getRunGeneration("chat-delayed-run");
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-delayed-run",
|
||||
status: "running",
|
||||
started_at: 12_345,
|
||||
turn_id: "turn-complete",
|
||||
});
|
||||
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion(
|
||||
"chat-delayed-run",
|
||||
requestGeneration,
|
||||
["turn-complete"],
|
||||
),
|
||||
).toBe(true);
|
||||
expect(client.getRunStartedAt("chat-delayed-run")).toBeNull();
|
||||
});
|
||||
|
||||
it("preflights canonical completion without fencing or settling the turn", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const chatHandler = vi.fn();
|
||||
client.onChat("chat-preflight", chatHandler);
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-preflight", "question", undefined, {
|
||||
turnId: "turn-preflight",
|
||||
});
|
||||
const requestGeneration = client.getRunGeneration("chat-preflight");
|
||||
|
||||
expect(
|
||||
client.canReconcileCanonicalCompletion(
|
||||
"chat-preflight",
|
||||
requestGeneration,
|
||||
["turn-preflight"],
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
client.canReconcileCanonicalCompletion("chat-preflight", requestGeneration, []),
|
||||
).toBe(false);
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "delta",
|
||||
chat_id: "chat-preflight",
|
||||
turn_id: "turn-preflight",
|
||||
text: "still live",
|
||||
});
|
||||
expect(chatHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ event: "delta", text: "still live" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the run cache and fences delayed frames after canonical completion", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const chatHandler = vi.fn();
|
||||
const runHandler = vi.fn();
|
||||
client.onChat("chat-canonical", chatHandler);
|
||||
client.onRunStatus(runHandler);
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-canonical",
|
||||
status: "running",
|
||||
started_at: 12_345,
|
||||
turn_id: "turn-canonical",
|
||||
});
|
||||
const requestGeneration = client.getRunGeneration("chat-canonical");
|
||||
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion(
|
||||
"chat-canonical",
|
||||
requestGeneration,
|
||||
["turn-canonical"],
|
||||
),
|
||||
).toBe(true);
|
||||
expect(client.getRunStartedAt("chat-canonical")).toBeNull();
|
||||
expect(runHandler).toHaveBeenLastCalledWith("chat-canonical", null);
|
||||
const deliveredBeforeLateFrames = chatHandler.mock.calls.length;
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "delta",
|
||||
chat_id: "chat-canonical",
|
||||
text: " delayed",
|
||||
turn_id: "turn-canonical",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "turn_end",
|
||||
chat_id: "chat-canonical",
|
||||
turn_id: "turn-canonical",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-canonical",
|
||||
status: "idle",
|
||||
turn_id: "turn-canonical",
|
||||
});
|
||||
|
||||
expect(chatHandler).toHaveBeenCalledTimes(deliveredBeforeLateFrames);
|
||||
expect(client.getRunStartedAt("chat-canonical")).toBeNull();
|
||||
});
|
||||
|
||||
it("notifies run status subscribers and replays running chats", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,15 +3,20 @@ import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useNanobotStream } from "@/hooks/useNanobotStream";
|
||||
import type { InboundEvent, GoalStateWsPayload } from "@/lib/types";
|
||||
import type { StreamError } from "@/lib/nanobot-client";
|
||||
import type { ConnectionStatus, InboundEvent, GoalStateWsPayload } from "@/lib/types";
|
||||
import { ClientProvider } from "@/providers/ClientProvider";
|
||||
|
||||
const EMPTY_MESSAGES: import("@/lib/types").UIMessage[] = [];
|
||||
|
||||
function fakeClient() {
|
||||
const handlers = new Map<string, Set<(ev: InboundEvent) => void>>();
|
||||
const statusHandlers = new Set<(status: ConnectionStatus) => void>();
|
||||
const errorHandlers = new Set<(error: StreamError) => void>();
|
||||
const runStartedAtByChatId = new Map<string, number>();
|
||||
const unsettledRunByChatId = new Map<string, boolean>();
|
||||
const goalStateByChatId = new Map<string, GoalStateWsPayload>();
|
||||
let status: ConnectionStatus = "open";
|
||||
|
||||
function recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent) {
|
||||
if (ev.event === "turn_end") {
|
||||
@@ -38,10 +43,19 @@ function fakeClient() {
|
||||
|
||||
return {
|
||||
client: {
|
||||
status: "open" as const,
|
||||
get status() {
|
||||
return status;
|
||||
},
|
||||
defaultChatId: null as string | null,
|
||||
onStatus: () => () => {},
|
||||
onError: () => () => {},
|
||||
onStatus(handler: (nextStatus: ConnectionStatus) => void) {
|
||||
statusHandlers.add(handler);
|
||||
handler(status);
|
||||
return () => statusHandlers.delete(handler);
|
||||
},
|
||||
onError(handler: (error: StreamError) => void) {
|
||||
errorHandlers.add(handler);
|
||||
return () => errorHandlers.delete(handler);
|
||||
},
|
||||
getRunStartedAt(chatId: string) {
|
||||
const v = runStartedAtByChatId.get(chatId);
|
||||
return v === undefined ? null : v;
|
||||
@@ -49,6 +63,9 @@ function fakeClient() {
|
||||
getGoalState(chatId: string) {
|
||||
return goalStateByChatId.get(chatId);
|
||||
},
|
||||
hasUnsettledRun(chatId: string) {
|
||||
return unsettledRunByChatId.get(chatId) === true;
|
||||
},
|
||||
onChat(chatId: string, h: (ev: InboundEvent) => void) {
|
||||
let set = handlers.get(chatId);
|
||||
if (!set) {
|
||||
@@ -72,6 +89,16 @@ function fakeClient() {
|
||||
const set = handlers.get(chatId);
|
||||
set?.forEach((h) => h(ev));
|
||||
},
|
||||
emitStatus(nextStatus: ConnectionStatus) {
|
||||
status = nextStatus;
|
||||
statusHandlers.forEach((handler) => handler(status));
|
||||
},
|
||||
emitError(error: StreamError) {
|
||||
errorHandlers.forEach((handler) => handler(error));
|
||||
},
|
||||
setUnsettled(chatId: string, unsettled: boolean) {
|
||||
unsettledRunByChatId.set(chatId, unsettled);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -180,6 +207,64 @@ describe("useNanobotStream", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the turn pending on disconnect without breaking a resumed stream", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream("chat-reconnect", EMPTY_MESSAGES),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-reconnect", {
|
||||
event: "goal_status",
|
||||
chat_id: "chat-reconnect",
|
||||
status: "running",
|
||||
started_at: 1_700,
|
||||
});
|
||||
fake.emit("chat-reconnect", {
|
||||
event: "delta",
|
||||
chat_id: "chat-reconnect",
|
||||
text: "partial",
|
||||
});
|
||||
});
|
||||
await flushStreamFrame();
|
||||
const assistantId = result.current.messages[0].id;
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
|
||||
act(() => fake.emitStatus("reconnecting"));
|
||||
expect(result.current.runStartedAt).toBe(1_700);
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
id: assistantId,
|
||||
content: "partial",
|
||||
isStreaming: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emitStatus("open");
|
||||
fake.emit("chat-reconnect", {
|
||||
event: "goal_status",
|
||||
chat_id: "chat-reconnect",
|
||||
status: "running",
|
||||
started_at: 1_800,
|
||||
});
|
||||
fake.emit("chat-reconnect", {
|
||||
event: "delta",
|
||||
chat_id: "chat-reconnect",
|
||||
text: " resumed",
|
||||
});
|
||||
});
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.runStartedAt).toBe(1_800);
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
id: assistantId,
|
||||
content: "partial resumed",
|
||||
isStreaming: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("flushes pending delta text before turn_end finalizes the turn", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-flush", EMPTY_MESSAGES), {
|
||||
@@ -1596,6 +1681,224 @@ describe("useNanobotStream", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("removes only the optimistic turn named by a correlated rejection", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream("chat-reject-one", EMPTY_MESSAGES),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
let first: ReturnType<typeof result.current.send> = null;
|
||||
let second: ReturnType<typeof result.current.send> = null;
|
||||
act(() => {
|
||||
first = result.current.send("first");
|
||||
second = result.current.send("second");
|
||||
});
|
||||
fake.setUnsettled("chat-reject-one", true);
|
||||
|
||||
act(() => {
|
||||
fake.emitError({
|
||||
kind: "turn_rejected",
|
||||
detail: "message_rejected",
|
||||
chatId: "chat-reject-one",
|
||||
turnId: first!.turnId,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
id: second!.userMessageId,
|
||||
turnId: second!.turnId,
|
||||
content: "second",
|
||||
}),
|
||||
]);
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.streamError).toMatchObject({
|
||||
kind: "turn_rejected",
|
||||
turnId: first!.turnId,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the previous running turn when the newer turn is rejected", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream("chat-reject-new", EMPTY_MESSAGES),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
let first: ReturnType<typeof result.current.send> = null;
|
||||
let second: ReturnType<typeof result.current.send> = null;
|
||||
act(() => {
|
||||
first = result.current.send("first");
|
||||
fake.emit("chat-reject-new", {
|
||||
event: "goal_status",
|
||||
chat_id: "chat-reject-new",
|
||||
status: "running",
|
||||
started_at: 1234,
|
||||
turn_id: first!.turnId,
|
||||
});
|
||||
second = result.current.send("second");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emitError({
|
||||
kind: "turn_rejected",
|
||||
detail: "attachment_rejected",
|
||||
chatId: "chat-reject-new",
|
||||
turnId: second!.turnId,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
id: first!.userMessageId,
|
||||
turnId: first!.turnId,
|
||||
}),
|
||||
]);
|
||||
expect(result.current.runStartedAt).toBe(1234);
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
});
|
||||
|
||||
it("ends the spinner and drops pending stream work when the only turn is rejected", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream("chat-reject-only", EMPTY_MESSAGES),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
let submitted: ReturnType<typeof result.current.send> = null;
|
||||
act(() => {
|
||||
submitted = result.current.send("only");
|
||||
fake.emit("chat-reject-only", {
|
||||
event: "delta",
|
||||
chat_id: "chat-reject-only",
|
||||
turn_id: submitted!.turnId,
|
||||
text: "must not survive",
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emitError({
|
||||
kind: "turn_rejected",
|
||||
detail: "access_denied",
|
||||
chatId: "chat-reject-only",
|
||||
turnId: submitted!.turnId,
|
||||
});
|
||||
});
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toEqual([]);
|
||||
expect(result.current.runStartedAt).toBeNull();
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("applies a correlated rejection replayed through the chat event queue", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream("chat-replayed-reject", EMPTY_MESSAGES),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
let submitted: ReturnType<typeof result.current.send> = null;
|
||||
act(() => {
|
||||
submitted = result.current.send("queued optimistic row");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-replayed-reject", {
|
||||
event: "error",
|
||||
detail: "message_rejected",
|
||||
reason: "policy",
|
||||
chat_id: "chat-replayed-reject",
|
||||
turn_id: submitted!.turnId,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toEqual([]);
|
||||
expect(result.current.streamError).toMatchObject({
|
||||
kind: "turn_rejected",
|
||||
chatId: "chat-replayed-reject",
|
||||
turnId: submitted!.turnId,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show or apply an error correlated to another chat", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream("chat-visible", EMPTY_MESSAGES),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
let submitted: ReturnType<typeof result.current.send> = null;
|
||||
act(() => {
|
||||
submitted = result.current.send("stay");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emitError({
|
||||
kind: "turn_rejected",
|
||||
detail: "message_rejected",
|
||||
chatId: "chat-background",
|
||||
turnId: submitted!.turnId,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].content).toBe("stay");
|
||||
expect(result.current.streamError).toBeNull();
|
||||
});
|
||||
|
||||
it("shows an uncorrelated 1009 fault without rolling back the current turn", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream("chat-generic-1009", EMPTY_MESSAGES),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
act(() => {
|
||||
result.current.send("stay visible");
|
||||
fake.emitError({ kind: "message_too_big" });
|
||||
});
|
||||
|
||||
expect(result.current.messages).toEqual([
|
||||
expect.objectContaining({ role: "user", content: "stay visible" }),
|
||||
]);
|
||||
expect(result.current.streamError).toEqual({ kind: "message_too_big" });
|
||||
});
|
||||
|
||||
it("removes rejected side-channel guidance without stopping the main run", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream("chat-side-reject", EMPTY_MESSAGES),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
let main: ReturnType<typeof result.current.send> = null;
|
||||
let side: ReturnType<typeof result.current.send> = null;
|
||||
act(() => {
|
||||
main = result.current.send("main");
|
||||
fake.emit("chat-side-reject", {
|
||||
event: "goal_status",
|
||||
chat_id: "chat-side-reject",
|
||||
status: "running",
|
||||
started_at: 9876,
|
||||
turn_id: main!.turnId,
|
||||
});
|
||||
side = result.current.send("guidance", undefined, { sideChannel: true });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emitError({
|
||||
kind: "turn_rejected",
|
||||
detail: "message_rejected",
|
||||
chatId: "chat-side-reject",
|
||||
turnId: side!.turnId,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
id: main!.userMessageId,
|
||||
turnId: main!.turnId,
|
||||
}),
|
||||
]);
|
||||
expect(result.current.runStartedAt).toBe(9876);
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
});
|
||||
|
||||
it("adds optimistic user file attachments as media", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-file-send", EMPTY_MESSAGES), {
|
||||
@@ -1801,6 +2104,7 @@ describe("useNanobotStream", () => {
|
||||
const call = fake.client.sendMessage.mock.calls.at(-1)!;
|
||||
const turnId = call[3]?.turnId;
|
||||
expect(call[3]).not.toHaveProperty("sideChannel");
|
||||
expect(call[3]).toMatchObject({ startsNewRun: false });
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
|
||||
act(() => {
|
||||
@@ -1956,6 +2260,7 @@ describe("useNanobotStream", () => {
|
||||
|
||||
const guideCall = fake.client.sendMessage.mock.calls.at(-1)!;
|
||||
expect(guideCall[3]).not.toHaveProperty("continueActiveTurn");
|
||||
expect(guideCall[3]).toMatchObject({ startsNewRun: false });
|
||||
expect(result.current.messages.map((message) => message.content)).toEqual([
|
||||
"research this",
|
||||
"Initial findings",
|
||||
|
||||
@@ -450,6 +450,32 @@ describe("useSessions", () => {
|
||||
expect(result.current.hasPendingToolCalls).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes turn ids backed by persisted completion events", async () => {
|
||||
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
|
||||
schemaVersion: 3,
|
||||
has_pending_tool_calls: false,
|
||||
completed_turn_ids: ["turn-empty", "", "turn-empty"],
|
||||
messages: [
|
||||
{
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: "stop",
|
||||
turnId: "turn-empty",
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessionHistory("websocket:chat-empty"), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.completedTurnIds).toEqual(["turn-empty"]);
|
||||
expect(result.current.hasPendingToolCalls).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flag transcript as pending when last row is not a trace", async () => {
|
||||
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
|
||||
schemaVersion: 3,
|
||||
@@ -520,6 +546,9 @@ describe("useSessions", () => {
|
||||
});
|
||||
expect(result.current.hasMoreBefore).toBe(true);
|
||||
expect(result.current.userMessageOffset).toBe(1);
|
||||
const latestVersion = result.current.version;
|
||||
const latestLineage = result.current.lineage;
|
||||
expect(result.current.continuity).toBe("initial");
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadOlder();
|
||||
@@ -537,6 +566,372 @@ describe("useSessions", () => {
|
||||
]);
|
||||
expect(result.current.hasMoreBefore).toBe(false);
|
||||
expect(result.current.userMessageOffset).toBe(0);
|
||||
expect(result.current.version).toBe(latestVersion);
|
||||
expect(result.current.lineage).toBe(latestLineage);
|
||||
expect(result.current.continuity).toBe("initial");
|
||||
});
|
||||
|
||||
it("preserves a loaded prefix when a canonical latest window overlaps its tail", async () => {
|
||||
vi.mocked(api.fetchWebuiThread)
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
has_pending_tool_calls: true,
|
||||
messages: [
|
||||
{ id: "u2", role: "user", content: "middle question", createdAt: 2 },
|
||||
{ id: "a2", role: "assistant", content: "middle answer", createdAt: 3 },
|
||||
],
|
||||
page: {
|
||||
before_cursor: "cursor-middle",
|
||||
has_more_before: true,
|
||||
loaded_message_count: 2,
|
||||
user_message_offset: 1,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
messages: [
|
||||
{ id: "u1", role: "user", content: "old question", createdAt: 0 },
|
||||
{ id: "a1", role: "assistant", content: "old answer", createdAt: 1 },
|
||||
],
|
||||
page: {
|
||||
before_cursor: null,
|
||||
has_more_before: false,
|
||||
loaded_message_count: 2,
|
||||
user_message_offset: 0,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
has_pending_tool_calls: false,
|
||||
completed_turn_ids: ["turn-3"],
|
||||
messages: [
|
||||
{ id: "a2-replayed", role: "assistant", content: "middle answer", createdAt: 3 },
|
||||
{
|
||||
id: "u3",
|
||||
role: "user",
|
||||
content: "latest question",
|
||||
turnId: "turn-3",
|
||||
createdAt: 4,
|
||||
},
|
||||
{
|
||||
id: "a3",
|
||||
role: "assistant",
|
||||
content: "latest answer",
|
||||
turnId: "turn-3",
|
||||
createdAt: 5,
|
||||
},
|
||||
],
|
||||
page: {
|
||||
before_cursor: "cursor-shifted",
|
||||
has_more_before: true,
|
||||
loaded_message_count: 3,
|
||||
user_message_offset: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessionHistory("websocket:paged-refresh"), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
await act(async () => {
|
||||
await result.current.loadOlder();
|
||||
});
|
||||
const loadedVersion = result.current.version;
|
||||
const loadedLineage = result.current.lineage;
|
||||
|
||||
act(() => result.current.refresh());
|
||||
await waitFor(() => expect(result.current.version).toBeGreaterThan(loadedVersion));
|
||||
|
||||
expect(result.current.messages.map((message) => message.id)).toEqual([
|
||||
"u1",
|
||||
"a1",
|
||||
"u2",
|
||||
"a2-replayed",
|
||||
"u3",
|
||||
"a3",
|
||||
]);
|
||||
expect(result.current.hasMoreBefore).toBe(false);
|
||||
expect(result.current.userMessageOffset).toBe(0);
|
||||
expect(result.current.hasPendingToolCalls).toBe(false);
|
||||
expect(result.current.completedTurnIds).toEqual(["turn-3"]);
|
||||
expect(result.current.continuity).toBe("overlap");
|
||||
expect(result.current.lineage).toBe(loadedLineage);
|
||||
});
|
||||
|
||||
it("starts a new lineage when more than 160 new rows remove all latest-page overlap", async () => {
|
||||
const oldWindow = Array.from({ length: 160 }, (_, index) => ({
|
||||
id: `old-${index}`,
|
||||
role: index % 2 === 0 ? "user" as const : "assistant" as const,
|
||||
content: `old window row ${index}`,
|
||||
turnId: `old-turn-${Math.floor(index / 2)}`,
|
||||
createdAt: index,
|
||||
}));
|
||||
const newWindow = Array.from({ length: 160 }, (_, index) => ({
|
||||
id: `new-${index}`,
|
||||
role: index % 2 === 0 ? "user" as const : "assistant" as const,
|
||||
content: `new window row ${index}`,
|
||||
turnId: `new-turn-${Math.floor(index / 2)}`,
|
||||
createdAt: 1_000 + index,
|
||||
}));
|
||||
vi.mocked(api.fetchWebuiThread)
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
messages: oldWindow,
|
||||
page: {
|
||||
before_cursor: "old-window-cursor",
|
||||
has_more_before: true,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
messages: newWindow,
|
||||
page: {
|
||||
before_cursor: "new-window-cursor",
|
||||
has_more_before: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessionHistory("websocket:window-reset"), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
const initialLineage = result.current.lineage;
|
||||
expect(result.current.messages[0]?.id).toBe("old-0");
|
||||
|
||||
act(() => result.current.refresh());
|
||||
await waitFor(() => expect(result.current.messages[0]?.id).toBe("new-0"));
|
||||
|
||||
expect(result.current.messages).toHaveLength(160);
|
||||
expect(result.current.messages.at(-1)?.id).toBe("new-159");
|
||||
expect(result.current.continuity).toBe("reset");
|
||||
expect(result.current.lineage).toBeGreaterThan(initialLineage);
|
||||
expect(result.current.hasMoreBefore).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the longest consecutive semantic overlap for legacy unstable replay metadata", async () => {
|
||||
vi.mocked(api.fetchWebuiThread)
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
messages: [
|
||||
{ id: "repeat-1-old", role: "user", content: "repeat", createdAt: 10 },
|
||||
{ id: "answer-1-old", role: "assistant", content: "first answer", createdAt: 11 },
|
||||
{ id: "repeat-2-old", role: "user", content: "repeat", createdAt: 12 },
|
||||
{ id: "answer-2-old", role: "assistant", content: "second answer", createdAt: 13 },
|
||||
],
|
||||
page: {
|
||||
before_cursor: "legacy-cursor",
|
||||
has_more_before: true,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
messages: [
|
||||
{ id: "old-prefix", role: "user", content: "old prefix", createdAt: 1 },
|
||||
],
|
||||
page: {
|
||||
before_cursor: null,
|
||||
has_more_before: false,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
messages: [
|
||||
{ id: "repeat-2-new", role: "user", content: "repeat", createdAt: 9_012 },
|
||||
{ id: "answer-2-new", role: "assistant", content: "second answer", createdAt: 9_013 },
|
||||
{ id: "new-tail", role: "assistant", content: "new tail", createdAt: 9_014 },
|
||||
],
|
||||
page: {
|
||||
before_cursor: "shifted-legacy-cursor",
|
||||
has_more_before: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessionHistory("websocket:legacy-overlap"), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
await act(async () => {
|
||||
await result.current.loadOlder();
|
||||
});
|
||||
const lineage = result.current.lineage;
|
||||
|
||||
act(() => result.current.refresh());
|
||||
await waitFor(() => expect(result.current.messages.at(-1)?.id).toBe("new-tail"));
|
||||
|
||||
expect(result.current.messages.map((message) => message.id)).toEqual([
|
||||
"old-prefix",
|
||||
"repeat-1-old",
|
||||
"answer-1-old",
|
||||
"repeat-2-new",
|
||||
"answer-2-new",
|
||||
"new-tail",
|
||||
]);
|
||||
expect(result.current.continuity).toBe("overlap");
|
||||
expect(result.current.lineage).toBe(lineage);
|
||||
});
|
||||
|
||||
it("ignores an older-page response after a latest refresh resets its lineage", async () => {
|
||||
let resolveOlder:
|
||||
| ((value: Awaited<ReturnType<typeof api.fetchWebuiThread>>) => void)
|
||||
| null = null;
|
||||
vi.mocked(api.fetchWebuiThread)
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
messages: [
|
||||
{ id: "old-latest", role: "assistant", content: "old latest", createdAt: 10 },
|
||||
],
|
||||
page: {
|
||||
before_cursor: "cursor-old-lineage",
|
||||
has_more_before: true,
|
||||
},
|
||||
})
|
||||
.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveOlder = resolve;
|
||||
}))
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
messages: [
|
||||
{ id: "new-latest", role: "assistant", content: "new latest", createdAt: 20 },
|
||||
],
|
||||
page: {
|
||||
before_cursor: "cursor-new-lineage",
|
||||
has_more_before: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessionHistory("websocket:paged-race"), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
const oldLineage = result.current.lineage;
|
||||
let olderRequest: Promise<void> | undefined;
|
||||
act(() => {
|
||||
olderRequest = result.current.loadOlder();
|
||||
});
|
||||
await waitFor(() => expect(api.fetchWebuiThread).toHaveBeenCalledTimes(2));
|
||||
|
||||
act(() => result.current.refresh());
|
||||
await waitFor(() => expect(result.current.messages[0]?.id).toBe("new-latest"));
|
||||
expect(result.current.continuity).toBe("reset");
|
||||
expect(result.current.lineage).toBeGreaterThan(oldLineage);
|
||||
|
||||
await act(async () => {
|
||||
resolveOlder?.({
|
||||
schemaVersion: 3,
|
||||
messages: [
|
||||
{ id: "stale-prefix", role: "user", content: "stale prefix", createdAt: 1 },
|
||||
],
|
||||
page: {
|
||||
before_cursor: null,
|
||||
has_more_before: false,
|
||||
},
|
||||
});
|
||||
await olderRequest;
|
||||
});
|
||||
|
||||
expect(result.current.messages.map((message) => message.id)).toEqual(["new-latest"]);
|
||||
expect(result.current.hasMoreBefore).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves authoritative active state while prepending older history", async () => {
|
||||
vi.mocked(api.fetchWebuiThread)
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
has_pending_tool_calls: true,
|
||||
messages: [
|
||||
{ id: "u2", role: "user", content: "current question", createdAt: 2 },
|
||||
{ id: "a2", role: "assistant", content: "partial answer", createdAt: 3 },
|
||||
],
|
||||
page: {
|
||||
before_cursor: "cursor-active",
|
||||
has_more_before: true,
|
||||
loaded_message_count: 2,
|
||||
user_message_offset: 1,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
has_pending_tool_calls: false,
|
||||
messages: [
|
||||
{ id: "u1", role: "user", content: "old question", createdAt: 0 },
|
||||
{ id: "a1", role: "assistant", content: "old answer", createdAt: 1 },
|
||||
],
|
||||
page: {
|
||||
before_cursor: null,
|
||||
has_more_before: false,
|
||||
loaded_message_count: 2,
|
||||
user_message_offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessionHistory("websocket:paged-active"), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.hasPendingToolCalls).toBe(true);
|
||||
const latestVersion = result.current.version;
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadOlder();
|
||||
});
|
||||
|
||||
expect(result.current.hasPendingToolCalls).toBe(true);
|
||||
expect(result.current.version).toBe(latestVersion);
|
||||
});
|
||||
|
||||
it("preserves authoritative completed state while prepending trace history", async () => {
|
||||
vi.mocked(api.fetchWebuiThread)
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
has_pending_tool_calls: false,
|
||||
messages: [
|
||||
{
|
||||
id: "t2",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "completed trace",
|
||||
traces: ["completed trace"],
|
||||
createdAt: 2,
|
||||
},
|
||||
],
|
||||
page: {
|
||||
before_cursor: "cursor-complete",
|
||||
has_more_before: true,
|
||||
loaded_message_count: 1,
|
||||
user_message_offset: 1,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
messages: [
|
||||
{ id: "u1", role: "user", content: "old question", createdAt: 0 },
|
||||
{ id: "a1", role: "assistant", content: "old answer", createdAt: 1 },
|
||||
],
|
||||
page: {
|
||||
before_cursor: null,
|
||||
has_more_before: false,
|
||||
loaded_message_count: 2,
|
||||
user_message_offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessionHistory("websocket:paged-complete"), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.hasPendingToolCalls).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadOlder();
|
||||
});
|
||||
|
||||
expect(result.current.hasPendingToolCalls).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the session in the list when delete fails", async () => {
|
||||
|
||||
Reference in New Issue
Block a user