Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/web/src/shared/lib/error-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,30 @@ export function httpErrorCode(err: unknown): string | undefined {
return err instanceof HttpError ? err.code : undefined;
}

export function httpErrorDetail(err: unknown): string | undefined {
if (!(err instanceof HttpError)) return undefined;
const detail = err.extensions.detail;
return typeof detail === "string" && detail.length > 0 ? detail : undefined;
}

export function httpErrorTitle(err: unknown): string | undefined {
if (!(err instanceof HttpError)) return undefined;
const title = err.extensions.title;
return typeof title === "string" && title.length > 0 ? title : undefined;
}

export function httpErrorExtension(err: unknown, key: string): unknown {
return err instanceof HttpError ? err.extensions[key] : undefined;
}

export function httpErrorExtensionString(
err: unknown,
key: string
): string | undefined {
const value = httpErrorExtension(err, key);
return typeof value === "string" && value.length > 0 ? value : undefined;
}

export interface ErrorMessageOptions {
/** Базовая фраза без завершающей точки, напр. «Не удалось привязать». */
base: string;
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/shared/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ export { filesFromClipboard } from "./clipboard-images";
export {
errorMessage,
httpErrorCode,
httpErrorDetail,
httpErrorExtension,
httpErrorExtensionString,
httpErrorStatus,
httpErrorTitle,
type ErrorMessageOptions
} from "./error-message";
export {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";

import { HttpError } from "@/shared/api";

import { deriveTerminalSessionErrorMessage } from "./terminal-error-message";

function problemError(body: Record<string, unknown>, status = 422): HttpError {
return new HttpError(status, "/terminal/run", "request failed", body);
}

describe("deriveTerminalSessionErrorMessage", () => {
it("показывает причину недоступного git-провайдера вместо tmux-заглушки", () => {
const message = deriveTerminalSessionErrorMessage(
problemError({
title: "API error",
status: 422,
detail:
'ERROR Get "https://gitlab.ati.st/api/v4/user": dial tcp: lookup gitlab.ati.st: no such host.',
code: "repository.provider_not_authenticated",
provider: "gitlab",
host: "gitlab.ati.st"
})
);

expect(message).toBe(
"Провайдер gitlab недоступен или не авторизован: gitlab.ati.st. Проверьте доступ к gitlab.ati.st (VPN/DNS) и авторизацию."
);
});

it("оставляет текущую tmux-ошибку только для отключенной tmux capability", () => {
const message = deriveTerminalSessionErrorMessage(
problemError({
title: "API error",
status: 422,
code: "capability.disabled",
capability: "tmux"
})
);

expect(message).toBe(
"Возможность «Терминал агента» выключена или tmux недоступен."
);
});

it("объясняет timeout ожидания клонов", () => {
const message = deriveTerminalSessionErrorMessage(
problemError({
title: "API error",
status: 422,
code: "terminal.clone_wait_timeout"
})
);

expect(message).toBe(
"Клоны репозиториев ещё не готовы. Дождитесь завершения клонирования и запустите терминал снова."
);
});

it("для неизвестного 422-кода показывает detail из ProblemDetails", () => {
const message = deriveTerminalSessionErrorMessage(
problemError({
title: "API error",
status: 422,
detail: "Реальная причина из API.",
code: "terminal.future_reason"
})
);

expect(message).toBe("Реальная причина из API.");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {
errorMessage,
httpErrorCode,
httpErrorDetail,
httpErrorExtensionString,
httpErrorStatus,
httpErrorTitle
} from "@/shared/lib";

const TMUX_UNAVAILABLE =
"Возможность «Терминал агента» выключена или tmux недоступен.";

export function deriveTerminalSessionErrorMessage(err: unknown): string {
const code = httpErrorCode(err);
const status = httpErrorStatus(err);

if (status === 422 && code !== undefined) {
return terminalProblemMessage(err, code) ?? terminalProblemFallback(err);
}

return errorMessage(err, {
base: "Не удалось запустить сессию",
byStatus: {
409: "Запуск отклонён: сессия уже запущена или тело интента изменилось с момента предпросмотра. Откройте модалку заново.",
400: "Недопустимая комбинация вендора, модели и усилия.",
404: "Интент не найден."
}
});
}

function terminalProblemMessage(err: unknown, code: string): string | null {
switch (code) {
case "repository.provider_not_authenticated":
return repositoryProviderMessage(err);
case "capability.disabled":
return capabilityDisabledMessage(err);
case "terminal.mode_invalid":
return "Выбранный режим запуска недоступен для терминала агента.";
case "terminal.clone_wait_timeout":
return "Клоны репозиториев ещё не готовы. Дождитесь завершения клонирования и запустите терминал снова.";
case "terminal.spawn_failed":
return "Не удалось создать сессию терминала агента. Проверьте настройки терминала и повторите запуск.";
case "terminal.run_preflight_blocked":
return "Запуск заблокирован pre-flight проверкой. Исправьте найденные проблемы и повторите запуск.";
case "terminal.native_provider_unavailable":
return "Нативный терминал не настроен или не найден на этом хосте.";
case "terminal.session_skill.unknown":
return "Один из выбранных скилов терминала неизвестен.";
case "terminal.session_skill.not_materializable":
return "Один из выбранных скилов нельзя подготовить для этой сессии.";
case "terminal.session_skill.vendor_unsupported":
return "Один из выбранных скилов не поддерживает текущего вендора агента.";
default:
return null;
}
}

function repositoryProviderMessage(err: unknown): string {
const provider = httpErrorExtensionString(err, "provider") ?? "репозитория";
const host = httpErrorExtensionString(err, "host");
const target = host ?? provider;
return `Провайдер ${provider} недоступен или не авторизован: ${target}. Проверьте доступ к ${target} (VPN/DNS) и авторизацию.`;
}

function capabilityDisabledMessage(err: unknown): string {
const capability = httpErrorExtensionString(err, "capability");
if (capability === "tmux") return TMUX_UNAVAILABLE;
return "Нужная возможность выключена. Проверьте настройки возможностей и повторите запуск.";
}

function terminalProblemFallback(err: unknown): string {
return (
httpErrorDetail(err) ??
httpErrorTitle(err) ??
errorMessage(err, {
base: "Не удалось запустить сессию"
})
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
TerminalRunPayload,
TerminalSessionState
} from "./types";
import { deriveTerminalSessionErrorMessage } from "./terminal-error-message";

export interface TerminalSessionStartedAt {
/** Per-mount nonce — bumped on every successful spawn so xterm reattaches. */
Expand Down Expand Up @@ -126,7 +127,10 @@ export function useTerminalSession(
apply(response, true);
} catch (err) {
if (abort.signal.aborted) return;
setInternal((prev) => ({ ...prev, error: deriveErrorMessage(err) }));
setInternal((prev) => ({
...prev,
error: deriveTerminalSessionErrorMessage(err)
}));
} finally {
if (!abort.signal.aborted) setProbeSettled(true);
}
Expand Down Expand Up @@ -158,7 +162,7 @@ export function useTerminalSession(
} catch (err) {
setInternal((prev) => ({
...prev,
error: deriveErrorMessage(err)
error: deriveTerminalSessionErrorMessage(err)
}));
} finally {
setIsStarting(false);
Expand All @@ -178,7 +182,10 @@ export function useTerminalSession(
const response = await killIntentTerminal(intentId);
apply(response);
} catch (err) {
setInternal((prev) => ({ ...prev, error: deriveErrorMessage(err) }));
setInternal((prev) => ({
...prev,
error: deriveTerminalSessionErrorMessage(err)
}));
} finally {
setIsStopping(false);
}
Expand Down Expand Up @@ -260,18 +267,3 @@ export function useTerminalSession(
markSessionEnded
};
}

function deriveErrorMessage(err: unknown): string {
return errorMessage(err, {
base: "Не удалось запустить сессию",
byStatus: {
// 409 covers two pre-flight aborts: a live session already exists, or the
// intent body changed since preview (stale expected_version). Both resolve
// by reopening the modal — the agent did not start.
409: "Запуск отклонён: сессия уже запущена или тело интента изменилось с момента предпросмотра. Откройте модалку заново.",
400: "Недопустимая комбинация вендора, модели и усилия.",
422: "Возможность «Терминал агента» выключена или tmux недоступен.",
404: "Интент не найден."
}
});
}
Loading