mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-30 21:59:46 +02:00
feat(chat): implement pre-accept failure handling and unified retry messaging for chat operations, enhancing user experience and error management
This commit is contained in:
parent
35ea0eae53
commit
f60e742fac
3 changed files with 229 additions and 40 deletions
|
|
@ -189,3 +189,75 @@ def test_premium_classification_is_error_code_driven():
|
||||||
assert "RATE_LIMIT_KEYWORDS" not in source
|
assert "RATE_LIMIT_KEYWORDS" not in source
|
||||||
assert "normalized.includes(" not in source
|
assert "normalized.includes(" not in source
|
||||||
assert 'if (errorCode === "PREMIUM_QUOTA_EXHAUSTED") {' in source
|
assert 'if (errorCode === "PREMIUM_QUOTA_EXHAUSTED") {' in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_terminal_error_handler_has_pre_accept_soft_rollback_hook():
|
||||||
|
page_path = (
|
||||||
|
Path(__file__).resolve().parents[3]
|
||||||
|
/ "surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx"
|
||||||
|
)
|
||||||
|
source = page_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "onPreAcceptFailure?: () => Promise<void>;" in source
|
||||||
|
assert "if (!accepted) {" in source
|
||||||
|
assert "await onPreAcceptFailure?.();" in source
|
||||||
|
assert "await onAcceptedStreamError?.();" in source
|
||||||
|
assert "setMessages((prev) => prev.filter((m) => m.id !== userMsgId));" in source
|
||||||
|
assert "setMessageDocumentsMap((prev) => {" in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_toast_only_pre_accept_policy_has_no_inline_failed_marker():
|
||||||
|
user_message_path = (
|
||||||
|
Path(__file__).resolve().parents[3] / "surfsense_web/components/assistant-ui/user-message.tsx"
|
||||||
|
)
|
||||||
|
source = user_message_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "Not sent. Edit and retry." not in source
|
||||||
|
assert "failed_pre_accept" not in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_network_send_failures_use_unified_retry_toast_message():
|
||||||
|
classifier_path = Path(__file__).resolve().parents[3] / "surfsense_web/lib/chat/chat-error-classifier.ts"
|
||||||
|
classifier_source = classifier_path.read_text(encoding="utf-8")
|
||||||
|
page_path = (
|
||||||
|
Path(__file__).resolve().parents[3]
|
||||||
|
/ "surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx"
|
||||||
|
)
|
||||||
|
page_source = page_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert '"send_failed_pre_accept"' in classifier_source
|
||||||
|
assert 'errorCode === "SEND_FAILED_PRE_ACCEPT"' in classifier_source
|
||||||
|
assert "if (withCode.code) return withCode.code;" in classifier_source
|
||||||
|
assert 'userMessage: "Message not sent. Please retry."' in classifier_source
|
||||||
|
assert 'userMessage: "Connection issue. Please try again."' in classifier_source
|
||||||
|
assert "tagPreAcceptSendFailure(error)" in page_source
|
||||||
|
assert 'existingCode === "THREAD_BUSY"' in page_source
|
||||||
|
assert 'existingCode === "AUTH_EXPIRED"' in page_source
|
||||||
|
assert 'existingCode === "UNAUTHORIZED"' in page_source
|
||||||
|
assert 'existingCode === "RATE_LIMITED"' in page_source
|
||||||
|
assert 'errorCode: "SEND_FAILED_PRE_ACCEPT"' in page_source
|
||||||
|
assert 'errorCode: "NETWORK_ERROR"' not in page_source
|
||||||
|
assert "Failed to start chat. Please try again." not in page_source
|
||||||
|
|
||||||
|
|
||||||
|
def test_pre_post_accept_abort_contract_exists_for_new_resume_regenerate_flows():
|
||||||
|
page_path = (
|
||||||
|
Path(__file__).resolve().parents[3]
|
||||||
|
/ "surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx"
|
||||||
|
)
|
||||||
|
source = page_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Each flow tracks accepted boundary and passes it into shared terminal handling.
|
||||||
|
assert "let newAccepted = false;" in source
|
||||||
|
assert "let resumeAccepted = false;" in source
|
||||||
|
assert "let regenerateAccepted = false;" in source
|
||||||
|
assert "accepted: newAccepted," in source
|
||||||
|
assert "accepted: resumeAccepted," in source
|
||||||
|
assert "accepted: regenerateAccepted," in source
|
||||||
|
|
||||||
|
# Pre-accept abort in resume/regenerate exits without persistence.
|
||||||
|
assert "if (!resumeAccepted) return;" in source
|
||||||
|
assert "if (!regenerateAccepted) return;" in source
|
||||||
|
|
||||||
|
# New flow persists only when accepted and not already persisted.
|
||||||
|
assert "if (newAccepted && !userPersisted) {" in source
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,26 @@ async function toHttpResponseError(response: Response): Promise<Error & { errorC
|
||||||
return Object.assign(new Error(message), { errorCode });
|
return Object.assign(new Error(message), { errorCode });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tagPreAcceptSendFailure(error: unknown): unknown {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const withCode = error as Error & { errorCode?: string; code?: string };
|
||||||
|
const existingCode = withCode.errorCode ?? withCode.code;
|
||||||
|
if (
|
||||||
|
existingCode === "THREAD_BUSY" ||
|
||||||
|
existingCode === "AUTH_EXPIRED" ||
|
||||||
|
existingCode === "UNAUTHORIZED" ||
|
||||||
|
existingCode === "RATE_LIMITED"
|
||||||
|
) {
|
||||||
|
return Object.assign(error, { errorCode: existingCode });
|
||||||
|
}
|
||||||
|
return Object.assign(error, { errorCode: "SEND_FAILED_PRE_ACCEPT" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.assign(new Error("Failed to send message before stream acceptance"), {
|
||||||
|
errorCode: "SEND_FAILED_PRE_ACCEPT",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zod schema for mentioned document info (for type-safe parsing)
|
* Zod schema for mentioned document info (for type-safe parsing)
|
||||||
*/
|
*/
|
||||||
|
|
@ -610,6 +630,7 @@ export default function NewChatPage() {
|
||||||
assistantMsgId,
|
assistantMsgId,
|
||||||
accepted,
|
accepted,
|
||||||
onAbort,
|
onAbort,
|
||||||
|
onPreAcceptFailure,
|
||||||
onAcceptedStreamError,
|
onAcceptedStreamError,
|
||||||
}: {
|
}: {
|
||||||
error: unknown;
|
error: unknown;
|
||||||
|
|
@ -618,6 +639,7 @@ export default function NewChatPage() {
|
||||||
assistantMsgId: string;
|
assistantMsgId: string;
|
||||||
accepted: boolean;
|
accepted: boolean;
|
||||||
onAbort?: () => Promise<void>;
|
onAbort?: () => Promise<void>;
|
||||||
|
onPreAcceptFailure?: () => Promise<void>;
|
||||||
onAcceptedStreamError?: () => Promise<void>;
|
onAcceptedStreamError?: () => Promise<void>;
|
||||||
}) => {
|
}) => {
|
||||||
if (error instanceof Error && error.name === "AbortError") {
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
|
|
@ -625,12 +647,14 @@ export default function NewChatPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accepted) {
|
if (!accepted) {
|
||||||
|
await onPreAcceptFailure?.();
|
||||||
|
} else {
|
||||||
await onAcceptedStreamError?.();
|
await onAcceptedStreamError?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
await handleChatFailure({
|
await handleChatFailure({
|
||||||
error,
|
error: !accepted ? tagPreAcceptSendFailure(error) : error,
|
||||||
flow,
|
flow,
|
||||||
threadId,
|
threadId,
|
||||||
assistantMsgId: accepted ? assistantMsgId : "no-persist-assistant",
|
assistantMsgId: accepted ? assistantMsgId : "no-persist-assistant",
|
||||||
|
|
@ -863,7 +887,12 @@ export default function NewChatPage() {
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[NewChatPage] Failed to create thread:", error);
|
console.error("[NewChatPage] Failed to create thread:", error);
|
||||||
toast.error("Failed to start chat. Please try again.");
|
await handleChatFailure({
|
||||||
|
error: tagPreAcceptSendFailure(error),
|
||||||
|
flow: "new",
|
||||||
|
threadId: currentThreadId,
|
||||||
|
assistantMsgId: "no-persist-assistant",
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -948,27 +977,6 @@ export default function NewChatPage() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
appendMessage(currentThreadId, {
|
|
||||||
role: "user",
|
|
||||||
content: persistContent,
|
|
||||||
})
|
|
||||||
.then((savedMessage) => {
|
|
||||||
const newUserMsgId = `msg-${savedMessage.id}`;
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((m) => (m.id === userMsgId ? { ...m, id: newUserMsgId } : m))
|
|
||||||
);
|
|
||||||
setMessageDocumentsMap((prev) => {
|
|
||||||
const docs = prev[userMsgId];
|
|
||||||
if (!docs) return prev;
|
|
||||||
const { [userMsgId]: _, ...rest } = prev;
|
|
||||||
return { ...rest, [newUserMsgId]: docs };
|
|
||||||
});
|
|
||||||
if (isNewThread) {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["threads", String(searchSpaceId)] });
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => console.error("Failed to persist user message:", err));
|
|
||||||
|
|
||||||
// Start streaming response
|
// Start streaming response
|
||||||
setIsRunning(true);
|
setIsRunning(true);
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
@ -988,17 +996,7 @@ export default function NewChatPage() {
|
||||||
let wasInterrupted = false;
|
let wasInterrupted = false;
|
||||||
let tokenUsageData: Record<string, unknown> | null = null;
|
let tokenUsageData: Record<string, unknown> | null = null;
|
||||||
let newAccepted = false;
|
let newAccepted = false;
|
||||||
|
let userPersisted = false;
|
||||||
// Add placeholder assistant message
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: assistantMsgId,
|
|
||||||
role: "assistant",
|
|
||||||
content: [{ type: "text", text: "" }],
|
|
||||||
createdAt: new Date(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||||
|
|
@ -1062,6 +1060,15 @@ export default function NewChatPage() {
|
||||||
throw await toHttpResponseError(response);
|
throw await toHttpResponseError(response);
|
||||||
}
|
}
|
||||||
newAccepted = true;
|
newAccepted = true;
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: assistantMsgId,
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "" }],
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
const flushMessages = () => {
|
const flushMessages = () => {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
|
|
@ -1224,6 +1231,20 @@ export default function NewChatPage() {
|
||||||
// Skip persistence for interrupted messages -- handleResume will persist the final version
|
// Skip persistence for interrupted messages -- handleResume will persist the final version
|
||||||
const finalContent = buildContentForPersistence(contentPartsState, toolsWithUI);
|
const finalContent = buildContentForPersistence(contentPartsState, toolsWithUI);
|
||||||
if (contentParts.length > 0 && !wasInterrupted) {
|
if (contentParts.length > 0 && !wasInterrupted) {
|
||||||
|
if (!userPersisted) {
|
||||||
|
const persistedUserMsgId = await persistUserTurn({
|
||||||
|
threadId: currentThreadId,
|
||||||
|
userMsgId,
|
||||||
|
content: persistContent,
|
||||||
|
mentionedDocs: allMentionedDocs,
|
||||||
|
logContext: "new chat",
|
||||||
|
});
|
||||||
|
userPersisted = Boolean(persistedUserMsgId);
|
||||||
|
if (userPersisted && isNewThread) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["threads", String(searchSpaceId)] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await persistAssistantTurn({
|
await persistAssistantTurn({
|
||||||
threadId: currentThreadId,
|
threadId: currentThreadId,
|
||||||
assistantMsgId,
|
assistantMsgId,
|
||||||
|
|
@ -1251,6 +1272,20 @@ export default function NewChatPage() {
|
||||||
assistantMsgId,
|
assistantMsgId,
|
||||||
accepted: newAccepted,
|
accepted: newAccepted,
|
||||||
onAbort: async () => {
|
onAbort: async () => {
|
||||||
|
if (newAccepted && !userPersisted) {
|
||||||
|
const persistedUserMsgId = await persistUserTurn({
|
||||||
|
threadId: currentThreadId,
|
||||||
|
userMsgId,
|
||||||
|
content: persistContent,
|
||||||
|
mentionedDocs: allMentionedDocs,
|
||||||
|
logContext: "new chat (aborted)",
|
||||||
|
});
|
||||||
|
userPersisted = Boolean(persistedUserMsgId);
|
||||||
|
if (userPersisted && isNewThread) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["threads", String(searchSpaceId)] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Request was cancelled by user - persist partial response if any content was received
|
// Request was cancelled by user - persist partial response if any content was received
|
||||||
const hasContent = contentParts.some(
|
const hasContent = contentParts.some(
|
||||||
(part) =>
|
(part) =>
|
||||||
|
|
@ -1267,6 +1302,29 @@ export default function NewChatPage() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onAcceptedStreamError: async () => {
|
||||||
|
if (!userPersisted) {
|
||||||
|
const persistedUserMsgId = await persistUserTurn({
|
||||||
|
threadId: currentThreadId,
|
||||||
|
userMsgId,
|
||||||
|
content: persistContent,
|
||||||
|
mentionedDocs: allMentionedDocs,
|
||||||
|
logContext: "new chat (stream error)",
|
||||||
|
});
|
||||||
|
userPersisted = Boolean(persistedUserMsgId);
|
||||||
|
if (userPersisted && isNewThread) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["threads", String(searchSpaceId)] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPreAcceptFailure: async () => {
|
||||||
|
setMessages((prev) => prev.filter((m) => m.id !== userMsgId));
|
||||||
|
setMessageDocumentsMap((prev) => {
|
||||||
|
if (!(userMsgId in prev)) return prev;
|
||||||
|
const { [userMsgId]: _removed, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsRunning(false);
|
setIsRunning(false);
|
||||||
|
|
@ -1291,7 +1349,9 @@ export default function NewChatPage() {
|
||||||
setPendingUserImageUrls,
|
setPendingUserImageUrls,
|
||||||
toolsWithUI,
|
toolsWithUI,
|
||||||
handleStreamTerminalError,
|
handleStreamTerminalError,
|
||||||
|
handleChatFailure,
|
||||||
persistAssistantTurn,
|
persistAssistantTurn,
|
||||||
|
persistUserTurn,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1548,6 +1608,22 @@ export default function NewChatPage() {
|
||||||
threadId: resumeThreadId,
|
threadId: resumeThreadId,
|
||||||
assistantMsgId,
|
assistantMsgId,
|
||||||
accepted: resumeAccepted,
|
accepted: resumeAccepted,
|
||||||
|
onAbort: async () => {
|
||||||
|
if (!resumeAccepted) return;
|
||||||
|
const hasContent = contentParts.some(
|
||||||
|
(part) =>
|
||||||
|
(part.type === "text" && part.text.length > 0) ||
|
||||||
|
(part.type === "tool-call" && toolsWithUI.has(part.toolName))
|
||||||
|
);
|
||||||
|
if (!hasContent) return;
|
||||||
|
const partialContent = buildContentForPersistence(contentPartsState, toolsWithUI);
|
||||||
|
await persistAssistantTurn({
|
||||||
|
threadId: resumeThreadId,
|
||||||
|
assistantMsgId,
|
||||||
|
content: partialContent,
|
||||||
|
logContext: "partial resumed chat",
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsRunning(false);
|
setIsRunning(false);
|
||||||
|
|
@ -1882,6 +1958,33 @@ export default function NewChatPage() {
|
||||||
threadId,
|
threadId,
|
||||||
assistantMsgId,
|
assistantMsgId,
|
||||||
accepted: regenerateAccepted,
|
accepted: regenerateAccepted,
|
||||||
|
onAbort: async () => {
|
||||||
|
if (!regenerateAccepted) return;
|
||||||
|
if (!userPersisted) {
|
||||||
|
const persistedUserMsgId = await persistUserTurn({
|
||||||
|
threadId,
|
||||||
|
userMsgId,
|
||||||
|
content: userContentToPersist,
|
||||||
|
mentionedDocs: sourceMentionedDocs,
|
||||||
|
logContext: "regenerated (aborted)",
|
||||||
|
});
|
||||||
|
userPersisted = Boolean(persistedUserMsgId);
|
||||||
|
}
|
||||||
|
const hasContent = contentParts.some(
|
||||||
|
(part) =>
|
||||||
|
(part.type === "text" && part.text.length > 0) ||
|
||||||
|
(part.type === "tool-call" && toolsWithUI.has(part.toolName))
|
||||||
|
);
|
||||||
|
if (!hasContent) return;
|
||||||
|
const partialContent = buildContentForPersistence(contentPartsState, toolsWithUI);
|
||||||
|
await persistAssistantTurn({
|
||||||
|
threadId,
|
||||||
|
assistantMsgId,
|
||||||
|
content: partialContent,
|
||||||
|
tokenUsage: tokenUsageData ?? undefined,
|
||||||
|
logContext: "partial regenerated chat",
|
||||||
|
});
|
||||||
|
},
|
||||||
onAcceptedStreamError: async () => {
|
onAcceptedStreamError: async () => {
|
||||||
if (!userPersisted) {
|
if (!userPersisted) {
|
||||||
const persistedUserMsgId = await persistUserTurn({
|
const persistedUserMsgId = await persistUserTurn({
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ export type ChatFlow = "new" | "resume" | "regenerate";
|
||||||
export type ChatErrorKind =
|
export type ChatErrorKind =
|
||||||
| "premium_quota_exhausted"
|
| "premium_quota_exhausted"
|
||||||
| "thread_busy"
|
| "thread_busy"
|
||||||
|
| "send_failed_pre_accept"
|
||||||
| "auth_expired"
|
| "auth_expired"
|
||||||
| "rate_limited"
|
| "rate_limited"
|
||||||
| "network_offline"
|
| "network_offline"
|
||||||
|
|
@ -54,8 +55,9 @@ function getErrorMessage(error: unknown): string {
|
||||||
|
|
||||||
function getErrorCode(error: unknown, parsedJson: Record<string, unknown> | null): string | undefined {
|
function getErrorCode(error: unknown, parsedJson: Record<string, unknown> | null): string | undefined {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
const withCode = error as Error & { errorCode?: string };
|
const withCode = error as Error & { errorCode?: string; code?: string };
|
||||||
if (withCode.errorCode) return withCode.errorCode;
|
if (withCode.errorCode) return withCode.errorCode;
|
||||||
|
if (withCode.code) return withCode.code;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof error === "object" && error !== null) {
|
if (typeof error === "object" && error !== null) {
|
||||||
|
|
@ -161,6 +163,20 @@ export function classifyChatError(input: RawChatErrorInput): NormalizedChatError
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (errorCode === "SEND_FAILED_PRE_ACCEPT") {
|
||||||
|
return {
|
||||||
|
kind: "send_failed_pre_accept",
|
||||||
|
channel: "toast",
|
||||||
|
severity: "warn",
|
||||||
|
telemetryEvent: "chat_blocked",
|
||||||
|
isExpected: true,
|
||||||
|
userMessage: "Message not sent. Please retry.",
|
||||||
|
rawMessage,
|
||||||
|
errorCode: errorCode ?? "SEND_FAILED_PRE_ACCEPT",
|
||||||
|
details: { flow: input.flow },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
errorCode === "AUTH_EXPIRED" ||
|
errorCode === "AUTH_EXPIRED" ||
|
||||||
errorCode === "UNAUTHORIZED"
|
errorCode === "UNAUTHORIZED"
|
||||||
|
|
@ -196,16 +212,14 @@ export function classifyChatError(input: RawChatErrorInput): NormalizedChatError
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (errorCode === "NETWORK_ERROR") {
|
||||||
errorCode === "NETWORK_ERROR"
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
kind: "network_offline",
|
kind: "network_offline",
|
||||||
channel: "toast",
|
channel: "toast",
|
||||||
severity: "warn",
|
severity: "warn",
|
||||||
telemetryEvent: "chat_error",
|
telemetryEvent: "chat_error",
|
||||||
isExpected: true,
|
isExpected: true,
|
||||||
userMessage: "Connection issue detected. Check your internet and try again.",
|
userMessage: "Connection issue. Please try again.",
|
||||||
rawMessage,
|
rawMessage,
|
||||||
errorCode: errorCode ?? "NETWORK_ERROR",
|
errorCode: errorCode ?? "NETWORK_ERROR",
|
||||||
details: { flow: input.flow },
|
details: { flow: input.flow },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue