fix(webui): reconcile threads after browser resume

This commit is contained in:
chengyongru
2026-07-28 16:25:08 +08:00
committed by chengyongru
parent 78cf68c291
commit ae089aa3ae
39 changed files with 6151 additions and 243 deletions
+3
View File
@@ -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.
+377 -46
View File
@@ -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],
);
+121 -12
View File
@@ -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
View File
@@ -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,
};
}
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -1237,6 +1237,10 @@
"workspaceScopeRejected": {
"title": "Lespace de travail na pas changé",
"body": "La passerelle a refusé le projet ou le mode daccès demandé ; Nanobot a conservé lespace de travail précédent."
},
"turnRejected": {
"title": "Le message na pas été envoyé",
"body": "La passerelle a refusé ce message. Vérifiez le texte ou les pièces jointes, puis réessayez."
}
},
"workspace": {
+4
View File
@@ -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": {
+4
View File
@@ -1237,6 +1237,10 @@
"workspaceScopeRejected": {
"title": "ワークスペースは変更されませんでした",
"body": "要求されたプロジェクトまたはアクセスモードがゲートウェイで拒否されたため、Nanobot は以前のワークスペースをそのまま使用しています。"
},
"turnRejected": {
"title": "メッセージは送信されませんでした",
"body": "ゲートウェイがこのメッセージを拒否しました。本文または添付ファイルを確認して、もう一度お試しください。"
}
},
"workspace": {
+4
View File
@@ -1237,6 +1237,10 @@
"workspaceScopeRejected": {
"title": "작업공간이 변경되지 않았습니다",
"body": "요청한 프로젝트 또는 접근 모드가 게이트웨이에서 거부되어 Nanobot이 이전 작업공간을 계속 사용합니다."
},
"turnRejected": {
"title": "메시지가 전송되지 않았습니다",
"body": "게이트웨이가 이 메시지를 거부했습니다. 텍스트나 첨부 파일을 확인한 후 다시 시도하세요."
}
},
"workspace": {
+4
View File
@@ -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": {
+4
View File
@@ -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": {
+4
View File
@@ -1251,6 +1251,10 @@
"workspaceScopeRejected": {
"title": "工作区未更改",
"body": "网关拒绝了请求的项目或访问权限,Nanobot 已继续使用之前的工作区。"
},
"turnRejected": {
"title": "消息未发送",
"body": "网关拒绝了这条消息。请检查消息内容或附件后重试。"
}
},
"workspace": {
+4
View File
@@ -1237,6 +1237,10 @@
"workspaceScopeRejected": {
"title": "工作區未變更",
"body": "閘道拒絕要求的專案或存取模式,因此 Nanobot 繼續使用先前的工作區。"
},
"turnRejected": {
"title": "訊息未傳送",
"body": "閘道拒絕了這則訊息。請檢查內容或附件後再試一次。"
}
},
"workspace": {
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+1
View File
@@ -77,6 +77,7 @@ describe("webui API helpers", () => {
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
credentials: "same-origin",
cache: "no-store",
}),
);
});
+1
View File
@@ -217,6 +217,7 @@ vi.mock("@/lib/nanobot-client", () => {
attach = attachSpy;
close = vi.fn();
updateUrl = updateUrlSpy;
updateMaxFrameBytes = vi.fn();
}
return { NanobotClient: MockClient };
+885
View File
@@ -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
+309 -4
View File
@@ -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",
+395
View File
@@ -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 () => {