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
30 changes: 30 additions & 0 deletions packages/chat-channel/src/__tests__/ws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,15 @@ class FakeWebSocket {
const originalWebSocket = Object.getOwnPropertyDescriptor(globalThis, "WebSocket");
const originalSetTimeout = Object.getOwnPropertyDescriptor(globalThis, "setTimeout");
const originalClearTimeout = Object.getOwnPropertyDescriptor(globalThis, "clearTimeout");
const originalDateNow = Date.now;
let currentTime: number;
let timers: ScheduledTimer[];

function installFakes(): void {
FakeWebSocket.instances = [];
currentTime = 0;
timers = [];
Date.now = () => currentTime;

Object.defineProperty(globalThis, "WebSocket", {
configurable: true,
Expand Down Expand Up @@ -120,6 +124,7 @@ describe("createYjsWsClient", () => {
});

afterEach(() => {
Date.now = originalDateNow;
restoreGlobal("WebSocket", originalWebSocket);
restoreGlobal("setTimeout", originalSetTimeout);
restoreGlobal("clearTimeout", originalClearTimeout);
Expand All @@ -142,6 +147,29 @@ describe("createYjsWsClient", () => {
expect(timers[0]?.delay).toBe(4000);
});

// 网关余额不足等持续故障可能表现为“连接成功后立刻再次断开”;自动恢复必须有上限,
// 不能因每次 onopen 重置退避而无限创建连接、重复触发 Agent/网关请求。
test("反复短连接达到上限后停止自动重连", () => {
const states: string[] = [];
const reconnectDelays: Array<number | undefined> = [];
const client = createClient((state) => states.push(state));
client.connect();

for (let attempt = 0; attempt < 6; attempt += 1) {
FakeWebSocket.instances[attempt]?.open();
FakeWebSocket.instances[attempt]?.closeFromServer(1011, "relay handle closed");
if (attempt < 5) {
reconnectDelays.push(timers[0]?.delay);
runNextTimer();
}
}

expect(FakeWebSocket.instances).toHaveLength(6);
expect(reconnectDelays).toEqual([1000, 2000, 4000, 8000, 16000]);
expect(timers).toHaveLength(0);
expect(states.at(-1)).toBe("error");
});

// 连接曾成功恢复后,下一次断线应从最短退避时间重新开始。
test("连接成功后重置重连延迟", () => {
const client = createClient();
Expand All @@ -150,6 +178,7 @@ describe("createYjsWsClient", () => {
FakeWebSocket.instances[0]?.closeFromServer();
runNextTimer();
FakeWebSocket.instances[1]?.open();
currentTime = 30_000;
FakeWebSocket.instances[1]?.closeFromServer();

expect(timers[0]?.delay).toBe(1000);
Expand Down Expand Up @@ -388,6 +417,7 @@ describe("createYjsWsClient 二进制 yjs:update 帧(SP-A4)", () => {
});

afterEach(() => {
Date.now = originalDateNow;
restoreGlobal("WebSocket", originalWebSocket);
restoreGlobal("setTimeout", originalSetTimeout);
restoreGlobal("clearTimeout", originalClearTimeout);
Expand Down
20 changes: 19 additions & 1 deletion packages/chat-channel/src/transport/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ const NO_RECONNECT_CODES = new Set<number>([

/** 重连间隔(指数退避),单位毫秒 */
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000, 30000];
/** 短连接连续失败上限;达到后停止自动重连,避免持续故障形成无限请求循环。 */
const MAX_CONSECUTIVE_UNSTABLE_DISCONNECTS = 6;
/** 连接持续达到此时间才视为稳定恢复,并清零短连接失败计数。 */
const STABLE_CONNECTION_MS = 30_000;

export type YjsWsState = "connecting" | "connected" | "disconnected" | "error";

Expand Down Expand Up @@ -118,6 +122,8 @@ export function createYjsWsClient(options: YjsWsOptions): YjsWsClient {
let ws: WebSocket | null = null;
let reconnectDelayIdx = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let consecutiveUnstableDisconnects = 0;
let connectedAt: number | null = null;
let destroyed = false;

function setState(state: YjsWsState) {
Expand Down Expand Up @@ -147,7 +153,7 @@ export function createYjsWsClient(options: YjsWsOptions): YjsWsClient {

socket.onopen = () => {
if (destroyed || ws !== socket) return;
reconnectDelayIdx = 0;
connectedAt = Date.now();
setState("connected");
for (const vector of getYjsStateVectors?.() ?? []) {
socket.send(
Expand Down Expand Up @@ -241,6 +247,18 @@ export function createYjsWsClient(options: YjsWsOptions): YjsWsClient {
setState("error");
return;
}
const connectionDuration = connectedAt === null ? 0 : Date.now() - connectedAt;
connectedAt = null;
if (connectionDuration >= STABLE_CONNECTION_MS) {
consecutiveUnstableDisconnects = 0;
reconnectDelayIdx = 0;
} else {
consecutiveUnstableDisconnects += 1;
}
if (consecutiveUnstableDisconnects >= MAX_CONSECUTIVE_UNSTABLE_DISCONNECTS) {
setState("error");
return;
}
setState("disconnected");
scheduleReconnect();
};
Expand Down
27 changes: 27 additions & 0 deletions src/__tests__/round43-acp-ws-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
stubRegistryHeartbeat,
} from "../test-utils/helpers";
import {
closeAcpConnectionsForEnvironments,
closeAllAcpConnections,
findMachineConnectionByAgentId,
findMachineConnectionById,
Expand Down Expand Up @@ -152,6 +153,32 @@ describe("round43 acp ws handler", () => {
expect(listAcpConnections().find((item) => item.wsId === "unknown-43")).toBeUndefined();
});

// 删除 Environment 必须关闭本地绑定连接,防止客户端继续用失效环境重连/发请求。
test("关闭已删除 Environment 的 ACP 连接并清理缓存", () => {
const ws = new FakeWs();
handleAcpWsOpen(ws, "deleted-env-ws", "user-a", "env-deleted", false);
setAgentMachineCache("env-deleted", "machine-deleted");

closeAcpConnectionsForEnvironments(["env-deleted"]);

expect(ws.closed).toEqual([[1000, "environment deleted"]]);
expect(listAcpConnections().find((item) => item.wsId === "deleted-env-ws")).toBeUndefined();
expect(getAgentMachineCache().has("env-deleted")).toBe(false);
});

// 删除一个 Environment 不得影响其他环境或 machine 连接。
test("关闭 Environment 连接时保留其他连接", () => {
const deletedWs = new FakeWs();
const retainedWs = new FakeWs();
handleAcpWsOpen(deletedWs, "deleted-env-ws-2", "user-a", "env-deleted-2", false);
handleAcpWsOpen(retainedWs, "retained-env-ws", "user-a", "env-retained", false);

closeAcpConnectionsForEnvironments(["env-deleted-2"]);

expect(deletedWs.closed).toHaveLength(1);
expect(retainedWs.closed).toEqual([]);
expect(listAcpConnections().map((item) => item.wsId)).toEqual(["retained-env-ws"]);
});
// machine 连接只保存调用者身份,不会复用其他连接的用户标识。
test("machine 快照按连接保留各自用户身份", () => {
handleAcpWsOpen(new FakeWs(), "machine-user-a", "user-a", null, true);
Expand Down
12 changes: 8 additions & 4 deletions src/services/config/agent-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,15 +250,19 @@ export async function deleteAgentConfig(ctx: AuthContext, name: string): Promise
.from(environment)
.where(and(eq(environment.organizationId, row.organizationId), eq(environment.agentConfigId, row.id)));
if (boundEnvs.length > 0) {
const environmentIds = boundEnvs.map((env) => env.id);
// 先关闭本地 ACP 连接,再停止运行实例;否则客户端仍会用已删除环境继续发消息。
const { closeAcpConnectionsForEnvironments } = await import("../../transport/acp-ws-handler");
closeAcpConnectionsForEnvironments(environmentIds);

// 动态 import 打破模块循环:orchestration-instance 顶层静态 import ./config 的
// getReadableAgentConfigById(本文件所在 index 的 re-export),此处若静态反向
// import 会形成 agent-config → orchestration-instance → config 的循环依赖
// (同 orchestration-instance 内 reclaimYjsDocs 的惰性导入模式)。
const { stopInstancesForEnvironments } = await import("../orchestration-instance");
await stopInstancesForEnvironments(
boundEnvs.map((env) => env.id),
{ organizationId: row.organizationId },
);
await stopInstancesForEnvironments(environmentIds, {
organizationId: row.organizationId,
});
}

return db.transaction(async (tx) => {
Expand Down
2 changes: 2 additions & 0 deletions src/services/environment-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ export async function getOwnedEnvironment(
* 已校验归属)均在此前完成权限校验,环境内实例必然同属该环境。
*/
export async function deleteEnvironment(envId: string): Promise<boolean> {
const { closeAcpConnectionsForEnvironments } = await import("../transport/acp-ws-handler");
closeAcpConnectionsForEnvironments([envId]);
await stopInstancesForEnvironments([envId]);
return environmentRepo.delete(envId);
}
Expand Down
28 changes: 28 additions & 0 deletions src/transport/acp-ws-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,34 @@ export function triggerMachineCleanupByMachineId(machineId: string, reason: stri
});
}

/**
* 关闭指定 Environment 关联的本地 ACP 连接,并清理 agent → machine 缓存。
*
* 删除 Environment/Agent 后连接端仍可能继续发送消息;只删除 DB 和 runtime instance
* 不会使这类连接失效,导致服务端持续用已删除的 environmentId 处理请求。
*/
export function closeAcpConnectionsForEnvironments(environmentIds: string[]): void {
if (environmentIds.length === 0) return;
const environmentIdSet = new Set(environmentIds);

for (const [wsId, entry] of connections) {
if (entry.isMachine || !entry.boundEnvId || !environmentIdSet.has(entry.boundEnvId)) continue;

if (entry.unsub) entry.unsub();
if (entry.keepalive) clearInterval(entry.keepalive);
connections.delete(wsId);
try {
entry.ws.close(1000, "environment deleted");
} catch (error) {
logError(`[ACP-WS-CLOSE] failed to close deleted environment connection: wsId=${wsId}`, error);
}
}

for (const environmentId of environmentIds) {
agentMachineCache.delete(environmentId);
}
}

/** Called from onClose — marks agent offline and cleans up */
export function handleAcpWsClose(_ws: WsConnection, wsId: string, code?: number, reason?: string): void {
const entry = connections.get(wsId);
Expand Down
36 changes: 30 additions & 6 deletions web/components/chat/SystemMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,48 @@
import { memo } from "react";
import { memo, useState } from "react";
import { useTranslation } from "react-i18next";
import { NS } from "../../src/i18n";
import { cn } from "../../src/lib/utils";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../ui/dialog";

interface SystemMessageProps {
/** 原始 system-reminder 块仅用于判空,内容不在界面暴露。 */
/** 原始 system-reminder 块仅在用户主动查看详情时展示。 */
rawText: string;
className?: string;
}

/**
* 系统消息仅展示弱化胶囊,不展开内部注入内容
* 系统消息默认展示弱化胶囊,双击后可检查完整原始内容
*/
export const SystemMessage = memo(function SystemMessage({ rawText, className }: SystemMessageProps) {
const { t } = useTranslation(NS.COMPONENTS);
const [detailsOpen, setDetailsOpen] = useState(false);
if (!rawText) return null;

return (
<div className={cn("flex justify-start", className)}>
<span className="chat-system-reminder">{t("messageBubble.systemMessage")}</span>
</div>
<>
<div className={cn("flex justify-start", className)}>
<button
type="button"
className="chat-system-reminder"
onDoubleClick={() => setDetailsOpen(true)}
aria-expanded={detailsOpen}
aria-haspopup="dialog"
aria-label={t("messageBubble.openSystemMessage")}
>
{t("messageBubble.systemMessage")}
</button>
</div>
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
<DialogContent className="max-h-[80vh] sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("messageBubble.systemMessage")}</DialogTitle>
<DialogDescription>{t("messageBubble.systemMessageDescription")}</DialogDescription>
</DialogHeader>
<pre className="max-h-[60vh] overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-4 text-xs">
{rawText}
</pre>
</DialogContent>
</Dialog>
</>
);
});
20 changes: 20 additions & 0 deletions web/components/chat/chat-navigation-aids.css
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,21 @@
background: #202936;
}

.chat-entry--active-prompt {
border-radius: 12px;
animation: chat-active-prompt-flash 900ms ease-out;
}

@keyframes chat-active-prompt-flash {
0%,
20% {
background-color: rgb(100 116 139 / 14%);
}
100% {
background-color: transparent;
}
}

.chat-prompt-jump-index__preview {
position: fixed;
z-index: 30;
Expand Down Expand Up @@ -109,6 +124,11 @@
}

@media (prefers-reduced-motion: reduce) {
.chat-entry--active-prompt {
animation: none;
background-color: rgb(100 116 139 / 10%);
}

.chat-prompt-jump-index__tick,
.chat-prompt-jump-index__preview {
transition: none;
Expand Down
6 changes: 6 additions & 0 deletions web/components/chat/chat-navigation-aids.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ export function PromptJumpRail({ entries }: PromptJumpRailProps) {
}
}, [activeId, visiblePrompts]);

useEffect(() => {
const activePrompt = activeId ? document.getElementById(`chat-entry-${activeId}`) : null;
activePrompt?.classList.add("chat-entry--active-prompt");
return () => activePrompt?.classList.remove("chat-entry--active-prompt");
}, [activeId]);

useEffect(() => {
const rail = railRef.current;
const conversation = rail?.parentElement;
Expand Down
2 changes: 1 addition & 1 deletion web/src/__tests__/agent-create-enter-flow.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, mock, test } from "bun:test";
import type { EnterEnvironmentResponse, EnvironmentDetail } from "../api/environments";
import { resolveCreatedAgentChatTarget } from "../pages/agent-panel/AgentPanelLayout";
import { resolveCreatedAgentChatTarget } from "../pages/agent-panel/agent-create-navigation";

describe("新建智能体进入对话", () => {
// 新建智能体创建环境后必须显式进入环境,并携带实例 UID 导航,避免聊天页永久等待连接。
Expand Down
11 changes: 11 additions & 0 deletions web/src/__tests__/agent-home-generation.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, test } from "bun:test";
import { hasAgentGenerationPrompt } from "../pages/agent-panel/pages/AgentHomePage";

const agentHomeSource = Bun.file(new URL("../pages/agent-panel/pages/AgentHomePage.tsx", import.meta.url));

describe("Agent 首页生成输入校验", () => {
// 空字符串和纯空白不应启用一键创建,避免点击按钮后没有任何反馈。
test("拒绝空白描述", () => {
Expand All @@ -12,4 +14,13 @@ describe("Agent 首页生成输入校验", () => {
test("接受有效描述", () => {
expect(hasAgentGenerationPrompt("创建一个代码审查 Agent")).toBe(true);
});

// 首页提交必须复用统一的实例进入流程,并携带实例 UID 导航,避免聊天页永久等待。
test("创建后显式进入实例并导航到实例路由", async () => {
const source = await agentHomeSource.text();

expect(source).toContain("resolveCreatedAgentChatTarget(agentConfigId");
expect(source).toContain('to: "/agent/chat/$agentId/$sessionId"');
expect(source).toContain("sessionId: target.instanceUid");
});
});
6 changes: 6 additions & 0 deletions web/src/__tests__/agent-sidebar-instance-order.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ describe("Agent sidebar Instance 排序", () => {
});
});

test("聊天路由向侧边栏传递当前 Instance", () => {
const source = readFileSync(resolve(import.meta.dir, "../pages/agent-panel/AgentPanelLayout.tsx"), "utf8");

expect(source).toContain("selectedInstanceId={isChatRoute ? chatSessionId : lastChatSessionRef.current}");
});

// 配置导航分区只允许内容容器滚动,避免与外层包装形成嵌套双滚动条。
test("配置导航分区保持单一滚动容器", () => {
const source = readFileSync(resolve(import.meta.dir, "../pages/agent-panel/agent-panel.css"), "utf8");
Expand Down
Loading
Loading