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
11 changes: 9 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1762,9 +1762,13 @@ export default function App() {
// very first resolve, restore the previously-open session (if it still
// exists and we weren't on a create view); otherwise start a fresh chat.
useEffect(() => {
if (myAgents || agentDetailTarget || !appName || !userId) return;
if (myAgents || agentDetailTarget || sandboxSession || !appName || !userId) {
return;
}
let cancelled = false;
(async () => {
const list = await refreshSessions(appName);
if (cancelled) return;
if (!restoredRef.current) {
restoredRef.current = true;
const savedId = localStorage.getItem(LS.session) || "";
Expand All @@ -1775,8 +1779,11 @@ export default function App() {
}
startNewChat();
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agentDetailTarget, appName, myAgents, userId]);
}, [agentDetailTarget, appName, myAgents, sandboxSession, userId]);

// After switching agent from a search result, open the target session (runs
// after the agent-switch effect above, so it wins over its startNewChat()).
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/adk/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { parseJsonResponse } from "./jsonResponse";
import { formatRunSseError } from "./runSseError";
import { parseSSE } from "./sse";
import { normalizeRuntimeDescription } from "./runtimeDescription";
import {
DEFAULT_REQUEST_TIMEOUT_MS,
requestSignal,
Expand Down Expand Up @@ -1440,7 +1441,7 @@ export async function deployAgentkitProject(
config,
taskId,
runtimeId: opts?.runtimeId,
description: opts?.description,
description: normalizeRuntimeDescription(opts?.description ?? ""),
im: opts?.im,
envs: opts?.envs,
}),
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/adk/runtimeDescription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export const RUNTIME_DESCRIPTION_MAX_BYTES = 255;

const allowedRuntimeDescriptionCharacter = /[\p{L}\p{M}\p{N}\p{P}\p{Zs}]/u;

export function normalizeRuntimeDescription(value: string): string {
const singleLine = value.normalize("NFKC").replace(/\s+/gu, " ").trim();
const encoder = new TextEncoder();
let byteLength = 0;
let normalized = "";

for (const character of singleLine) {
if (!allowedRuntimeDescriptionCharacter.test(character)) continue;
const characterBytes = encoder.encode(character).byteLength;
if (byteLength + characterBytes > RUNTIME_DESCRIPTION_MAX_BYTES) break;
normalized += character;
byteLength += characterBytes;
}

return normalized.replace(/ +/g, " ").trimEnd();
}
4 changes: 3 additions & 1 deletion frontend/src/create/CustomCreate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3421,7 +3421,9 @@ export function CustomCreate({
</span>
) : (
<span className="cw-help">
描述会显示在 Agent 列表与选择器中。
{isRootAgent
? "完整描述会保留;部署时会自动整理为符合 Runtime 规范的单行描述。"
: "描述会显示在 Agent 列表与选择器中。"}
</span>
)}
</div>
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/ui/MyAgents.css
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,22 @@
font-size: 12px;
}

.my-agent-empty .my-agent-empty-create {
height: auto;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
color: hsl(var(--primary));
font-size: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}

.my-agent-empty .my-agent-empty-create:hover {
color: hsl(var(--primary) / 0.78);
}

.my-agent-card-main:focus-visible,
.my-agent-connect:focus-visible,
.my-agent-type-pill:focus-visible,
Expand Down
15 changes: 14 additions & 1 deletion frontend/src/ui/MyAgents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,20 @@ export function MyAgents({
</div>
) : showEmpty ? (
<div className="my-agent-empty">
<p>{emptyMessage}</p>
{!query.trim() && activeType === "general" ? (
<p>
暂无智能体,
<button
type="button"
className="my-agent-empty-create"
onClick={() => onCreateAgent(region)}
>
点此创建
</button>
</p>
) : (
<p>{emptyMessage}</p>
)}
{query.trim() && activeType !== "openclaw" && activeType !== "hermes" && (
<span>请尝试搜索其他名称</span>
)}
Expand Down
13 changes: 12 additions & 1 deletion frontend/tests/myAgents.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ test("defers conversation data-plane requests until leaving the Agent list", ()
);
assert.match(
appSource,
/if \(myAgents \|\| agentDetailTarget \|\| !appName \|\| !userId\) return;[\s\S]*?refreshSessions/,
/if \(myAgents \|\| agentDetailTarget \|\| sandboxSession \|\| !appName \|\| !userId\)[\s\S]*?return;[\s\S]*?refreshSessions/,
);
assert.match(
appSource,
Expand Down Expand Up @@ -224,6 +224,17 @@ test("keeps all requested type filters without nested category sections", () =>
assert.match(pageStyles, /\.my-agent-empty p\s*\{[\s\S]*?font-weight: 400/);
});

test("offers a link-styled create action when the Runtime list is empty", () => {
assert.match(
pageSource,
/!query\.trim\(\) && activeType === "general"[\s\S]*?暂无智能体,[\s\S]*?className="my-agent-empty-create"[\s\S]*?onClick=\{\(\) => onCreateAgent\(region\)\}[\s\S]*?点此创建/,
);
assert.match(
pageStyles,
/\.my-agent-empty \.my-agent-empty-create\s*\{[\s\S]*?border: 0;[\s\S]*?background: transparent;[\s\S]*?text-decoration: underline/,
);
});

test("shows connecting progress and preserves the connected Runtime state", () => {
assert.match(pageSource, /const \[connectingAgentId, setConnectingAgentId\] = useState\(""\)/);
assert.match(
Expand Down
43 changes: 43 additions & 0 deletions frontend/tests/runtimeDescription.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import ts from "typescript";

const source = readFileSync(
new URL("../src/adk/runtimeDescription.ts", import.meta.url),
"utf8",
);
const clientSource = readFileSync(
new URL("../src/adk/client.ts", import.meta.url),
"utf8",
);
const { outputText } = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.ES2022,
target: ts.ScriptTarget.ES2022,
},
});
const moduleUrl = `data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`;
const { normalizeRuntimeDescription, RUNTIME_DESCRIPTION_MAX_BYTES } =
await import(moduleUrl);

test("runtime descriptions are normalized to a safe single line", () => {
assert.equal(
normalizeRuntimeDescription(" 数据\n分析\u0000 Agent 🤖 "),
"数据 分析 Agent",
);
assert.equal(normalizeRuntimeDescription("Agent_1"), "Agent_1");
});

test("runtime descriptions stay within the AgentKit byte limit", () => {
const normalized = normalizeRuntimeDescription("数".repeat(100));
assert.ok(Buffer.byteLength(normalized, "utf8") <= RUNTIME_DESCRIPTION_MAX_BYTES);
assert.equal(normalized, "数".repeat(85));
});

test("every deployment caller uses the shared runtime description rule", () => {
assert.match(
clientSource,
/description: normalizeRuntimeDescription\(opts\?\.description \?\? ""\)/,
);
});
15 changes: 15 additions & 0 deletions frontend/tests/sandboxSessionPresentation.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,21 @@ test("active sandbox conversation is visibly temporary and never uses normal ses
);
});

test("normal session refresh cannot close a newly launched sandbox session", () => {
assert.match(
appSource,
/if \(myAgents \|\| agentDetailTarget \|\| sandboxSession \|\| !appName \|\| !userId\)/,
);
assert.match(
appSource,
/let cancelled = false;[\s\S]*?await refreshSessions\(appName\);[\s\S]*?if \(cancelled\) return;[\s\S]*?startNewChat\(\);[\s\S]*?cancelled = true;/,
);
assert.match(
appSource,
/\[agentDetailTarget, appName, myAgents, sandboxSession, userId\]/,
);
});

test("sandbox visuals use repository-owned icons and reduced motion", () => {
assert.match(iconSource, /export function InsightIcon/);
assert.match(iconSource, /viewBox="0 0 24 24"/);
Expand Down
61 changes: 61 additions & 0 deletions tests/cli/test_frontend_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,67 @@ def delete_session(self, request: object) -> None:
)


@pytest.mark.asyncio
async def test_gateway_retries_session_creation_in_shanghai_and_deletes_there() -> None:
created_regions: list[str] = []
deleted_regions: list[str] = []

class _Client:
def __init__(self, region: str) -> None:
self.region = region

def create_session(self, request: object) -> SimpleNamespace:
del request
created_regions.append(self.region)
if self.region == "cn-beijing":
raise RuntimeError("InvalidResource.NotFound")
return SimpleNamespace(
session_id="remote-1",
user_session_id="user-1",
endpoint="https://sandbox.example",
)

def delete_session(self, request: object) -> None:
del request
deleted_regions.append(self.region)

gateway = AgentkitSandboxGateway(
_Client,
region_candidates=("cn-beijing", "cn-shanghai"),
)

session = await gateway.create_session("tool-1")
await gateway.delete_session(session)

assert created_regions == ["cn-beijing", "cn-shanghai"]
assert session.region == "cn-shanghai"
assert deleted_regions == ["cn-shanghai"]


@pytest.mark.asyncio
async def test_gateway_does_not_retry_non_not_found_creation_errors() -> None:
regions: list[str] = []

class _Client:
def __init__(self, region: str) -> None:
self.region = region

def create_session(self, request: object) -> None:
del request
regions.append(self.region)
raise RuntimeError("AccessDenied")

gateway = AgentkitSandboxGateway(
_Client,
region_candidates=("cn-beijing", "cn-shanghai"),
)

with pytest.raises(SandboxProvisioningError, match="AccessDenied"):
await gateway.create_session("tool-1")

assert regions == ["cn-beijing"]


@pytest.mark.asyncio
async def test_delete_failure_keeps_session_for_cleanup_retry() -> None:
class _FailDeleteGateway(_FakeGateway):
Expand Down
32 changes: 32 additions & 0 deletions tests/cli/test_frontend_skill_creator.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,3 +483,35 @@ def test_skill_creator_uses_configured_sandbox_region(monkeypatch) -> None:
SkillCreatorService(tool_id="tool-id")._validate_tool("tool-id")

client_class.assert_called_once_with(region="cn-shanghai")


def test_skill_creator_retries_tool_lookup_in_shanghai() -> None:
regions: list[str] = []
tool = SimpleNamespace(
tool_type="CodeEnv",
status="Ready",
envs=[
SimpleNamespace(key="CODEX_API_KEY", value=os.urandom(24).hex()),
SimpleNamespace(key="CODEX_BASE_URL", value=_MODEL_BASE_URL),
],
)

class FakeClient:
def __init__(self, region: str) -> None:
self.region = region

def get_tool(self, _request: object) -> SimpleNamespace:
regions.append(self.region)
if self.region == "cn-beijing":
raise RuntimeError("InvalidResource.NotFound")
return tool

service = SkillCreatorService(tool_id="tool-id", region="cn-beijing")
with patch(
"veadk.cli.frontend_skill_creator.AgentkitToolsClient",
side_effect=FakeClient,
):
service._validate_tool("tool-id")

assert regions == ["cn-beijing", "cn-shanghai"]
assert service._region == "cn-shanghai"
49 changes: 47 additions & 2 deletions tests/cli/test_studio_rbac.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@
from fastapi import FastAPI
from fastapi.testclient import TestClient

from veadk.cli.cli_frontend import _run_frontend_server, studio
from veadk.cli.cli_frontend import (
_create_runtime_with_description_fallback,
_is_malformed_runtime_description_error,
_normalize_runtime_description,
_run_frontend_server,
studio,
)
from veadk.cli.studio_rbac import (
StudioAccessPolicy,
StudioPrincipal,
Expand Down Expand Up @@ -105,6 +111,45 @@ def _runtime(
)


def test_runtime_description_is_safe_and_bounded() -> None:
normalized = _normalize_runtime_description(
" 数据\n分析\u0000 Agent 🤖 " + "数" * 100
)

assert normalized.startswith("数据 分析 Agent 数")
assert "\n" not in normalized
assert "\u0000" not in normalized
assert "🤖" not in normalized
assert len(normalized.encode("utf-8")) <= 255


def test_runtime_description_error_detection_is_specific() -> None:
assert _is_malformed_runtime_description_error(
"CreateRuntime failed: InvalidDescription.Malformed"
)
assert not _is_malformed_runtime_description_error(
"CreateRuntime failed: AccessDenied"
)


def test_runtime_creation_retries_without_a_rejected_description() -> None:
attempts: list[str | None] = []
request = SimpleNamespace(description="bad description")

def create_runtime(_client: object, current_request: SimpleNamespace):
attempts.append(current_request.description)
if len(attempts) == 1:
raise RuntimeError("InvalidDescription.Malformed")
return SimpleNamespace(runtime_id="runtime-1")

result = _create_runtime_with_description_fallback(
create_runtime, object(), request
)

assert result.runtime_id == "runtime-1"
assert attempts == ["bad description", None]


def test_parse_role_members_normalizes_csv() -> None:
assert parse_role_members(" Admin@Example.com, alice, ALICE, ") == {
"admin@example.com",
Expand Down Expand Up @@ -499,7 +544,7 @@ def launch(*, config_file: str, **_kwargs: Any) -> SimpleNamespace:
headers={"X-VeADK-Local-User": "developer"},
json={
"name": "updated-agent",
"description": "Updated description",
"description": "Updated\n description 🤖",
"runtimeId": runtime.runtime_id,
"files": [{"path": "app.py", "content": "app = object()\n"}],
"config": {"region": "cn-beijing", "projectName": "default"},
Expand Down
Loading
Loading