From ba584939093ef66d4eee9465bac828c2e746b9f7 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sat, 11 Jul 2026 05:58:09 +0300 Subject: [PATCH] =?UTF-8?q?fix(client):=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81?= =?UTF-8?q?=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=20409-=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4=D0=BE=D0=B2=20+=20run-=D1=84=D0=B0=D0=BA=D1=82=20?= =?UTF-8?q?=D0=BF=D0=BB=D1=83=D0=BC=D0=B1=D0=B8=D0=BD=D0=B3=20(#488)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Потребляет реальный контракт #487 на клиенте (без выдумывания кодов): - error-message.ts: ветки для 409 A_RUN_ALREADY_ACTIVE / SUPERSEDE_TARGET_MISMATCH / SUPERSEDE_TIMEOUT / SUPERSEDE_INVALID — человеческие тексты СТРОГО ДО generic- веток по статусу (иначе юзер видит сырой JSON {"code":"A_RUN_ALREADY_ACTIVE"}); - extractRunId(message): чтение runId из start-метаданных (зеркало extractServerChatId) — live-обновление run-факта для FSM; - getRun(chatId): POST /ai-chat/run — first-class run-факт с сервера (init на маунте + verify после supersede-mismatch). Плумбинг под FSM-обвязку коммитов 2–5. Тесты: классификатор (все 4 кода + order- guard), extractRunId. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai-chat/services/ai-chat-service.ts | 19 ++++++++ .../ai-chat/utils/adopt-chat-id.test.ts | 15 +++++++ .../features/ai-chat/utils/adopt-chat-id.ts | 14 ++++++ .../ai-chat/utils/error-message.test.ts | 45 +++++++++++++++++++ .../features/ai-chat/utils/error-message.ts | 38 ++++++++++++++++ 5 files changed, 131 insertions(+) diff --git a/apps/client/src/features/ai-chat/services/ai-chat-service.ts b/apps/client/src/features/ai-chat/services/ai-chat-service.ts index 9946248a..34e6aa71 100644 --- a/apps/client/src/features/ai-chat/services/ai-chat-service.ts +++ b/apps/client/src/features/ai-chat/services/ai-chat-service.ts @@ -57,6 +57,25 @@ export async function stopRun( return req.data; } +/** + * #488: the run-fact — "is a run active on this chat?" — first-class from the + * server (POST /ai-chat/run). Called on mount to seed the client FSM's run-fact + * and to VERIFY after a supersede mismatch (an observer following a superseded + * run asks for the latest run and follows it). Returns the latest run row (with + * its `id` and `status`) and its projected assistant message, or `run: null` when + * the chat has never had a run. Owner-gated server-side. + */ +export async function getRun(chatId: string): Promise<{ + run: { id: string; status: string } | null; + message: IAiChatMessageRow | null; +}> { + const req = await api.post<{ + run: { id: string; status: string } | null; + message: IAiChatMessageRow | null; + }>("/ai-chat/run", { chatId }); + return req.data; +} + /** * Resolve the chat bound to a document (the current user's most-recent chat * created on that page), or null when there is none. Drives auto-open-on-page. diff --git a/apps/client/src/features/ai-chat/utils/adopt-chat-id.test.ts b/apps/client/src/features/ai-chat/utils/adopt-chat-id.test.ts index c9ff117a..27ec39b5 100644 --- a/apps/client/src/features/ai-chat/utils/adopt-chat-id.test.ts +++ b/apps/client/src/features/ai-chat/utils/adopt-chat-id.test.ts @@ -3,6 +3,7 @@ import { resolveAdoptedChatId, newlyAddedChatIds, extractServerChatId, + extractRunId, } from "./adopt-chat-id"; describe("resolveAdoptedChatId", () => { @@ -70,3 +71,17 @@ describe("extractServerChatId", () => { expect(extractServerChatId(undefined)).toBeUndefined(); }); }); + +describe("extractRunId", () => { + it("reads a string runId from the start metadata", () => { + expect(extractRunId({ metadata: { runId: "run-1" } })).toBe("run-1"); + }); + it("returns undefined when runId is absent", () => { + expect(extractRunId({ metadata: { chatId: "c" } })).toBeUndefined(); + expect(extractRunId({})).toBeUndefined(); + expect(extractRunId(undefined)).toBeUndefined(); + }); + it("returns undefined for a non-string runId", () => { + expect(extractRunId({ metadata: { runId: 7 } })).toBeUndefined(); + }); +}); diff --git a/apps/client/src/features/ai-chat/utils/adopt-chat-id.ts b/apps/client/src/features/ai-chat/utils/adopt-chat-id.ts index 0c01dd91..b7dad698 100644 --- a/apps/client/src/features/ai-chat/utils/adopt-chat-id.ts +++ b/apps/client/src/features/ai-chat/utils/adopt-chat-id.ts @@ -56,6 +56,20 @@ export function extractServerChatId( return typeof m?.chatId === "string" ? m.chatId : undefined; } +/** + * #488: read the authoritative RUN id off a streaming assistant message. The + * server attaches it as `message.metadata.runId` on the `start` part when a run + * wraps the turn (see server `chatStreamMetadata`, #184/#487). This is the live + * run-fact update the client FSM adopts (mirrors `extractServerChatId`). Returns + * it only when it is a string; undefined otherwise. + */ +export function extractRunId( + message: { metadata?: unknown } | undefined, +): string | undefined { + const m = message?.metadata as { runId?: string } | undefined; + return typeof m?.runId === "string" ? m.runId : undefined; +} + /** * The deduped set of ids present in `afterIds` but not in `beforeIds`. A * paginated/flatMapped list can repeat the same id, so dedupe: one genuinely-new diff --git a/apps/client/src/features/ai-chat/utils/error-message.test.ts b/apps/client/src/features/ai-chat/utils/error-message.test.ts index 153455ac..594503f5 100644 --- a/apps/client/src/features/ai-chat/utils/error-message.test.ts +++ b/apps/client/src/features/ai-chat/utils/error-message.test.ts @@ -42,6 +42,51 @@ describe("describeChatError", () => { ); }); + // #488 commit 5: the #487 concurrency-gate / supersede 409s. FULL real bodies: + // a ConflictException(object) whose response is serialized verbatim, carrying a + // `code` and statusCode 409. Each must classify to a human text, not raw JSON. + it("classifies A_RUN_ALREADY_ACTIVE (409) as already-answering, not raw JSON", () => { + const body = + '{"message":"A run is already active for this chat","code":"A_RUN_ALREADY_ACTIVE","statusCode":409}'; + expect(describeChatError(body, t).title).toBe( + "The agent is already answering", + ); + // Never leaks the raw code as the detail. + expect(describeChatError(body, t).detail).not.toContain("A_RUN_ALREADY_ACTIVE"); + }); + + it("classifies SUPERSEDE_TARGET_MISMATCH (409) as run-changed", () => { + const body = + '{"message":"active run does not match the supersede target","code":"SUPERSEDE_TARGET_MISMATCH","runId":"run-x","statusCode":409}'; + expect(describeChatError(body, t).title).toBe( + "Couldn't interrupt — the run changed", + ); + }); + + it("classifies SUPERSEDE_TIMEOUT (409) as couldn't-interrupt-in-time", () => { + const body = + '{"message":"the run did not settle within the supersede window","code":"SUPERSEDE_TIMEOUT","statusCode":409}'; + expect(describeChatError(body, t).title).toBe("Couldn't interrupt in time"); + }); + + it("classifies SUPERSEDE_INVALID (409) as couldn't-interrupt-that-run", () => { + const body = + '{"message":"supervise requires chatId","code":"SUPERSEDE_INVALID","statusCode":409}'; + expect(describeChatError(body, t).title).toBe( + "Couldn't interrupt that run", + ); + }); + + it("ORDER GUARD: A_RUN_ALREADY_ACTIVE wins over any generic status branch", () => { + // Even though the body could superficially look 4xx-ish, the code branch runs + // first, so it is never mislabeled by a generic status heading. + const body = + '{"message":"conflict","code":"A_RUN_ALREADY_ACTIVE","statusCode":409}'; + const view = describeChatError(body, t); + expect(view.title).not.toBe("Something went wrong"); + expect(view.title).not.toBe("AI provider not configured"); + }); + it("classifies a dropped connection (ECONNRESET) as a lost-connection error", () => { expect( describeChatError("Cannot connect to API: read ECONNRESET", t).title, diff --git a/apps/client/src/features/ai-chat/utils/error-message.ts b/apps/client/src/features/ai-chat/utils/error-message.ts index 2d59c177..897bb629 100644 --- a/apps/client/src/features/ai-chat/utils/error-message.ts +++ b/apps/client/src/features/ai-chat/utils/error-message.ts @@ -39,6 +39,44 @@ export function describeChatError( }; } + // #488 commit 5: the #487 concurrency-gate / supersede 409s. These arrive as a + // ConflictException(object) body carrying a `code` (and statusCode 409). They + // MUST be classified by `code` STRICTLY BEFORE any generic status branch, or the + // user sees the raw JSON `{"code":"A_RUN_ALREADY_ACTIVE",…}`. The code strings + // are the real #487 server contract (ai-chat.controller.ts) — do not invent. + if (/"code"\s*:\s*"A_RUN_ALREADY_ACTIVE"/.test(msg)) { + return { + title: t("The agent is already answering"), + detail: t( + "This chat already has a run in progress. Wait for it to finish, or interrupt it and send now.", + ), + }; + } + if (/"code"\s*:\s*"SUPERSEDE_TARGET_MISMATCH"/.test(msg)) { + return { + title: t("Couldn't interrupt — the run changed"), + detail: t( + "The run you tried to interrupt is no longer the active one. Check the latest answer and try again.", + ), + }; + } + if (/"code"\s*:\s*"SUPERSEDE_TIMEOUT"/.test(msg)) { + return { + title: t("Couldn't interrupt in time"), + detail: t( + "The previous run didn't stop in time. Nothing was sent — try sending again.", + ), + }; + } + if (/"code"\s*:\s*"SUPERSEDE_INVALID"/.test(msg)) { + return { + title: t("Couldn't interrupt that run"), + detail: t( + "The run to interrupt doesn't belong to this chat. Reload and try again.", + ), + }; + } + if (/"statusCode"\s*:\s*403\b/.test(msg)) { return { title: t("AI chat is disabled"),