From 148fbd6a1092f072dbbc1a7cdca13d1bbbebd8c1 Mon Sep 17 00:00:00 2001
From: xiayu
Date: Wed, 8 Jul 2026 00:56:38 +0800
Subject: [PATCH 1/5] chore(release): prepare ksadk 0.6.9 public candidate
---
CHANGELOG.md | 26 +
...30\351\207\217\345\217\202\350\200\203.md" | 619 +++++
docs/maintainer-approval-record.md | 6 +-
...30\351\207\217\345\217\202\350\200\203.md" | 1 +
...45\345\217\243\350\257\264\346\230\216.md" | 2061 +++++++++++++++++
ksadk/api/client.py | 83 +-
ksadk/builders/code_builder.py | 2 +-
ksadk/builders/ks3_uploader.py | 17 +-
ksadk/cli/cmd_agent.py | 6 +-
ksadk/cli/cmd_deploy.py | 2 +-
ksadk/cli/cmd_destroy.py | 13 +-
ksadk/cli/cmd_hermes.py | 31 +-
ksadk/cli/cmd_launch.py | 2 +-
ksadk/cli/cmd_status.py | 11 +-
ksadk/configs/env_registry.py | 6 +
ksadk/configs/global_config.py | 35 +-
ksadk/conversations/run_kinds.py | 61 +
ksadk/conversations/run_status.py | 53 +
ksadk/conversations/runtime.py | 410 +++-
ksadk/deployment/agent_access.py | 33 +
ksadk/detection/detector.py | 123 +-
ksadk/detection/mcp_detector.py | 117 +-
ksadk/identity/__init__.py | 17 +
ksadk/identity/resolver.py | 368 +++
ksadk/runners/adk_runner.py | 13 +-
ksadk/runners/base_runner.py | 23 +
ksadk/runners/langchain_runner.py | 9 +
ksadk/runners/langgraph_runner.py | 16 +-
ksadk/runners/usage_accumulator.py | 38 +
ksadk/server/app.py | 376 ++-
ksadk/sessions/base.py | 5 +-
ksadk/sessions/in_memory.py | 21 +-
ksadk/sessions/local_service.py | 48 +-
ksadk/sessions/postgres_service.py | 122 +-
ksadk/tracing/setup.py | 52 +-
ksadk/version.py | 2 +-
.../memory_backend/providers/lancedb.py | 123 +-
.../memory_backend_manifest.schema.json | 257 +-
pyproject.toml | 2 +-
scripts/open_source_audit.py | 1 +
tests/snapshots/workflow_help_snapshots.txt | 4 +-
tests/test_background_run.py | 12 +-
tests/test_check_approval_record.py | 12 +-
tests/test_client_permission_precheck.py | 11 +
tests/test_client_user_uuid_header.py | 179 ++
tests/test_cmd_hermes.py | 114 +
tests/test_conversation_runtime.py | 58 +-
tests/test_identity_resolver.py | 295 +++
...est_langchain_runner_session_continuity.py | 9 +
tests/test_langgraph_runner_resume.py | 18 +
tests/test_open_source_audit.py | 6 +-
tests/test_postgres_session_service.py | 48 +
tests/test_public_release_positioning.py | 8 +-
tests/test_runner.py | 71 +-
tests/test_server_session_app.py | 67 +-
tests/test_sessions_service.py | 71 +
tests/test_tracing_setup_otlp.py | 3 +-
tests/test_usage_accumulator.py | 43 +
uv.lock | 2 +-
59 files changed, 5732 insertions(+), 510 deletions(-)
create mode 100644 "docs/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md"
create mode 100644 "docs/\350\277\234\347\250\213Agent\350\277\220\350\241\214\346\227\266\346\216\245\345\217\243\350\257\264\346\230\216.md"
create mode 100644 ksadk/conversations/run_kinds.py
create mode 100644 ksadk/conversations/run_status.py
create mode 100644 ksadk/identity/__init__.py
create mode 100644 ksadk/identity/resolver.py
create mode 100644 ksadk/runners/usage_accumulator.py
create mode 100644 tests/test_client_user_uuid_header.py
create mode 100644 tests/test_identity_resolver.py
create mode 100644 tests/test_usage_accumulator.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 792f10d6..70cd4501 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,32 @@
格式参考 [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
版本遵循 [Semantic Versioning](https://semver.org/spec/v2.0.0.html)。
+## [0.6.9] - 2026-07-07
+
+### 亮点
+
+- **run 状态双维度字段**:新增 `run_mode`(background/foreground/unknown)和 `run_trigger`(new_run/checkpoint_resume/approval_resume/unknown)两个独立维度字段,区分"怎么跑"和"怎么开始",替代单字段 `run_kind` 的语义错误。后台长任务从 checkpoint 恢复时不再丢失"这是后台任务"的信息,前端可直接消费 `ActiveRunMode` / `ActiveRunTrigger` 判断长任务会话,无需从事件流推断。
+- **checkpoint 可恢复性聚合字段**:`ListSessionCheckpoints` 响应新增 `ResumableTotal` / `HasResumableCheckpoint`,解决 `Total > 0` 不能代表"可恢复"的误判(终态/过期/memory_local checkpoint 会让 Total 非空但不可恢复)。恢复按钮可用性应看 `HasResumableCheckpoint`。
+- **state_delta.active_run 对齐**:ksadk 与 agentengine-server 现都把 `run_mode` / `run_trigger` 写入 `state_delta.active_run`,Session 对象的 `ActiveRunMode` / `ActiveRunTrigger` 由 state 重建,刷新/分享链接/切 session 都能恢复一致状态。
+
+### 新增
+
+- 新增 `ksadk/conversations/run_kinds.py`:`run_mode` / `run_trigger` 枚举常量 + `validate_run_mode` / `validate_run_trigger` + `trigger_from_resume_input`(从 resume_input 推导 trigger)。
+- `append_run_status_event` 新增 `run_mode` / `run_trigger` 参数,写入事件 `metadata` 与 `state_delta.active_run`。
+- `PreparedConversationTurn` 新增 `run_mode` / `run_trigger` 字段,`build_run_input` 按 checkpoint_resume / approval_resume / new_run 分支回填。
+- `invoke_conversation_once` / `_iter_conversation_turn_events` / `stream_responses_conversation_turn` / `stream_conversation_turn` 透传 `run_mode`;18+ 处 `append_run_status_event` 调用点按 endpoint 语义传值。
+- 各 endpoint 按产品语义标记 run_mode:`RunAgent Background:true` 与 `ResumeRun Stream:true` 标 `background`;普通 `RunAgent Stream:true`、`ResumeRun Stream:false`、`/v1/responses`、`/v1/chat/completions`、`/run_sse` 标 `foreground`。
+- `_DetachedSSEStream` 构造与终态 fallback 写入 `run_mode` / `run_trigger`。
+- 新增 `_latest_session_run_metadata` helper(不改原 `_latest_session_run_status`,保护现有契约),`_session_to_action_payload` 顶层新增 `ActiveRunMode` / `ActiveRunTrigger`。
+- `_list_checkpoints_payload` 新增 `ResumableTotal` / `HasResumableCheckpoint` 聚合字段。规则:`IsResumable===true && ReplayAllowed!==false && IsTerminal!==true && CheckpointStatus not in {expired, disabled}`,不排除 `resumed`(已恢复过的仍计入,符合存档点可反复读)。
+- agentengine-server 侧新建 `app/services/run_kinds.py`(独立维护,不 import ksadk,用测试约束一致性),`_append_run_status` 与 `_serialize_session` 同步写入/读取新字段。
+
+### 变更
+
+- `run_status` 事件 `metadata` 与 `state_delta.active_run` 扩展为含 `run_mode` / `run_trigger`;旧 session 缺字段降级 `unknown`,不破坏现有 `ActiveInvocationId` / `ActiveRunStatus` 契约。
+- approval 续跑的 `run_mode` 跟随原 run(不写死 foreground),需从原 run 上下文透传。
+- server 侧 `run_status` 事件 `content` 仍为 `{status, detail}`,`run_mode` / `run_trigger` 只写进 `state_delta`,避免破坏现有消费方。
+
## [0.6.8] - 2026-07-03
### 亮点
diff --git "a/docs/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" "b/docs/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md"
new file mode 100644
index 00000000..2206df11
--- /dev/null
+++ "b/docs/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md"
@@ -0,0 +1,619 @@
+# KSADK 环境变量参考
+
+本文档面向部署、运行、运维和 SDK 集成排障。它不是业务代码 `.env` 模板;业务方自己的变量,例如 `APP_ENV`、`DB_URL`、`CUSTOM_API_KEY`,只要不是 KsADK / 平台运行时读取的变量,都属于业务自定义变量,不在本文逐项维护。
+
+本文档基于当前 `feat/skill-runtime` 工作树和 `master` 分支源码扫描整理,覆盖 `ksadk/`、`deploy/`、`tests/` 中已经注册或常见可配置的运行时变量。测试专用变量、PID/marker/cache 等进程内部临时变量、镜像构建脚本内部常量不会逐项列入表格;如果要排查这些高级项,以对应脚本源码和模板 README 为准。
+
+## 1. 阅读规则
+
+| 字段 | 含义 |
+| --- | --- |
+| 变量 | 环境变量名。 |
+| 作用层级 | 主要读取方:CLI、本地运行时、云端 Runtime、Runner、Sandbox、Skill Runtime、平台服务等。 |
+| 是否必传 | `是` 表示该场景启用时必须设置;`条件必传` 表示只有选择某个 backend/能力时才必传;`否` 表示有默认值或可不启用。 |
+| 默认值 | 未设置时的 SDK 行为。`未设置` 表示没有默认值。`代码常量` 表示源码内部表名/依赖列表,不建议用户改。 |
+| 别名/兼容 | 可替代变量、旧变量或 fallback 链路。优先使用表中第一列变量。 |
+| 敏感 | 是否包含 token、secret、DSN、API key。敏感变量只能通过本地 shell、CI Secret、K8S Secret 或平台 Secret 注入。 |
+| 配置方/来源 | 一般由谁提供或注入。 |
+| 是否业务自定义 | `否` 表示 KsADK/平台读取;`是` 表示业务方可自由定义,本文只说明边界。 |
+| 说明 | 用途、取值、注意事项。 |
+
+## 2. 常见场景必传清单
+
+### 2.1 本地运行普通 Agent
+
+| 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| `OPENAI_API_KEY` | 条件必传 | 部分 OpenAI 兼容实现也支持 `MODEL_API_KEY` | 是 | 开发者 / 模型网关 Secret | 使用 OpenAI 兼容模型时需要。 |
+| `OPENAI_BASE_URL` | 条件必传 | `OPENAI_API_BASE` | 否 | 开发者 / 模型网关 | OpenAI 兼容接口 base url。 |
+| `OPENAI_MODEL_NAME` | 条件必传 | `MODEL_NAME` | 否 | 开发者 | 默认模型名。 |
+| `KSYUN_REGION` | 否 | 无 | 否 | 开发者 / 平台 | 本地 CLI 默认 `cn-beijing-6`。跨环境建议显式设置。 |
+
+### 2.2 CLI 构建、发布、部署到金山云
+
+| 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| `KSYUN_ACCESS_KEY` | 是 | `KS3_ACCESS_KEY` | 是 | 开发者 / CI Secret | 金山云 API / KS3 / KOP 签名 AK。 |
+| `KSYUN_SECRET_KEY` | 是 | `KS3_SECRET_KEY` | 是 | 开发者 / CI Secret | 金山云 API / KS3 / KOP 签名 SK。 |
+| `KSYUN_ACCOUNT_ID` | 条件必传 | 无 | 否 | 开发者 / 平台账号 | 创建/查询/删除资源、权限预检查、个人版 KCR 用户名兜底等场景需要。 |
+| `KSYUN_REGION` | 否 | 无 | 否 | 开发者 / 平台 | 默认 `cn-beijing-6`。 |
+| `AGENTENGINE_SERVER_URL` | 否 | 无 | 否 | 平台 / 开发者 | 覆盖 AgentEngine Server 地址。内部账号/内网环境建议 `http://aicp.inner.api.ksyun.com`;公网账号通常不设置或使用 `https://aicp.api.ksyun.com`。 |
+| `AGENTENGINE_API_VERSION` | 否 | 无 | 否 | 平台 / 开发者 | 覆盖 KOP API version。 |
+| `AGENTENGINE_SIGN_SERVICE` | 否 | 无 | 否 | 平台 / 开发者 | 覆盖 KOP signing service。 |
+| `KSADK_AICP_ENDPOINT_MODE` | 否 | 无 | 否 | 平台 / 开发者 | AICP endpoint 选择策略,支持 `auto/detect/internal/inner/public`。内网环境可显式设为 `inner`,跳过自动探测。 |
+
+### 2.3 ADK Runner 注入远端 MCP tools
+
+| 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| `KSADK_ENABLE_MCP_TOOLS` | 否 | 无 | 否 | 开发者 / 平台 | 默认 `1`,设为 `0/false/no/off` 禁用自动注入。 |
+| `KSADK_MCP_SERVERS` | 条件必传 | 无 | 是 | 开发者 / 平台 Secret | JSON 数组,配置 MCP server url、api_key、tool_filter、tool_name_prefix。可能包含 token。 |
+
+### 2.4 Skill Runtime 本地模式
+
+| 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| `KSADK_SKILLS_MODE` | 否 | 无 | 否 | 开发者 / Runner 环境 | `auto/local/sandbox`。本地调试可显式设为 `local`。 |
+| `KSADK_LOCAL_SKILLS_DIR` | 条件必传 | `KSADK_SKILL_CACHE_DIR` 可作为 fallback | 否 | 开发者 | 本地已解压 Skill 包目录;目录下每个 skill 应包含 `SKILL.md`。 |
+| `KSADK_SKILL_RUNTIME_BACKEND` | 否 | 无 | 否 | 开发者 | 本地进程模式设为 `local_process`。 |
+| `KSADK_SKILL_RUNTIME_AGENT_PATH` | 条件必传 | 默认使用 SDK 内置 agent | 否 | 开发者 | `local_process` backend 的 agent 入口。 |
+
+### 2.5 Skill Runtime 远程 Sandbox / E2B 模式
+
+| 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| `KSADK_SANDBOX_TEMPLATE_ID` | 是 | `KSADK_SKILL_RUNTIME_TEMPLATE_ID` | 否 | 沙箱控制台 / 沙箱团队 | 新部署优先使用。AIO template 是 Skill Runtime 默认推荐。 |
+| `E2B_API_URL` | 是 | 无 | 否 | 沙箱团队 / Secret 配置 | E2B 兼容 manager endpoint。 |
+| `E2B_API_KEY` | 是 | 无 | 是 | 沙箱团队 / Secret 配置 | E2B SDK 原生 API key,不能写入代码、文档示例明文、测试 fixture 或日志。 |
+| `KSADK_SANDBOX_BACKEND` | 否 | 无 | 否 | 平台 / 开发者 | 默认 `e2b`。后续可扩展其他 backend。 |
+| `KSADK_SANDBOX_TYPE` | 否 | 无 | 否 | 平台 / 开发者 | `aio/code/browser/private`,默认 `aio`。 |
+| `KSADK_SANDBOX_TIMEOUT` | 否 | `KSADK_SKILL_RUNTIME_TIMEOUT` | 否 | 平台 / 开发者 | Sandbox 会话超时秒数,默认 `900`。 |
+| `KSADK_SANDBOX_ALLOW_INTERNET_ACCESS` | 否 | `KSADK_SKILL_RUNTIME_ALLOW_INTERNET_ACCESS` | 否 | 平台 / 开发者 | 是否允许 sandbox 出网,默认 `true`。 |
+| `KSADK_SKILLS_MODE` | 否 | 无 | 否 | Runner 环境 | `auto` 下检测到 sandbox backend/template 会注入 `execute_skills`;也可显式设为 `sandbox`。 |
+| `KSADK_SKILL_RUNTIME_BACKEND` | 否 | 无 | 否 | Runner 环境 | 显式设为 `e2b` 会走远程 backend;显式 `disabled` 会禁止 Skill Runtime 注入。未设置且存在 `KSADK_SANDBOX_TEMPLATE_ID` 时自动使用 `e2b`。 |
+
+### 2.6 Skill Center / Skill Service
+
+| 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| `KSADK_SKILL_SERVICE_URL` | 条件必传 | 无 | 否 | 平台 / Skill Service | 配置后 Runtime agent 才会从 Skill Center 拉取 skill。直连 REST 可用 `/agentengine/skill/api/v1`,AICP KOP 可用 `http://aicp.inner.api.ksyun.com`。 |
+| `KSADK_SKILL_SERVICE_ENDPOINT` | 否 | 无 | 否 | 平台 / Skill Service | 未设置 `KSADK_SKILL_SERVICE_URL` 时的 AICP endpoint 覆盖,只写 host/path,不含 scheme。 |
+| `KSADK_SKILL_SERVICE_SCHEME` | 否 | 无 | 否 | 平台 / Skill Service | 未设置 `KSADK_SKILL_SERVICE_URL` 时的 AICP URL scheme 覆盖;内网 endpoint 默认会使用 `http`。 |
+| `KSADK_SKILL_SPACE_IDS` | 条件必传 | `SKILL_SPACE_ID` | 否 | Agent 创建/更新时注入 / Runner 环境 | 逗号分隔 space id;单 space 兼容变量为 `SKILL_SPACE_ID`。 |
+| `SKILL_SPACE_ID` | 条件必传 | `KSADK_SKILL_SPACE_IDS` | 否 | 兼容旧/单 space 注入 | 单个 Skill Space id。新部署优先 `KSADK_SKILL_SPACE_IDS`。 |
+| `KSADK_SKILL_SERVICE_ACCOUNT_ID` | 条件必传 | `KSYUN_ACCOUNT_ID` | 否 | 平台 / 租户上下文 | Skill Service 租户隔离 header。KOP 或直连 REST 租户视图通常需要。 |
+| `KSADK_SKILL_SERVICE_ACCESS_KEY` | 条件必传 | `KSYUN_ACCESS_KEY`、`KS3_ACCESS_KEY` | 是 | 平台 Secret | AICP KOP 签名 AK。直连 REST 或 bearer token 模式不需要。 |
+| `KSADK_SKILL_SERVICE_SECRET_KEY` | 条件必传 | `KSYUN_SECRET_KEY`、`KS3_SECRET_KEY` | 是 | 平台 Secret | AICP KOP 签名 SK。直连 REST 或 bearer token 模式不需要。 |
+| `KSADK_SKILL_SERVICE_TOKEN` | 条件必传 | 无 | 是 | 平台 Secret | Bearer token 模式使用;KOP 签名模式通常不使用。 |
+| `KSADK_SKILL_SERVICE_REGION` | 否 | `KSYUN_REGION` | 否 | 平台 / 开发者 | 默认 `cn-beijing-6`。 |
+| `KSADK_SKILL_SERVICE_API_VERSION` | 否 | 无 | 否 | 平台 / 开发者 | 默认 `2024-06-12`;不要复用 Sandbox KOP 的 `2026-04-01`。 |
+| `KSADK_SKILL_SERVICE_SIGN_SERVICE` | 否 | 无 | 否 | 平台 / 开发者 | 默认 `aicp`。 |
+| `KSADK_SKILL_MANIFEST_LIMIT` | 否 | 无 | 否 | 平台 / 开发者 | 外层 Agent instruction 最多注入的远端 skill manifest 数量,默认 `30`。 |
+| `KSADK_SKILL_MANIFEST_TIMEOUT` | 否 | 无 | 否 | 平台 / 开发者 | 拉取远端 skill manifest 的超时秒数,默认 `5`。 |
+| `KSADK_SELECTED_SKILL_NAMES` | 否 | 无 | 否 | Runner / Runtime agent | `execute_skills` 选中的 skill 名称列表,Runtime agent 优先按它下载;通常由 SDK 自动注入。 |
+| `KSADK_SKILL_CACHE_DIR` | 否 | 无 | 否 | Runtime agent | Skill archive 下载和解压缓存目录。 |
+| `KSADK_SKILL_WORKDIR` | 否 | 无 | 否 | Runtime agent | workflow 工作目录。 |
+| `KSADK_SKILL_ARTIFACT_PROJECT` | 否 | 无 | 否 | Runtime agent | 最小 artifact workflow 默认项目名,默认 `ksadk-artifact`。 |
+
+### 2.7 知识库、记忆库、会话存储
+
+| 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| `KSADK_KB_DATASET_ID` | 条件必传 | 无 | 否 | 平台 / 开发者 | 配置后启用知识库检索。 |
+| `KSADK_KB_ACCESS_KEY` | 条件必传 | `KSYUN_ACCESS_KEY` | 是 | 平台 Secret | SDK 知识库 backend AK。 |
+| `KSADK_KB_SECRET_KEY` | 条件必传 | `KSYUN_SECRET_KEY` | 是 | 平台 Secret | SDK 知识库 backend SK。 |
+| `KSADK_KB_ENDPOINT` | 否 | 无 | 否 | 平台 / 开发者 | 默认 `aicp.api.ksyun.com`。 |
+| `KSADK_KB_REGION` | 否 | 无 | 否 | 平台 / 开发者 | 默认 `cn-beijing-6`。 |
+| `KSADK_KB_SCHEME` | 否 | 无 | 否 | 平台 / 开发者 | KB endpoint 协议。内网 endpoint 默认 `http`,其他默认 `https`。 |
+| `KSADK_KB_AMBIENT_POLICY` | 否 | 无 | 否 | 平台 / 开发者 | runtime 自动注入知识库上下文策略:`on_demand/always/disabled`。 |
+| `KSADK_LTM_BACKEND` | 否 | 无 | 否 | 开发者 | 长期记忆 backend,默认 `local`,可选 `http/sdk`。 |
+| `KSADK_LTM_HTTP_URL` | 条件必传 | 无 | 是 | 平台 Secret | `KSADK_LTM_BACKEND=http` 时需要。 |
+| `KSADK_LTM_HTTP_TOKEN` | 条件必传 | 无 | 是 | 平台 Secret | HTTP LTM 鉴权 token。 |
+| `KSADK_LTM_ACCESS_KEY` | 条件必传 | `KSYUN_ACCESS_KEY` | 是 | 平台 Secret | SDK LTM AK。 |
+| `KSADK_LTM_SECRET_KEY` | 条件必传 | `KSYUN_SECRET_KEY` | 是 | 平台 Secret | SDK LTM SK。 |
+| `KSADK_LTM_AMBIENT_POLICY` | 否 | 无 | 否 | 平台 / 开发者 | runtime 自动注入长期记忆上下文策略:`on_demand/always/disabled`。 |
+| `KSADK_MEMORY_BACKEND` | 否 | 无 | 否 | 开发者 | 轻量 KV/消息历史 MemoryManager backend,默认 `memory`。 |
+| `KSADK_MEMORY_URL` | 条件必传 | 无 | 是 | 开发者 / Secret | `KSADK_MEMORY_BACKEND=redis` 等远端 backend 连接 URL。 |
+| `KSADK_SESSION_BACKEND` | 否 | `AGENTENGINE_SESSION_BACKEND`、`KSADK_STM_BACKEND` | 否 | 平台 / 开发者 | 会话 backend,默认 `local`。ADK/STM 也会把它作为兜底。 |
+| `KSADK_SESSION_DSN` | 条件必传 | `KSADK_STM_URL`、`KSADK_STM_DB_URL`、`KSADK_ADK_SESSION_URL` | 是 | 平台 Secret | `postgres` / `database` backend 时必传。ADK/STM 也会把它作为兜底。 |
+| `KSADK_SESSION_PATH` | 否 | `KSADK_STM_PATH`、`KSADK_STM_DB_PATH` | 否 | 本地运行时 | 本地 SQLite 会话库路径。 |
+| `KSADK_SESSION_NAMESPACE` | 否 | `KSADK_WORKSPACE_ID`、`AGENTENGINE_WORKSPACE_ID`、`KSADK_TENANT_ID`、`AGENTENGINE_TENANT_ID` | 否 | 平台 / 开发者 | 会话命名空间。 |
+
+### 2.8 可观测性和 Langfuse
+
+| 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 |
+| --- | --- | --- | --- | --- | --- |
+| `LANGFUSE_PUBLIC_KEY` | 条件必传 | 无 | 是 | 平台 Secret / 开发者 | 启用 Langfuse 时需要。 |
+| `LANGFUSE_SECRET_KEY` | 条件必传 | 无 | 是 | 平台 Secret / 开发者 | 启用 Langfuse 时需要。 |
+| `LANGFUSE_BASE_URL` | 否 | `LANGFUSE_HOST` | 否 | 平台 / 开发者 | Langfuse endpoint。 |
+| `LANGFUSE_USE_CALLBACK` | 否 | 无 | 否 | 开发者 | 控制是否启用 callback 集成。 |
+| `CLOUD_MONITOR_APP_KEY` | 条件必传 | 无 | 是 | 平台 Secret | 云监控 OTLP AppKey。 |
+| `CLOUD_MONITOR_OTLP_ENDPOINT` | 条件必传 | 无 | 否 | 平台 / 开发者 | CloudMonitor 通用 OTLP HTTP endpoint。 |
+| `OTEL_EXPORTER_OTLP_ENDPOINT` | 条件必传 | 无 | 否 | 平台 / 开发者 | OTel Collector endpoint;未设置 traces 专用 endpoint 时,KsADK 会派生 `/v1/traces`。 |
+| `OTEL_EXPORTER_OTLP_PROTOCOL` | 否 | 无 | 否 | 平台 / 开发者 | 通用 OTLP 协议;KsADK 自动 HTTP exporter 当前支持 `http/protobuf`。 |
+| `OTEL_EXPORTER_OTLP_HEADERS` | 否 | 无 | 是 | 平台 / 开发者 | 通用 OTLP headers,逗号分隔,值按 URL encoding;可能包含 `Authorization`。 |
+| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | 否 | 无 | 否 | 平台 / 开发者 | traces 专用 endpoint;设置后优先于通用 endpoint。 |
+| `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | 否 | 无 | 否 | 平台 / 开发者 | traces 专用 OTLP 协议;设置后优先于通用 protocol。 |
+| `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | 否 | 无 | 是 | 平台 / 开发者 | traces 专用 OTLP headers;设置后优先于通用 headers。 |
+| `OTEL_SERVICE_NAME` | 否 | 无 | 否 | 平台 / 开发者 | OTel service name。 |
+| `OTEL_RESOURCE_ATTRIBUTES` | 否 | 无 | 否 | 平台 / 开发者 | OTel resource attributes。 |
+
+## 3. 通用模型与 LLM 变量
+
+| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| `OPENAI_API_KEY` | 本地运行时 / Runtime 镜像 / OpenClaw / Hermes | 条件必传 | 未设置 | `LLM_API_KEY`、`MODEL_API_KEY`、部分 OpenClaw 场景使用 `OPENCLAW_MODEL_API_KEY` | 是 | 开发者 / Secret | 否 | OpenAI 兼容接口 API key。 |
+| `OPENAI_BASE_URL` | 本地运行时 / Runtime 镜像 / OpenClaw / Hermes | 条件必传 | 未设置 | `OPENAI_API_BASE`、`LLM_API_BASE`、`MODEL_API_BASE`、部分 OpenClaw 场景使用 `OPENCLAW_MODEL_BASE_URL` | 否 | 开发者 / 平台 | 否 | OpenAI 兼容接口 base url。 |
+| `OPENAI_MODEL_NAME` | 本地运行时 / Runtime 镜像 | 条件必传 | 未设置 | `LLM_MODEL`、`MODEL_NAME`、Hermes fallback 读取 `OPENAI_FALLBACK_MODEL_NAME` | 否 | 开发者 / 平台 | 否 | 默认模型名。 |
+| `OPENAI_CONTEXT_LENGTH` | Hermes / 模型配置 | 否 | 未设置 | `MODEL_CONTEXT_LENGTH`、`HERMES_CONTEXT_LENGTH` | 否 | 开发者 / 平台 | 否 | 模型上下文长度提示。 |
+| `OPENAI_FALLBACK_MODEL_NAME` | Hermes / 模型配置 | 否 | 未设置 | `HERMES_FALLBACK_MODEL` | 否 | 开发者 / 平台 | 否 | Hermes fallback 模型名 fallback。 |
+| `LLM_API_KEY` | Serverless / 兼容模型配置 | 条件必传 | 未设置 | `OPENAI_API_KEY`、`MODEL_API_KEY` | 是 | 平台 Secret / 开发者 | 否 | Serverless 平台兼容模型 API key。 |
+| `LLM_API_BASE` | Serverless / 兼容模型配置 | 条件必传 | 未设置 | `OPENAI_BASE_URL`、`MODEL_API_BASE` | 否 | 平台 / 开发者 | 否 | Serverless 平台兼容模型 endpoint。 |
+| `LLM_MODEL` | Serverless / OpenClaw | 条件必传 | 未设置 | `OPENAI_MODEL_NAME`、`MODEL_NAME` | 否 | 平台 / 开发者 | 否 | Serverless/OpenClaw 兼容模型名。 |
+| `MODEL_API_KEY` | OpenClaw / 兼容模型配置 | 条件必传 | 未设置 | `OPENAI_API_KEY` | 是 | 开发者 / Secret | 否 | 兼容 OpenClaw 模型配置。 |
+| `MODEL_API_BASE` | OpenClaw / 兼容模型配置 | 条件必传 | 未设置 | `OPENAI_BASE_URL` | 否 | 开发者 / 平台 | 否 | 兼容 OpenClaw 模型 endpoint。 |
+| `MODEL_BASE_URL` | CLI model / 兼容模型配置 | 否 | 未设置 | `OPENAI_BASE_URL`、`OPENAI_API_BASE`、`MODEL_API_BASE` | 否 | 开发者 / 平台 | 否 | 部分 CLI model 命令和历史配置读取的 base url。 |
+| `MODEL_NAME` | 本地运行时 / OpenClaw | 条件必传 | 未设置 | `OPENAI_MODEL_NAME` | 否 | 开发者 / 平台 | 否 | 旧版模型名变量。 |
+| `COZE_WORKLOAD_IDENTITY_API_KEY` | Coze 导出项目兼容 | 条件必传 | 未设置 | 未设置时可由 `OPENAI_API_KEY` 自动补齐 | 是 | 开发者 / Secret | 否 | 部分 Coze 导出项目依赖 `coze_coding_dev_sdk`,SDK 会尝试从 OpenAI 兼容配置补齐。 |
+| `COZE_INTEGRATION_BASE_URL` | Coze 导出项目兼容 | 条件必传 | 未设置 | 未设置时可由 `OPENAI_BASE_URL` 自动补齐 | 否 | 开发者 / 平台 | 否 | Coze integration endpoint。 |
+| `COZE_INTEGRATION_MODEL_BASE_URL` | Coze 导出项目兼容 | 条件必传 | 未设置 | 未设置时可由 `OPENAI_BASE_URL` 自动补齐 | 否 | 开发者 / 平台 | 否 | Coze model endpoint。 |
+| `COZE_MODEL_NAME` | Coze 导出项目兼容 | 条件必传 | 未设置 | 通常跟随业务导出项目 | 否 | 开发者 / 平台 | 否 | Coze 导出项目模型名。 |
+
+## 4. 金山云账号、KOP、KS3 与镜像仓库
+
+| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| `KSYUN_ACCESS_KEY` | CLI / KOP / KS3 / Skill Service fallback | 条件必传 | 未设置 | `KS3_ACCESS_KEY` | 是 | 开发者 / CI Secret / K8S Secret | 否 | 金山云 AK。启用云端资源操作、KS3、KOP 签名时需要。 |
+| `KSYUN_SECRET_KEY` | CLI / KOP / KS3 / Skill Service fallback | 条件必传 | 未设置 | `KS3_SECRET_KEY` | 是 | 开发者 / CI Secret / K8S Secret | 否 | 金山云 SK。 |
+| `KSYUN_ACCOUNT_ID` | CLI / KOP / 权限预检查 / Skill Service fallback | 条件必传 | 未设置 | 无 | 否 | 平台账号 / 开发者 | 否 | 账号 ID。资源管理、租户隔离、个人版 KCR 用户名兜底等场景需要。 |
+| `KSYUN_REGION` | CLI / KOP / KS3 / Skill Service fallback | 否 | `cn-beijing-6` | 无 | 否 | 开发者 / 平台 | 否 | 区域。跨环境、预发、生产联调建议显式设置。 |
+| `KS_ACCESS_KEY_ID` | 旧 KingsoftCloudConfig | 条件必传 | 未设置 | 建议迁移到 `KSYUN_ACCESS_KEY` | 是 | 兼容旧配置 | 否 | 早期 SDK settings 读取的 AK;不与 `KSYUN_ACCESS_KEY` 自动互通。 |
+| `KS_SECRET_ACCESS_KEY` | 旧 KingsoftCloudConfig | 条件必传 | 未设置 | 建议迁移到 `KSYUN_SECRET_KEY` | 是 | 兼容旧配置 | 否 | 早期 SDK settings 读取的 SK;不与 `KSYUN_SECRET_KEY` 自动互通。 |
+| `KS_REGION` | 旧 KingsoftCloudConfig | 否 | `cn-beijing-6` | 建议迁移到 `KSYUN_REGION` | 否 | 兼容旧配置 | 否 | 早期 SDK settings 读取的 region;不与 `KSYUN_REGION` 自动互通。 |
+| `KS3_ACCESS_KEY` | KS3 / 兼容 fallback | 条件必传 | 未设置 | `KSYUN_ACCESS_KEY` | 是 | 开发者 / Secret | 否 | KS3 专用 AK 兼容变量。 |
+| `KS3_SECRET_KEY` | KS3 / 兼容 fallback | 条件必传 | 未设置 | `KSYUN_SECRET_KEY` | 是 | 开发者 / Secret | 否 | KS3 专用 SK 兼容变量。 |
+| `KS3_BUCKET` | 构建上传 / 版本发布 | 条件必传 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | 自定义 KS3 bucket。 |
+| `KS3_ENDPOINT_MODE` | KS3 上传 | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | KS3 endpoint 选择策略。 |
+| `KS3_ENDPOINT_PROBE_TIMEOUT_SECONDS` | KS3 上传 | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | KS3 endpoint 探测超时。 |
+| `KS3_UPLOAD_TIMEOUT_SECONDS` | KS3 上传 | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | KS3 上传超时。 |
+| `KCR_REGISTRY` | 镜像构建 / MCP / Serverless | 条件必传 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | 镜像仓库地址,通常为 `/`,例如 `agenthzzqy-vpc.ksyunkcr.com/testagent-pub` 或第三方 registry/namespace。 |
+| `KCR_ENDPOINT` | 镜像构建 / MCP / Serverless | 否 | `hub.kce.ksyun.com` | 无 | 否 | 开发者 / 平台 | 否 | KCR endpoint。 |
+| `KCR_USERNAME` | 镜像构建 / MCP / Serverless | 条件必传 | 未设置 | 个人版 KCR 可回退 `KSYUN_ACCOUNT_ID` | 否 | 开发者 / 平台 | 否 | 镜像仓库访问凭证用户名。企业版 KCR 和第三方镜像仓库必须显式设置;个人版 KCR 可留空并使用 `KSYUN_ACCOUNT_ID` 作为用户名兜底。 |
+| `KCR_PASSWORD` | 镜像构建 / MCP / Serverless | 条件必传 | 未设置 | 无 | 是 | 开发者 / Secret | 否 | 镜像仓库访问凭证密码或 token。 |
+
+## 5. 通用 Sandbox Runtime
+
+| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| `KSADK_SANDBOX_BACKEND` | Sandbox backend factory | 否 | `e2b` | 无 | 否 | 平台 / 开发者 | 否 | 通用 sandbox backend。首版支持 `e2b`。 |
+| `KSADK_SANDBOX_TYPE` | Sandbox spec | 否 | `aio` | 无 | 否 | 沙箱控制台 / 平台 | 否 | `aio/code/browser/private`。Skill Runtime 默认推荐 `aio`。 |
+| `KSADK_SANDBOX_TEMPLATE_ID` | Sandbox spec / Skill Runtime E2B backend | 条件必传 | 未设置 | `KSADK_SKILL_RUNTIME_TEMPLATE_ID` | 否 | 沙箱控制台 / 沙箱团队 | 否 | 远程 sandbox 执行时必传。新部署优先使用。 |
+| `KSADK_SANDBOX_TIMEOUT` | Sandbox spec | 否 | `900` | `KSADK_SKILL_RUNTIME_TIMEOUT` | 否 | 平台 / 开发者 | 否 | Sandbox 会话超时秒数。 |
+| `KSADK_SANDBOX_ALLOW_INTERNET_ACCESS` | Sandbox spec | 否 | `true` | `KSADK_SKILL_RUNTIME_ALLOW_INTERNET_ACCESS` | 否 | 平台 / 开发者 | 否 | 是否允许 sandbox 出网。 |
+| `KSADK_SANDBOX_STARTUP_RETRY_ATTEMPTS` | E2B Sandbox backend | 否 | `6` | 无 | 否 | 平台 / 开发者 | 否 | 沙箱创建后 readiness 探测最大重试次数,用于兜底短暂 `NotFoundException` / `FileNotFoundException`。 |
+| `KSADK_SANDBOX_STARTUP_RETRY_DELAY` | E2B Sandbox backend | 否 | `0.2` | 无 | 否 | 平台 / 开发者 | 否 | 沙箱 readiness 首次重试间隔秒数,后续指数退避,单次 sleep 上限 1 秒。 |
+| `E2B_API_URL` | E2B SDK | 条件必传 | 未设置 | 无 | 否 | 沙箱团队 / Secret 配置 | 否 | E2B 兼容 manager endpoint。使用 E2B backend 时必传。 |
+| `E2B_API_KEY` | E2B SDK | 条件必传 | 未设置 | 无 | 是 | 沙箱团队 / Secret 配置 | 否 | E2B API key。严禁写入代码、文档明文、测试 fixture、日志。 |
+
+## 6. Skill Runtime 与 Skill Center
+
+| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| `KSADK_SKILLS_MODE` | ADK Runner | 否 | `auto` | 无 | 否 | 开发者 / 平台 | 否 | `auto/local/sandbox`。`auto` 会根据 sandbox template 或本地 skill 目录自动选择。 |
+| `KSADK_LOCAL_SKILLS_DIR` | ADK Runner / Runtime agent | 条件必传 | 未设置 | `KSADK_SKILL_CACHE_DIR` 可作为 Runner 本地扫描 fallback | 否 | 开发者 | 否 | 本地 skill 目录。 |
+| `KSADK_SKILL_RUNTIME_BACKEND` | Skill Runtime factory | 否 | `disabled`;未设置且存在 `KSADK_SANDBOX_TEMPLATE_ID` 时自动走 `e2b` | 无 | 否 | 开发者 / 平台 | 否 | `disabled/local_process/e2b`。显式 `disabled` 会阻止自动注入。 |
+| `KSADK_SKILL_RUNTIME_TEMPLATE_ID` | Skill Runtime E2B backend | 否 | 未设置 | `KSADK_SANDBOX_TEMPLATE_ID` | 否 | 旧部署 / 兼容 | 否 | 兼容变量。新部署不要优先使用。 |
+| `KSADK_SKILL_RUNTIME_TIMEOUT` | Skill Runtime command | 否 | `900` | `KSADK_SANDBOX_TIMEOUT` 在 E2B 会话层优先 | 否 | 开发者 / 平台 | 否 | workflow 命令超时秒数。 |
+| `KSADK_SKILL_RUNTIME_ALLOW_INTERNET_ACCESS` | Skill Runtime E2B backend | 否 | `true` | `KSADK_SANDBOX_ALLOW_INTERNET_ACCESS` 优先 | 否 | 旧部署 / 兼容 | 否 | 兼容变量。 |
+| `KSADK_SKILL_RUNTIME_AGENT_PATH` | local_process backend | 条件必传 | SDK 内置 `ksadk/skills/runtime/agent.py` | 无 | 否 | 开发者 | 否 | 本地进程 backend 的 agent 路径。 |
+| `KSADK_SKILL_SERVICE_URL` | Runtime agent / Skill Service client | 条件必传 | 未设置 | 无 | 否 | Skill Service / 平台 | 否 | 配置后从 Skill Center 拉取技能。支持直连 REST 和 AICP KOP endpoint。 |
+| `KSADK_SKILL_SERVICE_ENDPOINT` | Runtime agent / AICP resolver | 否 | 按 `KSADK_AICP_ENDPOINT_MODE` 自动选择 | 无 | 否 | Skill Service / 平台 | 否 | 未设置 `KSADK_SKILL_SERVICE_URL` 时覆盖 Skill Service AICP endpoint。 |
+| `KSADK_SKILL_SERVICE_SCHEME` | Runtime agent / AICP resolver | 否 | 内网 endpoint 为 `http`,公网默认 `https` | 无 | 否 | Skill Service / 平台 | 否 | 未设置 `KSADK_SKILL_SERVICE_URL` 时覆盖 Skill Service AICP URL scheme。 |
+| `KSADK_SKILL_SPACE_IDS` | Runner / Runtime agent | 条件必传 | 未设置 | `SKILL_SPACE_ID` | 否 | Agent 创建/更新 / 平台注入 | 否 | 逗号分隔 Skill Space id。 |
+| `KSADK_PUBLIC_SKILL_ALLOWLIST` | Runtime agent | 否 | 未设置 | 无 | 否 | 平台 / Skill Service | 否 | 逗号分隔 public skill 名称白名单;未设置时加载 public space 下全部 active skills。 |
+| `KSADK_PUBLIC_SKILL_SPACE_IDS` | Runner / Runtime agent | 否 | 未设置 | 无 | 否 | 平台 / Skill Service | 否 | 逗号分隔官方公共 Skill Space id,会追加在用户 space 之后。 |
+| `SKILL_SPACE_ID` | Runtime agent / 兼容 | 条件必传 | 未设置 | `KSADK_SKILL_SPACE_IDS` | 否 | 旧部署 / 单 space 注入 | 否 | 单 space 兼容变量。 |
+| `KSADK_SKILL_SERVICE_ACCOUNT_ID` | Skill Service client | 条件必传 | 未设置 | `KSYUN_ACCOUNT_ID` | 否 | 平台租户上下文 | 否 | 租户隔离 account id。 |
+| `KSADK_SKILL_SERVICE_ACCESS_KEY` | Skill Service KOP signing | 条件必传 | 未设置 | `KSYUN_ACCESS_KEY`、`KS3_ACCESS_KEY` | 是 | Secret | 否 | AICP KOP endpoint 签名 AK。 |
+| `KSADK_SKILL_SERVICE_SECRET_KEY` | Skill Service KOP signing | 条件必传 | 未设置 | `KSYUN_SECRET_KEY`、`KS3_SECRET_KEY` | 是 | Secret | 否 | AICP KOP endpoint 签名 SK。 |
+| `KSADK_SKILL_SERVICE_TOKEN` | Skill Service bearer auth | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | Bearer token 模式。 |
+| `KSADK_SKILL_SERVICE_REGION` | Skill Service KOP signing | 否 | `cn-beijing-6` | `KSYUN_REGION` | 否 | 平台 / 开发者 | 否 | KOP 签名 region。 |
+| `KSADK_SKILL_SERVICE_API_VERSION` | Skill Service KOP action | 否 | `2024-06-12` | 无 | 否 | Skill Service / 平台 | 否 | Skill Center KOP API 版本。 |
+| `KSADK_SKILL_SERVICE_SIGN_SERVICE` | Skill Service KOP signing | 否 | `aicp` | 无 | 否 | Skill Service / 平台 | 否 | KOP signing service。 |
+| `KSADK_SKILL_MANIFEST_LIMIT` | ADK Runner | 否 | `30` | 无 | 否 | 平台 / 开发者 | 否 | 外层 Agent instruction 最多注入的远端 skill manifest 数量。 |
+| `KSADK_SKILL_MANIFEST_TIMEOUT` | Skill Service client | 否 | `5` | 无 | 否 | 平台 / 开发者 | 否 | 拉取远端 skill manifest 的超时秒数。 |
+| `KSADK_SELECTED_SKILL_NAMES` | Runtime agent | 否 | 未设置 | 无 | 否 | Runner / Runtime agent | 否 | `execute_skills` 选中的 skill 名称列表,Runtime agent 优先按它下载。 |
+| `KSADK_SKILL_ALLOW_HASH_MISMATCH` | Runtime agent / PackageStore | 否 | `false` | 无 | 否 | 调试 / 兼容旧包 | 否 | 允许 ContentHash 校验失败后以 unverified cache 加载旧 skill 包;生产不建议开启。 |
+| `KSADK_SKILL_CACHE_DIR` | Runtime agent / PackageStore | 否 | 系统临时目录下 `ksadk-skill-cache` | 无 | 否 | Runtime agent | 否 | Skill archive 下载与解压缓存。 |
+| `KSADK_SKILL_WORKDIR` | Runtime agent | 否 | 系统临时目录下 `ksadk-skill-workflow` | 无 | 否 | Runtime agent | 否 | workflow 工作目录。 |
+| `KSADK_SKILL_OUTPUT_DIR` | Runtime agent workflow | 否 | `KSADK_SKILL_WORKDIR/artifacts` | 无 | 否 | Runtime agent | 否 | 传给本地 skill workflow 脚本的产物输出目录。 |
+| `KSADK_SKILL_ROOT_DIR` | Runtime agent workflow | 否 | 当前执行 skill 根目录 | 无 | 否 | Runtime agent | 否 | 传给本地 skill workflow 脚本的 skill 根目录。 |
+| `KSADK_SKILL_ARTIFACT_PROJECT` | Runtime agent | 否 | `ksadk-artifact` | 无 | 否 | Runtime agent | 否 | 最小 artifact workflow 项目目录名。 |
+| `KSADK_WORKFLOW_PROMPT` | Runtime agent workflow | 否 | 当前 workflow prompt | 无 | 否 | Runtime agent | 否 | 传给本地 skill workflow 脚本的用户请求文本。 |
+
+## 7. MCP Runtime
+
+| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| `KSADK_ENABLE_MCP_TOOLS` | ADK Runner | 否 | `1` | 无 | 否 | 开发者 / 平台 | 否 | 控制远端 MCP tools 自动注入。 |
+| `KSADK_MCP_SERVERS` | MCP runtime | 条件必传 | 未设置 | 无 | 是 | 开发者 / 平台 Secret | 否 | JSON 数组。可能包含 MCP server api_key。 |
+
+## 8. 会话、短期记忆和长期记忆
+
+| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| `KSADK_SESSION_BACKEND` | Sessions | 否 | `local` | `AGENTENGINE_SESSION_BACKEND`、`KSADK_STM_BACKEND` | 否 | 开发者 / 平台 | 否 | 会话存储 backend。ADK/STM 也会把它作为兜底。 |
+| `KSADK_SESSION_DSN` | Sessions | 条件必传 | 未设置 | `KSADK_STM_URL`、`KSADK_STM_DB_URL`、`KSADK_ADK_SESSION_URL` | 是 | Secret | 否 | PostgreSQL DSN。`postgres` / `database` backend 时必传。ADK/STM 也会把它作为兜底。 |
+| `KSADK_SESSION_PATH` | Sessions | 否 | 项目目录下本地 sqlite 路径 | `KSADK_STM_PATH`、`KSADK_STM_DB_PATH` | 否 | 开发者 / 本地运行时 | 否 | 本地 SQLite 会话路径。 |
+| `KSADK_SESSION_CONNECT_TIMEOUT` | Sessions | 否 | `5` | `KSADK_SESSION_PG_CONNECT_TIMEOUT` | 否 | 开发者 / 平台 | 否 | PostgreSQL 会话 backend 连接超时秒数。 |
+| `KSADK_SESSION_PG_CONNECT_TIMEOUT` | Sessions 旧兼容 | 否 | `5` | `KSADK_SESSION_CONNECT_TIMEOUT` | 否 | 兼容旧部署 | 否 | 旧 PostgreSQL session 连接超时变量。新部署优先 `KSADK_SESSION_CONNECT_TIMEOUT`。 |
+| `KSADK_SESSION_NAMESPACE` | Sessions | 否 | 未设置 | `KSADK_WORKSPACE_ID`、`AGENTENGINE_WORKSPACE_ID`、`KSADK_TENANT_ID`、`AGENTENGINE_TENANT_ID` | 否 | 平台 | 否 | 会话 namespace。 |
+| `KSADK_CHECKPOINT_BACKEND` | LangGraph checkpoint | 否 | `local` | `local` 等价本地 SQLite;也支持 `sqlite`、`memory`、`postgres` | 否 | 开发者 / 平台 | 否 | LangGraph checkpoint backend。`agentengine web` 本地调试默认优先使用 SQLite。 |
+| `KSADK_CHECKPOINT_PATH` | LangGraph checkpoint | 否 | 项目目录下 `.agentengine/ui/checkpoints.sqlite` | 无 | 否 | 开发者 / 本地运行时 | 否 | 本地 SQLite checkpoint 文件路径。 |
+| `KSADK_LANGGRAPH_CHECKPOINT_DSN` | LangGraph checkpoint | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | `KSADK_CHECKPOINT_BACKEND=postgres` 时的 LangGraph checkpointer PostgreSQL DSN。 |
+| `KSADK_TENANT_ID` | Sessions | 否 | 未设置 | `AGENTENGINE_TENANT_ID` | 否 | 平台 | 否 | 租户 id。 |
+| `KSADK_WORKSPACE_ID` | Sessions | 否 | 未设置 | `AGENTENGINE_WORKSPACE_ID` | 否 | 平台 | 否 | workspace id。 |
+| `KSADK_STM_BACKEND` | 旧 STM / Sessions fallback | 否 | 未设置 | `KSADK_SESSION_BACKEND` | 否 | 兼容旧部署 | 否 | 旧变量。新部署优先 `KSADK_SESSION_BACKEND`,但 ADK/STM 仍可读。 |
+| `KSADK_STM_PATH` | 旧 STM / Sessions fallback | 否 | 未设置 | `KSADK_SESSION_PATH` | 否 | 兼容旧部署 | 否 | 旧变量。 |
+| `KSADK_STM_DB_PATH` | 旧 STM / Sessions fallback | 否 | 未设置 | `KSADK_SESSION_PATH` | 否 | 兼容旧部署 | 否 | 旧变量。 |
+| `KSADK_STM_URL` | 旧 STM / Sessions fallback | 条件必传 | 未设置 | `KSADK_SESSION_DSN` | 是 | 兼容旧部署 | 否 | 旧变量。ADK/STM 仍可读。 |
+| `KSADK_STM_DB_URL` | 旧 STM / Sessions fallback | 条件必传 | 未设置 | `KSADK_SESSION_DSN` | 是 | 兼容旧部署 | 否 | 旧变量。ADK/STM 仍可读。 |
+| `KSADK_ADK_SESSION_BACKEND` | ADK Memory | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | ADK 原生 session backend。 |
+| `KSADK_ADK_SESSION_PATH` | ADK Memory | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | ADK 原生 session sqlite 路径。 |
+| `KSADK_ADK_SESSION_URL` | ADK Memory | 条件必传 | 未设置 | `KSADK_SESSION_DSN` | 是 | Secret | 否 | ADK 原生 session 数据库 URL。统一 session DSN 也可兜底。 |
+| `KSADK_MEMORY_BACKEND` | MemoryManager | 否 | `memory` | 无 | 否 | 开发者 / 平台 | 否 | 轻量 KV/消息历史 backend。当前内置 `memory`,注册 Redis backend 后可用 `redis`。 |
+| `KSADK_MEMORY_URL` | MemoryManager | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | 远端 MemoryManager backend 连接 URL,例如 Redis URL。 |
+| `KSADK_MEMORY_PREFIX` | MemoryManager | 否 | `ksadk:memory:` | 无 | 否 | 开发者 / 平台 | 否 | MemoryManager key prefix。 |
+| `KSADK_MEMORY_TTL` | MemoryManager | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | MemoryManager 默认 TTL 秒数。 |
+| `KSADK_LTM_BACKEND` | Long-term memory | 否 | `local` | 无 | 否 | 开发者 / 平台 | 否 | LTM backend。 |
+| `KSADK_LTM_HTTP_URL` | HTTP LTM | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | HTTP LTM URL。 |
+| `KSADK_LTM_HTTP_TOKEN` | HTTP LTM | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | HTTP LTM token。 |
+| `KSADK_LTM_ACCESS_KEY` | SDK LTM | 条件必传 | 未设置 | `KSYUN_ACCESS_KEY` | 是 | Secret | 否 | SDK LTM AK。 |
+| `KSADK_LTM_SECRET_KEY` | SDK LTM | 条件必传 | 未设置 | `KSYUN_SECRET_KEY` | 是 | Secret | 否 | SDK LTM SK。 |
+| `KSADK_LTM_REGION` | SDK LTM | 否 | `cn-beijing-6` | 无 | 否 | 平台 / 开发者 | 否 | SDK LTM region。 |
+| `KSADK_LTM_ENDPOINT` | SDK LTM | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | SDK LTM endpoint。 |
+| `KSADK_LTM_SCHEME` | SDK LTM | 否 | `https` | 无 | 否 | 平台 / 开发者 | 否 | SDK LTM scheme。 |
+| `KSADK_LTM_INDEX` | LTM | 否 | 未设置 | 无 | 否 | 开发者 | 否 | LTM index。 |
+| `KSADK_LTM_NAMESPACE` | LTM | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | LTM 记忆库 ID;环境变量名保持不变,请填写新版 SDK 的 `MemoryCollectionId`。 |
+| `KSADK_LTM_AGENT_ID` | LTM | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | LTM agent id。 |
+| `KSADK_LTM_SCENE_ID` | LTM | 否 | `_sys_general` | 无 | 否 | 平台 / 开发者 | 否 | LTM scene id;新版记忆库保存必传,未设置时使用通用场景 `_sys_general`。 |
+| `KSADK_LTM_APP_NAME` | LTM | 否 | 未设置 | 无 | 否 | 开发者 | 否 | LTM application name 覆盖。 |
+| `KSADK_LTM_TOP_K` | LTM | 否 | `5` | 无 | 否 | 开发者 | 否 | LTM 返回条数。 |
+| `KSADK_LTM_AUTO_SAVE` | Conversations runtime | 否 | SDK LTM 已绑定时为 `true` | 无 | 否 | 平台 / 开发者 | 否 | 是否在每轮完成后 best-effort 镜像 user/assistant 文本到记忆库。只接受布尔语义:`true/false`、`1/0`、`on/off`。 |
+| `KSADK_LTM_AMBIENT_ENABLED` | Conversations runtime | 否 | `true` | 无 | 否 | 平台 / 开发者 | 否 | 是否允许 runtime 自动加载长期记忆上下文。 |
+| `KSADK_LTM_AMBIENT_POLICY` | Conversations runtime | 否 | `on_demand` | 无 | 否 | 平台 / 开发者 | 否 | 长期记忆 ambient context 策略:`on_demand/always/disabled`。 |
+| `MEM0_API_KEY` | OpenClaw memory backend | 条件必传 | 未设置 | 无 | 是 | 平台 Secret | 否 | 选择 `mem0` memory backend manifest 时需要。 |
+| `MEM0_USER_ID` | OpenClaw memory backend | 条件必传 | 未设置 | 无 | 否 | 平台 / 用户上下文 | 否 | 选择 `mem0` memory backend manifest 时需要。 |
+| `MEM0_BASE_URL` | OpenClaw memory backend | 条件必传 | 未设置 | 无 | 否 | 平台 | 否 | 选择 `mem0` memory backend manifest 时需要。 |
+
+## 9. 知识库
+
+| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| `KSADK_KB` | Knowledge base | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | AICP knowledge-base 连接配置前缀。 |
+| `KSADK_KB_DATASET_ID` | Knowledge base | 条件必传 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 存在时启用知识库。 |
+| `KSADK_KB_ACCESS_KEY` | Knowledge base | 条件必传 | 未设置 | `KSYUN_ACCESS_KEY` | 是 | Secret | 否 | KB AK。 |
+| `KSADK_KB_SECRET_KEY` | Knowledge base | 条件必传 | 未设置 | `KSYUN_SECRET_KEY` | 是 | Secret | 否 | KB SK。 |
+| `KSADK_KB_ENDPOINT` | Knowledge base | 否 | `aicp.api.ksyun.com` | 无 | 否 | 平台 / 开发者 | 否 | KB endpoint。 |
+| `KSADK_KB_REGION` | Knowledge base | 否 | `cn-beijing-6` | 无 | 否 | 平台 / 开发者 | 否 | KB region。 |
+| `KSADK_KB_SCHEME` | Knowledge base | 否 | 内网 endpoint 默认 `http`,其他默认 `https` | 无 | 否 | 平台 / 开发者 | 否 | KB endpoint scheme。 |
+| `KSADK_KB_SEARCH_METHOD` | Knowledge base | 否 | `intelligence_search` | 无 | 否 | 开发者 | 否 | 检索方法。 |
+| `KSADK_KB_TOP_K` | Knowledge base | 否 | `5` | 无 | 否 | 开发者 | 否 | 返回条数。 |
+| `KSADK_KB_SCORE_THRESHOLD` | Knowledge base | 否 | `0.0` | 无 | 否 | 开发者 | 否 | 分数阈值。 |
+| `KSADK_KB_RERANKING_ENABLE` | Knowledge base | 否 | `false` | 无 | 否 | 开发者 | 否 | 是否启用 reranking。 |
+| `KSADK_KB_AMBIENT_ENABLED` | Conversations runtime | 否 | `true` | 无 | 否 | 平台 / 开发者 | 否 | 是否允许 runtime 自动加载知识库上下文。 |
+| `KSADK_KB_AMBIENT_POLICY` | Conversations runtime | 否 | `on_demand` | 无 | 否 | 平台 / 开发者 | 否 | 知识库 ambient context 策略:`on_demand/always/disabled`。 |
+| `KSYUN_SECRET_ID` | Knowledge base fallback | 否 | 未设置 | `KSYUN_ACCESS_KEY` | 是 | 兼容旧配置 | 否 | 代码中作为 KB AK 的旧 fallback,建议使用 `KSADK_KB_ACCESS_KEY` 或 `KSYUN_ACCESS_KEY`。 |
+
+## 10. CLI、构建、部署和 UI 行为
+
+| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| `AGENTENGINE_SERVER_URL` | CLI / API client | 否 | 自动探测:优先 `http://aicp.inner.api.ksyun.com`,不可达时回落 `https://aicp.api.ksyun.com` | 无 | 否 | 平台 / 开发者 | 否 | 覆盖 AgentEngine Server 地址。内部账号/内网环境建议显式设为 `http://aicp.inner.api.ksyun.com`;公网账号通常不设置或使用 `https://aicp.api.ksyun.com`。如果公网 AICP 返回 `InnerAccountCanOnlyAccessThroughIntranet`,客户端会自动切内网重试一次。 |
+| `AGENTENGINE_API_VERSION` | CLI / API client | 否 | 内置版本 | 无 | 否 | 平台 / 开发者 | 否 | 覆盖 AgentEngine API version。 |
+| `AGENTENGINE_PRE_CONTROL_REGION` | CLI / API client | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 预发控制面 region 覆盖。 |
+| `AGENTENGINE_PRE_CUSTOM_SOURCE` | CLI / API client | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 预发 custom source 覆盖。 |
+| `KSADK_AICP_ENDPOINT_MODE` | AICP resolver | 否 | `auto` | 无 | 否 | 平台 / 开发者 | 否 | AICP endpoint 选择策略,支持 `auto/detect/internal/inner/public`。内网环境可显式设为 `inner`,跳过自动探测。 |
+| `AGENTENGINE_MODEL_ALLOWLIST` | CLI model / OpenClaw | 否 | 未设置 | `OPENCLAW_MODEL_ALLOWLIST` | 否 | 平台 / 开发者 | 否 | 模型列表过滤。OpenClaw 场景优先使用 `OPENCLAW_MODEL_ALLOWLIST`。 |
+| `AGENTENGINE_UI_DIR` | 本地 Web UI / Sessions | 否 | 未设置 | 无 | 否 | 本地开发者 | 否 | 本地 UI 静态目录覆盖,主要用于 Web/文件上传本地调试。 |
+| `KSADK_UI_PROFILE` | 本地 Web UI / Runtime bootstrap | 否 | `builtin` | 无 | 否 | 开发者 / 平台 | 否 | Agent UI profile。`custom` 时 runtime 会暴露自定义 UI bootstrap 信息。 |
+| `KSADK_UI_PATH` | 本地 Web UI / Runtime bootstrap | 否 | `/` | 无 | 否 | 开发者 / 平台 | 否 | 自定义 UI 挂载路径,例如 `/research`。 |
+| `KSADK_UI_URL` | Runtime bootstrap | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 外部自定义 UI URL。 |
+| `KSADK_UI_BUNDLE_PATH` | Runtime bootstrap | 否 | 自动探测 `research-ui/dist` | 无 | 否 | 开发者 / 平台 | 否 | 自定义 UI 静态 bundle 相对项目路径。 |
+| `KSADK_WEB_VERSION` | Hosted Web UI static sync | 否 | `latest` | 可显式设置 `0.2.7` / `v0.2.7` | 否 | 构建环境 / 开发者 | 否 | `make sync-ksadk-web-static` 使用的 `@kingsoftcloud/ksadk-web` npm dist-tag 或版本,默认消费最新 release。 |
+| `KSADK_WEB_PACKAGE` | Hosted Web UI static sync | 否 | `@kingsoftcloud/ksadk-web` | 无 | 否 | 构建环境 / 开发者 | 否 | 本地 UI static 同步使用的 npm 包名。 |
+| `KSADK_WEB_TARBALL_NAME` | Hosted Web UI static sync | 否 | 根据 `KSADK_WEB_VERSION` 派生 | 无 | 否 | 构建环境 | 否 | 仅在设置 `KSADK_WEB_RELEASE_URL` 时作为下载保存文件名;npm pack 模式会使用 npm 返回的真实 tarball 文件名。 |
+| `KSADK_WEB_RELEASE_URL` | Hosted Web UI static sync | 否 | 未设置 | 无 | 否 | 构建环境 / 开发者 | 否 | 可选兼容兜底。设置后跳过 npm pack,改从该 tarball URL 下载。 |
+| `KSADK_WEB_CACHE_DIR` | Hosted Web UI static sync | 否 | `.cache/ksadk-web` | 无 | 否 | 构建环境 / 开发者 | 否 | KsADK Web 包解压缓存目录。 |
+| `KSADK_GLOBAL_CONFIG_ENV_KEYS` | CLI | 否 | 未设置 | 无 | 否 | CLI 内部 | 否 | CLI 启动时记录哪些环境变量由 `~/.agentengine/settings.json` 补入,用于区分用户显式环境变量和全局配置默认值。 |
+| `AGENTENGINE_LOCAL_RUNTIME_VENV_REEXEC` | 本地 runtime CLI | 否 | 自动判断 | 无 | 否 | 本地开发者 / 测试 | 否 | 控制本地 runtime 是否在虚拟环境中 re-exec。普通用户通常无需设置。 |
+| `AGENTENGINE_WEB_VENV_REEXEC` | 本地 Web CLI | 否 | 自动判断 | 无 | 否 | 本地开发者 / 测试 | 否 | 控制本地 Web 命令是否在虚拟环境中 re-exec。普通用户通常无需设置。 |
+| `AGENTENGINE_DEBUG` | CLI | 否 | 未设置 | 无 | 否 | 开发者 | 否 | 开启更详细错误输出。 |
+| `AGENTENGINE_GLOBAL_DRY_RUN` | CLI / API client | 否 | 未设置 | 无 | 否 | 开发者 / 测试 | 否 | 全局 dry-run 开关。 |
+| `AGENTENGINE_OUTPUT_MODE` | CLI | 否 | `pretty` | 无 | 否 | 开发者 / CI | 否 | 输出模式,影响 JSON/pretty 渲染。 |
+| `AGENTENGINE_NO_COLOR` | CLI | 否 | 未设置 | `NO_COLOR` | 否 | 开发者 / CI | 否 | 禁用彩色输出。 |
+| `SESSION_TITLE_MODEL` | Conversations runtime | 否 | 未设置 | 默认模型配置 | 否 | 开发者 / 平台 | 否 | 会话标题生成模型覆盖。 |
+| `COMPACTION_DISABLE_SEMANTIC` | Conversations runtime | 否 | `false` | 无 | 否 | 开发者 / 平台 | 否 | 禁用语义压缩摘要。 |
+| `COMPACTION_SUMMARY_TIMEOUT_MS` | Conversations runtime | 否 | `45000` | 无 | 否 | 开发者 / 平台 | 否 | 语义压缩摘要超时毫秒数。 |
+| `COMPACTION_SUMMARY_MAX_GROUPS` | Conversations runtime | 否 | `12` | 无 | 否 | 开发者 / 平台 | 否 | 单次语义压缩最大分组数。 |
+| `COMPACTION_SUMMARY_MODEL` | Conversations runtime | 否 | 默认模型配置 | 无 | 否 | 开发者 / 平台 | 否 | 语义压缩摘要模型覆盖。 |
+| `PORT` | Runtime image / Web | 否 | `8080` | `KSADK_RUNTIME_PORT` 在部分模板中转写 | 否 | 平台 / Runtime 镜像 | 否 | 容器监听端口。业务服务也可能读取同名变量;此时属于业务自定义。 |
+| `HOST` | MCP / Web runtime | 否 | `0.0.0.0` | 无 | 否 | Runtime 镜像 | 否 | MCP/Web 服务监听地址。 |
+| `LOG_LEVEL` | Runtime image | 否 | `INFO` | 无 | 否 | 开发者 / 平台 | 否 | 模板运行时日志级别。 |
+| `CODE_PATH` | Runtime image | 否 | `/app/code` | 无 | 否 | Runtime 镜像 | 否 | 代码包解压/挂载目录。 |
+| `PIP_INDEX_URL` | 构建 / Runtime image | 否 | pip 默认 | `UV_INDEX_URL` | 否 | 开发者 / 平台 | 否 | Python 依赖安装源。 |
+| `UV_INDEX_URL` | 构建 / Runtime image | 否 | uv 默认 | `PIP_INDEX_URL` | 否 | 开发者 / 平台 | 否 | uv 依赖安装源。 |
+| `KSADK_BUILD_PIP_INSTALL_TIMEOUT_SECONDS` | Code Builder | 否 | `2700` | 无 | 否 | 构建环境 / 开发者 | 否 | 源码构建时 `pip install` 总超时秒数。 |
+| `KSADK_BUILD_ENABLE_ATTACHMENT_OCR` | Code Builder / Container Builder | 否 | `false` | 无 | 否 | 构建环境 / 开发者 | 否 | 是否把平台本地 OCR 依赖打进代码包。不开启不影响多模态模型直接消费 `input_image`。 |
+| `KSADK_BUILD_ENABLE_MCP` | Code Builder / Container Builder | 否 | `false` | 无 | 否 | 构建环境 / 开发者 | 否 | 强制把 `mcp` / `langchain-mcp-adapters` 打进包。通常会根据项目 import 或非空 `KSADK_MCP_SERVERS` 自动启用;`[]` 不会启用。 |
+| `KSADK_BUILD_ENABLE_POSTGRES_SESSION` | Code Builder / Container Builder | 否 | `false` | 无 | 否 | 构建环境 / 开发者 | 否 | 强制把 `asyncpg` 打进包。通常会根据 `KSADK_SESSION_BACKEND=postgres`、`KSADK_SESSION_DSN` 或 PostgreSQL DSN 自动启用。 |
+| `KSADK_RUNTIME_PORT` | Runtime image / CLI | 否 | `8080` | 无 | 否 | 平台 | 否 | 模板运行时 HTTP 端口。 |
+| `KSADK_PROJECT_DIR` | Sessions / Web | 否 | 当前工作目录 | 无 | 否 | 本地运行时 | 否 | 本地 session/workspace 状态 project root。 |
+| `KSADK_RESPONSES_SESSION_HEADER` | RemoteRunner | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 远端 Responses session 透传 header 名称。 |
+| `KSADK_TERMINAL_EXEC_SUBCOMMAND_ALLOWLIST` | Terminal exec | 否 | 默认常见只读命令 | 无 | 否 | 平台 / 开发者 | 否 | 追加允许远程 terminal exec 透传的命令前缀,多个前缀用逗号、分号或换行分隔;例如 `config,openclaw config`。设置为 `*` 时允许全部远程 exec 命令。 |
+| `KSADK_TOOL_APPROVAL_MODE` | Built-in tools / Conversations runtime | 否 | `off` | 无 | 否 | 平台 / 开发者 | 否 | 内置工具审批模式;`strict` 时中高风险工具需要审批。 |
+| `KSADK_FEISHU_APP_ID` | OpenClaw diagnostics | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | 飞书辅助 app id。 |
+| `KSADK_FEISHU_RESULT_PATH` | OpenClaw diagnostics | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | 飞书辅助结果路径。 |
+| `KSADK_WORKSPACE_FILES_ENABLED` | Hermes/OpenClaw workspace files | 否 | 镜像内通常默认 `1` | `OPENCLAW_WORKSPACE_FILES_ENABLED` | 否 | Runtime 镜像 / 平台 | 否 | 工作区文件服务开关。 |
+| `KSADK_WORKSPACE_ROOT` | Hermes/OpenClaw workspace files | 否 | 镜像工作目录 | `OPENCLAW_WORKSPACE_DIR`、`HERMES_WORKDIR` | 否 | Runtime 镜像 / 平台 | 否 | 工作区根目录。 |
+
+## 11. 可观测性
+
+| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| `LANGFUSE_PUBLIC_KEY` | Tracing / Runtime | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | Langfuse public key。 |
+| `LANGFUSE_SECRET_KEY` | Tracing / Runtime | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | Langfuse secret key。 |
+| `LANGFUSE_BASE_URL` | Tracing / Runtime | 否 | 未设置 | `LANGFUSE_HOST` | 否 | 平台 / 开发者 | 否 | Langfuse endpoint。 |
+| `LANGFUSE_HOST` | Tracing / Runtime | 否 | 未设置 | `LANGFUSE_BASE_URL` | 否 | 兼容旧配置 | 否 | Langfuse endpoint 旧变量。 |
+| `LANGFUSE_PROJECT_ID` | Tracing | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | Langfuse project id。 |
+| `LANGFUSE_USE_CALLBACK` | Tracing | 否 | 未设置 | 无 | 否 | 开发者 | 否 | 是否启用 Langfuse callback。 |
+| `LANGCHAIN_TRACING_V2` | LangChain tracing | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | LangChain v2 tracing 开关。 |
+| `LANGCHAIN_VERBOSE` | Runtime image | 否 | `true` | 无 | 否 | 开发者 / 平台 | 否 | 模板运行时 LangChain verbose 开关。 |
+| `CLOUD_MONITOR_APP_KEY` | CloudMonitor tracing | 条件必传 | 未设置 | 无 | 是 | 平台 Secret | 否 | 云监控 OTLP AppKey;启用 CloudMonitor OTLP 上报时需要。 |
+| `CLOUD_MONITOR_OTLP_ENABLED` | CloudMonitor tracing | 否 | 自动判断 | 无 | 否 | 平台 / 开发者 | 否 | 显式启用或禁用 CloudMonitor OTLP exporter。 |
+| `CLOUD_MONITOR_OTLP_ENDPOINT` | CloudMonitor tracing | 条件必传 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | CloudMonitor 通用 OTLP HTTP endpoint;未设置 traces endpoint 时会派生 `/v1/traces`。 |
+| `CLOUD_MONITOR_OTLP_PROTOCOL` | CloudMonitor tracing | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | CloudMonitor 通用 OTLP 协议,当前支持 `http/protobuf`。 |
+| `CLOUD_MONITOR_OTLP_HEADERS` | CloudMonitor tracing | 否 | 未设置 | 无 | 是 | 平台 / 开发者 | 否 | CloudMonitor OTLP 附加 headers,逗号分隔且 URL encoded。 |
+| `CLOUD_MONITOR_OTLP_TRACES_ENDPOINT` | CloudMonitor tracing | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | CloudMonitor traces 专用 endpoint,优先于通用 endpoint。 |
+| `CLOUD_MONITOR_OTLP_TRACES_PROTOCOL` | CloudMonitor tracing | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | CloudMonitor traces 专用协议,优先于通用 protocol。 |
+| `CLOUD_MONITOR_LANGFUSE_ENABLED` | CloudMonitor Langfuse callback | 否 | 自动判断 | 无 | 否 | 平台 / 开发者 | 否 | 显式启用或禁用 CloudMonitor Langfuse SDK callback。 |
+| `CLOUD_MONITOR_LANGFUSE_HOST` | CloudMonitor Langfuse callback | 条件必传 | 未设置 | `CLOUD_MONITOR_OTLP_ENDPOINT` | 否 | 平台 / 开发者 | 否 | CloudMonitor AppMonitor Langfuse SDK host。 |
+| `CLOUD_MONITOR_LANGFUSE_PUBLIC_KEY` | CloudMonitor Langfuse callback | 条件必传 | 未设置 | 无 | 是 | 平台 Secret | 否 | CloudMonitor AppMonitor Langfuse public key。 |
+| `CLOUD_MONITOR_LANGFUSE_SECRET_KEY` | CloudMonitor Langfuse callback | 条件必传 | 未设置 | 无 | 是 | 平台 Secret | 否 | CloudMonitor AppMonitor Langfuse secret key。 |
+| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTel | 条件必传 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | OTel Collector endpoint;未设置 traces 专用 endpoint 时,KsADK 会派生 `/v1/traces`。 |
+| `OTEL_EXPORTER_OTLP_PROTOCOL` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 通用 OTLP 协议;KsADK 自动 HTTP exporter 当前支持 `http/protobuf`。 |
+| `OTEL_EXPORTER_OTLP_HEADERS` | OTel | 否 | 未设置 | 无 | 是 | 平台 / 开发者 | 否 | 通用 OTLP headers,逗号分隔,值按 URL encoding;可能包含 `Authorization`。 |
+| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | traces 专用 endpoint;设置后优先于通用 endpoint。 |
+| `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | traces 专用 OTLP 协议;设置后优先于通用 protocol。 |
+| `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | OTel | 否 | 未设置 | 无 | 是 | 平台 / 开发者 | 否 | traces 专用 OTLP headers;设置后优先于通用 headers。 |
+| `OTEL_SERVICE_NAME` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | service name。 |
+| `OTEL_RESOURCE_ATTRIBUTES` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | resource attributes。 |
+
+## 12. Hermes 和 OpenClaw 常见运行时变量
+
+Hermes / OpenClaw 有大量镜像启动和安全策略变量,本文只列常见运行时可配置项。`*_PID`、`*_MARKER`、`*_CACHE_DIR`、`*_SPEC`、`*_PLUGIN_ID`、`*_PATCH_ROOTS`、`*_READY_STATUSES` 等主要是脚本内部状态或模板常量,未逐项列出。完整模板变量以 `deploy/hermes/`、`deploy/openclaw/`、`deploy/openclaw-user-template/` 内 README 和 bootstrap 脚本为准。
+
+| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| `HERMES_MODEL_PROVIDER` | Hermes | 否 | `custom` | 无 | 否 | 开发者 / 平台 | 否 | Hermes 模型 provider。 |
+| `HERMES_CONTEXT_LENGTH` | Hermes | 否 | `OPENAI_CONTEXT_LENGTH` / `MODEL_CONTEXT_LENGTH` | 无 | 否 | 开发者 / 平台 | 否 | 上下文长度。 |
+| `HERMES_COMPRESSION_MODEL` | Hermes | 否 | `OPENAI_MODEL_NAME` | 无 | 否 | 开发者 / 平台 | 否 | 压缩模型。 |
+| `HERMES_COMPRESSION_BASE_URL` | Hermes | 否 | `OPENAI_BASE_URL` | 无 | 否 | 开发者 / 平台 | 否 | 压缩模型 endpoint。 |
+| `HERMES_COMPRESSION_PROVIDER` | Hermes | 否 | `HERMES_MODEL_PROVIDER` | 无 | 否 | 开发者 / 平台 | 否 | 压缩模型 provider。 |
+| `HERMES_COMPRESSION_CONTEXT_LENGTH` | Hermes | 否 | `HERMES_CONTEXT_LENGTH` | 无 | 否 | 开发者 / 平台 | 否 | 压缩模型上下文长度。 |
+| `HERMES_COMPRESSION_TIMEOUT` | Hermes | 否 | `120` | 无 | 否 | 开发者 / 平台 | 否 | 压缩请求超时秒数。 |
+| `HERMES_FALLBACK_MODEL` | Hermes | 否 | `OPENAI_FALLBACK_MODEL_NAME` | 无 | 否 | 开发者 / 平台 | 否 | fallback 模型。 |
+| `HERMES_FALLBACK_BASE_URL` | Hermes | 否 | `OPENAI_BASE_URL` | 无 | 否 | 开发者 / 平台 | 否 | fallback endpoint。 |
+| `HERMES_FALLBACK_PROVIDER` | Hermes | 否 | `custom` | 无 | 否 | 开发者 / 平台 | 否 | fallback 模型 provider。 |
+| `HERMES_HOSTED_RUNTIME` | Hermes | 否 | `1` | 无 | 否 | Runtime 镜像 / 平台 | 否 | 标识 Hermes 以 hosted runtime 模式运行。 |
+| `HERMES_HOME` | Hermes | 否 | `${HERMES_STATE_DIR}` | 无 | 否 | Runtime 镜像 | 否 | Hermes 状态根目录。 |
+| `HERMES_STATE_DIR` | Hermes | 否 | `${HOME}/.hermes` | 无 | 否 | Runtime 镜像 | 否 | Hermes 状态目录。 |
+| `HERMES_WORKDIR` | Hermes | 否 | 镜像默认值 | 无 | 否 | Runtime 镜像 | 否 | Hermes 工作目录。 |
+| `HERMES_RUN_DIR` | Hermes | 否 | `${HERMES_HOME}/run` | 无 | 否 | Runtime 镜像 | 否 | Hermes 运行时 PID/socket 目录。 |
+| `HERMES_SESSION_DIR` | Hermes | 否 | `${HERMES_HOME}/sessions` | 无 | 否 | Runtime 镜像 | 否 | Hermes 会话目录。 |
+| `MCPORTER_HOME` | Hermes | 否 | `${HERMES_HOME}/mcporter` | 无 | 否 | Runtime 镜像 | 否 | MCPorter 状态目录。 |
+| `XDG_CONFIG_HOME` | Hermes | 否 | `${HERMES_HOME}/xdg/config` | 无 | 否 | Runtime 镜像 | 否 | XDG config 目录覆盖。 |
+| `XDG_CACHE_HOME` | Hermes | 否 | `${HERMES_HOME}/xdg/cache` | 无 | 否 | Runtime 镜像 | 否 | XDG cache 目录覆盖。 |
+| `XDG_STATE_HOME` | Hermes | 否 | `${HERMES_HOME}/xdg/state` | 无 | 否 | Runtime 镜像 | 否 | XDG state 目录覆盖。 |
+| `AGENT_BROWSER_HOME` | Hermes browser | 否 | `/usr/local/lib/node_modules/agent-browser` | 无 | 否 | Runtime 镜像 | 否 | browser agent 安装目录。 |
+| `AGENT_BROWSER_EXECUTABLE_PATH` | Hermes/OpenClaw browser | 否 | `/usr/bin/chromium` 或自动探测 | `OPENCLAW_BROWSER_EXECUTABLE_PATH` | 否 | Runtime 镜像 / 开发者 | 否 | 浏览器可执行文件路径覆盖。 |
+| `AGENT_BROWSER_STATE_DIR` | Hermes browser | 否 | `${HERMES_HOME}/browser` | 无 | 否 | Runtime 镜像 | 否 | browser agent 状态目录。 |
+| `AGENT_BROWSER_RUN_DIR` | Hermes browser | 否 | `${AGENT_BROWSER_STATE_DIR}/run` | 无 | 否 | Runtime 镜像 | 否 | browser agent 运行目录。 |
+| `AGENT_BROWSER_SESSION_DIR` | Hermes browser | 否 | `${AGENT_BROWSER_STATE_DIR}/sessions` | 无 | 否 | Runtime 镜像 | 否 | browser agent 会话目录。 |
+| `AGENT_BROWSER_SOCKET_DIR` | Hermes browser | 否 | `${AGENT_BROWSER_RUN_DIR}` | 无 | 否 | Runtime 镜像 | 否 | browser agent socket 目录。 |
+| `AGENT_BROWSER_ARTIFACTS_DIR` | Hermes browser | 否 | `${AGENT_BROWSER_STATE_DIR}/artifacts` | 无 | 否 | Runtime 镜像 | 否 | browser agent 产物目录。 |
+| `AGENT_BROWSER_LOG_DIR` | Hermes browser | 否 | `${AGENT_BROWSER_STATE_DIR}/logs` | 无 | 否 | Runtime 镜像 | 否 | browser agent 日志目录。 |
+| `API_SERVER_ENABLED` | Hermes API server | 否 | `true` | 无 | 否 | Runtime 镜像 / 开发者 | 否 | Hermes 内置 API server 开关。 |
+| `API_SERVER_HOST` | Hermes API server | 否 | `127.0.0.1` | 无 | 否 | Runtime 镜像 / 开发者 | 否 | Hermes 内置 API server host。 |
+| `API_SERVER_PORT` | Hermes API server | 否 | `8642` | 无 | 否 | Runtime 镜像 / 开发者 | 否 | Hermes 内置 API server port。 |
+| `API_SERVER_KEY` | Hermes API server | 条件必传 | 未设置 | `HERMES_API_SERVER_KEY` | 是 | Secret | 否 | Hermes API server 鉴权 key。 |
+| `TAVILY_API_KEY` | Hermes / OpenClaw web search | 条件必传 | 未设置 | `OPENCLAW_TAVILY_API_KEY` | 是 | Secret | 否 | Tavily 搜索 key。 |
+| `FIRECRAWL_API_KEY` | Hermes web/search skill | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | bundled web/search skill 使用 Firecrawl 时需要。 |
+| `EXA_API_KEY` | Hermes web/search skill | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | bundled web/search skill 使用 Exa 时需要。 |
+| `PARALLEL_API_KEY` | Hermes web/search skill | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | bundled web/search skill 使用 Parallel 时需要。 |
+| `BROWSERBASE_API_KEY` | Hermes browser skill | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | browser skill 使用 Browserbase 时需要。 |
+| `BROWSER_USE_API_KEY` | Hermes browser skill | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | browser-use 云服务模式需要。 |
+| `CAMOFOX_URL` | Hermes browser skill | 条件必传 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | browser skill 使用 Camofox 服务时的 endpoint。 |
+| `KDOCS_OPEN_BROWSER` | Hermes kdocs skill | 否 | `0` | 无 | 否 | 开发者 | 否 | kdocs token 获取脚本是否自动打开浏览器。 |
+| `HERMES_DASHBOARD_HOST` | Hermes | 否 | `127.0.0.1` | 无 | 否 | Runtime 镜像 / 开发者 | 否 | Dashboard 监听 host。 |
+| `HERMES_DASHBOARD_PORT` | Hermes | 否 | `9119` | 无 | 否 | Runtime 镜像 / 开发者 | 否 | Dashboard 端口。 |
+| `HERMES_UI_LOCALE` | Hermes | 否 | `zh` | `LANG`、`LC_ALL` | 否 | Runtime 镜像 / 开发者 | 否 | UI 语言。 |
+| `HERMES_API_SERVER_KEY` | Hermes CLI | 条件必传 | 未设置 | `API_SERVER_KEY` | 是 | Secret | 否 | Hermes API server 鉴权 key。 |
+| `HERMES_IMAGE` | Hermes CLI | 否 | CLI 内置镜像 | `HERMES_DOCKER_IMAGE` | 否 | 开发者 / CI | 否 | Hermes 镜像覆盖。 |
+| `HERMES_RESOURCE` | Hermes CLI | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | Hermes 资源规格覆盖。 |
+| `OPENCLAW_GATEWAY_AUTH_MODE` | OpenClaw | 条件必传 | 模板默认值 | 无 | 否 | 平台 / 开发者 | 否 | Gateway 鉴权模式。 |
+| `OPENCLAW_GATEWAY_TOKEN` | OpenClaw | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | token 模式鉴权 token。 |
+| `OPENCLAW_GATEWAY_PASSWORD` | OpenClaw | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | password 模式鉴权密码。 |
+| `OPENCLAW_GATEWAY_PORT` | OpenClaw | 否 | 模板默认值 | 无 | 否 | Runtime 镜像 / 平台 | 否 | Gateway 端口。 |
+| `OPENCLAW_GATEWAY_BIND` | OpenClaw | 否 | `lan` | 无 | 否 | Runtime 镜像 / 平台 | 否 | gateway 绑定模式。 |
+| `OPENCLAW_GATEWAY_TRUSTED_PROXY_USER_HEADER` | OpenClaw | 条件必传 | 模板默认值 | `OPENCLAW_TRUSTED_PROXY_USER_HEADER` | 否 | 平台 | 否 | trusted-proxy 用户 header。 |
+| `OPENCLAW_TRUSTED_PROXIES` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 | 否 | trusted-proxy 允许代理列表。 |
+| `OPENCLAW_INTERNAL_TRUSTED_PROXY_USER` | OpenClaw | 否 | `openclaw-backend` | 无 | 否 | Runtime 镜像 / 平台 | 否 | 内部 loopback 请求用户。 |
+| `OPENCLAW_INTERNAL_TRUSTED_PROXY_USER_HEADER` | OpenClaw | 否 | `OPENCLAW_TRUSTED_PROXY_USER_HEADER` | 无 | 否 | Runtime 镜像 / 平台 | 否 | 内部 loopback 用户 header。 |
+| `OPENCLAW_ALLOWED_ORIGINS` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 / 开发者 | 否 | CORS allowed origins,支持列表/JSON。 |
+| `OPENCLAW_ALLOW_INSECURE_AUTH` | OpenClaw | 否 | `false` | 无 | 否 | 开发者 / 测试 | 否 | 允许不安全鉴权配置,生产不要开启。 |
+| `OPENCLAW_DISABLE_DEVICE_AUTH` | OpenClaw | 否 | `false` | 无 | 否 | 开发者 / 测试 | 否 | 禁用设备鉴权。 |
+| `OPENCLAW_MODEL_API_KEY` | OpenClaw | 条件必传 | 未设置 | `OPENAI_API_KEY` / `MODEL_API_KEY` | 是 | Secret | 否 | OpenClaw 模型 API key。 |
+| `OPENCLAW_MODEL_BASE_URL` | OpenClaw | 条件必传 | 未设置 | `OPENAI_BASE_URL` / `MODEL_API_BASE` | 否 | 平台 / 开发者 | 否 | OpenClaw 模型 endpoint。 |
+| `OPENCLAW_DEFAULT_MODEL` | OpenClaw | 条件必传 | 未设置 | `OPENAI_MODEL_NAME` / `MODEL_NAME` | 否 | 平台 / 开发者 | 否 | OpenClaw 默认模型。 |
+| `OPENCLAW_MODEL_PROVIDER_ID` | OpenClaw | 否 | `ksyun` | 无 | 否 | 平台 / 开发者 | 否 | OpenClaw 模型 provider id。 |
+| `OPENCLAW_MODEL_API` | OpenClaw | 否 | `openai-completions` | 无 | 否 | 平台 / 开发者 | 否 | OpenClaw 模型 API 类型。 |
+| `OPENCLAW_MODEL_CATALOG_JSON` | OpenClaw | 否 | 自动生成 | 无 | 否 | 平台 / 开发者 | 否 | 覆盖模型 catalog。 |
+| `OPENCLAW_MODEL_ALLOWLIST` | OpenClaw | 否 | 未设置 | `AGENTENGINE_MODEL_ALLOWLIST` | 否 | 平台 / 开发者 | 否 | OpenClaw 模型白名单。 |
+| `OPENCLAW_MODEL_API_KEY_SECRET_SOURCE` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 | 否 | 模型 API key secret 来源,例如 env/file。 |
+| `OPENCLAW_MODEL_API_KEY_SECRET_PROVIDER` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 | 否 | 模型 API key secret provider 标识。 |
+| `OPENCLAW_MODEL_API_KEY_SECRET_ID` | OpenClaw / safe exec | 条件必传 | 未设置 | 无 | 是 | Secret 配置 | 否 | file/secret-provider 模式下的模型或 web-search key 引用 ID。 |
+| `OPENCLAW_MODEL_API_KEY_SECRET_FILE_PATH` | OpenClaw | 条件必传 | 模板默认值 | 无 | 是 | Secret 挂载 | 否 | file secret 模式下的 key 文件路径。 |
+| `OPENCLAW_BROWSER_ENABLED` | OpenClaw | 否 | 安全策略决定 | 无 | 否 | 平台 / 开发者 | 否 | 是否启用浏览器能力。 |
+| `OPENCLAW_BROWSER_NO_SANDBOX` | OpenClaw | 否 | `true` | 无 | 否 | Runtime 镜像 / 平台 | 否 | Chromium no-sandbox 开关。 |
+| `OPENCLAW_BROWSER_HEADLESS` | OpenClaw | 否 | `true` | 无 | 否 | Runtime 镜像 / 平台 | 否 | 浏览器 headless 开关。 |
+| `OPENCLAW_BROWSER_EXECUTABLE_PATH` | OpenClaw | 否 | 自动探测 | `OPENCLAW_BROWSER_EXECUTABLE` | 否 | Runtime 镜像 / 平台 | 否 | 浏览器可执行文件路径。 |
+| `OPENCLAW_BROWSER_SSRF_POLICY_JSON` | OpenClaw | 否 | 模板默认策略 | 无 | 否 | 平台 / 开发者 | 否 | 浏览器 SSRF 策略 JSON。 |
+| `OPENCLAW_WEB_FETCH_ENABLED` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 / 开发者 | 否 | web fetch 能力开关。 |
+| `OPENCLAW_WEB_SEARCH_PROVIDER` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 / 开发者 | 否 | web search provider。 |
+| `OPENCLAW_WEB_SEARCH_BASE_URL` | OpenClaw | 条件必传 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | web search endpoint。 |
+| `OPENCLAW_WEB_SEARCH_MODEL` | OpenClaw | 条件必传 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | web search 模型名。 |
+| `OPENCLAW_WEB_SEARCH_API_KEY` | OpenClaw | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | web search API key。 |
+| `OPENCLAW_WEB_SEARCH_API_KEY_SECRET_SOURCE` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 | 否 | web search key secret 来源。 |
+| `OPENCLAW_WEB_SEARCH_API_KEY_SECRET_PROVIDER` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 | 否 | web search key secret provider 标识。 |
+| `OPENCLAW_WEB_SEARCH_API_KEY_SECRET_ID` | OpenClaw | 条件必传 | 未设置 | 无 | 是 | Secret 配置 | 否 | web search key 引用 ID。 |
+| `OPENCLAW_TAVILY_API_KEY` | OpenClaw web search | 条件必传 | 未设置 | `TAVILY_API_KEY` | 是 | Secret | 否 | OpenClaw Tavily 搜索 key。 |
+| `OPENCLAW_WEB_SAFE_SEARCH_MODE` | OpenClaw safe exec | 否 | `bing` | 无 | 否 | 平台 / 开发者 | 否 | safe web search 模式,支持默认 Bing RSS 或模型搜索。 |
+| `OPENCLAW_WEB_SAFE_SEARCH_MODEL` | OpenClaw safe exec | 条件必传 | 未设置 | `OPENCLAW_WEB_SEARCH_MODEL` / 默认模型 | 否 | 平台 / 开发者 | 否 | safe web search 模型名覆盖。 |
+| `OPENCLAW_WEB_SAFE_SEARCH_BASE_URL` | OpenClaw safe exec | 条件必传 | 未设置 | `OPENCLAW_WEB_SEARCH_BASE_URL` / 模型 base url | 否 | 平台 / 开发者 | 否 | safe web search 模型 endpoint。 |
+| `OPENCLAW_WEB_SAFE_SEARCH_API` | OpenClaw safe exec | 否 | `OPENCLAW_MODEL_API` 或 `openai-completions` | 无 | 否 | 平台 / 开发者 | 否 | safe web search 模型 API 类型。 |
+| `OPENCLAW_WEB_SAFE_SEARCH_API_KEY` | OpenClaw safe exec | 条件必传 | 模型 key fallback | 无 | 是 | Secret | 否 | safe web search 专用 API key。 |
+| `OPENCLAW_WEB_SAFE_SEARCH_SECRET_SOURCE` | OpenClaw safe exec | 否 | `OPENCLAW_MODEL_API_KEY_SECRET_SOURCE` | 无 | 否 | 平台 | 否 | safe web search key secret 来源。 |
+| `OPENCLAW_WEB_SAFE_SEARCH_SECRET_FILE_PATH` | OpenClaw safe exec | 条件必传 | 未设置 | 无 | 是 | Secret 挂载 | 否 | safe web search file secret 路径。 |
+| `OPENCLAW_WEB_SAFE_SEARCH_SECRET_ID` | OpenClaw safe exec | 条件必传 | 未设置 | 无 | 是 | Secret 配置 | 否 | safe web search key 引用 ID。 |
+| `OPENCLAW_WEB_SAFE_SEARCH_ENDPOINT` | OpenClaw safe exec | 否 | `https://cn.bing.com/search?format=rss&q={query}` | 无 | 否 | 平台 / 开发者 | 否 | safe web search HTTP endpoint。 |
+| `OPENCLAW_WEB_SAFE_READER_ENDPOINT` | OpenClaw safe exec | 否 | `https://r.jina.ai/` | 无 | 否 | 平台 / 开发者 | 否 | safe web reader endpoint。 |
+| `OPENCLAW_WEB_SAFE_UNRESTRICTED` | OpenClaw safe exec | 否 | `false` | `OPENCLAW_EXEC_UNSAFE_MODE` 派生 | 否 | 开发者 / 测试 | 否 | 放宽 safe web SSRF 限制,生产不要开启。 |
+| `OPENCLAW_WORKSPACE_FILES_ENABLED` | OpenClaw | 否 | 模板默认值 | `KSADK_WORKSPACE_FILES_ENABLED` | 否 | Runtime 镜像 / 平台 | 否 | workspace files 服务开关。 |
+| `OPENCLAW_WORKSPACE_DIR` | OpenClaw | 否 | 模板默认值 | `KSADK_WORKSPACE_ROOT` | 否 | Runtime 镜像 / 平台 | 否 | workspace 目录。 |
+| `OPENCLAW_WORKSPACE_FILES_PORT` | OpenClaw workspace files | 否 | 模板默认值 | 无 | 否 | Runtime 镜像 / 平台 | 否 | workspace files 服务端口。 |
+| `OPENCLAW_WORKSPACE_FILES_PROXY_URL` | OpenClaw workspace files | 否 | 未设置 | 无 | 否 | Runtime 镜像 / 平台 | 否 | workspace files 代理地址。 |
+| `OPENCLAW_PRESET_SKILLS_DIR` | OpenClaw bootstrap | 否 | `/opt/openclaw/preset-skills` | 无 | 否 | Runtime 镜像 / 平台 | 否 | 预置 skills 目录。 |
+| `OPENCLAW_DEFAULT_EXTENSIONS_DIR` | OpenClaw bootstrap | 否 | `/opt/openclaw/default-extensions` | 无 | 否 | Runtime 镜像 / 平台 | 否 | 默认 extensions 目录。 |
+| `OPENCLAW_GATEWAY_INTERNAL_HOST` | OpenClaw runtime proxy | 否 | `127.0.0.1` | 无 | 否 | Runtime 镜像 / 平台 | 否 | runtime proxy 连接内部 gateway 的 host。 |
+| `OPENCLAW_GATEWAY_INTERNAL_PORT` | OpenClaw runtime proxy | 否 | `18080` | 无 | 否 | Runtime 镜像 / 平台 | 否 | runtime proxy 连接内部 gateway 的端口。 |
+| `OPENCLAW_GATEWAY_PROXY_BASE_URL` | OpenClaw runtime proxy | 否 | 自动生成 | 无 | 否 | Runtime 镜像 / 平台 | 否 | runtime proxy HTTP base url 覆盖。 |
+| `OPENCLAW_GATEWAY_PROXY_WS_URL` | OpenClaw runtime proxy | 否 | 自动生成 | 无 | 否 | Runtime 镜像 / 平台 | 否 | runtime proxy WebSocket url 覆盖。 |
+| `OPENCLAW_GATEWAY_HANDOFF_GRACE_SECONDS` | OpenClaw gateway supervisor | 否 | `5` | 无 | 否 | Runtime 镜像 / 平台 | 否 | gateway handoff 等待秒数。 |
+| `OPENCLAW_GATEWAY_LOCAL_RESTART_MAX` | OpenClaw gateway supervisor | 否 | `3` | 无 | 否 | Runtime 镜像 / 平台 | 否 | gateway 本地重启最大次数。 |
+| `OPENCLAW_GATEWAY_LOCAL_RESTART_WINDOW_SECONDS` | OpenClaw gateway supervisor | 否 | `120` | 无 | 否 | Runtime 镜像 / 平台 | 否 | gateway 本地重启计数窗口。 |
+| `OPENCLAW_GATEWAY_LOCAL_RESTART_BACKOFF_SECONDS` | OpenClaw gateway supervisor | 否 | `1` | 无 | 否 | Runtime 镜像 / 平台 | 否 | gateway 本地重启退避秒数。 |
+| `OPENCLAW_EXEC_STRICT_MODE` | OpenClaw | 否 | `false` | `OPENCLAW_EXEC_SAFE_MODE` | 否 | 平台 / 开发者 | 否 | 收紧 exec/fs 策略。 |
+| `OPENCLAW_EXEC_HOST` | OpenClaw | 否 | `gateway` | 无 | 否 | 平台 / 开发者 | 否 | exec tool host 策略。 |
+| `OPENCLAW_EXEC_SECURITY` | OpenClaw | 否 | `full` 或 profile 默认 | 无 | 否 | 平台 / 开发者 | 否 | exec 安全级别:`full/allowlist/deny` 等。 |
+| `OPENCLAW_EXEC_ASK` | OpenClaw | 否 | `off` | 无 | 否 | 平台 / 开发者 | 否 | exec 询问策略。 |
+| `OPENCLAW_EXEC_ASK_FALLBACK` | OpenClaw | 否 | profile 默认 | 无 | 否 | 平台 / 开发者 | 否 | 询问失败时的 fallback 策略。 |
+| `OPENCLAW_EXEC_AUTO_ALLOW_SKILLS` | OpenClaw | 否 | `false` | 无 | 否 | 平台 / 开发者 | 否 | 是否自动允许预置 skill 调用 exec。 |
+| `OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED` | OpenClaw | 否 | profile 默认 | 无 | 否 | 平台 / 开发者 | 否 | 是否启用默认 exec allowlist。 |
+| `OPENCLAW_EXEC_ALLOWLIST` | OpenClaw | 否 | 未设置 | `OPENCLAW_EXEC_DEFAULT_ALLOWLIST` | 否 | 平台 / 开发者 | 否 | exec allowlist 覆盖。 |
+| `OPENCLAW_FS_WORKSPACE_ONLY` | OpenClaw | 否 | profile 默认 | 无 | 否 | 平台 / 开发者 | 否 | 文件系统访问限制到 workspace。 |
+| `OPENCLAW_ELEVATED_ENABLED` | OpenClaw | 否 | `false` | 无 | 否 | 平台 / 开发者 | 否 | elevated tool 开关。 |
+| `OPENCLAW_PRESET_SKILLS_ALLOWLIST` | OpenClaw | 否 | 模板默认值 | 无 | 否 | Runtime 镜像 / 平台 | 否 | 预置 skills allowlist。 |
+| `OPENCLAW_PRESET_PLUGINS_ALLOWLIST` | OpenClaw | 否 | 模板默认值 | 无 | 否 | Runtime 镜像 / 平台 | 否 | 预置 plugins allowlist。 |
+| `OPENCLAW_RUNTIME_PROXY_ENABLED` | OpenClaw | 否 | 模板默认值 | 无 | 否 | Runtime 镜像 / 平台 | 否 | runtime proxy 开关。 |
+| `OPENCLAW_RESPONSES_API_ENABLED` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 / 开发者 | 否 | Responses API 兼容入口开关。 |
+| `OPENCLAW_THINKING_DEFAULT` | OpenClaw | 否 | `off` | 无 | 否 | 平台 / 开发者 | 否 | 默认 thinking effort。 |
+| `OPENCLAW_VERBOSE_DEFAULT` | OpenClaw | 否 | `off` | 无 | 否 | 平台 / 开发者 | 否 | 默认 verbose 行为。 |
+| `OPENCLAW_TYPING_MODE` | OpenClaw | 否 | `instant` | 无 | 否 | 平台 / 开发者 | 否 | UI typing 展示模式。 |
+| `OPENCLAW_UI_LOCALE` | OpenClaw | 否 | 模板默认值 | `LANG`、`LC_ALL` | 否 | Runtime 镜像 / 开发者 | 否 | OpenClaw UI 语言。 |
+| `OPENCLAW_CHANNEL_BOOTSTRAP_JSON` | OpenClaw | 否 | 未设置 | 无 | 是 | 平台 Secret / 部署配置 | 否 | channel 启动配置,可能包含登录/连接 token。 |
+| `OPENCLAW_CONFIG_PATCH_JSON` | OpenClaw | 否 | 未设置 | 无 | 是 | 平台 Secret / 部署配置 | 否 | openclaw 配置 patch,可能包含 secret。 |
+| `OPENCLAW_BOOTSTRAP_ONLY` | OpenClaw | 否 | `false` | 无 | 否 | Runtime 镜像 / 开发者 | 否 | 只执行 bootstrap,不启动 gateway。 |
+| `OPENCLAW_STATE_DIR` | OpenClaw | 否 | `/home/node/.openclaw` | 无 | 否 | Runtime 镜像 | 否 | OpenClaw 状态目录。 |
+| `OPENCLAW_TEMPLATE_DIR` | OpenClaw user template | 否 | `/opt/openclaw-template` | 无 | 否 | Runtime 镜像 | 否 | user template 根目录。 |
+| `OPENCLAW_TEMPLATE_ENV_STRICT` | OpenClaw user template | 否 | `1` | 无 | 否 | Runtime 镜像 / 开发者 | 否 | user bootstrap 是否严格校验环境变量。 |
+| `OPENCLAW_IMAGE` | OpenClaw CLI | 否 | CLI 内置镜像 | `OPENCLAW_DOCKER_IMAGE` | 否 | 开发者 / CI | 否 | OpenClaw 镜像覆盖。 |
+| `OPENCLAW_RESOURCE` | OpenClaw CLI | 否 | CLI 默认规格 | 无 | 否 | 开发者 / 平台 | 否 | OpenClaw 资源规格快捷配置。 |
+| `OPENCLAW_CPU` | OpenClaw CLI | 否 | CLI 默认规格 | 无 | 否 | 开发者 / 平台 | 否 | OpenClaw CPU 规格覆盖。 |
+| `OPENCLAW_MEMORY` | OpenClaw CLI | 否 | CLI 默认规格 | 无 | 否 | 开发者 / 平台 | 否 | OpenClaw memory 规格覆盖。 |
+| `OPENCLAW_RUNTIME_NPM_REGISTRY` | OpenClaw bootstrap | 否 | 镜像默认值 | 无 | 否 | Runtime 镜像 / 平台 | 否 | OpenClaw npm registry 覆盖。 |
+| `OPENCLAW_RUNTIME_PIP_INDEX_URL` | OpenClaw bootstrap | 否 | 镜像默认值 | 无 | 否 | Runtime 镜像 / 平台 | 否 | OpenClaw pip index 覆盖。 |
+| `OPENCLAW_RUNTIME_PIP_TRUSTED_HOST` | OpenClaw bootstrap | 否 | `mirrors.aliyun.com` | `PIP_TRUSTED_HOST` | 否 | Runtime 镜像 / 平台 | 否 | OpenClaw pip trusted-host 覆盖。 |
+| `OPENCLAW_RUNTIME_UV_INDEX_URL` | OpenClaw bootstrap | 否 | `OPENCLAW_RUNTIME_PIP_INDEX_URL` | 无 | 否 | Runtime 镜像 / 平台 | 否 | OpenClaw uv index 覆盖。 |
+| `OPENCLAW_RUNTIME_PLAYWRIGHT_DOWNLOAD_HOST` | OpenClaw bootstrap | 否 | `https://npmmirror.com/mirrors/playwright` | `PLAYWRIGHT_DOWNLOAD_HOST` | 否 | Runtime 镜像 / 平台 | 否 | Playwright 浏览器下载源覆盖。 |
+| `OPENCLAW_RUNTIME_PUPPETEER_DOWNLOAD_BASE_URL` | OpenClaw bootstrap | 否 | `https://npmmirror.com/mirrors/chrome-for-testing` | `PUPPETEER_DOWNLOAD_BASE_URL` | 否 | Runtime 镜像 / 平台 | 否 | Puppeteer chrome-for-testing 下载源覆盖。 |
+| `OPENCLAW_RUNTIME_PUPPETEER_DOWNLOAD_HOST` | OpenClaw bootstrap | 否 | `https://npmmirror.com/mirrors` | `PUPPETEER_DOWNLOAD_HOST` | 否 | Runtime 镜像 / 平台 | 否 | Puppeteer 下载 host 覆盖。 |
+| `OPENCLAW_RUNTIME_CLAWHUB_SITE` | OpenClaw bootstrap | 否 | `https://cn.clawhub-mirror.com` | `CLAWHUB_SITE` | 否 | Runtime 镜像 / 平台 | 否 | ClawHub 站点地址覆盖。 |
+| `OPENCLAW_RUNTIME_CLAWHUB_REGISTRY` | OpenClaw bootstrap | 否 | `CLAWHUB_SITE` 推导值 | `CLAWHUB_REGISTRY` | 否 | Runtime 镜像 / 平台 | 否 | ClawHub 插件仓库地址覆盖。 |
+| `OPENCLAW_NPM_REGISTRY` | OpenClaw user template examples | 否 | `https://registry.npmmirror.com` | `OPENCLAW_RUNTIME_NPM_REGISTRY` | 否 | 镜像构建 / 开发者 | 否 | user template 示例中安装插件依赖的 npm registry。 |
+| `PIP_TRUSTED_HOST` | Runtime image | 否 | 镜像或 bootstrap 设置 | `OPENCLAW_RUNTIME_PIP_TRUSTED_HOST` | 否 | Runtime 镜像 / 平台 | 否 | pip trusted-host。 |
+| `PLAYWRIGHT_DOWNLOAD_HOST` | Runtime image | 否 | 镜像或 bootstrap 设置 | `OPENCLAW_RUNTIME_PLAYWRIGHT_DOWNLOAD_HOST` | 否 | Runtime 镜像 / 平台 | 否 | Playwright 浏览器下载源。 |
+| `PUPPETEER_DOWNLOAD_BASE_URL` | Runtime image | 否 | 镜像或 bootstrap 设置 | `OPENCLAW_RUNTIME_PUPPETEER_DOWNLOAD_BASE_URL` | 否 | Runtime 镜像 / 平台 | 否 | Puppeteer 下载 base url。 |
+| `PUPPETEER_DOWNLOAD_HOST` | Runtime image | 否 | 镜像或 bootstrap 设置 | `OPENCLAW_RUNTIME_PUPPETEER_DOWNLOAD_HOST` | 否 | Runtime 镜像 / 平台 | 否 | Puppeteer 下载 host。 |
+| `KDOCS_TOKEN` | OpenClaw / Hermes kdocs skill | 条件必传 | 未设置 | 推荐迁移到 mcporter 配置 | 是 | 用户授权 / Secret | 否 | kdocs skill 运行态 token。Hermes 新流程优先 mcporter。 |
+| `KDOCS_SKILL_REPO` | Hermes/OpenClaw image build | 否 | `https://github.com/kdocs-app/kdocs-skill.git` | 无 | 否 | 镜像构建 / 开发者 | 否 | 构建镜像时覆盖 kdocs skill 源仓库。 |
+| `PLUGIN_API_KEY` | OpenClaw user template 示例 | 条件必传 | 未设置 | 无 | 是 | 业务扩展 Secret | 是 | user template 示例插件使用的业务 token,不属于 KsADK 标准契约。 |
+| `DEMO_CHANNEL_API_KEY` | OpenClaw user template 示例 | 条件必传 | 未设置 | 无 | 是 | 业务扩展 Secret | 是 | user template 示例 channel 使用的业务 token,不属于 KsADK 标准契约。 |
+
+## 13. 内部常量和表名
+
+这些变量名由源码作为常量导出或用于内部表名/依赖集合,一般不需要用户配置。
+
+| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| `KSADK_ALLOWED_SUFFIXES` | builders | 否 | 代码常量 | 无 | 否 | SDK 内部 | 否 | 代码打包允许后缀集合。 |
+| `KSADK_ATTACHMENT_RUNTIME_REQUIREMENTS` | builders | 否 | 代码常量 | 无 | 否 | SDK 内部 | 否 | 附件运行时内置依赖集合。 |
+| `KSADK_ATTACHMENT_OCR_RUNTIME_REQUIREMENTS` | builders | 否 | 代码常量 | 无 | 否 | SDK 内部 | 否 | 附件 OCR 运行时内置依赖集合。 |
+| `KSADK_BUILD_ENABLE_ATTACHMENT_OCR` | builders | 否 | `false` | 无 | 否 | 构建环境 / 开发者 | 否 | 是否把平台本地 OCR 依赖打进代码包。 |
+| `KSADK_BUILD_ENABLE_MCP` | builders | 否 | `false` | 无 | 否 | 构建环境 / 开发者 | 否 | 强制加入 MCP adapter 构建依赖。 |
+| `KSADK_BUILD_PIP_INSTALL_TIMEOUT_SECONDS` | builders | 否 | `2700` | 无 | 否 | 构建环境 / 开发者 | 否 | 源码构建时 pip install 的超时秒数。 |
+| `KSADK_BUILD_ENABLE_POSTGRES_SESSION` | builders | 否 | `false` | 无 | 否 | 构建环境 / 开发者 | 否 | 强制加入 PostgreSQL session 构建依赖。 |
+| `KSADK_CORE_RUNTIME_REQUIREMENTS` | builders | 否 | 代码常量 | 无 | 否 | SDK 内部 | 否 | 核心运行时内置依赖集合。 |
+| `KSADK_MCP_RUNTIME_REQUIREMENTS` | builders | 否 | 代码常量 | 无 | 否 | SDK 内部 | 否 | MCP adapter 可选运行时内置依赖集合。 |
+| `KSADK_POSTGRES_SESSION_REQUIREMENTS` | builders | 否 | 代码常量 | 无 | 否 | SDK 内部 | 否 | PostgreSQL session 可选运行时内置依赖集合。 |
+| `KSADK_RUNTIME_REQUIREMENTS` | builders | 否 | 代码常量 | 无 | 否 | SDK 内部 | 否 | 完整运行时内置依赖集合。 |
+| `KSADK_SKILL_SERVICE` | skills | 否 | 代码调用前缀 | 无 | 否 | SDK 内部 | 否 | Skill Service AICP 连接配置前缀,用于解析 `KSADK_SKILL_SERVICE_ENDPOINT` / `KSADK_SKILL_SERVICE_SCHEME` / `KSADK_SKILL_SERVICE_REGION`;一般不需要用户单独设置。 |
+| `KSADK_EVENTS_TABLE` | sessions | 否 | `ksadk_events` | 无 | 否 | SDK 内部 | 否 | 本地 SQLite events 表名。 |
+| `KSADK_SESSIONS_TABLE` | sessions | 否 | `ksadk_sessions` | 无 | 否 | SDK 内部 | 否 | 本地 SQLite sessions 表名。 |
+| `KSADK_STATES_TABLE` | sessions | 否 | `ksadk_states` | 无 | 否 | SDK 内部 | 否 | 本地 SQLite states 表名。 |
+| `KSADK_PG_EVENTS_TABLE` | sessions | 否 | `ksadk_events` | 无 | 否 | SDK 内部 | 否 | PostgreSQL events 表名。 |
+| `KSADK_PG_SESSIONS_TABLE` | sessions | 否 | `ksadk_sessions` | 无 | 否 | SDK 内部 | 否 | PostgreSQL sessions 表名。 |
+| `KSADK_PG_STATES_TABLE` | sessions | 否 | `ksadk_states` | 无 | 否 | SDK 内部 | 否 | PostgreSQL states 表名。 |
+| `KSADK_UPDATED_AT` | configs | 否 | 写入部署环境时生成 | 无 | 否 | SDK 内部 | 否 | serverless 部署更新触发时间戳。 |
+| `KSADK_VERSION` | configs | 否 | 代码常量 | 无 | 否 | SDK 内部 | 否 | SDK version 导出名。 |
+
+## 14. 兼容、历史和不推荐变量
+
+| 变量 | 状态 | 替代变量 | 说明 |
+| --- | --- | --- | --- |
+| `KSADK_ENABLE_SANDBOX_TOOLS` | master 旧 sandbox tools 开关,当前 Skill Runtime 重构后不再推荐 | `KSADK_SKILLS_MODE` + `KSADK_SKILL_RUNTIME_BACKEND` | master 分支仍存在。新实现不再默认注入 `execute_python/execute_bash/execute_javascript`。 |
+| `KSADK_SANDBOX_TOOL_ID` | 早期 Skills 草案变量,不作为当前契约 | `KSADK_SANDBOX_TEMPLATE_ID` | 只保留在历史设计草案中。 |
+| `KSADK_SANDBOX_HOST` | 早期/草案变量,不作为当前实现契约 | `E2B_API_URL` 或未来 provider endpoint | 当前通用 sandbox E2B backend 不读取。 |
+| `KSADK_SANDBOX_REGION` | 早期/草案变量,不作为当前实现契约 | `KSADK_SANDBOX_TYPE` / provider 自身 region | 当前通用 sandbox E2B backend 不读取。 |
+| `KSADK_SKILLS_DIR` | 早期/草案变量,不作为当前实现契约 | `KSADK_LOCAL_SKILLS_DIR` 或 `KSADK_SKILL_CACHE_DIR` | 当前 Runner/agent 不读取。 |
+| `KSADK_SKILL_RUNTIME_ENDPOINT` | 早期/草案变量,不作为当前实现契约 | `E2B_API_URL` | E2B SDK 使用原生变量。 |
+| `KSADK_SKILL_RUNTIME_API_KEY` | 早期/草案变量,不作为当前实现契约 | `E2B_API_KEY` | E2B SDK 使用原生变量。 |
+| `KSADK_SKILL_RUNTIME_REGION` | 早期/草案变量,不作为当前实现契约 | 无 | 当前 E2B backend 不读取。 |
+| `KSADK_SKILL_RUNTIME_TEMPLATE_ID` | 兼容变量 | `KSADK_SANDBOX_TEMPLATE_ID` | 仍可用,但新部署优先通用 sandbox 变量。 |
+| `KSADK_SKILL_RUNTIME_ALLOW_INTERNET_ACCESS` | 兼容变量 | `KSADK_SANDBOX_ALLOW_INTERNET_ACCESS` | 通用 sandbox 变量优先。 |
+| `KSADK_STM_*` | 旧短期记忆变量 | `KSADK_SESSION_*` | 仍作为 fallback。 |
+| `AGENTENGINE_SESSION_BACKEND` / `AGENTENGINE_TENANT_ID` / `AGENTENGINE_WORKSPACE_ID` | 平台兼容变量 | `KSADK_SESSION_BACKEND` / `KSADK_TENANT_ID` / `KSADK_WORKSPACE_ID` | 仍作为 fallback。 |
+| `OPENAI_API_BASE` | OpenAI 旧变量 | `OPENAI_BASE_URL` | 仍作为兼容。 |
+| `MODEL_NAME` | 旧模型名变量 | `OPENAI_MODEL_NAME` | 仍作为兼容。 |
+| `MODEL_API_KEY` / `MODEL_API_BASE` | OpenClaw/模型兼容变量 | `OPENAI_API_KEY` / `OPENAI_BASE_URL` 或 OpenClaw 专用变量 | 按运行时模板选择。 |
+| `LLM_API_KEY` / `LLM_API_BASE` / `LLM_MODEL` | Serverless/OpenClaw 兼容变量 | `OPENAI_API_KEY` / `OPENAI_BASE_URL` / `OPENAI_MODEL_NAME` | 仍作为 fallback。 |
+| `KINGSOFT_DOCS_TOKEN` | Hermes kdocs 旧变量,不推荐 | mcporter 内的 kdocs token | 只允许一次性迁移到 mcporter,不再建议写入环境变量或 `.env`。 |
+
+## 15. 业务自定义变量边界
+
+| 类型 | 是否业务自定义 | 是否写入本文 | 说明 |
+| --- | --- | --- | --- |
+| 业务代码读取的变量,例如 `APP_ENV`、`DATABASE_URL`、`REDIS_URL`、`MY_SERVICE_TOKEN` | 是 | 否 | 由业务方自己定义,KsADK 不做含义约束。 |
+| Agent 依赖的第三方工具变量,例如某业务 API token | 是 | 否 | 可以通过部署环境注入,但不属于 KsADK 标准契约。 |
+| SDK/镜像内置扩展读取的第三方 token,例如 `TAVILY_API_KEY`、`FIRECRAWL_API_KEY`、`MEM0_API_KEY` | 否 | 部分写入 | 只有被 KsADK runtime、Hermes/OpenClaw 模板或内置 skill 明确读取的变量才列入本文。 |
+| 平台或 SDK 读取的变量,例如 `KSADK_*`、`KSYUN_*`、`E2B_*`、`OPENAI_*`、`LANGFUSE_*` | 否 | 是 | 本文维护常见和核心变量。 |
+| 镜像模板内部变量,例如大量 `OPENCLAW_*` / `HERMES_*` 高级开关 | 否 | 部分写入 | 本文只列常见运行时可配置项,完整列表以对应模板 README/bootstrap 为准。 |
+
+## 16. 配置建议
+
+- 新部署优先使用通用变量:`KSADK_SANDBOX_TEMPLATE_ID`、`KSADK_SANDBOX_TIMEOUT`、`KSADK_SANDBOX_ALLOW_INTERNET_ACCESS`。
+- Skill Runtime 兼容变量 `KSADK_SKILL_RUNTIME_TEMPLATE_ID` 仅用于迁移期。
+- E2B backend 必须使用 SDK 原生 `E2B_API_URL` / `E2B_API_KEY`。
+- Secret 不要写入代码、仓库文档、测试 fixture、日志、snapshot;使用 Secret 注入。
+- 平台注入 Skill Space 时优先用 `KSADK_SKILL_SPACE_IDS`,单 space 兼容才使用 `SKILL_SPACE_ID`。
+- `KSYUN_ACCESS_KEY` / `KSYUN_SECRET_KEY` 是多个服务的 fallback。生产 sandbox 中建议使用更窄权限的 `KSADK_SKILL_SERVICE_ACCESS_KEY` / `KSADK_SKILL_SERVICE_SECRET_KEY`。
diff --git a/docs/maintainer-approval-record.md b/docs/maintainer-approval-record.md
index 8d07f6e3..3c283f9c 100644
--- a/docs/maintainer-approval-record.md
+++ b/docs/maintainer-approval-record.md
@@ -11,7 +11,7 @@ PyPI publication.
| License | Apache-2.0 |
| Python repository | kingsoftcloud/ksadk-python |
| Web UI repository | kingsoftcloud/ksadk-web |
-| Python package version | 0.6.8 |
+| Python package version | 0.6.9 |
| Public docs URL | https://kingsoftcloud.github.io/ksadk-python/ |
| Package metadata repository URL | https://github.com/kingsoftcloud/ksadk-python |
| Package metadata documentation URL | https://kingsoftcloud.github.io/ksadk-python/ |
@@ -40,7 +40,7 @@ changes.
## Required Evidence Before Approval
- `make public-preflight` exits successfully.
-- `make public-publish-check PUBLIC_PUBLISH_PHASE=pre-publish V=0.6.8` confirms
+- `make public-publish-check PUBLIC_PUBLISH_PHASE=pre-publish V=0.6.9` confirms
the target version is not already on PyPI.
- Branch protection and publish environment are configured according to
`.github/BRANCH_PROTECTION.md`.
@@ -61,4 +61,4 @@ changes.
| --- | --- | --- | --- |
| Maintainer | xiayu | Approved for one-time public main rewrite | 2026-07-03 |
| Security reviewer | automated public audit | Passed source, wheel, and sdist audits with 0 violations | 2026-07-03 |
-| Release owner | xiayu | Approved GitHub Release / PyPI Trusted Publishing for 0.6.8 | 2026-07-03 |
+| Release owner | xiayu | Approved GitHub Release / PyPI Trusted Publishing for 0.6.9 | 2026-07-03 |
diff --git "a/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" "b/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md"
index a2517525..ac6c26b3 100644
--- "a/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md"
+++ "b/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md"
@@ -428,6 +428,7 @@
| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | traces 专用 endpoint;设置后优先于通用 endpoint。 |
| `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | traces 专用 OTLP 协议;设置后优先于通用 protocol。 |
| `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | OTel | 否 | 未设置 | 无 | 是 | 平台 / 开发者 | 否 | traces 专用 OTLP headers;设置后优先于通用 headers。 |
+| `KSADK_OTLP_MAX_EXPORT_BATCH_SIZE` | OTel | 否 | `64` | 无 | 否 | 平台 / 开发者 | 否 | 单次 OTLP export 的最大 span 数,降低 collector 413 风险。 |
| `OTEL_SERVICE_NAME` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | service name。 |
| `OTEL_RESOURCE_ATTRIBUTES` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | resource attributes。 |
diff --git "a/docs/\350\277\234\347\250\213Agent\350\277\220\350\241\214\346\227\266\346\216\245\345\217\243\350\257\264\346\230\216.md" "b/docs/\350\277\234\347\250\213Agent\350\277\220\350\241\214\346\227\266\346\216\245\345\217\243\350\257\264\346\230\216.md"
new file mode 100644
index 00000000..322c880a
--- /dev/null
+++ "b/docs/\350\277\234\347\250\213Agent\350\277\220\350\241\214\346\227\266\346\216\245\345\217\243\350\257\264\346\230\216.md"
@@ -0,0 +1,2061 @@
+# 远程Agent运行时接口说明
+
+本文档基于当前 `master` 分支的真实代码实现整理,目标是说明:
+
+- Agent 部署到远程 K8s / Serverless Pod 之后,最终通过 `PublicEndpoint` 对外暴露哪些接口
+- 不同运行时类型的接口差异:通用 Agent、Hermes、OpenClaw
+- 公共鉴权、公共 Header、流式行为、WebSocket 约束
+- 各接口的请求体 / 响应体 shape
+
+本文档只把当前代码里可以确认的 contract 写出来;对仓库中未完整定义、但依赖上游项目的 OpenClaw 原生接口,不做超出代码证据的推断。
+
+## 1. 事实来源
+
+本文档主要依据以下代码与文档:
+
+- `agentengine-server/app/api/v1/actions/agent_actions.py`
+- `agentengine-server/app/api/v1/actions/chat_actions.py`
+- `agentengine-server/app/api/v1/actions/feedback_actions.py`
+- `agentengine-server/app/gateway/api.py`
+- `agentengine-server/app/gateway/router_service.py`
+- `agentengine-server/docs/技术设计.md`
+- `agentengine-server/docs/网关鉴权说明.md`
+- `ksadk-python/ksadk/server/app.py`
+- `ksadk-python/ksadk/server/api_models.py`
+- `ksadk-python/ksadk/conversations/runtime.py`
+- `ksadk-python/ksadk_runtime_common/workspace_files/*.py`
+- `ksadk-python/deploy/hermes/runtime/app.py`
+- `ksadk-python/deploy/hermes/README.md`
+- `ksadk-python/deploy/openclaw/bootstrap.sh`
+- `ksadk-python/deploy/openclaw-user-template/Dockerfile`
+
+## 2. 入口模型
+
+### 2.1 公网入口
+
+远程 Agent 部署成功后,控制面 `GetAgent` 会返回:
+
+- `QuickAccess.PublicEndpoint`
+
+这个地址就是外部调用运行时接口时应使用的根地址。例如:
+
+```text
+http://ar-20260506162108-d30283cd.agent-pre.kspmas.ksyun.com
+```
+
+说明:
+
+- 对外看到的是 `PublicEndpoint`
+- 实际请求先进入 Ingress / Gateway,再由 `agentengine-server` 的 router 做鉴权和转发
+- 因此“部署后暴露的接口”应以公网入口经过网关后可访问的路径为准,而不是简单把 Pod 内部监听端口当成外部 contract
+
+### 2.2 内网入口
+
+`GetAgent` 也可能返回:
+
+- `QuickAccess.PrivateEndpoint`
+
+这类地址用于内网访问,不作为本文主线。本文默认描述通过 `PublicEndpoint` 暴露的接口。
+
+## 3. 鉴权与公共 Header
+
+## 3.1 外部访问鉴权
+
+当前数据面统一通过网关校验,外部调用主要有两种认证方式:
+
+1. `Authorization: Bearer `
+2. `ae_ui_session` Cookie
+
+其中:
+
+- API/SDK/CLI 直连运行时接口时,使用 `Authorization: Bearer `
+- 浏览器经 dashboard share link 或 hosted UI 访问时,通常使用 `ae_ui_session` Cookie
+
+代码证据:
+
+- `agentengine-server/docs/网关鉴权说明.md`
+- `agentengine-server/app/gateway/api.py`
+
+### 3.1.1 Bearer Token 的含义
+
+Bearer Token 有两种来源:
+
+1. AgentEngine 为该 Agent 签发的 API Key,通常是 `ak-...` 或 `sk-...`
+2. OpenClaw 在 `token` 模式下使用的 shared secret
+
+对绝大多数自动化调用,推荐理解为:
+
+```http
+Authorization: Bearer
+```
+
+### 3.1.2 Cookie 会话的适用场景
+
+`ae_ui_session` 主要用于:
+
+- `https:///chat`
+- `https:///`
+- share link 跳转后的浏览器会话
+
+它不是给通用脚本调用运行时 API 设计的主接口。
+
+## 3.2 公共请求 Header
+
+### 3.2.1 通用 HTTP Header
+
+建议按以下方式构造:
+
+| Header | 是否必填 | 说明 |
+| --- | --- | --- |
+| `Authorization: Bearer ` | 外部 API 调用必填 | 由网关校验 |
+| `Content-Type: application/json` | JSON 请求推荐 | `POST /v1/*`、`POST /agentengine/api/v1/*` 常用 |
+| `Accept: application/json` | 非流式请求推荐 | 返回 JSON |
+| `Accept: text/event-stream` | 流式请求推荐 | `stream=true` 时推荐显式声明 |
+
+说明:
+
+- 对于 `multipart/form-data` 上传,如 `UploadFile` / `AddWorkspaceFile`,`Content-Type` 由客户端自动生成 boundary
+- 运行时应用本身没有在 `ksadk.server.app` 内显式校验 Bearer;鉴权发生在网关层
+
+### 3.2.2 WebSocket Header
+
+Hermes 终端 WebSocket 额外要求:
+
+| Header | 是否必填 | 说明 |
+| --- | --- | --- |
+| `Authorization: Bearer ` | 公网访问建议携带 | 网关鉴权 |
+| `Sec-WebSocket-Protocol: ks-terminal.v1` | 必填 | Hermes 终端子协议 |
+
+如果缺少 `ks-terminal.v1`,Hermes runtime 会直接拒绝连接。
+
+## 3.3 内部 Header 与外部调用边界
+
+以下 Header 会在网关和运行时之间使用,但**不应由外部调用方手工构造**:
+
+| Header | 用途 |
+| --- | --- |
+| `X-Auth-Agent-Id` | 网关鉴权后注入的 Agent ID |
+| `X-Auth-Account-Id` | 网关鉴权后注入的账号 ID |
+| `X-Auth-Framework` | 网关鉴权后注入的 framework |
+| `X-Auth-Openclaw-Gateway-Mode` | OpenClaw 模式透传 |
+| `X-Forwarded-Host` | 原始 Host 透传 |
+| `x-forwarded-user` | OpenClaw trusted-proxy / workspace 代理链路使用 |
+| `X-Hermes-Session-Token` | Hermes dashboard 内部 fetch shim 使用 |
+
+外部用户应只关心:
+
+- Bearer API Key
+- Cookie Session
+- WebSocket 子协议
+
+## 4. 运行时类型矩阵
+
+当前主线下,公网可见接口按运行时分为三类:
+
+| 运行时类型 | 典型 framework | 主入口实现 | 对外特征 |
+| --- | --- | --- | --- |
+| 通用 Agent 运行时 | `adk` / `langchain` / `langgraph` / `deepagents` | `ksadk.server.app` | `/v1/*` + workspace files;公网 `/chat` 由独立 hosted UI 服务承载并调用 Hosted UI action 接口 |
+| Hermes 托管运行时 | `hermes` | `deploy/hermes/runtime/app.py` 外层 wrapper | `/` dashboard、`/v1/*`、`/_ksadk/terminal/ws`、workspace files;公网 `/chat` 同样由独立 hosted UI 服务承载 |
+| OpenClaw 托管运行时 | `openclaw` | OpenClaw gateway + ksadk 补丁 | 以 OpenClaw gateway 为主,平台额外挂出 workspace files |
+
+## 5. 公网暴露范围总览
+
+### 5.1 通用 Agent 运行时
+
+公网入口可确认的主路径:
+
+- `GET /health`
+- `POST /v1/responses`
+- `POST /v1/chat/completions`
+- `GET /chat`
+- `GET /build`
+- `GET /deploy`
+- `GET /agentengine/api/v1/AttachmentContent`
+- `GET /agentengine/api/v1/GetWorkspaceFileContent`
+- `POST /agentengine/api/v1/GetAgentUiBootstrap`
+- `POST /agentengine/api/v1/CreateSession`
+- `POST /agentengine/api/v1/GetSession`
+- `POST /agentengine/api/v1/ListSessions`
+- `POST /agentengine/api/v1/DeleteSession`
+- `POST /agentengine/api/v1/ListSessionEvents`
+- `GET /agentengine/api/v1/SubscribeRunEvents`
+- `POST /agentengine/api/v1/RunAgent`
+- `POST /agentengine/api/v1/ListSessionCheckpoints`
+- `POST /agentengine/api/v1/GetCheckpointResumePreview`
+- `POST /agentengine/api/v1/ListToolReceipts`
+- `POST /agentengine/api/v1/ResumeRun`
+- `POST /agentengine/api/v1/CancelRun`
+- `POST /agentengine/api/v1/UploadFile`
+- `POST /agentengine/api/v1/ListWorkspaceFiles`
+- `POST /agentengine/api/v1/AddWorkspaceFile`
+- `POST /agentengine/api/v1/DeleteWorkspaceFile`
+- `POST /agentengine/api/v1/ListAgentModels`
+- `GET /agentengine/api/v1/ExportWorkspaceZip`
+- `POST /run_sse`
+- `GET/POST/DELETE /apps/{app_name}/users/{user_id}/sessions*`
+
+注意:
+
+- 并不是所有 `/agentengine/api/v1/*` 都会通过公网数据面暴露
+- 网关只放行 Hosted UI 所需的那一小组 action
+- 对 `PublicEndpoint` 而言,`POST /agentengine/api/v1/*` 这组 Hosted UI action 实际会被 router 代理回 `agentengine-server`,不是直接命中 runtime pod 的本地同名路由
+
+### 5.2 Hermes 运行时
+
+公网入口可确认的主路径:
+
+- `GET /`
+- `GET /health`
+- `GET/POST/PUT/PATCH/DELETE/OPTIONS /v1/{path}`
+- `GET/POST/PUT/PATCH/DELETE/OPTIONS /{path}`
+ 这部分本质是 Hermes dashboard 与其 API 的代理入口
+- `GET/HEAD/POST/DELETE /_ksadk/workspace/v1/*`
+- `WS /_ksadk/terminal/ws`
+- `GET /chat`
+
+### 5.3 OpenClaw 运行时
+
+当前代码中可以**准确确认**的平台追加 contract 只有:
+
+- `/_ksadk/workspace/v1/*`:通过 ksadk sidecar / proxy 增加的文件接口
+
+此外还可以确认:
+
+- OpenClaw gateway 默认跑在 `8080`
+- 鉴权模式支持 `trusted-proxy | token | none`
+- 健康检查使用的是上游 gateway 的 `/healthz`
+
+但 OpenClaw gateway 原生完整 API 面不是本仓当前代码独立定义的,因此本文不把其所有原生端点逐条列为平台 contract。
+
+## 6. 通用 Agent 运行时详细接口
+
+本节适用于原始 runtime 服务本身:
+
+- `adk`
+- `langchain`
+- `langgraph`
+- `deepagents`
+
+底层实现:`ksadk-python/ksadk/server/app.py`
+
+重要边界:
+
+- 本节里的 `/v1/*`、`/health`、`/run_sse`、`/apps/.../sessions*` 是 runtime pod 自身实现
+- 但对公网 `PublicEndpoint` 来说,`/agentengine/api/v1/*` Hosted UI action 以 `agentengine-server` facade 为准
+- 因此本文后续会把“runtime 原始接口”和“公网 Hosted facade”拆开写
+
+## 6.1 健康检查
+
+### `GET /health`
+
+用途:
+
+- 检查运行时是否启动
+- 返回当前 runner 识别出的 framework 和 agent 名
+
+请求示例:
+
+```bash
+curl -H "Authorization: Bearer " \
+ "https:///health"
+```
+
+响应示例:
+
+```json
+{
+ "status": "ok",
+ "framework": "langgraph",
+ "agent": "demo-agent"
+}
+```
+
+## 6.2 OpenAI Responses 兼容接口
+
+### `POST /v1/responses`
+
+说明:
+
+- 非流式返回 OpenAI Responses 风格 JSON
+- 流式返回 `text/event-stream`
+
+请求体字段:
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `input` | `string | array` | 是 | 用户输入;字符串或 KOP 风格消息数组 |
+| `model` | `string` | 否 | 本次调用显式模型 |
+| `model_metadata` | `object` | 否 | 模型元数据 |
+| `instructions` | `string` | 否 | 额外系统指令 |
+| `metadata` | `object` | 否 | 请求级 metadata |
+| `conversation` | `string | object` | 否 | OpenAI Responses 会话绑定字段;可传 `"conv_xxx"` 或 `{ "id": "conv_xxx" }`,runtime 会映射为内部会话 ID |
+| `previous_response_id` | `string` | 否 | OpenAI Responses 上一轮 response id;不能和 `conversation` 同时使用 |
+| `safety_identifier` | `string` | 否 | OpenAI 推荐的最终用户稳定标识;runtime 会映射为内部 user id 和 Langfuse UserID,建议传 hash 后值 |
+| `prompt_cache_key` | `string` | 否 | OpenAI prompt cache 路由提示;runtime 当前保留到请求 metadata,不作为用户身份 |
+| `user` | `string` | 否 | OpenAI deprecated 用户字段;仅在未传 `safety_identifier` 时作为兼容兜底 |
+| `store` | `boolean` | 否 | OpenAI Responses 存储开关;runtime 当前保留到请求 metadata |
+| `stream` | `boolean` | 否 | 是否流式 |
+| `session_id` | `string` | 否 | ksadk legacy extension;兼容旧客户端。新接入应优先使用 `conversation` |
+
+最小请求示例:
+
+```json
+{
+ "input": "你好",
+ "stream": false
+}
+```
+
+带会话与模型示例:
+
+```json
+{
+ "input": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "text": "请总结一下这份设计"
+ }
+ ]
+ }
+ ],
+ "model": "glm-5.1",
+ "stream": true,
+ "conversation": "conv_customer_001",
+ "safety_identifier": "hash_user_001"
+}
+```
+
+会话字段边界:
+
+- 官方兼容路径:连续对话传 `conversation`;最终用户标识传 `safety_identifier`。
+- `previous_response_id` 只表达 Responses 链式上下文,不能和 `conversation` 同时使用。
+- `session_id` 是 ksadk 早期扩展字段,仅为旧客户端保留;不要在新代码中把它当作 OpenAI 官方字段。
+- 不要通过 `metadata.user_id`、`metadata.session_id` 或其他私有 metadata 约定传用户身份和会话身份。
+
+推荐请求示例:
+
+```json
+{
+ "model": "deepseek-v4-pro",
+ "input": "帮我分析这张账单",
+ "conversation": "conv_bill_20260525_001",
+ "safety_identifier": "user_hash_001",
+ "stream": false
+}
+```
+
+图片与附件输入:
+
+推荐写法:
+
+- `/v1/responses` 推荐使用 OpenAI Responses content blocks:`input_text` / `input_image` / `input_file`
+- runner 业务代码推荐读取 `payload["input_content"]` / `payload["input_messages"]`,这是 KsADK 默认 canonical 输入
+- 判断当前轮是否传了图片或文件,推荐使用 `payload["has_current_files"]` 和 `payload["current_attachments"]`
+- 读取当前轮 OCR、文档抽取、压缩包摘要,推荐使用 `payload["current_attachment_results"]`
+
+兼容写法:
+
+- 老客户端仍可使用 KsADK 兼容扩展 part 数组:`text` / `inlineData` / `fileData`
+- runner 里仍保留 `payload["input_parts"]`,用于兼容已有 `text / inlineData / fileData` 业务代码
+- `payload["attachments"]` / `payload["attachment_results"]` 仍保留,但语义是最近有效附件上下文,可能来自历史 fallback;不要用它判断当前最新 user turn 是否上传了文件
+- `/v1/chat/completions` 对外仍保持 Chat Completions 语义,官方图片块使用 `text` / `image_url`;`inlineData` / `fileData` 在 Chat 入口只属于 KsADK 兼容扩展,不是 OpenAI Chat 官方能力
+
+字段细节:
+
+- `input_image.image_url` 支持远程图片 URL 或 `data:image/...;base64,...`,运行时会归一化为内部附件上下文
+- `input_file.file_data` 会归一化为内部 `inlineData`;`input_file.file_url` / `input_file.file_id` 会归一化为内部 `fileData` 引用
+- `inlineData` 适合旧客户端直接内联 base64 内容
+- `fileData` 适合旧客户端先调用 `UploadFile`,再引用返回的 `ksadk-upload://...`
+- 远程图片 URL 会作为引用保留,并可在支持原生图片输入的 LangGraph 路径下继续传给模型;KsADK 不会主动拉取远程图片或远程文件做 OCR / 文本提取。需要平台提取、OCR 或本地附件内容时,请使用 data URL、`file_data`、`inlineData` 或 `fileData`
+
+图片示例(OpenAI Responses 风格 data URL):
+
+```json
+{
+ "input": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "input_text",
+ "text": "请分析这张图片"
+ },
+ {
+ "type": "input_image",
+ "image_url": "data:image/png;base64,"
+ }
+ ]
+ }
+ ],
+ "model": "glm-5.1",
+ "stream": false
+}
+```
+
+业务代码获取图片信息:
+
+```python
+def ksadk_prepare_input(payload, session_context):
+ # 当前轮是否真的上传了图片/文件。不要用 attachments 判断当前轮,
+ # attachments 可能是历史最近一次有效附件上下文。
+ has_current_files = payload.get("has_current_files", False)
+ current_attachments = payload.get("current_attachments", [])
+
+ images = [
+ item
+ for item in current_attachments
+ if str(item.get("mime_type", "")).startswith("image/")
+ ]
+
+ # OpenAI Responses canonical content,适合直接转给支持原生多模态的模型。
+ input_content = payload.get("input_content", [])
+ image_blocks = [
+ block
+ for block in input_content
+ if block.get("type") == "input_image"
+ ]
+
+ return {
+ "input": payload.get("input", ""),
+ "images": images,
+ "image_blocks": image_blocks,
+ }
+```
+
+如果业务 agent 使用 LangGraph / LangChain 并且模型支持原生多模态,优先从 `input_content` 或 `input_messages` 读取 `input_image`,按底层模型 SDK 需要的消息格式继续传递;如果需要读取平台归一化后的附件元信息、OCR / 文档抽取结果,则读取 `current_attachments` 和 `current_attachment_results`。`input_parts`、`inlineData`、`fileData` 是 legacy/internal 兼容输入,仍可作为老客户端兜底。
+
+多模态模型“看图”和平台 OCR 是两条不同链路:推荐让支持图片的模型直接消费 `input_image` / `input_content`,这样不需要在代码包里安装本地 OCR 依赖。平台本地 OCR 只用于需要把图片预先转成 `current_attachment_results[*].text` 的场景;源码构建默认不打包 OCR 二进制栈,如需启用请在构建环境设置 `KSADK_BUILD_ENABLE_ATTACHMENT_OCR=true`,或在项目 `requirements.txt` 中显式加入 OCR 相关依赖。
+
+图片 data URL 或 `inlineData.data` 本身就是 base64 字符串,payload 可能很大,这是内联传图时的正常现象。业务日志不要直接打印完整 `payload`、`input_content`、`input_parts` 或 `current_attachments`;建议只记录字段摘要,例如文件名、MIME、大小、transport、data URL 前缀和长度:
+
+```python
+def summarize_attachment(item):
+ data = item.get("data") or ""
+ return {
+ "display_name": item.get("display_name"),
+ "mime_type": item.get("mime_type"),
+ "transport": item.get("transport"),
+ "file_uri": item.get("file_uri"),
+ "size_bytes": item.get("size_bytes"),
+ "has_inline_data": bool(data),
+ "inline_data_length": len(data),
+ }
+
+logger.info(
+ "ksadk_prepare_state attachments=%s has_current_files=%s",
+ [summarize_attachment(item) for item in payload.get("current_attachments", [])],
+ payload.get("has_current_files", False),
+)
+```
+
+旧客户端图片示例(先上传,再引用):
+
+```json
+{
+ "input": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "text": "请分析这张图片"
+ },
+ {
+ "fileData": {
+ "fileUri": "ksadk-upload://abc123.png",
+ "displayName": "diagram.png",
+ "mimeType": "image/png"
+ }
+ }
+ ]
+ }
+ ],
+ "model": "glm-5.1",
+ "stream": false
+}
+```
+
+旧客户端图片示例(直接内联):
+
+```json
+{
+ "input": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "text": "请分析这张图片"
+ },
+ {
+ "inlineData": {
+ "data": "",
+ "displayName": "diagram.png",
+ "mimeType": "image/png"
+ }
+ }
+ ]
+ }
+ ]
+}
+```
+
+当前附件类型支持矩阵:
+
+| 类型 | 典型扩展名 / MIME | 传输支持 | 平台提取支持 | 原生多模态直通 |
+| --- | --- | --- | --- | --- |
+| 文本 | `.txt` `.md` `.json` `.yaml` `.yml` `.csv` `.tsv` `.log` | 支持 | 支持 | 不适用 |
+| 文档 | `.pdf` `.docx` `.pptx` `.xlsx` `.html` `.htm` | 支持 | 部分支持:文本提取 / OCR | 不适用 |
+| 图片 | `.png` `.jpg` `.jpeg` `.webp` / `image/*` | 支持 | 元信息提取默认支持;OCR 需构建时显式启用 | 部分支持,见下方框架差异 |
+| 压缩包 | `.zip` | 支持 | 支持:目录/可读文件抽样提取 | 不适用 |
+| 其他二进制 | 其他后缀或 `application/octet-stream` | 支持 | 通常仅保留为附件引用 | 不支持 |
+
+框架差异:
+
+- `ADK`
+ - 图片附件会优先以 bytes 形式构造成底层 SDK `Part`
+ - 若底层模型支持原生多模态,可直接消费图片
+- `LangGraph`
+ - 简化输入路径下,若模型支持图片输入,图片附件会自动转换为多模态 `HumanMessage.content` blocks
+ - 非图片附件仍保留为普通附件上下文
+- `LangChain`
+ - 当前没有对所有 agent 统一做“自动图片直通”
+ - 如需原生多模态,建议在 `ksadk_prepare_input(payload, session_context)` 中优先消费 `input_content / input_messages`,必要时再兼容 `input_parts / current_attachments / attachments`
+ - 判断当前轮是否传文件用 KsADK runner payload 扩展字段 `has_current_files`;该字段不是 OpenAI Responses API 官方字段
+
+模型能力判断优先级:
+
+1. 请求里显式传入的 `model_metadata`
+2. runtime 通过 `OPENAI_BASE_URL` / `OPENAI_API_KEY` 查询上游 `/v1/models` 返回的 `architecture.input_modalities`
+3. 本地默认兜底(按文本模型处理)
+
+多轮会话历史:
+
+- `/v1/responses` 本身不要求客户端每轮重传完整历史
+- 新客户端应持续传同一个 `conversation`,runtime 会从服务端会话存储里恢复该会话的历史 transcript
+- 旧客户端只传 `session_id` 时仍可恢复同一会话,但这是 ksadk legacy extension
+- 进入 runner 前,`ksadk` 会把历史、附件上下文、知识库上下文和长期记忆上下文统一重建成标准运行输入
+- `safety_identifier` 会作为内部 user id,并用于 Langfuse UserID;未传时 deprecated `user` 字段可作为兜底
+- `previous_response_id` 按 OpenAI Responses 语义接收并保留;当使用 `conversation` 时不要同时传 `previous_response_id`
+
+### Responses approval / interrupt 恢复
+
+如果流式执行遇到工具审批或人工确认,runtime 不会把本轮包装成 completed,而是返回 incomplete:
+
+- `status`: `incomplete`
+- `incomplete_details.reason`: `approval_required`
+- MCP/tool approval 场景会输出 `mcp_approval_request`
+- 非 MCP 的通用 interrupt 会输出 `response.ksadk.approval_request`
+
+#### MCP approval 恢复
+
+MCP/tool approval 场景按 OpenAI Responses 标准语义恢复。客户端应传同一个 `conversation` 或 legacy `session_id`,并把 `input` 写成 `mcp_approval_response`:
+
+```json
+{
+ "conversation": "conv_customer_001",
+ "input": [
+ {
+ "type": "mcp_approval_response",
+ "id": "mcprsp_123",
+ "approval_request_id": "appr_123",
+ "approve": true,
+ "reason": "approved by user"
+ }
+ ],
+ "stream": true
+}
+```
+
+运行时处理方式:
+
+- 记录一条 `approval_response` 会话事件
+- 向 runner 传入 `resume=True`
+- `input` 原样保留为 `mcp_approval_response`
+- LangGraphRunner 在内部转换成 `Command(resume=...)`
+
+调用方不需要、也不应该直接传 Python `Command`。
+
+#### 通用 interrupt 恢复
+
+如果 interrupt 不是 MCP/tool approval,而是普通人工确认、补充信息或业务分支选择,客户端可以使用平台扩展 `ksadk_resume`:
+
+```json
+{
+ "conversation": "conv_customer_001",
+ "input": [
+ {
+ "type": "ksadk_resume",
+ "interrupt_id": "intr_123",
+ "value": {
+ "approved": true,
+ "answer": "继续"
+ }
+ }
+ ],
+ "stream": true
+}
+```
+
+这类事件属于 `ksadk` 扩展,不伪装成 OpenAI MCP approval。
+
+### Agent 开发者如何在业务代码中拿到上下文
+
+这部分不属于远程 API 调用 contract。不同框架的业务代码接入方式已经内化到框架专属文档:
+
+- LangGraph: [LangGraph开发最佳实践](./frameworks/LangGraph开发最佳实践.md)
+- 平台公共上下文总览: [Agent 开发者上下文接入指南](./Agent 开发者上下文接入指南.md)
+
+调用方只需要理解:
+
+- `/v1/responses` 不要求每轮重传完整历史
+- 同一会话应持续传同一个 `conversation`;旧客户端传 `session_id` 也能继续兼容
+- runtime 会在进入 runner 前重建历史、附件、知识库和长期记忆上下文
+- 框架业务代码如何消费这些上下文,由对应框架最佳实践文档说明
+
+### 历史压缩(compaction)是怎么做的
+
+长会话不会无限把所有历史原样塞进模型。
+
+当前策略是:
+
+1. transcript 按 API round / `invocation_id` 分组
+2. 保留最近若干轮原始消息
+3. 把更早历史压成一条 `context_checkpoint`
+4. 后续模型看到的是:
+ - 一条 `Earlier conversation summary: ...`
+ - 最近若干轮原始 user / assistant 消息
+
+重要特性:
+
+- 原始事件不会物理删除,compaction 是 append-only
+- 工具调用、审批请求、附件引用等关键信息不会简单丢弃,会以 summary 或占位文本形式保留
+- 压缩阈值会结合 `model_metadata` 的上下文窗口能力自动调整
+
+非流式响应字段:
+
+| 字段 | 说明 |
+| --- | --- |
+| `id` | response ID |
+| `object` | 固定 `response` |
+| `created_at` | Unix 时间戳 |
+| `status` | 默认 `completed` |
+| `model` | 模型名 |
+| `output` | 输出条目数组 |
+| `output_text` | 文本聚合结果 |
+| `usage` | 简化 token 统计 |
+| `session_id` | ksadk 返回的内部会话 ID;当请求传了 `conversation` 时与其 id 一致 |
+
+非流式响应示例:
+
+```json
+{
+ "id": "resp_123",
+ "object": "response",
+ "created_at": 1710000000,
+ "status": "completed",
+ "error": null,
+ "incomplete_details": null,
+ "instructions": null,
+ "metadata": {},
+ "model": "glm-5.1",
+ "parallel_tool_calls": true,
+ "temperature": null,
+ "top_p": null,
+ "tools": [],
+ "output": [
+ {
+ "id": "msg_abc",
+ "type": "message",
+ "status": "completed",
+ "role": "assistant",
+ "content": [
+ {
+ "type": "output_text",
+ "text": "你好,我可以帮你分析代码。"
+ }
+ ]
+ }
+ ],
+ "output_text": "你好,我可以帮你分析代码。",
+ "usage": {
+ "input_tokens": 0,
+ "output_tokens": 12,
+ "total_tokens": 12
+ },
+ "session_id": "conv_customer_001"
+}
+```
+
+流式行为:
+
+- `Content-Type: text/event-stream`
+- 每个事件格式为:
+
+```text
+event:
+data:
+
+```
+
+当前可能出现的主要事件:
+
+- `response.created`
+- `response.in_progress`
+- `response.output_text.delta`
+- `response.reasoning.delta`
+- `response.tool_call`
+- `response.tool_result`
+- `response.output_item.added` / `response.output_item.done`:MCP approval request 等结构化 output item
+- `response.ksadk.approval_request`:非 MCP 的通用 interrupt 扩展事件
+- `response.compaction.start`
+- `response.compaction.done`
+- `response.incomplete`
+- `response.completed`
+
+## 6.3 OpenAI Chat Completions 兼容接口
+
+### `POST /v1/chat/completions`
+
+请求体字段:
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `messages` | `array
-
+
## 30 Seconds Quick Start
@@ -37,9 +37,9 @@ Start the local debugging Web UI:
agentengine web . --no-open
```
-
+
-
+
## Why KsADK
@@ -53,16 +53,16 @@ Most agent frameworks solve how to build agents. KsADK solves how to run, debug,
## Architecture
-
+
## Docs And Examples
- Documentation:
-- Quick Start:
-- Why KsADK:
-- Architecture:
-- Ecosystem Positioning:
-- Observability:
+- Quick Start:
+- Why KsADK:
+- Architecture:
+- Ecosystem Positioning:
+- Observability:
- Samples:
## Related Projects
diff --git a/README.md b/README.md
index f4baf508..23a8d39b 100644
--- a/README.md
+++ b/README.md
@@ -58,11 +58,11 @@ agentengine web . --no-open
## 文档与样例
- 文档:
-- 快速开始:
-- 为什么需要 KsADK:
-- 架构:
-- 生态定位对比:
-- 可观测:
+- 快速开始:
+- 为什么需要 KsADK:
+- 架构:
+- 生态定位对比:
+- 可观测:
- 样例仓库:
## 相关项目
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 4ec558f3..c212f115 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -16,7 +16,7 @@
-
+
## 30 秒快速体验
@@ -37,9 +37,9 @@ agentengine run -i
agentengine web . --no-open
```
-
+
-
+
## 为什么需要 KsADK
@@ -53,16 +53,16 @@ agentengine web . --no-open
## 架构
-
+
## 文档与样例
- 文档:
-- 快速开始:
-- 为什么需要 KsADK:
-- 架构:
-- 生态定位对比:
-- 可观测:
+- 快速开始:
+- 为什么需要 KsADK:
+- 架构:
+- 生态定位对比:
+- 可观测:
- 样例仓库:
## 相关项目
diff --git a/docs-site/content/docs/framework/guides/build-and-package.en.mdx b/docs-site/content/docs/framework/guides/build-and-package.en.mdx
index ef407f78..c2b28071 100644
--- a/docs-site/content/docs/framework/guides/build-and-package.en.mdx
+++ b/docs-site/content/docs/framework/guides/build-and-package.en.mdx
@@ -9,7 +9,7 @@ itself.
```bash
uv run --extra dev python -m twine check dist/*
-make open-source-review
+make public-review
```
## Web UI Artifacts
@@ -69,7 +69,7 @@ The assertions guarantee:
`make public-preflight` layers `public-audit`, `public-test`, and
-`public-docs-build` on top of `public-build-check`. It is the mandatory local
+`docs-site-build` on top of `public-build-check`. It is the mandatory local
gate before pushing to GitHub, PyPI, or a Release.
diff --git a/docs-site/content/docs/framework/guides/build-and-package.mdx b/docs-site/content/docs/framework/guides/build-and-package.mdx
index 95644bdd..8c20b7a2 100644
--- a/docs-site/content/docs/framework/guides/build-and-package.mdx
+++ b/docs-site/content/docs/framework/guides/build-and-package.mdx
@@ -8,7 +8,7 @@ title: "构建与打包"
```bash
uv run --extra dev python -m twine check dist/*
-make open-source-review
+make public-review
```
## Web UI 产物
@@ -58,7 +58,7 @@ agentengine --dry-run deploy .
- wheel 含同步后的 `ksadk/server/static/index.html` 及 `assets/` 入口,保证安装即可打开本地 UI。
-`make public-preflight` 在 `public-build-check` 之上追加 `public-audit`、`public-test`、`public-docs-build`,是推 GitHub/PyPI/Release 前必须通过的完整本地门禁。
+`make public-preflight` 在 `public-build-check` 之上追加 `public-audit`、`public-test`、`docs-site-build`,是推 GitHub/PyPI/Release 前必须通过的完整本地门禁。
## Artifact 规则
diff --git a/docs-site/content/docs/references/contributing/index.en.mdx b/docs-site/content/docs/references/contributing/index.en.mdx
index e1303f6b..30f74a5d 100644
--- a/docs-site/content/docs/references/contributing/index.en.mdx
+++ b/docs-site/content/docs/references/contributing/index.en.mdx
@@ -18,22 +18,21 @@ pytest
Build docs locally:
```bash
-make public-docs-build
-make public-docs-serve
+make docs-site-build
```
Run open-source checks:
```bash
make open-source-audit
-make public-docs-audit
+make public-audit
```
For changes that touch packaging, public docs, release metadata, or repository
layout, run the broader review target before asking for release approval:
```bash
-make open-source-review
+make public-preflight
```
## Public CI Expectations
@@ -43,7 +42,7 @@ Public CI must not require internal kubeconfig files, internal registries, inter
Before submitting a public PR:
- run focused tests for the changed area.
-- run docs build when editing `public-docs/` or `mkdocs.yml`.
+- run `make docs-site-build` when editing `docs-site/`; use `make docs-site-dev` for local preview.
- update CLI docs when command behavior changes.
- update release notes when changing packaging or public API behavior.
- keep examples local-first unless a hosted feature is explicitly approved.
@@ -59,7 +58,7 @@ KsADK keeps public confidence through layered tests:
| ASGI service tests | validate FastAPI routes and session events without a real network server | service/session pytest files |
| HTTP protocol E2E | validate `/v1/responses`, `/v1/chat/completions`, upload, and local Web UI action payloads | OpenAI protocol E2E tests |
| browser E2E | validate built UI behavior when Chromium is available | browser-tagged E2E tests |
-| open-source audits | verify public tree, docs, Pages artifact, sdist, wheel, and clean export boundaries | `make open-source-review` |
+| open-source audits | verify public tree, docs, Pages artifact, sdist, wheel, and clean export boundaries | `make public-preflight` |
When a change affects protocol shape, attachment handling, session events, or
the local Web UI payload, prefer a test that crosses the same boundary a real
@@ -98,7 +97,7 @@ Avoid:
- references to generated `.zread/` output as the published source.
Local zread wiki output can be useful as an engineering note source, but public
-documentation should be curated Markdown under `public-docs/`. Do not publish
+documentation should be curated Markdown under `docs-site/`. Do not publish
the generated wiki directory or depend on it during public CI.
## Open-Source Review Boundary
diff --git a/docs-site/content/docs/references/contributing/index.mdx b/docs-site/content/docs/references/contributing/index.mdx
index ae1ae76d..00e1d4de 100644
--- a/docs-site/content/docs/references/contributing/index.mdx
+++ b/docs-site/content/docs/references/contributing/index.mdx
@@ -17,22 +17,21 @@ pytest
本地构建文档:
```bash
-make public-docs-build
-make public-docs-serve
+make docs-site-build
```
运行开源检查:
```bash
make open-source-audit
-make public-docs-audit
+make public-audit
```
如果变更影响 packaging、公开文档、release metadata 或仓库布局,请在请求 release
approval 前运行更完整的审核目标:
```bash
-make open-source-review
+make public-preflight
```
## 公开 CI 期望
@@ -42,7 +41,7 @@ make open-source-review
提交公开 PR 前:
- 运行变更区域的 focused tests。
-- 修改 `public-docs/` 或 `mkdocs.yml` 时运行文档构建。
+- 修改 `docs-site/` 时运行 `make docs-site-build`,需要本地预览时运行 `make docs-site-dev`。
- 命令行为变化时更新 CLI 文档。
- packaging 或公开 API 变化时更新 release notes。
- 示例应保持本地优先,除非某个 hosted feature 已明确批准公开。
@@ -56,7 +55,7 @@ make open-source-review
| ASGI service tests | 不启动真实网络 server 验证 FastAPI routes 和 session events | service/session pytest 文件 |
| HTTP protocol E2E | 验证 `/v1/responses`、`/v1/chat/completions`、upload 和本地 Web UI action payload | OpenAI protocol E2E tests |
| browser E2E | Chromium 可用时验证构建后的 UI 行为 | browser-tagged E2E tests |
-| open-source audits | 验证公开 tree、docs、Pages artifact、sdist、wheel 和 clean export 边界 | `make open-source-review` |
+| open-source audits | 验证公开 tree、docs、Pages artifact、sdist、wheel 和 clean export 边界 | `make public-preflight` |
当变更影响协议形态、附件处理、session event 或本地 Web UI payload 时,优先写一个
跨越真实客户端边界的测试。
@@ -78,7 +77,7 @@ make open-source-review
- 真实 token 或客户数据。
- 把生成的 `.zread/` 输出当作发布源引用。
-本地 zread wiki 输出可以作为工程笔记来源,但公开文档应是 `public-docs/` 下经过整理的
+本地 zread wiki 输出可以作为工程笔记来源,但公开文档应是 `docs-site/` 下经过整理的
Markdown。不要发布生成的 wiki 目录,也不要让公开 CI 依赖它。
## 开源审核边界
diff --git a/docs-site/content/docs/references/contributing/release.en.mdx b/docs-site/content/docs/references/contributing/release.en.mdx
index 765c4147..ccef15a7 100644
--- a/docs-site/content/docs/references/contributing/release.en.mdx
+++ b/docs-site/content/docs/references/contributing/release.en.mdx
@@ -38,8 +38,7 @@ publication strategy, and maintainer sign-offs.
## Local Commands
```bash
-make open-source-review
-make open-source-review-bundle
+make public-review
python3 scripts/audit_public_history_paths.py --json --allow-violations
git diff --check
```
@@ -93,7 +92,7 @@ The workflow runs the following steps:
it into the packaging tree. When triggered via `workflow_dispatch`, the
`ksadk_web_version` input pins a specific npm version (for example `1.2.3`).
2. **Release preflight**: `make public-preflight` chains `public-audit`,
- `public-test`, `public-docs-build`, and `public-build-check`.
+ `public-test`, `docs-site-build`, and `public-build-check`.
3. **Build and content check**: inside `public-build-check`, `uv build`
produces the artifacts and `twine check dist/*` validates wheel and sdist
metadata.
diff --git a/docs-site/content/docs/references/contributing/release.mdx b/docs-site/content/docs/references/contributing/release.mdx
index dbb5bd37..378561ce 100644
--- a/docs-site/content/docs/references/contributing/release.mdx
+++ b/docs-site/content/docs/references/contributing/release.mdx
@@ -7,8 +7,7 @@ title: 发布流程
## 发布前
```bash
-make open-source-review
-make open-source-review-bundle
+make public-review
make public-publish-check PUBLIC_PUBLISH_PHASE=pre-publish V=0.6.7
```
@@ -48,7 +47,7 @@ workflow 执行步骤如下:
通过 `workflow_dispatch` 触发时可传 `ksadk_web_version` 输入指定版本
(如 `1.2.3`)。
2. **发布前预检**:`make public-preflight`,串起 `public-audit`、
- `public-test`、`public-docs-build` 以及 `public-build-check`。
+ `public-test`、`docs-site-build` 以及 `public-build-check`。
3. **构建与内容检查**:`public-build-check` 内执行 `uv build` 并
`twine check dist/*`,校验 wheel 与 sdist 元数据。
4. **OIDC 上传**:用 `pypa/gh-action-pypi-publish` 通过 Trusted Publishing
diff --git a/docs-site/content/docs/references/contributing/testing.en.mdx b/docs-site/content/docs/references/contributing/testing.en.mdx
index daeda5c7..4e26e59c 100644
--- a/docs-site/content/docs/references/contributing/testing.en.mdx
+++ b/docs-site/content/docs/references/contributing/testing.en.mdx
@@ -16,7 +16,7 @@ publishable artifacts.
| ASGI service tests | FastAPI routes without opening a real port | service and session pytest files |
| HTTP protocol E2E | `/v1/responses`, `/v1/chat/completions`, upload, session events | protocol E2E tests |
| browser E2E | local Web UI request construction and upload behavior | browser-capable E2E tests |
-| open-source audits | public tree, clean exports, Pages, sdist, wheel | `make open-source-review` |
+| open-source audits | public tree, clean exports, Pages, sdist, wheel | `make public-preflight` |
Use the narrowest test that proves the change, then run the broader gate when a
change touches public behavior, packaging, docs, or release boundaries.
@@ -31,15 +31,14 @@ uv run --extra dev pytest tests/ -q
For documentation changes:
```bash
-make public-docs-build
-make public-docs-audit
+make docs-site-build
+make public-audit
```
For release or open-source boundary changes:
```bash
-make open-source-review
-make open-source-review-bundle
+make public-review
```
## Snapshot Tests
@@ -88,7 +87,7 @@ Use browser E2E for:
## Open-Source Review Gate
-`make open-source-review` is the local gate for the public release candidate. It
+`make public-review` is the local gate for the public release candidate. It
checks:
- open-source contract tests.
@@ -111,7 +110,7 @@ publication.
| CLI help, options, or error text | focused CLI tests and affected snapshots |
| runtime request/response behavior | focused runtime tests plus protocol E2E |
| attachments or workspace files | protocol tests and workspace security tests |
-| public docs | `make public-docs-build` and `make public-docs-audit` |
+| public docs | `make docs-site-build` and `make public-audit` |
| package metadata or release scripts | `uv build`, `twine check`, artifact audit |
| open-source export policy | export tests, open-source audit, review bundle |
diff --git a/docs-site/content/docs/references/contributing/testing.mdx b/docs-site/content/docs/references/contributing/testing.mdx
index 81b6e13b..900f0940 100644
--- a/docs-site/content/docs/references/contributing/testing.mdx
+++ b/docs-site/content/docs/references/contributing/testing.mdx
@@ -8,8 +8,8 @@ title: 测试策略
```bash
uv run --extra dev pytest
-uv run --extra dev python -m mkdocs build --strict
-make open-source-review
+make docs-site-build
+make public-review
```
## 覆盖重点
diff --git a/docs-site/content/docs/references/environment-variables.en.mdx b/docs-site/content/docs/references/environment-variables.en.mdx
index f2400ca7..558b8a14 100644
--- a/docs-site/content/docs/references/environment-variables.en.mdx
+++ b/docs-site/content/docs/references/environment-variables.en.mdx
@@ -57,6 +57,21 @@ Hosted deployments can inject a shared policy through `AGENTENGINE_MODEL_POLICY_
| `KSADK_UI_BUNDLE_PATH` | custom UI static bundle path relative to project; local auto-detects `research-ui/dist` (0.6.7) |
| `KSYUN_REGION` | region used by cloud actions and some SDK clients |
+## Cloud Account Identity Resolution
+
+| Variable | Purpose |
+| --- | --- |
+| `KSYUN_IAM_ENDPOINT` | Optional public IAM endpoint override; defaults to `iam.api.ksyun.com` when unset |
+| `KSYUN_IAM_INTRANET_URL` | Optional IAM intranet endpoint fallback override; defaults to `iam.inner.api.ksyun.com` when unset |
+| `IAM_INTRANET_URL` | Compatibility alias for `KSYUN_IAM_INTRANET_URL` |
+
+
+ When public IAM returns an error such as “inner account can only access through
+ intranet”, the runtime retries the intranet endpoint. Public environments
+ normally do not hit this branch; internal environments can override the address
+ with `KSYUN_IAM_INTRANET_URL` or `IAM_INTRANET_URL`.
+
+
## Session Storage
| Variable | Purpose |
@@ -269,6 +284,7 @@ KSADK_MCP_SERVERS='[{"name":"docs","url":"http://127.0.0.1:9000/mcp"}]'
| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | traces-specific OTLP HTTP endpoint; takes precedence over the generic endpoint |
| `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | traces-specific OTLP protocol; takes precedence over the generic protocol |
| `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | traces-specific OTLP HTTP headers; takes precedence over generic headers and may contain auth data |
+| `KSADK_OTLP_MAX_EXPORT_BATCH_SIZE` | maximum spans exported per OTLP batch, default `64`, to avoid oversized collector requests |
| `LANGFUSE_PUBLIC_KEY` | compatibility path for older Langfuse tracing auto-configuration |
| `LANGFUSE_SECRET_KEY` | Langfuse secret |
| `LANGFUSE_BASE_URL` | Langfuse base URL |
diff --git a/docs-site/content/docs/references/environment-variables.mdx b/docs-site/content/docs/references/environment-variables.mdx
index c0eef32c..e024105f 100644
--- a/docs-site/content/docs/references/environment-variables.mdx
+++ b/docs-site/content/docs/references/environment-variables.mdx
@@ -57,6 +57,20 @@ OPENAI_MODEL_NAME=my-model
| `KSADK_UI_BUNDLE_PATH` | 自定义 UI 静态 bundle 相对项目路径;本地默认自动探测 `research-ui/dist`(0.6.7) |
| `KSYUN_REGION` | 云端 action 和部分 SDK client 使用的区域 |
+## 云账号身份解析
+
+| 变量 | 用途 |
+| --- | --- |
+| `KSYUN_IAM_ENDPOINT` | 可选:覆盖 IAM 公网 endpoint;未配置时使用 `iam.api.ksyun.com` |
+| `KSYUN_IAM_INTRANET_URL` | 可选:覆盖 IAM 内网 endpoint fallback;未配置时使用默认 `iam.inner.api.ksyun.com` |
+| `IAM_INTRANET_URL` | `KSYUN_IAM_INTRANET_URL` 的兼容别名 |
+
+
+ 当公网 IAM 返回“inner account can only access through intranet”这类错误时,
+ 运行时会尝试内网 endpoint。外部环境通常不会命中该分支;内部环境如需覆盖地址,
+ 可设置 `KSYUN_IAM_INTRANET_URL` 或 `IAM_INTRANET_URL`。
+
+
## 会话存储
| 变量 | 用途 |
@@ -269,6 +283,7 @@ KSADK_MCP_SERVERS='[{"name":"docs","url":"http://127.0.0.1:9000/mcp"}]'
| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | traces 专用 OTLP HTTP endpoint,优先于通用 endpoint |
| `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | traces 专用 OTLP protocol,优先于通用 protocol |
| `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | traces 专用 OTLP HTTP headers,优先于通用 headers,可能包含鉴权信息 |
+| `KSADK_OTLP_MAX_EXPORT_BATCH_SIZE` | OTLP 单次 export 最大 span 数,默认 `64`,用于避免 collector 请求过大 |
| `LANGFUSE_PUBLIC_KEY` | 兼容旧 Langfuse tracing 自动配置 |
| `LANGFUSE_SECRET_KEY` | Langfuse secret |
| `LANGFUSE_BASE_URL` | Langfuse base URL |
diff --git a/docs-site/content/docs/references/openai-compatible-api.en.mdx b/docs-site/content/docs/references/openai-compatible-api.en.mdx
index e1777131..b1ca8036 100644
--- a/docs-site/content/docs/references/openai-compatible-api.en.mdx
+++ b/docs-site/content/docs/references/openai-compatible-api.en.mdx
@@ -118,8 +118,8 @@ of KsADK extensions:
| `status` | compatible | `completed` / `failed` / `incomplete` |
| `model` | compatible | model or agent used for the request |
| `output` | compatible | output item, usually an assistant message |
-| `metadata` | compatible | caller metadata |
-| `usage` | compatible-shaped | usage info when available |
+| `metadata` | compatible | caller metadata; KsADK 0.6.9+ adds `metadata.last_usage` when available |
+| `usage` | compatible-shaped | per-response accumulated usage when available, used for the real cost of this response |
| `output_text` | KsADK extension | convenient concatenated text |
| `session_id` | KsADK extension | local session id |
@@ -139,6 +139,20 @@ of KsADK extensions:
]
}
],
+ "usage": {
+ "input_tokens": 1250,
+ "output_tokens": 180,
+ "total_tokens": 1430,
+ "input_token_details": {"cached": 900}
+ },
+ "metadata": {
+ "last_usage": {
+ "input_tokens": 980,
+ "output_tokens": 180,
+ "total_tokens": 1160,
+ "input_token_details": {"cached": 700}
+ }
+ },
"output_text": "This agent can...",
"session_id": "local-demo-session"
}
@@ -147,6 +161,14 @@ of KsADK extensions:
Consumers aiming for broad compatibility should read `output` first and treat
`output_text` as a convenience field.
+
+ `usage` keeps the OpenAI-style accumulated usage for this response; if an
+ agent loop calls the model multiple times, those calls are summed. The
+ KsADK-specific `metadata.last_usage` is the final model-call usage snapshot,
+ used by higher layers to compute current context-window occupancy. It is not a
+ cumulative session total.
+
+
diff --git a/docs-site/content/docs/references/openai-compatible-api.mdx b/docs-site/content/docs/references/openai-compatible-api.mdx
index ccb6ee07..358fb1e1 100644
--- a/docs-site/content/docs/references/openai-compatible-api.mdx
+++ b/docs-site/content/docs/references/openai-compatible-api.mdx
@@ -113,8 +113,8 @@ checkpoint resume 与 approval payload。
| `status` | compatible | `completed` / `failed` / `incomplete` |
| `model` | compatible | 本次请求使用的模型或 Agent |
| `output` | compatible | output item,通常是 assistant message |
-| `metadata` | compatible | 调用方 metadata |
-| `usage` | compatible-shaped | 可用时的 usage 信息 |
+| `metadata` | compatible | 调用方 metadata;KsADK 0.6.9+ 会在可用时追加 `metadata.last_usage` |
+| `usage` | compatible-shaped | 可用时的本轮累计 usage,用于统计本次响应真实消耗 |
| `output_text` | KsADK 扩展 | 拼接后的便捷文本 |
| `session_id` | KsADK 扩展 | 本地 session id |
@@ -134,6 +134,20 @@ checkpoint resume 与 approval payload。
]
}
],
+ "usage": {
+ "input_tokens": 1250,
+ "output_tokens": 180,
+ "total_tokens": 1430,
+ "input_token_details": {"cached": 900}
+ },
+ "metadata": {
+ "last_usage": {
+ "input_tokens": 980,
+ "output_tokens": 180,
+ "total_tokens": 1160,
+ "input_token_details": {"cached": 700}
+ }
+ },
"output_text": "This agent can...",
"session_id": "local-demo-session"
}
@@ -141,6 +155,12 @@ checkpoint resume 与 approval payload。
追求广泛兼容的消费者应优先读取 `output`,把 `output_text` 视为便捷字段。
+
+ `usage` 保持 OpenAI 风格的本轮累计 usage(agent loop 内多次 LLM 调用会累加)。
+ `metadata.last_usage` 是 KsADK 扩展,表示最后一次 LLM 调用的 usage 快照,用于
+ 上层计算当前上下文窗口占用;它不是会话累计值。
+
+
diff --git a/docs-site/content/docs/references/remote-runtime-api.en.mdx b/docs-site/content/docs/references/remote-runtime-api.en.mdx
index b4273c42..b4a01897 100644
--- a/docs-site/content/docs/references/remote-runtime-api.en.mdx
+++ b/docs-site/content/docs/references/remote-runtime-api.en.mdx
@@ -117,9 +117,9 @@ All action paths live under `POST /agentengine/api/v1/*` (download surfaces are
| Group | Purpose | Actions |
| --- | --- | --- |
-| Sessions | Create, get, list, delete sessions | `CreateSession`, `GetSession`, `ListSessions`, `DeleteSession` |
+| Sessions | Create, get, list, delete sessions, read projected chat messages | `CreateSession`, `GetSession`, `ListSessions`, `ListSessionMessages`, `DeleteSession` |
| Runs | Invoke or cancel a run, subscribe to events | `RunAgent`, `SubscribeRunEvents`, `CancelRun` |
-| Events | List historical session events | `ListSessionEvents` |
+| Events | List historical session events with incremental resume and older-page cursors | `ListSessionEvents` |
| Files | Upload, download attachments, workspace files, export archive | `UploadFile`, `AttachmentContent`, `ListWorkspaceFiles`, `AddWorkspaceFile`, `DeleteWorkspaceFile`, `GetWorkspaceFileContent`, `ExportWorkspaceZip` |
| Models | List the available model catalog | `ListAgentModels` |
| UI Bootstrap | Bootstrap metadata for UI rendering and capability discovery | `GetAgentUiBootstrap` |
@@ -136,7 +136,7 @@ All action paths live under `POST /agentengine/api/v1/*` (download surfaces are
1. **Probe capabilities**: call `GetAgentUiBootstrap` first to check `Capabilities.WorkspaceFiles`, `Capabilities.ResumeRun`, etc.
2. **Create a session**: `CreateSession`, take `Session.SessionId`.
-3. **Pull history**: `ListSessions` / `ListSessionEvents` to render the sidebar and timeline.
+3. **Pull history**: `ListSessions` for the sidebar and `ListSessionMessages` for chat history; use `ListSessionEvents` directly only for debugging or raw timelines.
4. **Start a run**: `RunAgent` (foreground or background), subscribe to progress via `SubscribeRunEvents`.
5. **Reclaim a session**: `DeleteSession` to release persisted records.
@@ -185,6 +185,12 @@ Creates (or reuses) a session. When `SessionId` is omitted, the runtime generate
"LastPrompt": "",
"ActiveInvocationId": "",
"ActiveRunStatus": "",
+ "ActiveRunMode": "unknown",
+ "ActiveRunTrigger": "unknown",
+ "ActiveRunUpdatedAt": "",
+ "Model": null,
+ "ContextUsage": null,
+ "TokenUsage": null,
"State": {},
"CreatedAt": 1719900000.0,
"UpdatedAt": 1719900000.0,
@@ -202,7 +208,12 @@ Creates (or reuses) a session. When `SessionId` is omitted, the runtime generate
| `Title` / `TitleSource` | Session title and its source (`fallback_first_prompt`, `heuristic`, etc.) |
| `Summary` | Runtime-maintained session summary |
| `FirstPrompt` / `LastPrompt` | Truncated first/last user message preview |
-| `ActiveInvocationId` / `ActiveRunStatus` | Active run invocation and status for this session |
+| `ActiveInvocationId` / `ActiveRunStatus` | Active run invocation and status for this session; stale orphaned active runs are read as `interrupted` |
+| `ActiveRunMode` / `ActiveRunTrigger` | 0.6.9+ two independent run dimensions: `background` / `foreground` / `unknown` and `new_run` / `checkpoint_resume` / `approval_resume` / `unknown` |
+| `ActiveRunUpdatedAt` | 0.6.9+ timestamp from `active_run` itself, mainly for diagnostics; orphan detection uses the session `UpdatedAt` heartbeat |
+| `Model` | Model metadata from the most recent run when available |
+| `ContextUsage` | 0.6.9+ latest-turn context-window usage: `used_tokens`, `cached_tokens`, `context_window_tokens`, `percent` |
+| `TokenUsage` | 0.6.9+ cumulative session token usage: `input_tokens`, `output_tokens`, `total_tokens`, `turns`, `last_response_id`, and detail fields |
| `State` | Session state dict (sanitized) |
| `CreatedAt` / `UpdatedAt` / `Version` | Timestamps and version |
| `Continuity` | Optional: continuity info from the runner adapter |
@@ -632,7 +643,9 @@ Request cancellation of an in-flight run (both detached stream and runner channe
#### ListSessionEvents
-Paginated list of historical events for a session, offset-based pagination.
+Paginated list of historical events for a session. Without a seq cursor,
+`Offset` / `Limit` page backward from the latest event window. `AfterSeqId` is
+for reconnect/incremental reads; `BeforeSeqId` is for loading older history.
@@ -644,6 +657,8 @@ Paginated list of historical events for a session, offset-based pagination.
| `SessionId` | string | yes | Target session id |
| `Offset` | int | no | 0-based offset, default `0` |
| `Limit` | int | no | Page size, min `1` |
+| `AfterSeqId` | int | no | 0.6.9+ return events with `SeqId > AfterSeqId` for reconnect catch-up |
+| `BeforeSeqId` | int | no | 0.6.9+ return events with `SeqId < BeforeSeqId` for older pages |
```json title="request.json"
{ "SessionId": "local-demo-session", "Offset": 0, "Limit": 50 }
@@ -662,7 +677,9 @@ Paginated list of historical events for a session, offset-based pagination.
"Events": [ /* event payload, same as SubscribeRunEvents */ ],
"Total": 128,
"Offset": 0,
- "Limit": 50
+ "Limit": 50,
+ "AfterSeqId": null,
+ "BeforeSeqId": null
}
}
```
@@ -674,6 +691,103 @@ Pagination fields:
| `Total` | Total event count for this session |
| `Offset` | Current offset (defaults to `0`) |
| `Limit` | Current page size (falls back to the returned count when not sent) |
+| `AfterSeqId` / `BeforeSeqId` | Echoed seq cursors; they move in opposite directions and should not be mixed |
+
+
+
+
+#### ListSessionMessages
+
+Project the raw event log into chat messages that the Web UI can render
+directly. The server handles assistant snapshot de-duplication, reasoning
+merging, tool / approval pairing, and attachment normalization so clients do not
+need to reconstruct messages from `ListSessionEvents`.
+
+
+
+
+`POST /agentengine/api/v1/ListSessionMessages`:
+
+| Field | Type | Required | Notes |
+| --- | --- | --- | --- |
+| `AgentId` | string | no | When present, the server fetches runtime `ListSessionEvents` and projects them in one place; when omitted, hosted reads the local session store |
+| `SessionId` | string | yes | Target session id |
+| `Limit` | int | no | Maximum messages to return, default `50`, range `1..200` |
+| `AfterSeqId` | int | no | Return incremental messages with `SeqId > AfterSeqId`; used for reconnect catch-up and not truncated to `Limit` |
+| `BeforeSeqId` | int | no | Return the latest page before `SeqId < BeforeSeqId`; used for loading older history |
+| `IncludeReasoning` | bool | no | Include `Reasoning[]` inside assistant messages |
+| `IncludeToolEvents` | bool | no | Include `ToolEvents[]` inside assistant messages |
+| `IncludeAttachments` | bool | no | Include `Attachments[]` on user messages, default `true` |
+
+```json title="request.json"
+{
+ "SessionId": "local-demo-session",
+ "Limit": 50,
+ "IncludeAttachments": true
+}
+```
+
+
+
+
+```json title="response.json"
+{
+ "Code": 0,
+ "Message": "Success",
+ "RequestId": "req_abc123",
+ "Action": "ListSessionMessages",
+ "Data": {
+ "SessionId": "local-demo-session",
+ "Messages": [
+ {
+ "MessageId": "evt_user_1",
+ "Role": "user",
+ "Content": {"text": "Summarize this file"},
+ "SeqId": 21,
+ "InvocationId": "inv_demo_001",
+ "Timestamp": 1719900001.0,
+ "Attachments": [
+ {
+ "file_uri": "ae-upload://abc123_report.pdf",
+ "name": "report.pdf",
+ "mime": "application/pdf",
+ "size": 204800,
+ "url": "/agentengine/api/v1/AttachmentContent?FileUri=ae-upload%3A%2F%2Fabc123_report.pdf",
+ "is_image": false
+ }
+ ]
+ },
+ {
+ "MessageId": "evt_assistant_1",
+ "Role": "assistant",
+ "Content": {"text": "Here is the summary..."},
+ "SeqId": 24,
+ "InvocationId": "inv_demo_001",
+ "ResponseId": "resp_a1b2c3",
+ "TraceId": "trace_abc",
+ "RootSpanId": "span_root"
+ }
+ ],
+ "LatestSeqId": 24,
+ "HasMore": true,
+ "NextCursor": 20
+ }
+}
+```
+
+Pagination fields:
+
+| Field | Notes |
+| --- | --- |
+| `LatestSeqId` | `SeqId` of the last message in the page; pass it to `SubscribeRunEvents(AfterSeqId=...)` as the reconnect starting point |
+| `HasMore` | Whether older messages remain |
+| `NextCursor` | Older-page cursor; pass it as `BeforeSeqId=NextCursor` on the next request |
+
+
+ `ListSessionMessages` only projects historical messages. To decide whether an
+ SSE stream should reconnect, read `GetSession.ActiveRunStatus` /
+ `ActiveInvocationId`, then call `SubscribeRunEvents(AfterSeqId=LatestSeqId)`.
+
diff --git a/docs-site/content/docs/references/remote-runtime-api.mdx b/docs-site/content/docs/references/remote-runtime-api.mdx
index 3281b653..2f5e1304 100644
--- a/docs-site/content/docs/references/remote-runtime-api.mdx
+++ b/docs-site/content/docs/references/remote-runtime-api.mdx
@@ -112,9 +112,9 @@ cancel 和 model listing。公开 API 客户端优先使用 OpenAI 兼容的 `/v
| 分组 | 用途 | Actions |
| --- | --- | --- |
-| 会话 Sessions | 创建、获取、列出、删除会话 | `CreateSession`、`GetSession`、`ListSessions`、`DeleteSession` |
+| 会话 Sessions | 创建、获取、列出、删除会话,读取投影后的聊天消息 | `CreateSession`、`GetSession`、`ListSessions`、`ListSessionMessages`、`DeleteSession` |
| 运行 Runs | 调用或取消 Agent run、订阅事件 | `RunAgent`、`SubscribeRunEvents`、`CancelRun` |
-| 事件 Events | 列出 session 历史 events | `ListSessionEvents` |
+| 事件 Events | 列出 session 历史 events,支持增量续订与向前翻页 | `ListSessionEvents` |
| 文件 Files | 上传、下载附件、workspace 文件、导出 archive | `UploadFile`、`AttachmentContent`、`ListWorkspaceFiles`、`AddWorkspaceFile`、`DeleteWorkspaceFile`、`GetWorkspaceFileContent`、`ExportWorkspaceZip` |
| 模型 Models | 列出可用模型目录 | `ListAgentModels` |
| UI Bootstrap | 获取 UI 渲染与能力探测所需 metadata | `GetAgentUiBootstrap` |
@@ -130,7 +130,7 @@ cancel 和 model listing。公开 API 客户端优先使用 OpenAI 兼容的 `/v
1. **探测能力**:先调用 `GetAgentUiBootstrap`,确认 `Capabilities.WorkspaceFiles`、`Capabilities.ResumeRun` 等。
2. **创建会话**:`CreateSession`,拿到 `Session.SessionId`。
-3. **拉取历史**:`ListSessions` / `ListSessionEvents` 渲染侧栏与时间线。
+3. **拉取历史**:`ListSessions` 渲染侧栏,`ListSessionMessages` 渲染聊天历史;只有调试或原始时间线才直接读 `ListSessionEvents`。
4. **发起运行**:`RunAgent`(前台或后台),用 `SubscribeRunEvents` 续订进度。
5. **回收会话**:`DeleteSession` 释放持久化记录。
@@ -179,6 +179,12 @@ cancel 和 model listing。公开 API 客户端优先使用 OpenAI 兼容的 `/v
"LastPrompt": "",
"ActiveInvocationId": "",
"ActiveRunStatus": "",
+ "ActiveRunMode": "unknown",
+ "ActiveRunTrigger": "unknown",
+ "ActiveRunUpdatedAt": "",
+ "Model": null,
+ "ContextUsage": null,
+ "TokenUsage": null,
"State": {},
"CreatedAt": 1719900000.0,
"UpdatedAt": 1719900000.0,
@@ -196,7 +202,12 @@ cancel 和 model listing。公开 API 客户端优先使用 OpenAI 兼容的 `/v
| `Title` / `TitleSource` | 会话标题及其来源(`fallback_first_prompt`、`heuristic` 等) |
| `Summary` | 运行时维护的会话摘要 |
| `FirstPrompt` / `LastPrompt` | 截断后的首/末用户消息预览 |
-| `ActiveInvocationId` / `ActiveRunStatus` | 当前会话活跃 run 的 invocation 与状态 |
+| `ActiveInvocationId` / `ActiveRunStatus` | 当前会话活跃 run 的 invocation 与状态;读侧会把超时孤儿活跃态兜底显示为 `interrupted` |
+| `ActiveRunMode` / `ActiveRunTrigger` | 0.6.9+ run 状态双维度:`background` / `foreground` / `unknown` 与 `new_run` / `checkpoint_resume` / `approval_resume` / `unknown` |
+| `ActiveRunUpdatedAt` | 0.6.9+ active_run 自身更新时间,主要用于诊断;孤儿判定使用 session `UpdatedAt` 心跳 |
+| `Model` | 最近一次 run 的模型 metadata(如可用) |
+| `ContextUsage` | 0.6.9+ 最近一轮上下文窗口占用:`used_tokens`、`cached_tokens`、`context_window_tokens`、`percent` |
+| `TokenUsage` | 0.6.9+ 会话累计 token 消耗:`input_tokens`、`output_tokens`、`total_tokens`、`turns`、`last_response_id` 与明细字段 |
| `State` | 会话状态 dict(已脱敏) |
| `CreatedAt` / `UpdatedAt` / `Version` | 时间戳与版本号 |
| `Continuity` | 可选:runner 适配器描述的连续性信息 |
@@ -619,7 +630,8 @@ data: [DONE]
#### ListSessionEvents
-分页列出某 session 的历史 events,offset-based 分页。
+分页列出某 session 的历史 events。无游标时 `Offset` / `Limit` 表示从最新事件窗口向前分页;
+`AfterSeqId` 用于断线后增量读取,`BeforeSeqId` 用于向上翻更早历史。
@@ -631,6 +643,8 @@ data: [DONE]
| `SessionId` | string | 是 | 目标会话 id |
| `Offset` | int | 否 | 0-based 偏移,缺省 `0` |
| `Limit` | int | 否 | 每页大小,最小 `1` |
+| `AfterSeqId` | int | 否 | 0.6.9+ 返回 `SeqId > AfterSeqId` 的事件,用于重连补齐 |
+| `BeforeSeqId` | int | 否 | 0.6.9+ 返回 `SeqId < BeforeSeqId` 的事件,用于向前翻页 |
```json title="request.json"
{ "SessionId": "local-demo-session", "Offset": 0, "Limit": 50 }
@@ -649,7 +663,9 @@ data: [DONE]
"Events": [ /* event payload,同 SubscribeRunEvents */ ],
"Total": 128,
"Offset": 0,
- "Limit": 50
+ "Limit": 50,
+ "AfterSeqId": null,
+ "BeforeSeqId": null
}
}
```
@@ -661,6 +677,102 @@ data: [DONE]
| `Total` | 该 session 的事件总数 |
| `Offset` | 当前偏移(缺省回填 `0`) |
| `Limit` | 当前页大小(未传时回填为实际返回数) |
+| `AfterSeqId` / `BeforeSeqId` | 请求中的 seq 游标原样回填;二者语义相反,不应混用 |
+
+
+
+
+#### ListSessionMessages
+
+把原始 event log 投影为前端可直接渲染的聊天消息列表。它会在服务端完成
+assistant snapshot 去重、reasoning 归并、tool / approval 配对和附件规范化,避免客户端
+从 `ListSessionEvents` 自行筛消息。
+
+
+
+
+`POST /agentengine/api/v1/ListSessionMessages`:
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `AgentId` | string | 否 | 传入时 server 调 runtime `ListSessionEvents` 再统一投影;不传则 hosted 直连 session store |
+| `SessionId` | string | 是 | 目标会话 id |
+| `Limit` | int | 否 | 返回消息条数上限,默认 `50`,范围 `1..200` |
+| `AfterSeqId` | int | 否 | 返回 `SeqId > AfterSeqId` 的增量消息;用于重连补齐,不截断到 `Limit` |
+| `BeforeSeqId` | int | 否 | 返回 `SeqId < BeforeSeqId` 之前的最新一页消息;用于向上加载更早历史 |
+| `IncludeReasoning` | bool | 否 | 是否在 assistant 消息中返回 `Reasoning[]` |
+| `IncludeToolEvents` | bool | 否 | 是否在 assistant 消息中返回 `ToolEvents[]` |
+| `IncludeAttachments` | bool | 否 | 是否在 user 消息中返回 `Attachments[]`,默认 `true` |
+
+```json title="request.json"
+{
+ "SessionId": "local-demo-session",
+ "Limit": 50,
+ "IncludeAttachments": true
+}
+```
+
+
+
+
+```json title="response.json"
+{
+ "Code": 0,
+ "Message": "Success",
+ "RequestId": "req_abc123",
+ "Action": "ListSessionMessages",
+ "Data": {
+ "SessionId": "local-demo-session",
+ "Messages": [
+ {
+ "MessageId": "evt_user_1",
+ "Role": "user",
+ "Content": {"text": "总结这个文件"},
+ "SeqId": 21,
+ "InvocationId": "inv_demo_001",
+ "Timestamp": 1719900001.0,
+ "Attachments": [
+ {
+ "file_uri": "ae-upload://abc123_report.pdf",
+ "name": "report.pdf",
+ "mime": "application/pdf",
+ "size": 204800,
+ "url": "/agentengine/api/v1/AttachmentContent?FileUri=ae-upload%3A%2F%2Fabc123_report.pdf",
+ "is_image": false
+ }
+ ]
+ },
+ {
+ "MessageId": "evt_assistant_1",
+ "Role": "assistant",
+ "Content": {"text": "文件摘要如下..."},
+ "SeqId": 24,
+ "InvocationId": "inv_demo_001",
+ "ResponseId": "resp_a1b2c3",
+ "TraceId": "trace_abc",
+ "RootSpanId": "span_root"
+ }
+ ],
+ "LatestSeqId": 24,
+ "HasMore": true,
+ "NextCursor": 20
+ }
+}
+```
+
+分页语义:
+
+| 字段 | 说明 |
+| --- | --- |
+| `LatestSeqId` | 本页最后一条消息的 `SeqId`;可作为 `SubscribeRunEvents(AfterSeqId=...)` 的续订起点 |
+| `HasMore` | 是否还有更早消息可取 |
+| `NextCursor` | 向前翻页游标;下一次请求传 `BeforeSeqId=NextCursor` |
+
+
+ `ListSessionMessages` 只负责历史消息投影。是否需要重连运行中的 SSE,应读取
+ `GetSession.ActiveRunStatus` / `ActiveInvocationId`,再用
+ `SubscribeRunEvents(AfterSeqId=LatestSeqId)` 续订。
+
diff --git a/docs-site/content/docs/references/security-boundaries.en.mdx b/docs-site/content/docs/references/security-boundaries.en.mdx
index 15a2200c..9b25d1b1 100644
--- a/docs-site/content/docs/references/security-boundaries.en.mdx
+++ b/docs-site/content/docs/references/security-boundaries.en.mdx
@@ -14,7 +14,7 @@ The public repository is expected to contain:
- Python SDK and CLI source.
- local runtime adapters.
- generated static assets required by `agentengine web`.
-- curated public docs under `public-docs/`.
+- curated public docs under `docs-site/`.
- public CI, release checks, and contribution policy.
It must not contain:
diff --git a/docs-site/content/docs/references/troubleshooting.en.mdx b/docs-site/content/docs/references/troubleshooting.en.mdx
index 3cc3bac2..74364718 100644
--- a/docs-site/content/docs/references/troubleshooting.en.mdx
+++ b/docs-site/content/docs/references/troubleshooting.en.mdx
@@ -234,14 +234,14 @@ short:
Run:
```bash
-uv run --extra dev python -m mkdocs build --strict
+make docs-site-build
```
Common causes:
- broken relative links.
-- page added to `nav` but not created.
-- duplicate Markdown extension entries.
+- page added to the docs tree but not linked correctly.
+- invalid MDX or duplicated frontmatter fields.
- generated files under `site/` accidentally committed.
- docs referencing private files excluded from the public repository.
diff --git a/docs-site/next.config.mjs b/docs-site/next.config.mjs
index 1999fe94..d332fb89 100644
--- a/docs-site/next.config.mjs
+++ b/docs-site/next.config.mjs
@@ -2,7 +2,7 @@ import { createMDX } from 'fumadocs-mdx/next';
const withMDX = createMDX();
-// For GitHub Pages project sites, set NEXT_PUBLIC_BASE_PATH=/veadk-python at
+// For GitHub Pages project sites, set NEXT_PUBLIC_BASE_PATH=/ksadk-python at
// build time. Left empty for local dev so the site is served from `/`.
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '';
diff --git a/docs-site/scripts/build-static.mjs b/docs-site/scripts/build-static.mjs
index 5b92dd66..9c6af6a6 100644
--- a/docs-site/scripts/build-static.mjs
+++ b/docs-site/scripts/build-static.mjs
@@ -6,7 +6,7 @@
// export build, restore it, and write a `.nojekyll` file so GitHub Pages serves
// the `_next/` directory.
//
-// Usage: NEXT_PUBLIC_BASE_PATH=/veadk-python node scripts/build-static.mjs
+// Usage: NEXT_PUBLIC_BASE_PATH=/ksadk-python node scripts/build-static.mjs
import { execSync } from 'node:child_process';
import { existsSync, renameSync, writeFileSync, mkdirSync } from 'node:fs';
diff --git a/docs-site/source.config.ts b/docs-site/source.config.ts
index 886f822a..d20a2164 100644
--- a/docs-site/source.config.ts
+++ b/docs-site/source.config.ts
@@ -20,7 +20,7 @@ export const docs = defineDocs({
},
});
-// GitHub Pages serves under a base path (e.g. /veadk-python). Next prefixes
+// GitHub Pages serves under a base path (e.g. /ksadk-python). Next prefixes
// `_next/` assets and next/link hrefs automatically, but NOT raw
// from markdown. Prepend the base path to absolute image sources at build time.
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH || '';
diff --git "a/docs/guides/Agent \345\274\200\345\217\221\350\200\205\344\270\212\344\270\213\346\226\207\346\216\245\345\205\245\346\214\207\345\215\227.md" "b/docs/guides/Agent \345\274\200\345\217\221\350\200\205\344\270\212\344\270\213\346\226\207\346\216\245\345\205\245\346\214\207\345\215\227.md"
deleted file mode 100644
index 1910394f..00000000
--- "a/docs/guides/Agent \345\274\200\345\217\221\350\200\205\344\270\212\344\270\213\346\226\207\346\216\245\345\205\245\346\214\207\345\215\227.md"
+++ /dev/null
@@ -1,567 +0,0 @@
-# Agent 开发者上下文接入指南
-
-本文档面向使用 `ksadk-python` 开发 Agent 业务逻辑的开发者,重点说明:
-
-- 通过 `/v1/responses`、Hosted UI 或 `RunAgent` 调用时,运行时会把哪些上下文喂给 Agent
-- LangGraph、LangChain、ADK 这三类主路径里,开发者应该从哪里拿:
- - 当前输入
- - 多轮历史
- - 图片 / 附件上下文
- - OCR / 文档抽取结果
- - 平台上下文
- - 知识库与长期记忆上下文
- - 模型能力元数据
-- 什么时候应该解析 `HumanMessage`,什么时候不应该
-
-本文档不讨论云端部署、权限治理或外部产品逻辑;只聚焦 agent 业务代码如何接上下文。
-
-## 1. 先给结论
-
-如果你是 Agent 业务开发者,**最推荐的接入方式不是直接拆 `messages[-1]`,而是使用 `ksadk_prepare_state()` / `ksadk_prepare_input()` 明确接收平台传入的标准上下文。**
-
-优先级建议:
-
-1. `LangGraph`
- - 自定义 `ksadk_prepare_state(payload, session_context)`
- - 具体项目结构、interrupt / resume、Responses approval 写法见 [LangGraph开发最佳实践](./LangGraph开发最佳实践.md)
-2. `LangChain`
- - 自定义 `ksadk_prepare_input(payload, session_context)`
-3. `ADK`
- - 使用 runner 已构造好的 `Part` / session 能力
-4. 只有在你确实做 messages-native agent 时,再直接解析 `HumanMessage`
-
-## 2. 运行时到底会给 Agent 什么
-
-进入 runner 前,`ksadk` 会把一次请求整理成标准运行输入。核心字段包括:
-
-- `input`
-- `history`
-- `input_content`
-- `input_messages`
-- `input_parts`
-- `attachments`
-- `attachment_results`
-- `current_attachments`
-- `current_attachment_results`
-- `has_current_files`
-- `model`
-- `model_metadata`
-- `platform_context`
-- `kb_context`
-- `memory_context`
-- `instructions`
-
-这些字段不是每个 framework 都以同样方式消费,但它们是当前平台提供给 Agent 的标准上下文来源。
-`input_content` / `input_messages` 是 runner 默认 canonical 输入,使用 OpenAI Responses 风格 content blocks;`input_parts` 是 legacy/internal normalized parts,用于兼容已有 runner。`attachments`、`current_attachments`、`has_current_files` 等是 KsADK runner payload 扩展上下文,不属于 OpenAI 官方请求或响应字段。
-
-对外协议不混写:`/v1/responses` 接收 OpenAI Responses 风格 `input_text / input_image / input_file`;`/v1/chat/completions` 保持 Chat Completions 风格 `messages`,官方多模态块优先使用 `text / image_url`。进入 runner 前,两条入口都会投影到同一套 `input_content / input_messages`,并额外生成兼容用 `input_parts`。KsADK 兼容扩展 `inlineData / fileData` 仍可用于老客户端,但不把它们声明成 OpenAI Chat 官方字段。
-
-### 2.1 字段说明
-
-| 字段 | 类型 | 说明 |
-| --- | --- | --- |
-| `input` | `str` | 当前这一轮的标准文本输入 |
-| `history` | `list[dict]` | 当前多轮会话历史,已经过 transcript 投影 / compaction |
-| `input_content` | `list[dict]` | 当前 user turn 的 OpenAI Responses content blocks,例如 `input_text / input_image / input_file` |
-| `input_messages` | `list[dict]` | OpenAI Responses 风格 message/input items;需要完整 role/content 结构的 runner 优先读这里 |
-| `input_parts` | `list[dict]` | legacy/internal 归一化片段,保留 `text / inlineData / fileData`;用于兼容已有 runner,不作为 OpenAI 官方协议字段暴露 |
-| `invocation_id` | `str` | 当前运行 ID。0.6.5 起写入 runner payload,用于 transcript 分组、`SubscribeRunEvents` 续订、`CancelRun` 以及 trace 串联 |
-| `attachments` | `list[dict]` | 当前会话最近有效附件上下文,兼容历史 fallback,不应用来判断本轮是否传文件 |
-| `attachment_results` | `list[dict]` | 最近有效附件理解结果,例如 OCR / 文本提取 |
-| `current_attachments` | `list[dict]` | 当前最新 user turn 解析出的附件列表,不包含历史 fallback |
-| `current_attachment_results` | `list[dict]` | 当前最新 user turn 的附件理解结果 |
-| `has_current_files` | `bool` | 当前最新 user turn 是否包含归一化后的 `inlineData` 或 `fileData`,包括 OpenAI `input_image / input_file` |
-| `model` | `str` | 当前请求显式使用的模型名 |
-| `model_metadata` | `dict` | 模型元数据,可能来自请求显式传入,也可能来自上游 `/v1/models` 自动解析 |
-| `platform_context` | `dict` | 平台上下文,含 `agent_id / user_id / session_id / account_id` |
-| `kb_context` | `dict` | 知识库召回构造的上下文 |
-| `memory_context` | `dict` | 长期记忆构造的上下文 |
-| `instructions` | `str` | 本轮额外系统/开发者指令 |
-
-## 3. 多轮会话历史是怎么来的
-
-平台不是要求前端每次把完整对话都重传回来,而是通过:
-
-- `session_id`
-- conversation event store
-- transcript 投影
-- 必要时 compaction
-
-来恢复当前会话历史。
-
-你在 Agent 业务里看到的 `history`,通常已经不是“原始数据库所有消息”,而是:
-
-- 更早历史被压缩成 summary checkpoint
-- 最近若干轮保持原始 user / assistant 消息
-- 特殊事件(tool call / tool result / approval / attachment)保留成可解释文本占位
-
-所以:
-
-- 如果你只需要“语义历史”,用 `history`
-- 如果你需要 OpenAI Responses 风格当前输入结构,优先看 `input_content / input_messages`
-- 如果你需要兼容老 runner 的内部结构,再看 `input_parts`
-- 如果你需要附件理解结果,看 `current_attachments / current_attachment_results / attachments / attachment_results`
-
-## 4. 图片 / 附件上下文怎么来的
-
-### 4.1 `attachments`
-
-`attachments` 是当前会话最近有效附件上下文。它可能来自当前轮,也可能来自同一 session 中最近一次带附件的 user turn,主要用于“继续围绕上个附件追问”的兼容场景。
-
-如果业务需要判断“本次问答是否带文件”,不要看 `attachments`,直接看 `has_current_files`;如果要当前轮附件列表,看 `current_attachments`。
-
-典型字段:
-
-```python
-{
- "display_name": "diagram.png",
- "mime_type": "image/png",
- "transport": "reference", # 或 "inline"
- "file_uri": "ksadk-upload://...",
- "data": "", # 仅 transport="inline" 时存在
- "size_bytes": 1356,
- "storage_path": "/tmp/.../diagram.png",
- "is_text": False,
-}
-```
-
-说明:
-
-- `transport="inline"`:调用方直接传了 `inlineData`
-- `transport="reference"`:调用方先 `UploadFile`,再传 `fileData.fileUri`
-
-### 4.2 当前轮文件判断
-
-推荐判断方式:
-
-```python
-has_file = bool(payload.get("has_current_files"))
-current_files = payload.get("current_attachments", [])
-```
-
-如果需要兼容旧版本 KsADK,可以退回检查 `input_parts`:
-
-```python
-has_file = any(
- isinstance(part, dict)
- and (part.get("inlineData") is not None or part.get("fileData") is not None)
- for part in payload.get("input_parts") or []
-)
-```
-
-### 4.3 `attachment_results`
-
-这是平台附件理解管线产出的结果,比 `attachments` 更适合业务逻辑消费。典型字段:
-
-```python
-{
- "display_name": "diagram.png",
- "mime_type": "image/png",
- "transport": "reference",
- "file_uri": "ksadk-upload://...",
- "size_bytes": 1356,
- "kind": "image",
- "status": "ok",
- "warnings": [],
- "extraction_method": "image_ocr",
- "text_excerpt": "KIMI E2E",
- "text": "KIMI E2E",
- "image": {"ocr_engine": "rapidocr_onnxruntime"}
-}
-```
-
-推荐使用方式:
-
-- 想拿当前轮 OCR 文本:读 `current_attachment_results[*]["text"]`
-- 想支持“围绕上次附件继续追问”:读 `attachment_results[*]["text"]`
-- 想区分图片 / 文档 / 压缩包:读 `kind`
-- 想看平台有没有降级或失败:读 `status / warnings / extraction_method`
-
-## 5. 模型能力元数据怎么来的
-
-`model_metadata` 的来源优先级是:
-
-1. 请求里显式传入的 `model_metadata`
-2. runtime 用 `OPENAI_BASE_URL / OPENAI_API_KEY` 查询上游 `/v1/models`
-3. 本地默认兜底
-
-当前最值得关注的字段是:
-
-```python
-{
- "id": "kimi-k2.7-code",
- "architecture": {
- "input_modalities": ["文字", "图片", "视频"],
- "output_modalities": ["文字"]
- },
- "capabilities": {
- "multimodal_input_image": True,
- "multimodal_input_video": True,
- "multimodal_input_file": False,
- "function_calling": True,
- "structured_output": True,
- "context_caching": True
- },
- "limits": {...},
- "pricing": {...}
-}
-```
-
-!!! info "多模态默认模型来源"
- 自策略 v1 起,运行时在请求未显式指定模型时,会按平台多模态默认模型策略挑选默认模型(含图片/视频输入能力的优先模型)。`model_metadata` 仍按下方来源优先级填充,业务侧不需要自己判断"默认模型是否多模态",直接读 `capabilities.multimodal_input_*` 即可。
-
-业务代码里最常用的判断是:
-
-```python
-supports_image = bool(
- (((model_metadata or {}).get("capabilities") or {}).get("multimodal_input_image"))
-)
-```
-
-## 6. LangGraph 怎么拿上下文
-
-LangGraph 的完整开发写法已经内化到框架专属文档:
-
-- [LangGraph开发最佳实践](./LangGraph开发最佳实践.md)
-
-这里只保留平台上下文接入的核心边界:
-
-- 默认 messages-based 图可以不写 hook,运行时会自动构造 `messages` state
-- 自定义 state 图推荐显式暴露 `ksadk_prepare_state(payload, session_context)`
-- `ksadk_prepare_state` 必须在 `agentengine.yaml` 的 `entry_point` 对应模块顶层可见
-- 附件、OCR、原始输入片段优先从 `payload` 读取
-- 会话历史、平台身份、知识库、长期记忆优先从 `session_context` 读取
-- LangGraph `interrupt()` 的恢复由平台协议层判断,业务代码不需要自己猜下一轮是否要 `Command(resume=...)`
-
-最小推荐写法:
-
-```python
-def ksadk_prepare_state(payload: dict, session_context: dict) -> dict:
- if session_context.get("is_resume"):
- return payload.get("input")
-
- return {
- "query": payload["input"],
- "history": session_context["history"],
- "attachments": payload.get("attachments", []),
- "attachment_results": payload.get("attachment_results", []),
- "current_attachments": payload.get("current_attachments", []),
- "current_attachment_results": payload.get("current_attachment_results", []),
- "has_current_files": payload.get("has_current_files", False),
- "platform_context": session_context.get("platform_context"),
- "kb_context": session_context.get("kb_context"),
- "memory_context": session_context.get("memory_context"),
- "model_metadata": payload.get("model_metadata", {}),
- }
-```
-
-如果涉及 human-in-the-loop / MCP 工具审批 / `interrupt()` 断点恢复,请优先阅读 [LangGraph开发最佳实践](./LangGraph开发最佳实践.md) 的 interrupt 与 Responses approval 章节。
-
-## 7. LangChain 怎么拿上下文
-
-推荐定义:
-
-```python
-def ksadk_prepare_input(payload: dict, session_context: dict) -> dict:
- return {
- "question": payload["input"],
- "history": session_context["history"],
- "attachments": payload.get("attachments", []),
- "attachment_results": payload.get("attachment_results", []),
- "current_attachments": payload.get("current_attachments", []),
- "current_attachment_results": payload.get("current_attachment_results", []),
- "has_current_files": payload.get("has_current_files", False),
- "input_content": payload.get("input_content", []),
- "input_messages": payload.get("input_messages", []),
- "input_parts": payload.get("input_parts", []),
- "model_metadata": payload.get("model_metadata"),
- }
-```
-
-然后业务链或 runnable 自己决定:
-
-- 要不要把 `history` 变成 prompt
-- 要不要优先消费 `attachment_results[*]["text"]`
-- 要不要在支持多模态的模型上直接消费 `input_content / input_messages`
-- 是否需要兼容旧 runner 的 `input_parts`
-
-说明:
-
-- LangChain 当前不保证所有 agent 自动原生图片直通
-- 所以如果你要做稳定多模态 LangChain agent,建议自己在 hook 里显式处理
-
-## 8. ADK 怎么拿上下文
-
-ADK 路径下,平台会优先把附件转成底层 SDK 的 `Part`:
-
-- 文本 -> `types.Part(text=...)`
-- 图片 / 其他附件 -> `types.Part.from_bytes(...)`
-
-所以对支持原生多模态的 ADK 模型,图片会优先按 bytes part 进入底层 SDK。
-
-ADK 侧开发者通常更应该依赖:
-
-- ADK 自己的 session 机制
-- 当前传进来的 `new_message.parts`
-- 平台附加的 state delta
-
-## 9. 通用运行时上下文:`get_current_invocation_context()`
-
-如果你在 tool、helper 或平台公共逻辑里,希望不通过 state/hook 也能拿到当前调用上下文,可以用:
-
-```python
-from ksadk.runtime_context import get_current_invocation_context
-
-ctx = get_current_invocation_context()
-if ctx:
- print(ctx.agent_id)
- print(ctx.user_id)
- print(ctx.account_id)
- print(ctx.session_id)
- print(ctx.invocation_id)
- print(ctx.model)
- print(ctx.input_content)
- print(ctx.input_messages)
- print(ctx.attachments)
- print(ctx.attachment_results)
- print(ctx.current_attachments)
- print(ctx.has_current_files)
- print(ctx.kb_context)
- print(ctx.memory_context)
- print(ctx.model_metadata)
-```
-
-`PlatformInvocationContext` 当前包含:
-
-- `agent_id`
-- `user_id`
-- `account_id`(默认空串 `""`,未携带平台身份时为空)
-- `session_id`
-- `invocation_id`
-- `history`
-- `input_content`
-- `input_messages`
-- `input_parts`
-- `attachments`
-- `attachment_results`
-- `current_attachments`
-- `current_attachment_results`
-- `has_current_files`
-- `runner_type`
-- `model`
-- `kb_context`
-- `memory_context`
-- `model_metadata`
-
-### 9.1 不抛异常的安全读取入口(0.6.5 新增)
-
-`get_current_invocation_context()` 在没有上下文时返回 `None`,调用方仍需自己判空。如果你只想要某个具体字段、且希望在没有上下文时拿到一个显式默认值而不是 `None`,可以用下面三个安全入口:
-
-```python
-from ksadk.runtime_context import (
- get_current_invocation_context_or_default,
- get_current_user_id,
- get_current_account_id,
-)
-
-# 无上下文时返回一个空 PlatformInvocationContext 实例,不抛异常
-ctx = get_current_invocation_context_or_default()
-
-# 无上下文或字段缺失时返回传入的 default,不抛异常
-user_id = get_current_user_id(default="")
-account_id = get_current_account_id(default="")
-```
-
-!!! tip "什么时候用安全入口"
- - 在 tool / 回调 / 后台任务里取身份字段,不希望因为没有运行上下文就中断流程
- - 写库、打日志、埋点等"best-effort"消费场景,缺失身份时用空串兜底即可
- - 需要拿到完整 ctx 做多字段消费时,优先用 `get_current_invocation_context_or_default()` 拿到一个非空实例,再按字段读
-
-!!! warning "不要用这些入口做权限决策"
- 这三个入口返回的是"尽力而为"的运行上下文,适合观测、埋点、默认参数;涉及鉴权或租户隔离的判断仍应走平台协议层显式传入的 `platform_context`,不要只依赖 `get_current_account_id()` 的默认值。
-
-### 9.2 Hosted 附件统一解析:`ae-upload://`
-
-Hosted 部署下,用户上传的附件会以 `ae-upload://` 协议的引用地址下发到 runner payload。运行时会按当前部署形态把这类引用统一解析成可消费的 `AttachmentContent`,业务代码不需要自己处理 `ae-upload://` 前缀:
-
-- 调用方先走平台 `UploadFile`,得到 `ae-upload://...` 引用
-- runner 进入前,平台把引用解析为 `AttachmentContent`(含 bytes 或本地可读路径)
-- 业务代码统一从 `current_attachments / attachments` 消费,`transport` 字段标记是 `inline` 还是 `reference`
-
-如果业务代码需要主动下载某个附件引用(例如从 `attachment_results` 拿到 `file_uri` 后再取原始字节),使用 `AttachmentContent` 下载入口:
-
-```python
-from ksadk.attachments import resolve_attachment_content
-
-# file_uri 可以是 ae-upload://... / ksadk-upload://... / https://...
-content = resolve_attachment_content(file_uri)
-# content.bytes: 原始字节
-# content.mime_type: 归一化后的 mime
-# content.display_name: 展示名
-```
-
-!!! info "公开口径"
- `ae-upload://` 是平台 Hosted 附件引用协议,具体 Host 端点不对外暴露;示例里统一用 `example.com` 占位,业务代码只认协议前缀和 `AttachmentContent` 入口。
-
-### 9.3 `ModelMetadata` 透传与多模态能力判断
-
-`model_metadata` 会在 runner payload 和 `PlatformInvocationContext` 之间透传:请求显式传入的 `model_metadata` 优先,未传入时由 runtime 查询上游 `/v1/models` 自动解析并填充。因此无论 LangGraph / LangChain / ADK 哪条路径,业务代码都可以用同一种方式判断模型多模态能力:
-
-```python
-from ksadk.runtime_context import get_current_invocation_context_or_default
-
-ctx = get_current_invocation_context_or_default()
-capabilities = (ctx.model_metadata or {}).get("capabilities") or {}
-supports_image = bool(capabilities.get("multimodal_input_image"))
-supports_video = bool(capabilities.get("multimodal_input_video"))
-supports_file = bool(capabilities.get("multimodal_input_file"))
-```
-
-多模态分流建议:
-
-- 支持图片/视频输入:优先消费 `input_content / input_messages` 中的原生多模态块
-- 不支持原生多模态:退回 `current_attachment_results[*]["text"]` 走 OCR / 文本提取降级
-- 需要判断当前轮是否真的带了文件:读 `has_current_files`,不要用 `attachments` 判断
-
-## 10. `HumanMessage` 什么时候需要自己解析
-
-只有在你明确做的是 messages-native graph / prompt pipeline 时,才建议自己拆 `HumanMessage`。
-
-### 10.1 纯文本模型
-
-```python
-HumanMessage(content="请分析这张图片\\n\\n[上传文件引用: ...]")
-```
-
-### 10.2 原生多模态模型
-
-```python
-HumanMessage(
- content=[
- {"type": "text", "text": "请分析这张图片"},
- {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}
- ]
-)
-```
-
-### 10.3 推荐解析方式
-
-```python
-def parse_human_message_content(content):
- result = {"texts": [], "images": []}
-
- if isinstance(content, str):
- result["texts"].append(content)
- return result
-
- if isinstance(content, list):
- for block in content:
- if not isinstance(block, dict):
- continue
- if block.get("type") == "text":
- result["texts"].append(str(block.get("text") or ""))
- elif block.get("type") == "image_url":
- result["images"].append(str((block.get("image_url") or {}).get("url") or ""))
- return result
-```
-
-但再次强调:
-
-- 如果你只是想拿图片 OCR 文本、附件摘要、平台上下文
-- 不推荐优先拆 `HumanMessage`
-- 更推荐直接用 `has_current_files / current_attachments / attachment_results / session_context`
-
-## 11. 常见接入模式
-
-### 模式 A:只关心最终文本输入
-
-适合简单问答 agent:
-
-```python
-def ksadk_prepare_state(payload: dict, session_context: dict) -> dict:
- return {"query": payload["input"]}
-```
-
-### 模式 B:同时关心附件 OCR
-
-适合简历、票据、截图理解类 agent:
-
-```python
-def ksadk_prepare_state(payload: dict, session_context: dict) -> dict:
- results = payload.get("current_attachment_results") or payload.get("attachment_results") or []
- attachment_texts = [
- item.get("text", "")
- for item in results
- if isinstance(item, dict) and item.get("text")
- ]
- return {
- "query": payload["input"],
- "attachment_texts": attachment_texts,
- }
-```
-
-### 模式 C:按模型能力分支
-
-适合既支持多模态模型、又要兼容纯文本模型的 agent:
-
-```python
-def ksadk_prepare_state(payload: dict, session_context: dict) -> dict:
- model_metadata = payload.get("model_metadata") or {}
- capabilities = model_metadata.get("capabilities") or {}
- return {
- "query": payload["input"],
- "supports_image": bool(capabilities.get("multimodal_input_image")),
- "attachments": payload.get("attachments", []),
- "attachment_results": payload.get("attachment_results", []),
- "current_attachments": payload.get("current_attachments", []),
- "has_current_files": payload.get("has_current_files", False),
- }
-```
-
-## 12. 常见坑
-
-### 12.1 把 `HumanMessage.content` 当成永远是字符串
-
-这是最常见坑。多模态模型下,它可能是 `list[block]`。
-
-### 12.2 用 `attachments` 判断当前轮是否传文件
-
-`attachments` 是最近有效附件上下文,可能来自历史 fallback。判断当前轮是否传文件用 `has_current_files`,当前轮附件列表用 `current_attachments`。
-
-### 12.3 想拿业务上下文,却只盯着 `messages[-1]`
-
-更稳的做法是:
-
-- `history` 看多轮语义
-- `has_current_files / current_attachments` 看当前轮文件
-- `attachments / attachment_results` 看最近有效文件上下文和理解结果
-- `platform_context` 看平台身份
-- `kb_context / memory_context` 看召回上下文
-
-### 12.4 以为客户端每轮都会重传完整历史
-
-不会。多轮历史恢复主要靠 `session_id + server 侧 event store`。
-
-### 12.5 以为图片一定会原生直通
-
-不会。是否走原生图片输入取决于:
-
-- 请求显式 `model_metadata`
-- 或 runtime 自动查到的模型目录能力
-
-纯文本模型会走附件/OCR/文本降级。
-
-## 13. 推荐实践
-
-1. 对 LangGraph / LangChain,优先写 `ksadk_prepare_state()` / `ksadk_prepare_input()`
-2. 只有 messages-native agent 才去直接拆 `HumanMessage`
-3. 想理解图片内容时,优先读 `attachment_results`
-4. 想做多模态分流时,读 `model_metadata.capabilities`
-5. 想依赖多轮历史时,确保前端持续复用 `session_id`
-
-## 14. 相关文档
-
-- [LangGraph开发最佳实践](./LangGraph开发最佳实践.md)
-- [远程Agent运行时接口说明](../reference/远程Agent运行时接口说明.md)
-- [ksadk使用文档](./ksadk使用文档.md)
-- [ksadk技术设计](../reference/ksadk技术设计.md)
diff --git "a/docs/guides/DeepAgents\350\257\264\346\230\216.md" "b/docs/guides/DeepAgents\350\257\264\346\230\216.md"
deleted file mode 100644
index d18f4d44..00000000
--- "a/docs/guides/DeepAgents\350\257\264\346\230\216.md"
+++ /dev/null
@@ -1,54 +0,0 @@
-# DeepAgents说明
-
-本文档说明 `ksadk` 当前对 `deepagents` 框架的接入方式。
-
-## 1. 设计原则
-
-- 最小适配:尽量复用现有 LangGraph 运行时路径
-- 统一入口:CLI、框架识别、构建依赖和部署参数都走 `deepagents`
-
-## 2. 当前实现
-
-### 2.1 框架识别
-
-当前支持:
-
-- 显式配置 `framework: deepagents`
-- 代码特征识别:
- - `from deepagents import ...`
- - `import deepagents`
- - `create_deep_agent(...)`
-
-### 2.2 运行时
-
-当前 `DeepAgentsRunner` 沿用 LangGraph 路径,原因是 `create_deep_agent()` 返回 LangGraph 图对象,天然兼容现有 invoke / stream 语义。
-
-### 2.3 平台能力
-
-`deepagents` 当前与 `langgraph` 一样可以复用:
-
-- KB 检索能力
-- LTM 环境变量注入
-- 默认 storage 挂载基座 `/home/node/.agentengine`
-- Hosted WorkspaceFiles capability
-
-## 3. 初始化示例
-
-```bash
-agentengine init my-agent -f deepagents
-```
-
-生成项目示意:
-
-```python
-from deepagents import create_deep_agent
-from langchain_openai import ChatOpenAI
-
-llm = ChatOpenAI(...)
-root_agent = create_deep_agent(model=llm)
-```
-
-## 4. 相关文档
-
-- [ksadk使用文档](./ksadk使用文档.md)
-- [ksadk技术设计](../reference/ksadk技术设计.md)
diff --git "a/docs/guides/LangGraph\345\274\200\345\217\221\346\234\200\344\275\263\345\256\236\350\267\265.md" "b/docs/guides/LangGraph\345\274\200\345\217\221\346\234\200\344\275\263\345\256\236\350\267\265.md"
deleted file mode 100644
index 2b6943d9..00000000
--- "a/docs/guides/LangGraph\345\274\200\345\217\221\346\234\200\344\275\263\345\256\236\350\267\265.md"
+++ /dev/null
@@ -1,682 +0,0 @@
-# LangGraph 开发最佳实践
-
-本文档面向使用 `ksadk-python` 开发 LangGraph Agent 的业务开发者,重点说明:
-
-- LangGraph 工程应该如何暴露 `root_agent`
-- `ksadk_prepare_state(payload, session_context)` 应该放在哪里、怎么写
-- 平台上下文、附件、OCR、知识库、长期记忆如何进入 LangGraph state
-- `interrupt()` / `Command(resume=...)` 在 AgentEngine 运行时里的职责边界
-- `/v1/responses` 下 MCP approval 与通用 human-in-the-loop 的恢复写法
-
-本文档只讨论业务代码接入方式,不展开远程部署、网关鉴权和托管 UI 协议。接口字段 contract 见 [远程Agent运行时接口说明](../reference/远程Agent运行时接口说明.md)。
-
-## 1. 推荐结论
-
-LangGraph Agent 推荐按以下原则接入:
-
-1. `agentengine.yaml` 的 `framework` 写 `langgraph`
-2. `entry_point` 指向真正加载 Agent 的 Python 模块
-3. 在 `entry_point` 模块顶层暴露 `root_agent`
-4. 自定义 state 图优先写 `ksadk_prepare_state(payload, session_context)`
-5. 业务节点只消费自己定义的 state 字段,不直接解析平台 event store
-6. 如果使用 `interrupt()`,业务代码只定义暂停点和 resume payload 的业务含义
-7. 是否把下一次请求转成 `Command(resume=...)` 由平台协议层决定,不由业务代码猜测
-
-`LangGraphRunner` 的设计目标是薄适配:尽量透传 LangGraph 原生能力,只在 `resume=True` 时把输入包装成 `langgraph.types.Command(resume=...)`。
-
-## 2. 推荐目录结构
-
-一个最小但清晰的项目可以这样组织:
-
-```text
-my_agent/
- agent.py
- state.py
- nodes.py
- prompts.py
-agentengine.yaml
-requirements.txt
-```
-
-其中:
-
-- `agent.py`:组装 `StateGraph`,暴露 `root_agent`,并 re-export `ksadk_prepare_state`
-- `state.py`:定义 `TypedDict` / reducer
-- `nodes.py`:放 LangGraph 节点逻辑
-- `prompts.py`:放 prompt 模板或系统指令
-
-简单项目也可以只保留一个 `agent.py`。关键不是文件数量,而是 `entry_point` 模块必须能被 `ksadk` 直接加载。
-
-## 3. agentengine.yaml
-
-示例:
-
-```yaml
-name: langgraph-demo
-framework: langgraph
-entry_point: my_agent/agent.py
-agent_variable: root_agent
-```
-
-字段说明:
-
-| 字段 | 说明 |
-| --- | --- |
-| `framework` | 必须为 `langgraph` |
-| `entry_point` | Python 模块文件路径,运行时会加载这个模块 |
-| `agent_variable` | 模块里的 LangGraph compiled graph 变量名,通常是 `root_agent` |
-
-## 4. root_agent 暴露方式
-
-`root_agent` 应该是 LangGraph 编译后的图:
-
-```python
-from langgraph.graph import END, StateGraph
-
-from .state import AgentState
-from .nodes import answer
-from .state_adapter import ksadk_prepare_state
-
-
-workflow = StateGraph(AgentState)
-workflow.add_node("answer", answer)
-workflow.set_entry_point("answer")
-workflow.add_edge("answer", END)
-
-root_agent = workflow.compile()
-```
-
-如果你把 `ksadk_prepare_state` 放在别的文件里,必须在 `entry_point` 模块 re-export:
-
-```python
-from .state_adapter import ksadk_prepare_state
-```
-
-`ksadk` 不会全项目扫描这个函数。它只会在 `entry_point` 对应模块上执行类似下面的查找:
-
-```python
-getattr(module, "ksadk_prepare_state", None)
-```
-
-所以以下写法不推荐:
-
-- 放在别的文件里,但没有从 `entry_point` 模块导入
-- 写成类方法
-- 写在函数内部
-- 运行时动态创建,但模块导入完成后顶层属性上拿不到
-
-## 5. 平台给 LangGraph 的标准输入
-
-进入 LangGraphRunner 前,平台会把一次请求整理成标准运行输入。常见字段如下:
-
-| 字段 | 类型 | 说明 |
-| --- | --- | --- |
-| `input` | `str` 或 `dict` | 当前输入;普通请求是文本,resume 请求是结构化恢复 payload |
-| `history` | `list[dict]` | 多轮历史,已经过 transcript 投影和必要的 compaction |
-| `input_content` | `list[dict]` | 当前 user turn 的 OpenAI Responses content blocks,例如 `input_text / input_image / input_file` |
-| `input_messages` | `list[dict]` | OpenAI Responses 风格 message/input items;需要完整 role/content 结构时优先读这里 |
-| `input_parts` | `list[dict]` | legacy/internal 归一化片段,保留 `text / inlineData / fileData`;用于兼容已有 runner |
-| `attachments` | `list[dict]` | 当前会话最近有效附件上下文,兼容历史 fallback |
-| `attachment_results` | `list[dict]` | 最近有效 OCR / 文本抽取 / 附件理解结果 |
-| `current_attachments` | `list[dict]` | 当前最新 user turn 的附件列表,不包含历史 fallback |
-| `current_attachment_results` | `list[dict]` | 当前最新 user turn 的附件理解结果 |
-| `has_current_files` | `bool` | 当前最新 user turn 是否包含归一化后的 `inlineData` 或 `fileData`,包括 OpenAI `input_image / input_file` |
-| `model` | `str` | 本轮显式模型名 |
-| `model_metadata` | `dict` | 模型能力元数据 |
-| `platform_context` | `dict` | `agent_id / user_id / account_id / session_id` 等平台身份(`PlatformInvocationContext.to_payload()`) |
-| `kb_context` | `dict` | 知识库召回上下文 |
-| `memory_context` | `dict` | 长期记忆上下文 |
-| `instructions` | `str` | 请求级系统/开发者指令 |
-| `resume` | `bool` | 平台判断本轮是否为断点恢复 |
-| `invocation_id` | `str` | 0.6.5 新增。本次 invocation 的唯一标识,对应 `ToolExecutionContext.invocation_id`,可用于日志/trace 关联;透传给 `ksadk_prepare_state(payload, ...)` 的 `payload["invocation_id"]` |
-
-`input_content` / `input_messages` 是 runner 默认 canonical 输入,沿用 OpenAI Responses content block 形态;`input_parts` 是 legacy/internal normalized parts。`attachments / current_attachments / has_current_files` 等字段是 KsADK 提供给 LangGraph runner 的运行时上下文扩展,不属于 OpenAI 官方请求或响应字段。
-
-对外协议不混写:`/v1/responses` 按 Responses 语义接收 `input_text / input_image / input_file`;`/v1/chat/completions` 保持 Chat Completions 语义,官方图片块使用 `text / image_url`。进入 runner 前,两条入口都会转换为 `input_content / input_messages`,同时生成兼容用 `input_parts`。KsADK 仍兼容 `inlineData / fileData` 老输入,但不要把它们当成 OpenAI Chat 官方字段。
-
-## 6. 默认 messages-based 图
-
-如果没有定义 `ksadk_prepare_state()`,运行时会自动把请求转换成 LangGraph 常见的 messages state:
-
-```python
-{
- "attachments": [...],
- "attachment_results": [...],
- "current_attachments": [...],
- "current_attachment_results": [...],
- "has_current_files": True,
- "input_content": [...],
- "input_messages": [...],
- "input_parts": [...],
- "model_metadata": {...},
- "messages": [
- SystemMessage(...),
- HumanMessage(...),
- AIMessage(...),
- HumanMessage(...),
- ],
-}
-```
-
-说明:
-
-- `messages` 是默认主上下文
-- `input_content / input_messages / input_parts / attachments / attachment_results / current_attachments / current_attachment_results / has_current_files` 保留在 state 顶层
-- 如果模型支持原生图片输入,最后一条 `HumanMessage.content` 可能是多模态 block 列表
-- 如果模型不支持原生图片输入,最后一条 `HumanMessage.content` 通常是字符串
-
-messages-based 图适合快速迁移。但如果你的业务需要稳定消费附件、OCR、平台身份、知识库或长期记忆,推荐显式写 `ksadk_prepare_state()`。
-
-## 7. 推荐:自定义 ksadk_prepare_state
-
-推荐在 `ksadk_prepare_state(payload, session_context)` 里把平台输入投影成业务 state。
-
-```python
-def ksadk_prepare_state(payload: dict, session_context: dict) -> dict:
- if session_context.get("is_resume"):
- return payload.get("input")
-
- return {
- "query": payload["input"],
- "history": session_context["history"],
- "attachments": payload.get("attachments", []),
- "attachment_results": payload.get("attachment_results", []),
- "current_attachments": payload.get("current_attachments", []),
- "current_attachment_results": payload.get("current_attachment_results", []),
- "has_current_files": payload.get("has_current_files", False),
- "input_content": payload.get("input_content", []),
- "input_messages": payload.get("input_messages", []),
- "input_parts": payload.get("input_parts", []),
- "platform_context": session_context.get("platform_context"),
- "kb_context": session_context.get("kb_context"),
- "memory_context": session_context.get("memory_context"),
- "model_metadata": payload.get("model_metadata", {}),
- }
-```
-
-字段来源建议:
-
-| 你想拿什么 | 推荐来源 |
-| --- | --- |
-| 当前用户输入 | `payload["input"]` |
-| 当前输入 OpenAI canonical content | `payload["input_content"]` |
-| 当前输入 OpenAI canonical messages | `payload["input_messages"]` |
-| 当前输入 legacy/internal parts | `payload["input_parts"]` |
-| 当前轮是否带文件 | `payload["has_current_files"]` |
-| 当前轮附件引用 | `payload["current_attachments"]` |
-| 当前轮 OCR / 文档抽取结果 | `payload["current_attachment_results"]` |
-| 最近有效附件上下文 | `payload["attachments"]` |
-| 最近有效 OCR / 文档抽取结果 | `payload["attachment_results"]` |
-| 会话历史 | `session_context["history"]` |
-| 平台身份 | `session_context["platform_context"]` |
-| account_id(计费/租户隔离) | `session_context["platform_context"]["account_id"]`,等价于 `PlatformInvocationContext.account_id` |
-| invocation_id(本次调用标识) | `payload["invocation_id"]`,对应 `ToolExecutionContext.invocation_id` |
-| 知识库上下文 | `session_context["kb_context"]` |
-| 长期记忆上下文 | `session_context["memory_context"]` |
-| 是否断点恢复 | `session_context["is_resume"]`,只建议在 adapter 中判断,用来返回 resume payload |
-
-不要在业务代码里读取平台内部 event store 来拼 history。平台已经把可喂给模型的历史投影成 `history`。
-
-如果你的图使用 LangGraph `interrupt()`,`session_context["is_resume"]` 为 `True` 时,`ksadk_prepare_state` 的返回值会作为 `Command(resume=...)` 的值传回 interrupt 调用点,而不是作为新的 graph state 注入。因此推荐在 resume 分支直接返回 `payload["input"]`,不要继续返回完整业务 state。
-
-!!! info "0.6.5 / 0.6.7 平台上下文字段"
- `session_context["platform_context"]` 来自平台 `PlatformInvocationContext.to_payload()`,业务代码可通过它稳定拿到:
-
- - `agent_id` / `user_id` / `session_id`:运行时身份,用于多租户隔离、日志关联
- - `account_id`(`PlatformInvocationContext.account_id`):计费与租户归属,0.6.5 起在托管 runtime 里始终填充;本地裸跑可能为空字符串,消费时请用 `or ""` 兜底
- - `runner_type`:当前 runner 类型,例如 `langgraph`
-
- 本次 invocation 的唯一标识 `invocation_id` 不在 `platform_context` 内,而是作为 `payload["invocation_id"]` 透传给 `ksadk_prepare_state`,对应平台 `ToolExecutionContext.invocation_id`。需要把业务日志、trace span 或外部副作用(写库、调用下游)与单次请求关联时,优先读这个字段而不是自己生成 ID。
-
- ```python
- def ksadk_prepare_state(payload: dict, session_context: dict) -> dict:
- platform_ctx = session_context.get("platform_context") or {}
- return {
- "query": payload.get("input", ""),
- "account_id": str(platform_ctx.get("account_id") or ""),
- "invocation_id": str(payload.get("invocation_id") or ""),
- # ...其他字段
- }
- ```
-
-!!! tip "0.6.5 / 0.6.7 LangGraph checkpoint resume 保留 checkpoint_ns"
- 当通过 `ResumeRun` 从历史 checkpoint 恢复运行时,LangGraph runner 会把 `framework_ref.langgraph.checkpoint_ns` 写回 `configurable.checkpoint_ns`:
-
- - **0.6.5**:runner 首次在 checkpoint resume 配置里保留 `checkpoint_ns`,此前多命名空间(subgraph)图回档会丢失命名空间上下文
- - **0.6.7**:runner 无条件保留 `checkpoint_ns`(即便上游未传也会补空串占位),并在 `run_checkpoint` 事件的 `framework_ref.langgraph.checkpoint_ns` 中回写,保证列表 / 预览 / 恢复三段链路一致
-
- 业务代码不需要直接读 `checkpoint_ns`。如果你的图使用了 subgraph 并依赖命名空间隔离,恢复后 LangGraph 会自动定位到正确的 subgraph checkpoint。仅当你在自定义节点里手动调用 `agent.get_state(config)` / `agent.aget_state(config)` 做诊断时,才需要把 `checkpoint_ns` 一并带上。
-
-!!! tip "0.6.5 / 0.6.7 time_travel ResumeMode 使用指导"
- LangGraph runner 的 `RuntimeCapabilities.ResumeRun.ResumeMode` 声明为 `time_travel`,表示控制台可以选择任意历史 checkpoint 回档重放,而不是只能沿最新 invocation 续跑。使用要点:
-
- - 前端入口门控以 `GetAgentUiBootstrap.Capabilities.RuntimeCapabilities.Checkpoint` 与 `CheckpointResumeCapability` 为准,不要仅凭 `RunLifecycle.Resume` 判断
- - `ResumeMode=time_travel` 时优先展示 checkpoint 列表(`ListSessionCheckpoints`)让用户选点,再用 `GetCheckpointResumePreview` 预览、`ResumeRun` 恢复
- - 恢复语义是“同一 run 续跑”(`RunId` 不变),不是新建 run;终态 checkpoint 返回 `200 noop`,前端按正常完成态收敛
- - `time_travel` 依赖持久化 checkpointer(`postgres` / `sqlite`);`memory` 后端 `Scope=process_local` 不可恢复,控制台应隐藏恢复入口
-
-## 8. 附件、OCR 和图片
-
-`attachments` 更接近原始引用,但语义是最近有效附件上下文;如果只判断当前轮是否传了文件,请使用 `has_current_files` 或 `current_attachments`。
-
-典型字段:
-
-```python
-{
- "display_name": "diagram.png",
- "mime_type": "image/png",
- "transport": "reference",
- "file_uri": "ksadk-upload://abc123.png",
- "data": "", # 仅 transport="inline" 时存在
- "size_bytes": 1356,
- "storage_path": "/tmp/.../diagram.png",
- "is_text": False,
-}
-```
-
-`attachment_results` 更适合业务逻辑消费,典型字段:
-
-```python
-{
- "display_name": "diagram.png",
- "mime_type": "image/png",
- "kind": "image",
- "status": "ok",
- "extraction_method": "image_ocr",
- "text": "KIMI E2E",
- "text_excerpt": "KIMI E2E",
-}
-```
-
-推荐模式:
-
-```python
-def collect_attachment_texts(state: dict) -> list[str]:
- results = state.get("current_attachment_results") or state.get("attachment_results") or []
- return [
- item.get("text", "")
- for item in results
- if isinstance(item, dict) and item.get("text")
- ]
-```
-
-如果你要根据模型能力决定是否走原生多模态:
-
-```python
-def supports_image_input(state: dict) -> bool:
- model_metadata = state.get("model_metadata") or {}
- capabilities = model_metadata.get("capabilities") or {}
- return bool(capabilities.get("multimodal_input_image"))
-```
-
-如果只是需要当前轮图片 OCR 文本,不建议拆 `HumanMessage.content`,直接用 `current_attachment_results[*]["text"]` 更稳定;需要支持“继续分析上次附件”时再 fallback 到 `attachment_results`。
-
-## 9. 知识库和长期记忆
-
-平台可能按策略把知识库和长期记忆上下文注入:
-
-```python
-kb_context = state.get("kb_context") or {}
-memory_context = state.get("memory_context") or {}
-
-kb_text = kb_context.get("formatted_text", "")
-memory_text = memory_context.get("formatted_text", "")
-```
-
-建议把它们当作外部上下文材料,而不是持久化状态源。业务图如果要写自己的记忆,应明确区分:
-
-- 平台长期记忆召回:`memory_context`
-- LangGraph 图内部状态:你的 `AgentState`
-- 业务数据库:你自己的外部存储
-
-## 10. interrupt 与断点恢复
-
-LangGraph 原生支持在图节点中调用 `interrupt()` 暂停,并在下一次 `invoke` / `stream` 时用 `Command(resume=...)` 恢复。
-
-在 AgentEngine 运行时里,职责边界是:
-
-| 层级 | 职责 |
-| --- | --- |
-| LangGraph 业务代码 | 调用 `interrupt()`,定义暂停信息和 resume payload 的业务语义 |
-| Responses / Hosted UI / API 层 | 接收用户审批或恢复输入,判断这是一次 resume |
-| conversation runtime | 记录 `approval_request / approval_response`,向 runner 传 `resume=True` |
-| LangGraphRunner | 薄适配:把 `resume=True` 转成 `Command(resume=...)` |
-
-如果你定义了 `ksadk_prepare_state`,resume 请求也会经过这个 hook。此时 hook 的返回值就是 `Command(resume=...)` 里的 `resume` 值。推荐写法是:
-
-```python
-def ksadk_prepare_state(payload: dict, session_context: dict) -> dict:
- if session_context.get("is_resume"):
- return payload.get("input")
- return build_normal_state(payload, session_context)
-```
-
-业务代码不应该:
-
-- 自己判断“上一轮是不是暂停了”
-- 自己构造平台 event store 查询
-- 依赖 LangGraph 内部 `state.tasks[*].interrupts` 结构
-- 要求客户端直接传 Python `Command`
-
-业务代码应该:
-
-- 在节点中用 `interrupt(value)` 暂停
-- 让 `value` 包含前端或调用方需要展示的信息
-- 在恢复后从 `Command(resume=...)` 传回的值继续执行业务逻辑
-
-## 11. MCP 工具审批:Responses 标准语义
-
-当 interrupt 表示 MCP/tool approval 时,运行时会按 OpenAI Responses 风格输出 `mcp_approval_request`,并以 `response.incomplete` 结束本轮。
-
-客户端恢复时,应调用 `/v1/responses`,传入同一个 `session_id`,并把 `input` 写成 `mcp_approval_response`:
-
-```json
-{
- "session_id": "sess_xxx",
- "previous_response_id": "resp_xxx",
- "input": [
- {
- "type": "mcp_approval_response",
- "id": "mcprsp_xxx",
- "approval_request_id": "appr_xxx",
- "approve": true,
- "reason": "approved by user"
- }
- ],
- "stream": true
-}
-```
-
-运行时会把它转换成 runner 输入:
-
-```python
-{
- "session_id": "sess_xxx",
- "resume": True,
- "input": {
- "type": "mcp_approval_response",
- "id": "mcprsp_xxx",
- "approval_request_id": "appr_xxx",
- "approve": True,
- "reason": "approved by user",
- },
-}
-```
-
-LangGraphRunner 随后调用:
-
-```python
-Command(resume={
- "type": "mcp_approval_response",
- "id": "mcprsp_xxx",
- "approval_request_id": "appr_xxx",
- "approve": True,
- "reason": "approved by user",
-})
-```
-
-注意:
-
-- `session_id` 是当前运行时定位 LangGraph thread 的关键字段
-- `previous_response_id` 按 Responses 语义保留到 metadata,但当前不能替代 `session_id`
-- 客户端不需要知道 Python `Command`
-- 业务代码只关心 resume payload 的业务含义
-
-## 12. 泛化 human-in-the-loop:ksadk_resume
-
-如果 interrupt 不是 MCP/tool approval,而是普通人工确认、补充信息或业务分支选择,运行时会使用平台扩展事件:
-
-- 流式事件:`response.ksadk.approval_request`
-- 结束事件:`response.incomplete`
-- `incomplete_details.reason`: `approval_required`
-- `incomplete_details.ksadk_interrupt`: 原始 interrupt 信息
-
-恢复请求可以使用 `ksadk_resume`:
-
-```json
-{
- "session_id": "sess_xxx",
- "input": [
- {
- "type": "ksadk_resume",
- "interrupt_id": "intr_xxx",
- "value": {
- "approved": true,
- "answer": "继续"
- }
- }
- ],
- "stream": true
-}
-```
-
-runner 收到的输入会是:
-
-```python
-{
- "session_id": "sess_xxx",
- "resume": True,
- "input": {
- "type": "ksadk_resume",
- "interrupt_id": "intr_xxx",
- "value": {
- "approved": True,
- "answer": "继续",
- },
- },
-}
-```
-
-业务节点恢复后应按自己约定解析 `value`。
-
-## 13. checkpoint resume 用户向使用流程
-
-前面几节讲的是 `interrupt()` / approval 的运行内恢复(同一 invocation 内续跑)。0.6.5 起 ksadk 还支持把整条 run 回档到某个历史 checkpoint 重新执行,这是面向控制台 / Hosted UI 的用户向操作,业务代码不需要写任何 resume 逻辑。
-
-适用场景:
-
-- 用户想“回到第 3 步重新选一个分支”
-- 某一步工具调用结果不对,想从那一步之前重跑
-- 长流程中途想换模型重试某一段
-
-!!! info "0.6.5 / 0.6.7 checkpoint resume 能力门控"
- 是否可回档以 `GetAgentUiBootstrap.Capabilities.RuntimeCapabilities` 为准:
-
- - `Checkpoint.Supported=true` 才有 checkpoint 列表 / 预览入口
- - `ResumeRun.Supported=true` 且 `ResumeRun.ResumeMode=time_travel` 才能选任意历史 checkpoint 回档
- - `CheckpointResumeCapability.Supported / Checkpoint / ResumeRun` 是整体能力总开关,前端应同时校验
-
- LangGraph runner 在配置了持久化 checkpointer(`postgres` / `sqlite`)且 `ResumeMode=time_travel` 时点亮该链路;`memory` 后端 `Scope=process_local` 不支持跨进程恢复,恢复入口应隐藏。
-
-用户向操作流程(控制台 / Hosted UI 视角):
-
-```mermaid
-flowchart LR
- A[ListSessionCheckpoints] --> B{IsResumable?}
- B -- 是 --> C[GetCheckpointResumePreview]
- C --> D[ResumeRun]
- D --> E[SubscribeRunEvents]
- B -- 否 --> F[引导选其他 checkpoint]
- D -. 409 .-> F
-```
-
-步骤说明:
-
-1. **列表**:调 `ListSessionCheckpoints(AgentId, SessionId, OnlyResumable=true)` 拿到可恢复 checkpoint 列表。每个 descriptor 含 `CheckpointId / RunId / FrameworkRef / IsResumable / IsTerminal / NextNode / Backend / Scope`
-2. **预览**:选中一个 checkpoint 后调 `GetCheckpointResumePreview(AgentId, SessionId, RunId, CheckpointId)`,展示“将从哪个节点继续、涉及哪些 tool receipt”,避免误恢复
-3. **恢复**:确认后调 `ResumeRun(AgentId, SessionId, RunId, CheckpointId, Stream=true)`。恢复语义是同一 `RunId` 续跑,不是新建 run;可带 `InvocationId` 便于 `SubscribeRunEvents` / `CancelRun`
-4. **订阅**:`Stream=true` 返回 SSE,事件流与普通 run 一致;终态 checkpoint 返回 `200 noop`,前端按正常完成态收敛 UI
-
-```bash hl_lines="6"
-# 恢复调用示例(占位符 endpoint / token)
-curl -X POST https://example.com/agentengine/api/v1/ResumeRun \
- -H "Authorization: Bearer sk-test" \
- -H "Content-Type: application/json" \
- -d '{
- "AgentId": "agent_xxx",
- "SessionId": "sess_xxx",
- "RunId": "run_xxx",
- "CheckpointId": "ckpt_xxx",
- "Stream": true
- }'
-```
-
-业务代码注意事项:
-
-- checkpoint resume **不会** 经过 `ksadk_prepare_state` 的 resume 分支。`session_context["is_resume"]` 描述的是 `interrupt()` 运行内恢复,与 checkpoint 回档是两条独立链路
-- LangGraph runner 会把 `framework_ref.langgraph.checkpoint_ns` 写回 `configurable.checkpoint_ns`(0.6.5 首次保留,0.6.7 无条件保留),subgraph 命名空间上下文自动恢复,业务节点不需要感知
-- 终态 checkpoint(`IsTerminal=true`)不可恢复,`ResumeRun` 返回 `200 noop`;非终态且 `IsResumable=false` 返回 `409 checkpoint_not_resumable`,前端应引导用户选其他 checkpoint 而不是无限重试
-- runtime 只信任服务端已保存的 `run_checkpoint` 事件解析 `framework_ref`,客户端不能自行伪造 checkpoint 状态
-
-## 14. 完整可跑示例
-
-下面示例演示:
-
-- 自定义 `AgentState`
-- 使用 `ksadk_prepare_state`
-- 消费附件 OCR、平台上下文和模型能力
-- 支持人工确认 interrupt / resume
-
-```python
-from __future__ import annotations
-
-from typing import Annotated, Any, TypedDict
-import operator
-
-from langgraph.graph import END, StateGraph
-from langgraph.types import interrupt
-
-
-class AgentState(TypedDict):
- query: str | dict[str, Any]
- history: list[dict[str, Any]]
- attachments: list[dict[str, Any]]
- attachment_results: list[dict[str, Any]]
- current_attachments: list[dict[str, Any]]
- current_attachment_results: list[dict[str, Any]]
- has_current_files: bool
- input_content: list[dict[str, Any]]
- input_messages: list[dict[str, Any]]
- input_parts: list[dict[str, Any]]
- platform_context: dict[str, Any] | None
- kb_context: dict[str, Any] | None
- memory_context: dict[str, Any] | None
- model_metadata: dict[str, Any]
- messages: Annotated[list[dict[str, str]], operator.add]
-
-
-def _attachment_texts(state: AgentState) -> list[str]:
- return [
- item.get("text", "")
- for item in state.get("attachment_results", [])
- if isinstance(item, dict) and item.get("text")
- ]
-
-
-def _is_approved(resume_value: Any) -> bool:
- if not isinstance(resume_value, dict):
- return False
- if resume_value.get("type") == "mcp_approval_response":
- return bool(resume_value.get("approve"))
- if resume_value.get("type") == "ksadk_resume":
- value = resume_value.get("value") or {}
- return isinstance(value, dict) and bool(value.get("approved"))
- return bool(resume_value.get("approved"))
-
-
-def answer(state: AgentState) -> AgentState:
- query = state["query"]
- attachment_texts = _attachment_texts(state)
- model_metadata = state.get("model_metadata") or {}
- supports_image = bool(
- ((model_metadata.get("capabilities") or {}).get("multimodal_input_image"))
- )
-
- if "删除" in str(query):
- resume_value = interrupt(
- {
- "id": "confirm-delete",
- "message": "检测到删除操作,请确认是否继续。",
- "operation": "delete",
- }
- )
- if not _is_approved(resume_value):
- return {"messages": [{"role": "assistant", "content": "已取消删除操作。"}]}
-
- content = (
- f"query={query}; "
- f"supports_image={supports_image}; "
- f"attachment_texts={attachment_texts}"
- )
- return {"messages": [{"role": "assistant", "content": content}]}
-
-
-def ksadk_prepare_state(payload: dict, session_context: dict) -> dict:
- if session_context.get("is_resume"):
- return payload.get("input")
-
- return {
- "query": payload.get("input", ""),
- "history": session_context.get("history", []),
- "attachments": payload.get("attachments", []),
- "attachment_results": payload.get("attachment_results", []),
- "current_attachments": payload.get("current_attachments", []),
- "current_attachment_results": payload.get("current_attachment_results", []),
- "has_current_files": payload.get("has_current_files", False),
- "input_content": payload.get("input_content", []),
- "input_messages": payload.get("input_messages", []),
- "input_parts": payload.get("input_parts", []),
- "platform_context": session_context.get("platform_context"),
- "kb_context": session_context.get("kb_context"),
- "memory_context": session_context.get("memory_context"),
- "model_metadata": payload.get("model_metadata", {}),
- "messages": [],
- }
-
-
-workflow = StateGraph(AgentState)
-workflow.add_node("answer", answer)
-workflow.set_entry_point("answer")
-workflow.add_edge("answer", END)
-
-root_agent = workflow.compile()
-```
-
-## 15. 常见反模式
-
-### 15.1 在业务代码里猜是否 resume
-
-不要通过读取数据库、检查上一轮输出文本、解析 event store 来判断是否恢复。平台会把恢复请求转成 `resume=True`。
-
-### 15.2 让客户端直接传 LangGraph Command
-
-外部协议应该是 JSON。`Command(resume=...)` 是 Python / LangGraph runner 内部调用形态,不应该暴露给客户端。
-
-### 15.3 依赖 LangGraph 内部状态结构
-
-不要依赖 `state.tasks[*].interrupts` 这类内部结构做业务判断。LangGraph 版本升级后这些结构可能变化。
-
-### 15.4 把 `HumanMessage.content` 当成永远是字符串
-
-多模态模型下它可能是 content block 列表。除非你明确在做 messages-native agent,否则优先使用 `payload / session_context`。
-
-### 15.5 用 attachments 判断当前轮是否传文件
-
-`attachments` 是最近有效附件上下文,可能来自历史 fallback。当前轮是否传文件看 `has_current_files`,当前轮附件列表看 `current_attachments`;OCR、文档抽取、压缩包摘要仍优先看对应的 `current_attachment_results` 或 `attachment_results`。
-
-## 16. 检查清单
-
-上线前建议确认:
-
-- `agentengine.yaml` 的 `framework` 是 `langgraph`
-- `entry_point` 指向包含 `root_agent` 的模块
-- `root_agent` 是 compiled graph
-- `ksadk_prepare_state` 在 `entry_point` 模块顶层可见
-- 自定义 state 明确包含业务需要的上下文字段
-- 当前轮文件判断使用 `has_current_files / current_attachments`
-- 附件理解优先读取 `current_attachment_results / attachment_results`
-- 多模态分支读取 `model_metadata.capabilities`
-- interrupt 恢复只依赖 resume payload,不依赖平台内部事件结构
-- `/v1/responses` 恢复调用传同一个 `session_id`
-- checkpoint 回档依赖持久化 checkpointer(`postgres` / `sqlite`),`memory` 后端隐藏恢复入口
-- checkpoint resume 与 `interrupt()` 运行内恢复是两条独立链路,不要在 `ksadk_prepare_state` 里混判
diff --git "a/docs/guides/ksadk\344\275\277\347\224\250\346\226\207\346\241\243.md" "b/docs/guides/ksadk\344\275\277\347\224\250\346\226\207\346\241\243.md"
deleted file mode 100644
index 642cdfb3..00000000
--- "a/docs/guides/ksadk\344\275\277\347\224\250\346\226\207\346\241\243.md"
+++ /dev/null
@@ -1,1036 +0,0 @@
-# ksadk使用文档
-
-本文档面向使用 `agentengine` / `ksadk` 的开发者、SA 与交付同学,口径以当前仓库代码、CLI 帮助、测试断言和 Docker/Makefile 默认值为准。
-
-## 1. 适用范围
-
-当前文档覆盖这些主线能力:
-
-- 本地初始化、配置、运行与调试
-- `build / deploy / launch` 的构建与部署参数
-- `agentengine files` 的完整工作区文件管理链路
-- Hermes 与 OpenClaw 的部署和常用验证路径
-- PVC 默认值、默认挂载目录、容量约束
-- `agentengine agent invoke` 的 Hermes 远端 native 调用与本地目录同步
-- 统一模型策略与 fallback(`0.6.6` 引入,`0.6.7` 补齐 reasoning 与 thinking 兼容)
-- Hosted 附件 `ae-upload://` scheme 与 `AttachmentContent` action
-- `ListSessions` / `ListSessionEvents` 分页字段
-- Custom UI bundle 与 `RuntimeCapabilities` 能力位
-- `--env` / `--env-file` 运行时环境变量与 `.env` 构建上下文边界
-- KCR 企业版 / 个人版 / 第三方镜像仓库凭证收敛
-- 长任务恢复与 `CancelRun` / `ResumeRun` 用户向流程
-
-## 2. 安装与入口
-
-```bash
-pip install -U ksadk
-```
-
-可选 extras:
-
-```bash
-pip install "ksadk[langgraph]"
-pip install "ksadk[langchain]"
-pip install "ksadk[deepagents]"
-pip install "ksadk[adk]"
-pip install "ksadk[skills]"
-```
-
-知识库和长期记忆使用的 `kingsoftcloud-sdk-python` 已包含在默认依赖中,不需要额外安装 `ksadk[kb]`。
-
-命令入口等价:
-
-```bash
-agentengine --help
-ksadk --help
-```
-
-## 3. CLI 全景
-
-当前主线命令组包括:
-
-- `init`
-- `config`
-- `run`
-- `web`
-- `build`
-- `deploy`
-- `launch`
-- `agent`
-- `files`
-- `dashboard`
-- `hermes`
-- `openclaw`
-- `mcp`
-- `a2a`
-
-全局选项包括:
-
-- `--output pretty|json`
-- `--dry-run`
-- `--no-color`
-
-```mermaid
-flowchart LR
- classDef client fill:#dbeafe,stroke:#1d4ed8,stroke-width:2px,color:#1e3a8a;
- classDef control fill:#ede9fe,stroke:#7c3aed,stroke-width:2px,color:#581c87;
- classDef data fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#166534;
- classDef runtime fill:#e2e8f0,stroke:#475569,stroke-width:2px,color:#1e293b;
-
- Init["init / config / run / web"]:::client --> Local["本地开发链路"]:::data
- Build["build / deploy / launch"]:::client --> Server["agentengine-server"]:::control
- Files["files / agent invoke"]:::client --> Runtime["远端 runtime / Hosted Action"]:::data
- Hermes["hermes *"]:::client --> HermesRT["Hermes Runtime"]:::runtime
- OpenClaw["openclaw *"]:::client --> OpenClawRT["OpenClaw Runtime"]:::runtime
-```
-
-## 4. 最短成功路径
-
-### 4.1 本地项目初始化
-
-```bash
-agentengine init my-agent -f langgraph
-cd my-agent
-agentengine config
-agentengine run -i
-```
-
-本地 Web UI:
-
-```bash
-agentengine web --port 8080
-```
-
-### 4.2 云端一键部署
-
-```bash
-agentengine launch . --target serverless
-```
-
-### 4.3 Hermes 云端部署
-
-```bash
-agentengine init my-hermes -f hermes
-cd my-hermes
-agentengine hermes deploy --name my-hermes
-agentengine hermes status
-```
-
-### 4.4 OpenClaw 云端部署
-
-```bash
-agentengine init my-openclaw -f openclaw
-cd my-openclaw
-agentengine openclaw deploy
-agentengine openclaw status
-```
-
-### 4.5 Skill Runtime 与内置工具接入
-
-`0.6.2` 新增 `ksadk.toolsets`,开发者可以在 LangGraph、LangChain、DeepAgents 或自定义 runner 中显式绑定 AgentEngine 内置工具。推荐默认使用渐进式披露,避免把所有低频或高风险工具直接塞进模型上下文:
-
-```python
-from ksadk.toolsets import describe_agentengine_tools, get_agentengine_tools
-
-tools = get_agentengine_tools(include=["focused", "agentengine_tool_dispatcher"])
-tool_specs = describe_agentengine_tools(include=["focused", "agentengine_tool_dispatcher"])
-```
-
-`focused` 默认直接暴露这些高频工具:
-
-- Skill Space:`list_skills`、`search_skills`、`load_skill`
-- Workspace:`workspace_status`、`search_workspace_files`、`edit_workspace_file`、`lint_workspace_file`
-- Platform:`component_status`
-- Sandbox:`sandbox_status`
-
-低频、高风险或上下文较重的工具通过 `agentengine_tool_dispatcher` 按需 `list` / `describe` / `call`:
-
-```python
-from ksadk.toolsets import agentengine_tool_dispatcher
-
-agentengine_tool_dispatcher("describe", tool_name="run_code")
-agentengine_tool_dispatcher(
- "call",
- tool_name="run_code",
- arguments={"code": "print(42)", "language": "python"},
-)
-```
-
-`get_agentengine_tools()` 无参仍返回全量工具,兼容旧项目;新项目建议显式写 `include=["focused", "agentengine_tool_dispatcher"]`。如果只需要某个分组,也可以写 `include=["skill"]`、`include=["workspace"]`、`include=["platform"]` 或 `include=["sandbox"]`。
-
-Skill Runtime 执行入口是 `execute_skills`。它只用于 workflow 型任务,普通 instruction-first Skill 推荐先 `load_skill` 读取 `SKILL.md`,再由外层 agent 按指令完成。隔离执行 backend 由环境变量决定:
-
-- `KSADK_SKILL_RUNTIME_BACKEND=local_process`:走本地 agent 进程
-- `KSADK_SKILL_RUNTIME_BACKEND=e2b`:走远程 sandbox / E2B backend
-- 未设置 backend 但存在 `KSADK_SANDBOX_TEMPLATE_ID`:自动走 E2B
-- 显式 `KSADK_SKILL_RUNTIME_BACKEND=disabled`:禁用隔离执行
-
-Workspace 内置工具只访问 AgentEngine UI workspace,不访问任意宿主机路径。`edit_workspace_file` 是 exact snippet replacement;匹配不到返回 `snippet_not_found`,匹配次数不符合预期返回 `ambiguous_edit`。`lint_workspace_file` 提供 Python AST、JSON parse 和通用文本轻量检查。
-
-Sandbox direct tools 只通过 configured isolated sandbox backend 执行。`run_command` / `run_code` 不会退化为宿主机 shell;未配置 sandbox 时会返回诊断。`execute_skills`、Workspace 写入/删除、sandbox command/code 等中高风险工具会经过 Tool Gateway;strict 模式下会返回 `approval_required`,由 UI 或调用方回传批准后继续。
-
-## 5. `/v1/responses` OpenAI 兼容接口
-
-本地 `agentengine run -i` 启动后,AgentEngine 暴露 `/v1/responses`。这一接口优先兼容 OpenAI Responses 的返回结构和 SSE 生命周期,同时保留少量 `ksadk` 扩展字段,方便会话和本地 CLI 继续工作。
-
-### 5.1 非流式调用
-
-```bash
-curl http://127.0.0.1:8000/v1/responses \
- -H "Content-Type: application/json" \
- -d '{
- "model": "glm-5.2",
- "input": "请用一句话介绍 AgentEngine",
- "instructions": "只用中文回答,语气简洁",
- "metadata": {"source": "local-doc"}
- }'
-```
-
-当前支持的常用请求字段:
-
-- `input`:字符串或 OpenAI message/input item 列表。
-- `model`:本轮请求使用的模型,会同步到运行时环境。
-- `instructions`:本轮系统/开发者指令,不写入用户消息正文;LangGraph 会转为 system message,字符串输入类 runner 会作为 prompt 前缀。
-- `metadata`:请求元数据,会回显到 response object,并记录到本轮事件 metadata;不参与模型生成。
-- `stream`:`true` 时返回 SSE。
-- `session_id`:复用已有会话;未传时自动创建。
-
-非流式响应包含官方风格字段:`id`、`object`、`created_at`、`status`、`model`、`output`、`metadata`、`usage`、`error`、`incomplete_details`。同时保留 `output_text` 和 `session_id` 作为 `ksadk` 扩展,兼容现有调用方。
-
-### 5.2 流式调用
-
-```bash
-curl -N http://127.0.0.1:8000/v1/responses \
- -H "Content-Type: application/json" \
- -d '{
- "model": "glm-5.2",
- "session_id": "sess-demo-001",
- "input": [{"role": "user", "content": [{"type": "input_text", "text": "分析这个任务"}]}],
- "stream": true
- }'
-```
-
-`model` 和 `session_id` 在流式与非流式调用中都可传;`session_id` 用来复用同一会话,未传时自动创建。
-
-流式事件按 Responses 生命周期输出:
-
-- `response.created` / `response.in_progress`:响应创建与开始运行。
-- `response.output_item.added` / `response.content_part.added`:开始输出 message、reasoning、function call 或 MCP approval request。
-- `response.output_text.delta` / `response.output_text.done`:正文增量与正文完成。
-- `response.reasoning.delta`:思考内容增量,前端可选择单独渲染。
-- `response.function_call_arguments.delta` / `response.function_call_arguments.done`:工具调用参数;底层一次性拿到参数时也会按一次 delta + done 输出。
-- `response.output_item.done` / `response.content_part.done`:输出项或内容块完成。
-- `response.completed`:本轮完成。
-- `response.failed`:运行失败。
-- `response.incomplete`:需要人工审核或中断恢复。
-
-工具结果和人工审核的兼容策略:
-
-- `response.ksadk.tool_result`:工具执行结果。
-- 能明确识别为工具审批的 interrupt 会渲染为官方风格 `mcp_approval_request` output item。
-- 其他通用 interrupt 使用 `response.ksadk.approval_request` 扩展事件;最终 response 会以 `status: "incomplete"` 返回,并在 `incomplete_details.ksadk_interrupt` 中包含中断信息。
-
-### 5.3 图片与附件输入
-
-`/v1/responses` 是默认主维护协议,按 OpenAI Responses 语义接收 `input_text` / `input_image` / `input_file`。运行时会把这些输入块原样投影到 runner 的 `input_content` / `input_messages`,同时生成 legacy/internal 的 `input_parts` 兼容旧 runner。
-
-当前 `/v1/responses` 推荐输入块:
-
-- `input_text`
-- `input_image`
-- `input_file`
-
-旧客户端仍可传 KOP 风格 part 数组,运行时会兼容:
-
-- `text`
-- `inlineData`
-- `fileData`
-
-推荐图片传法:
-
-1. 按 OpenAI Responses 官方形态传 `input_image.image_url`,其中 `image_url` 可以是远程图片 URL,也可以是 `data:image/...;base64,...`
-2. 老客户端可先调用 `UploadFile` 上传图片,再通过兼容扩展 `fileData.fileUri` 引用
-3. 老客户端可直接把图片 base64 放进兼容扩展 `inlineData.data`
-
-OpenAI 风格 data URL 示例:
-
-```json
-{
- "input": [
- {
- "role": "user",
- "content": [
- { "type": "input_text", "text": "请分析这张图片" },
- {
- "type": "input_image",
- "image_url": "data:image/png;base64,"
- }
- ]
- }
- ],
- "stream": false
-}
-```
-
-运行时会保留这个官方输入块到 `input_content`,并额外归一化为内部附件上下文,因此业务代码可以继续通过 `has_current_files` / `current_attachments` 判断本轮是否带图。远程图片 URL 会作为引用保留,并可在支持原生图片输入的 LangGraph 路径下继续传给模型;KsADK 不会主动拉取远程图片做 OCR。需要平台提取、OCR 或本地附件内容时,请使用 data URL、`inlineData` 或 `fileData`。
-
-多模态模型“看图”和平台 OCR 是两条不同链路:推荐让支持图片的模型直接消费 `input_image` / `input_content`,这样不需要在代码包里安装本地 OCR 依赖。平台本地 OCR 只用于需要把图片预先转成 `current_attachment_results[*].text` 的场景;源码构建默认不打包 OCR 二进制栈,如需启用请在构建环境设置 `KSADK_BUILD_ENABLE_ATTACHMENT_OCR=true`,或在项目 `requirements.txt` 中显式加入 OCR 相关依赖。
-
-OpenAI 风格文件示例:
-
-```json
-{
- "input": [
- {
- "role": "user",
- "content": [
- { "type": "input_text", "text": "请总结这个文件" },
- {
- "type": "input_file",
- "filename": "resume.txt",
- "file_data": ""
- }
- ]
- }
- ]
-}
-```
-
-`input_file.file_data` 会保留在 `input_content`,并归一化为内部 `inlineData`;`input_file.file_url` / `input_file.file_id` 会保留为引用,并归一化为内部 `fileData`。KsADK 不会主动拉取远程 `file_url` 内容;需要平台提取或 OCR 时,请使用 `file_data`、`inlineData` 或先上传后用 `fileData.fileUri`。
-
-旧客户端先上传再引用的兼容示例:
-
-```json
-{
- "input": [
- {
- "role": "user",
- "content": [
- { "text": "请分析这张图片" },
- {
- "fileData": {
- "fileUri": "ksadk-upload://abc123.png",
- "displayName": "diagram.png",
- "mimeType": "image/png"
- }
- }
- ]
- }
- ],
- "stream": false
-}
-```
-
-旧客户端直接内联的兼容示例:
-
-```json
-{
- "input": [
- {
- "role": "user",
- "content": [
- { "text": "请分析这张图片" },
- {
- "inlineData": {
- "data": "",
- "displayName": "diagram.png",
- "mimeType": "image/png"
- }
- }
- ]
- }
- ]
-}
-```
-
-当前附件类型支持矩阵:
-
-| 类型 | 典型扩展名 / MIME | 传输支持 | 平台内容提取 | 原生多模态直通 |
-| --- | --- | --- | --- | --- |
-| 文本 | `.txt` `.md` `.json` `.yaml` `.yml` `.csv` `.tsv` `.log` | 支持 | 支持 | 不适用 |
-| 文档 | `.pdf` `.docx` `.pptx` `.xlsx` `.html` `.htm` | 支持 | 部分支持:文本提取 / OCR | 不适用 |
-| 图片 | `.png` `.jpg` `.jpeg` `.webp` / `image/*` | 支持 | 元信息提取默认支持;OCR 需构建时显式启用 | 部分支持,见下方 |
-| 压缩包 | `.zip` | 支持 | 支持:目录/可读文件抽样提取 | 不适用 |
-| 其他二进制 | 其他后缀或 `application/octet-stream` | 支持 | 通常仅保留为附件引用 | 不支持 |
-
-框架差异:
-
-- `ADK`
- - 图片会优先按原生 bytes part 传给底层 SDK
- - 如果模型支持原生多模态,可直接吃图
-- `LangGraph`
- - 若模型支持图片输入,默认消息构造会把图片附件转换成多模态 `HumanMessage.content` blocks
-- `LangChain`
- - 当前不保证所有 agent 自动原生吃图
- - 如需原生多模态,建议在 `ksadk_prepare_input(payload, session_context)` 中优先消费 `input_content / input_messages`,必要时再兼容 `input_parts / current_attachments / attachments`
- - 判断当前轮是否传文件用 KsADK runner payload 扩展字段 `has_current_files`;该字段不是 OpenAI Responses API 官方字段
-
-模型能力判断优先级:
-
-1. 请求里显式传入的 `model_metadata`
-2. runtime 通过 `OPENAI_BASE_URL` / `OPENAI_API_KEY` 查询上游 `/v1/models` 返回的 `architecture.input_modalities`
-3. 本地默认兜底(按文本模型处理)
-
-### 5.4 Hosted 附件与 `ae-upload://`
-
-!!! new "0.6.6 新增"
-
-Hosted 部署下,用户在 Hosted UI 上传的附件不再以本地 `ksadk-upload://` 落盘,而是由控制面托管,统一以 `ae-upload://` scheme 引用。两类 URI 的区别:
-
-| 附件 URI scheme | 来源 | 存储 | 读取方式 |
-| --- | --- | --- | --- |
-| `ksadk-upload://` | 本地 `agentengine run -i` / CLI 上传 | 本地 `files/` 目录 + KS3 兜底 | 本地直读或 KS3 回源 |
-| `ae-upload://` | Hosted 控制面上传 | 控制面托管对象 | 经由 `AttachmentContent` action 拉取 |
-
-读取 `ae-upload://` 附件时,conversation runtime 会调用 Hosted 控制面的 `AttachmentContent` action:
-
-```
-GET /agentengine/api/v1/AttachmentContent?FileUri=ae-upload://
-```
-
-返回内容包含二进制字节、`display_name` 与 `content_type`。拉取成功后,runtime 会把内容写回本地附件 cache 目录(`/files/`)并落一份 `.meta.json`,后续同一会话内再次读取该附件时优先命中本地 cache,避免重复远端拉取。
-
-会话刷新(refresh / rehydrate)后,已写回本地 cache 的 Hosted 附件会继续以本地 cache 读取;未命中本地 cache 的 `ae-upload://` 附件会再次触发 `AttachmentContent` 拉取。`ensure_local_path` / `read` 会保证返回可用本地路径,业务代码无需关心附件原始来源。
-
-### 5.5 会话与事件分页
-
-`ListSessions` 与 `ListSessionEvents` 支持分页,字段对齐控制面 action 契约:
-
-| Action | 请求字段 | 响应字段 |
-| --- | --- | --- |
-| `ListSessions` | `AgentId`、`UserId`(默认 `user`)、`Page`(≥1)、`PageSize`(1~200,默认 20) | `Sessions`、`Total`、`Page`、`PageSize` |
-| `ListSessionEvents` | `SessionId`、`Offset`(≥0)、`Limit`(≥1) | `Events`、`Total`、`Offset`、`Limit` |
-
-`ListSessions` 用 `Page` / `PageSize` 做页式分页,客户端按 `Total` 计算总页数;`ListSessionEvents` 用 `Offset` / `Limit` 做偏移分页,`Total` 为该会话事件总数。事件按追加顺序返回,分页只读取已落盘事件,不会阻塞正在写入的事件流。
-
-### 5.6 当前不支持
-
-本期不支持 `previous_response_id`、`store`、复杂 `reasoning/text` 控制、完整 tool schema 请求面,也不新增原生 LangGraph state endpoint。自定义 LangGraph State 请使用 `ksadk_prepare_state` 或 `agentengine init --from-agent` 自动生成的 adapter。
-
-## 6. 统一模型策略与 fallback
-
-!!! new "0.6.7 新增"
-
-`0.6.6` 起引入统一模型策略契约,`0.6.7` 补齐 reasoning 声明与 thinking disabled 兼容。Hermes、OpenClaw 与通用 Agent 共用一套默认语义,避免三类 runtime 各自维护一份模型清单。
-
-### 6.1 策略契约
-
-策略以 JSON 描述,可通过环境变量 `AGENTENGINE_MODEL_POLICY_JSON` 整体覆盖。未设置时使用内置默认策略 `DEFAULT_MODEL_POLICY`(版本号 `v1`):
-
-```json
-{
- "version": "v1",
- "primary": {"model": "glm-5.2"},
- "multimodal": {"model": "kimi-k2.7-code"},
- "fallback": {
- "model": "deepseek-v4-pro",
- "fallback_errors": [
- "timeout",
- "temporarily unavailable",
- "model unavailable",
- "rate limit",
- "too many requests",
- "503",
- "504"
- ],
- "on_errors": [
- "timeout",
- "temporarily unavailable",
- "model unavailable",
- "rate limit",
- "too many requests",
- "503",
- "504"
- ]
- },
- "models": {
- "glm-5.2": {"reasoning": true, "options": {}},
- "kimi-k2.7-code": {"input": ["text", "image"], "reasoning": true, "options": {"temperature": 1}},
- "deepseek-v4-pro": {"reasoning": true, "options": {}}
- }
-}
-```
-
-三个角色的语义:
-
-| 角色 | 默认模型 | 用途 |
-| --- | --- | --- |
-| `primary` | `glm-5.2` | 默认文本主模型,未显式指定 `model` 时使用 |
-| `multimodal` | `kimi-k2.7-code` | 图片/多模态输入时路由到的模型 |
-| `fallback` | `deepseek-v4-pro` | 主模型遇到可恢复错误时重试一次的目标模型 |
-
-构建期会把策略序列化后注入 runtime 环境变量,并按 runtime 类型分别填充对应的模型变量:
-
-- 通用 Agent:`OPENAI_MODEL_NAME`(primary)、`OPENAI_FALLBACK_MODEL_NAME`(fallback)
-- `hermes`:`HERMES_DEFAULT_MODEL`(primary)、`HERMES_FALLBACK_MODEL`(fallback)、`HERMES_MODEL_CATALOG_JSON`
-- `openclaw`:`OPENAI_MODEL_NAME`(primary,带 `ksyun/` provider 前缀)、`OPENCLAW_FALLBACK_MODEL`、`OPENCLAW_IMAGE_MODEL`(multimodal)、`OPENCLAW_MODEL_CATALOG_JSON`
-
-!!! info "策略合并"
-`AGENTENGINE_MODEL_POLICY_JSON` 传入的 JSON 会与 `DEFAULT_MODEL_POLICY` 做深度合并(deep merge),未声明的字段保留默认值;只覆盖你想改的部分即可。`fallback.fallback_errors` / `fallback.on_errors` 会被同步成同一份列表。
-
-### 6.2 自动 fallback 与重试
-
-conversation runtime 在主模型调用失败时按错误信息判断是否自动 fallback 重试一次:
-
-- 可恢复错误会触发 fallback:超时、限流(rate limit / too many requests)、模型不可用、`503` / `504` 等临时不可用。
-- 不会吞掉的错误,直接抛回调用方:`400` 参数错误、`invalid request` / `bad request`、业务错误、tool 执行错误。
-- fallback 目标模型等于当前模型时不重试,避免空转。
-- 只重试一次;fallback 仍失败则按原错误返回。
-
-### 6.3 reasoning 声明与 catalog
-
-!!! new "0.6.7 新增"
-
-`0.6.7` 起在 `models.` 中声明 `reasoning: true`。构建期生成的 model catalog(`OPENCLAW_MODEL_CATALOG_JSON` / `HERMES_MODEL_CATALOG_JSON`)会输出 `reasoning` 字段,Hosted UI 可据此判断是否渲染思考内容区。catalog 每条记录形如:
-
-```json
-{
- "id": "glm-5.2",
- "name": "glm-5.2",
- "api": "openai-completions",
- "input": ["text"],
- "reasoning": true
-}
-```
-
-`input` 字段用于声明多模态能力(如 `kimi-k2.7-code` 为 `["text", "image"]`);`options` 透传模型默认参数(如 temperature)。
-
-### 6.4 thinking disabled 兼容
-
-当本轮请求显式关闭思考(`reasoning.effort` 归一化为 `none` / `disabled`,或 `max_reasoning_tokens=0`)时,runtime 会向 OpenAI 兼容请求的 `extra_body` 注入:
-
-```json
-{
- "enable_thinking": false,
- "chat_template_kwargs": {"enable_thinking": false}
-}
-```
-
-这套字段是 DeepSeek 系模型关闭 thinking 的兼容写法;对不识别该字段的模型无副作用。流式输出阶段会过滤 reasoning output item,确保关闭思考时不会向客户端回吐思考内容。
-
-```mermaid
-flowchart LR
- classDef model fill:#dbeafe,stroke:#1d4ed8,stroke-width:2px,color:#1e3a8a;
- classDef fallback fill:#fee2e2,stroke:#dc2626,stroke-width:2px,color:#7f1d1d;
- classDef drop fill:#f3f4f6,stroke:#6b7280,stroke-width:2px,color:#374151;
-
- Req["请求 model=primary"]:::model --> Call1["调用 primary 模型"]:::model
- Call1 -->|"超时/限流/503/504/模型不可用"| FB["fallback 重试一次 deepseek-v4-pro"]:::fallback
- Call1 -->|"400/业务错误/tool 错误"| Err["直接抛回调用方,不 fallback"]:::drop
- FB --> OK["返回结果"]:::model
- Call1 -->|"成功"| OK
- FB -->|"仍失败"| Err
-```
-
-## 7. Framework、挂盘与 workspace 约定
-
-### 7.1 默认 PVC 规则
-
-来自 `ksadk/cli/storage.py` 的统一约束:
-
-- 默认容量:`20Gi`
-- 最小容量:`20Gi`
-- 最大容量:`500Gi`
-
-### 7.2 默认挂载目录
-
-| Framework | 默认挂载目录 | workspace 逻辑根 | 当前代码里可直接确认的绝对路径 |
-| --- | --- | --- | --- |
-| `adk` | `/home/node/.agentengine` | `workspace:/` | 运行时对外统一以逻辑根 `workspace` 暴露 |
-| `langchain` | `/home/node/.agentengine` | `workspace:/` | 运行时对外统一以逻辑根 `workspace` 暴露 |
-| `langgraph` | `/home/node/.agentengine` | `workspace:/` | 运行时对外统一以逻辑根 `workspace` 暴露 |
-| `deepagents` | `/home/node/.agentengine` | `workspace:/` | 运行时对外统一以逻辑根 `workspace` 暴露 |
-| `hermes` | `/home/node/.hermes` | `workspace:/` | `/home/node/.hermes/workspace` |
-| `openclaw` | `/home/node/.openclaw` | `workspace:/` | `/home/node/.openclaw/workspace` |
-
-补充说明:
-
-- 本地 `ksadk server` 的 workspace 根目录是 `/.agentengine/ui/workspace`。
-- 对外 CLI 和 Hosted UI 一律展示逻辑根 `workspace:/...`。
-- 当运行时响应里带有 `workspace_real_root` 或 `workspace_path` 时,CLI 会同时显示“实际目录”。
-
-```mermaid
-flowchart TB
- classDef runtime fill:#e2e8f0,stroke:#475569,stroke-width:2px,color:#1e293b;
- classDef storage fill:#ffedd5,stroke:#ea580c,stroke-width:2px,color:#9a3412;
- classDef data fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#166534;
-
- Local["本地项目"]:::runtime --> LocalRoot[".agentengine/ui/workspace"]:::storage
- Generic["adk / langchain / langgraph / deepagents"]:::runtime --> GenericMount["/home/node/.agentengine"]:::storage
- Hermes["hermes"]:::runtime --> HermesRoot["/home/node/.hermes/workspace"]:::storage
- OpenClaw["openclaw"]:::runtime --> OpenClawRoot["/home/node/.openclaw/workspace"]:::storage
- LocalRoot --> Logical["逻辑展示统一为 workspace:/"]:::data
- GenericMount --> Logical
- HermesRoot --> Logical
- OpenClawRoot --> Logical
-```
-
-## 8. `build / deploy / launch` 参数
-
-### 8.1 构建体积与依赖策略
-
-`agentengine build` 默认优先保持源码包轻量,不会把所有平台增强能力的重依赖都打进包:
-
-- 默认包含:KsADK runtime 必需依赖、附件基础解析依赖、`kingsoftcloud-sdk-python`、`requests-aws4auth`。
-- 推荐多模态图片写法:让支持图片的模型直接消费 OpenAI Responses `input_image` / runner `input_content`,不要为了“看图”默认启用本地 OCR。
-- 兼容 OCR 写法:如果业务明确需要平台先把图片转成 `current_attachment_results[*].text`,再设置 `KSADK_BUILD_ENABLE_ATTACHMENT_OCR=true`,或在项目 `requirements.txt` 显式写入 OCR 依赖。
-- MCP adapter:默认不打包;当项目 import `mcp` / `langchain_mcp_adapters`,或 `.env` 配置了非空 `KSADK_MCP_SERVERS` 时自动加入。自动发现不到时可设置 `KSADK_BUILD_ENABLE_MCP=true`。
-- PostgreSQL session:默认不打包 `asyncpg`;当 `.env` 设置 `KSADK_SESSION_BACKEND=postgres` 或 PostgreSQL DSN 时自动加入。自动发现不到时可设置 `KSADK_BUILD_ENABLE_POSTGRES_SESSION=true`。
-
-构建会复用 `.agentengine/code_build/pip_cache`,依赖清单未变化时也会复用 `.agentengine/code_build/linux_deps`,避免第二次构建从头下载。`pip install` 默认超时为 45 分钟,可用 `KSADK_BUILD_PIP_INSTALL_TIMEOUT_SECONDS` 调整。
-
-构建完成会打印 zip 体积、解压体积和 Top 体积来源。只有当解压体积超过 500 MB 或 zip 超过 300 MB 时,才会提示切换 container 模式:
-
-```bash
-agentengine build . --mode container --push --registry
-```
-
-源码包里依赖本身很多时,优先建议业务拆分不必要依赖、使用环境变量显式关闭未用能力、或切到已有的 container 模式;本轮不建议把 `ksadk` 内置进固定 base 镜像,因为 SDK 更新频繁,固定 base 镜像会降低版本灵活性。
-
-### 8.2 部署、存储与网络参数
-
-以下参数在 `deploy`、`launch` 以及对应 framework 命令中统一存在:
-
-- `--storage-size-gi`
-- `--storage-mount-path`
-- `--no-storage`
-
-以下 network 参数在 `agentengine deploy`、`agentengine launch` 和 `agentengine openclaw deploy` 中统一存在:
-
-- `--enable-public-access / --disable-public-access`
-- `--enable-vpc-access`
-- `--vpc-id`
-- `--subnet-id`
-- `--security-group-id`
-- `--availability-zone`
-
-示例:
-
-```bash
-agentengine deploy . --target serverless --storage-size-gi 50
-agentengine launch . --target kce --storage-mount-path /home/node/.agentengine
-agentengine hermes deploy --storage-size-gi 20
-agentengine openclaw deploy --no-storage
-```
-
-VPC 网络示例:
-
-```bash
-agentengine deploy . \
- --target serverless \
- --disable-public-access \
- --enable-vpc-access \
- --vpc-id vpc-xxx \
- --subnet-id subnet-xxx \
- --security-group-id sg-xxx \
- --availability-zone cn-beijing-6a
-
-agentengine launch . \
- --enable-vpc-access \
- --vpc-id vpc-xxx \
- --subnet-id subnet-xxx \
- --security-group-id sg-xxx
-
-agentengine openclaw deploy \
- --enable-vpc-access \
- --vpc-id vpc-xxx \
- --subnet-id subnet-xxx \
- --security-group-id sg-xxx
-```
-
-配置文件也可写入 network。CLI 显式参数优先级高于配置文件:
-
-```yaml
-network:
- enable_public_access: false
- enable_vpc_access: true
- vpc_id: vpc-xxx
- subnet_id: subnet-xxx
- security_group_id: sg-xxx
- availability_zone: cn-beijing-6a
-
-deploy:
- network:
- enable_public_access: false
- enable_vpc_access: true
- vpc_id: vpc-xxx
- subnet_id: subnet-xxx
- security_group_id: sg-xxx
-```
-
-行为要点:
-
-- 不传时使用框架默认挂载目录。
-- 容量会在客户端侧先做 `20~500Gi` 校验。
-- `--no-storage` 会显式关闭默认 PVC 挂载。
-- 只要开启 VPC 访问,或传入 `--vpc-id` / `--subnet-id` / `--security-group-id` 中任意一个,就必须同时具备 `VpcId`、`SubnetId`、`SecurityGroupId`。
-- `--availability-zone` 是可选字段,不替代子网或安全组。
-
-### 8.3 运行时环境变量 `--env` / `--env-file`
-
-!!! new "0.6.7 新增"
-
-`agentengine deploy` 与 `agentengine launch` 支持显式透传运行时环境变量,不再只依赖项目根 `.env`:
-
-```bash
-# 多次 --env 传 KEY=VALUE
-agentengine deploy . --target serverless \
- --env MODEL_NAME=glm-5.2 \
- --env LOG_LEVEL=DEBUG
-
-# 或用一个 .env / JSON 对象文件
-agentengine launch . --target kce --env-file ./prod.env
-```
-
-- `--env` 可重复传入,格式 `KEY=VALUE`;变量名必须为合法环境变量名(`[A-Za-z_][A-Za-z0-9_]*`)。
-- `--env-file` 支持 `.env`(dotenv)或 JSON 对象文件;路径不存在或格式不合法会直接报错退出。
-- `--env` 与 `--env-file` 可同时使用,同名变量以 `--env` 为准(命令行优先级高于文件)。
-
-`.env` 构建上下文边界:
-
-- 真实 `.env` 只通过 deploy payload 注入 runtime,不会进入镜像构建上下文或源码包。
-- 构建期会复制 `.env.example`(作为模板),但跳过真实 `.env`,避免凭证被打进镜像。
-- `.git`、`__pycache__`、`node_modules`、`.pytest_cache` 等同样不会进入构建上下文。
-
-### 8.4 镜像仓库凭证(KCR 企业版 / 个人版 / 第三方)
-
-镜像拉取凭证按目标镜像仓库地址自动判别类型,避免企业版/第三方误用 `KSYUN_ACCOUNT_ID`:
-
-| 仓库类型 | 判别规则 | 凭证要求 |
-| --- | --- | --- |
-| 企业版 KCR | host 以 `.ksyunkcr.com` 结尾 | 必须配 `KCR_USERNAME` + `KCR_PASSWORD` |
-| 个人版 KCR | host 以 `.kce.ksyun.com` 结尾 | `KCR_USERNAME` 可留空,运行时用 `KSYUN_ACCOUNT_ID` 兜底;`KCR_PASSWORD` 必填 |
-| 第三方镜像仓库 | 其他 host | 必须配 `KCR_USERNAME` + `KCR_PASSWORD` |
-
-```bash
-agentengine config
-# 个人版 KCR 可留空 KCR_USERNAME,运行时使用 KSYUN_ACCOUNT_ID 作为用户名兜底
-# 企业版 KCR 和第三方镜像仓库必须配置 KCR_USERNAME + KCR_PASSWORD
-```
-
-行为要点:
-
-- 检测到 `KCR_PASSWORD` 但缺少 `KCR_USERNAME`,且仓库不是个人版 KCR 时,CLI 会忽略该凭证并给出告警,不会用错误的用户名去拉私有镜像。
-- 个人版 KCR 在缺 `KCR_USERNAME` 时,会用 `KSYUN_ACCOUNT_ID` 作为用户名兜底。
-- KCR 访问凭证获取:`https://kcr.console.ksyun.com/` → 访问凭证。
-
-### 8.5 Custom UI 与 RuntimeCapabilities
-
-!!! new "0.6.7 新增"
-
-Agent 可以声明自己的 Hosted UI bundle,替代内置统一 UI。两种声明方式:
-
-1. 在项目根 `agentengine.yaml`(或 `ksadk.yaml` / `ksadk.yml`)中声明:
-
-```yaml
-ui_profile: custom
-ui_path: /
-ui_bundle_path: research-ui/dist
-```
-
-2. 不写配置时,`agentengine web` 与 runtime 会自动探测项目根 `research-ui/dist/index.html`:存在即视为 custom UI bundle,自动启用 `ui_profile=custom`。
-
-Hosted 部署下,bootstrap 会返回 `RuntimeCapabilities` 字段,声明当前 runtime 支持的能力位,Hosted UI 据此决定是否渲染对应入口。当前包含的能力位:
-
-| 能力字段 | 含义 |
-| --- | --- |
-| `CancelRun` | 是否支持取消正在运行的 run |
-| `ResumeRun` | 是否支持从 checkpoint 恢复长任务(含 `Supported` 子字段) |
-
-`ResumeRun.Supported=true` 时,`ListSessionCheckpoints` 返回的每条 checkpoint 会带 `ResumeDisabled` / `ResumeDisabledReason`,标记哪些恢复点当前可用。已恢复过的 checkpoint 在当前策略下不允许重复恢复。
-
-## 9. 长任务恢复与 CancelRun / ResumeRun
-
-!!! new "0.6.7 新增"
-
-长任务(长 LLM 生成、多步 tool 编排、流式任务)可能因超时、用户主动中断或异常退出而中断。`0.6.7` 起补齐用户向的恢复流程,避免长任务丢失进度。
-
-用户侧典型流程:
-
-```mermaid
-sequenceDiagram
- participant U as 用户
- participant UI as Hosted / Local UI
- participant RT as Conversation Runtime
-
- U->>UI: 发起长任务请求
- UI->>RT: /v1/responses (stream=true)
- RT-->>UI: response.created / output_text.delta ...
- U->>UI: 点击「取消」
- UI->>RT: CancelRun(InvocationId)
- RT-->>UI: Status=cancelling
- Note over RT: 运行被中断,写入 checkpoint
- U->>UI: 刷新会话
- UI->>RT: ListSessionEvents(SessionId, Offset, Limit)
- RT-->>UI: 已落盘事件 + Total
- U->>UI: 选择「从恢复点继续」
- UI->>RT: ListSessionCheckpoints(AgentId, SessionId)
- RT-->>UI: checkpoints (含 ResumeDisabled 标记)
- UI->>RT: ResumeRun(AgentId, SessionId, RunId, CheckpointId)
- RT-->>UI: 从恢复点继续生成
-```
-
-关键 action:
-
-- `CancelRun`:传入 `InvocationId`(即 `run_id`)取消正在运行的流式任务。runtime 会先尝试取消进程内 detached stream,再调用 runner 的 cancel 接口;返回 `Cancelled`、`Found`、`Status`、`RunnerCancelStatus`。
-- `ListSessionCheckpoints`:列出某个会话可恢复的 checkpoint,支持 `OnlyResumable` 过滤、`Offset` / `Limit` 分页(`Limit` 上限 500)。
-- `ResumeRun`:传入 `AgentId` / `SessionId` / `RunId` / `CheckpointId` 从指定恢复点继续。同一 session+run 已有进行中的 resume 时会返回 `resume_already_running`,避免并发重复恢复。
-
-!!! warning "不可恢复的 checkpoint"
-- 已是终态的 checkpoint 不可恢复(`ResumeDisabledReason` 会提示「选择更早恢复点重跑」)。
-- 进程内 checkpoint 不能跨实例恢复。
-- 同一 checkpoint 在当前策略下不允许重复恢复。
-
-## 10. `agentengine files` 工作区文件管理
-
-### 10.1 子命令清单
-
-- `agentengine files list`
-- `agentengine files upload`
-- `agentengine files download`
-- `agentengine files delete`
-- `agentengine files push`
-- `agentengine files pull`
-
-### 10.2 路径语义
-
-- 远端路径统一是 workspace 相对路径。
-- `.`、空字符串和 `/` 会被解释为逻辑根 `workspace:/`。
-- 输出里会同时给出:
- - 逻辑路径:`workspace:/docs/readme.md`
- - 真实路径:当运行时返回真实根目录时,显示绝对路径
-
-### 10.3 常用示例
-
-列目录:
-
-```bash
-agentengine files list --path .
-agentengine files list --path docs --recursive
-```
-
-上传单文件:
-
-```bash
-agentengine files upload \
- --local-path ./report.md \
- --remote-path reports/report.md
-```
-
-下载文件:
-
-```bash
-agentengine files download \
- --remote-path reports/report.md \
- --output-path ./downloads/report.md
-```
-
-删除文件:
-
-```bash
-agentengine files delete --remote-path reports/report.md
-```
-
-推送目录:
-
-```bash
-agentengine files push \
- --local-dir ./dist \
- --remote-path releases/current
-```
-
-拉取目录:
-
-```bash
-agentengine files pull \
- --remote-path releases/current \
- --local-dir ./synced
-```
-
-### 10.4 `push / pull` 覆盖策略
-
-- 默认不会强制覆盖已有文件。
-- 加 `--force` 时,已有同名文件会进入 `overwritten` 结果集。
-- 输出会区分:
- - `created`
- - `overwritten`
- - `skipped`
-
-### 10.5 JSON 输出
-
-所有 `files` 子命令都可配合 `--output json` 使用。典型字段包括:
-
-- `workspace_root`
-- `workspace_display_path`
-- `workspace_real_path`
-- `entry_count`
-- `size_bytes`
-- `size_human`
-- `transport_mode`
-- `results.created / overwritten / skipped`
-
-### 10.6 大小限制
-
-当前统一上限来自 `ksadk_runtime_common.workspace_files.constants`:
-
-- 单文件上传上限:`100MB`
-
-目录同步时还有两条额外限制:
-
-- 本地目录中任一单文件不能超过上限
-- 本地目录总大小也不能超过同一个上限
-
-### 10.7 传输模式
-
-CLI 内部会在两种模式间切换:
-
-- `runtime_direct`:直连 runtime 的 `/_ksadk/workspace/v1/*`
-- `action_proxy`:经由控制面 Action 调用
-
-当前代码里的实际策略:
-
-- 常规 agent:优先使用 `runtime_direct`
-- OpenClaw:优先使用 `action_proxy`
-
-更完整的协议与安全说明见 [工作区文件技术设计](../internal/工作区文件技术设计.md)。
-
-## 11. `agentengine agent invoke`
-
-`agentengine agent invoke` 是当前主线命令;`agentengine invoke` 仍保留为兼容别名。
-
-### 11.1 常见用法
-
-```bash
-agentengine agent invoke my-agent
-agentengine agent invoke my-agent --message "你好"
-agentengine agent invoke my-agent --transport chat
-```
-
-### 11.2 Hermes 远端 native 模式
-
-`--local-workspace` 只支持 Hermes 的远端 native 模式:
-
-```bash
-agentengine agent invoke my-hermes \
- --transport native \
- --local-workspace ./local-workspace
-```
-
-可选指定远端目录:
-
-```bash
-agentengine agent invoke my-hermes \
- --transport native \
- --local-workspace ./local-workspace \
- --remote-workspace-path demos/hermes-pre
-```
-
-当前行为:
-
-- 如果不传 `--remote-workspace-path`,默认使用本地目录名作为远端子目录名
-- 本地空目录不会被同步
-- 会先读取 `GetAgentUiBootstrap` 中的 `WorkspaceFiles.MaxUploadBytes`
-- 如 bootstrap 获取失败,则回退到默认 `100MB`
-
-### 11.3 约束
-
-- `--remote-workspace-path` 必须与 `--local-workspace` 一起使用
-- `--local-workspace` 不能和单次 `--message` 模式一起使用
-- 当前只支持 Hermes 远端 native 模式
-
-## 12. Hermes 命令主线
-
-常用命令:
-
-```bash
-agentengine hermes deploy --name hermes-demo
-agentengine hermes status
-agentengine hermes open --chat
-agentengine hermes connect
-agentengine hermes exec -- status
-```
-
-部署相关默认值:
-
-- 默认 PVC 大小:`20Gi`
-- 默认挂载目录:`/home/node/.hermes`
-- 默认 workspace 根目录:`/home/node/.hermes/workspace`
-
-## 13. OpenClaw 命令主线
-
-常用命令:
-
-```bash
-agentengine openclaw deploy
-agentengine openclaw list
-agentengine openclaw status
-agentengine openclaw gateway doctor
-agentengine openclaw channel status --probe
-```
-
-### 13.1 当前支持的记忆参数
-
-```bash
-agentengine openclaw deploy --memory-system openclaw_default
-agentengine openclaw deploy \
- --memory-system mem0 \
- --mem0-instance-id \
- --mem0-instance-name my-mem0 \
- --mem0-region cn-beijing-6
-```
-
-约束:
-
-- `--memory-system mem0` 时必须传 `--mem0-instance-id`
-- `--memory-system openclaw_default` 时不能再传 mem0 细节参数
-- 不显式传 `--memory-system` 时,CLI 不会主动覆盖现有服务端配置
-
-### 13.2 当前 mem0 行为
-
-- OpenClaw 镜像内置 mem0 插件资产
-- bootstrap 默认把 `openclaw-mem0` 视为延迟同步插件
-- 只有在存在 `MEMORY_BACKEND_MANIFEST` 且渲染结果要求该插件时,才会把插件真正同步到实例目录
-- 不使用 mem0 时,不会把该插件种到实例的持久化状态里
-
-### 13.3 OpenClaw 存储默认值
-
-- 默认 PVC 大小:`20Gi`
-- 默认挂载目录:`/home/node/.openclaw`
-- 默认 workspace 根目录:`/home/node/.openclaw/workspace`
-
-更多 OpenClaw 细节见 [OpenClaw一键部署指南](../reference/openclaw一键部署指南.md)。
-
-## 14. 常见验证项
-
-### 14.1 验证工作区文件
-
-```bash
-agentengine files list --output json
-agentengine files push --local-dir ./workspace --remote-path demo
-agentengine files pull --remote-path demo --local-dir ./downloaded --force
-```
-
-### 14.2 验证 Hermes remote workspace
-
-```bash
-agentengine agent invoke hermes-demo \
- --transport native \
- --local-workspace ./workspace
-```
-
-### 14.3 验证 OpenClaw mem0 参数
-
-```bash
-agentengine openclaw deploy \
- --memory-system mem0 \
- --mem0-instance-id e52b7fac-e641-4b34-b9f7-6b0b9f190cd4
-```
-
-## 15. 相关文档
-
-- [ksadk技术设计](../reference/ksadk技术设计.md)
-- [工作区文件技术设计](../internal/工作区文件技术设计.md)
-- [记忆使用指南](./记忆使用指南.md)
-- [知识库与记忆示例](./知识库与记忆示例.md)
-- [OpenClaw一键部署指南](../reference/openclaw一键部署指南.md)
-- [DeepAgents说明](./DeepAgents说明.md)
diff --git "a/docs/guides/\347\237\245\350\257\206\345\272\223\344\270\216\350\256\260\345\277\206\347\244\272\344\276\213.md" "b/docs/guides/\347\237\245\350\257\206\345\272\223\344\270\216\350\256\260\345\277\206\347\244\272\344\276\213.md"
deleted file mode 100644
index 0cb5216a..00000000
--- "a/docs/guides/\347\237\245\350\257\206\345\272\223\344\270\216\350\256\260\345\277\206\347\244\272\344\276\213.md"
+++ /dev/null
@@ -1,114 +0,0 @@
-# 知识库与记忆示例
-
-本文档给出当前仓库可直接复用的知识库(KB)与长期记忆(LTM)组合示例,口径以现有示例目录、工具函数和测试为准。
-
-## 1. 能力矩阵
-
-| 能力 | 入口 | 典型用途 |
-| --- | --- | --- |
-| 知识库检索 | `search_knowledge` / `search_knowledge_base` | RAG、文档检索 |
-| 平台长期记忆 | `load_memory` / `save_memory` | 用户偏好、跨会话结论 |
-| ADK 长期记忆 | `LongTermMemory.from_env()` | ADK 场景下的自动检索与持久化 |
-
-## 2. 最小准备项
-
-### 2.1 KB
-
-- `KSADK_KB_DATASET_ID`
-- `KSADK_KB_ACCESS_KEY`
-- `KSADK_KB_SECRET_KEY`
-
-可选:
-
-- `KSADK_KB_REGION`
-- `KSADK_KB_ENDPOINT`
-- `KSADK_KB_SCHEME`
-- `KSADK_KB_TOP_K`
-
-### 2.2 LTM
-
-- `KSADK_LTM_BACKEND`
-
-后端相关:
-
-- `http`:`KSADK_LTM_HTTP_URL`、`KSADK_LTM_HTTP_TOKEN`
-- `sdk`:`KSADK_LTM_ACCESS_KEY`、`KSADK_LTM_SECRET_KEY` 等
-
-## 3. ADK 示例
-
-参考目录:`examples/knowledge_base_adk/`
-
-```python
-from google.adk.agents import Agent
-from ksadk.knowledge_base.adk_tool import search_knowledge_base
-
-root_agent = Agent(
- name="knowledge_base_assistant",
- tools=[search_knowledge_base],
-)
-```
-
-运行:
-
-```bash
-cd examples/knowledge_base_adk
-agentengine run .
-```
-
-## 4. 跨框架记忆工具示例
-
-```python
-from ksadk.memory.tool import load_memory, save_memory
-
-memory_text = load_memory("这个用户对输出风格有什么偏好")
-save_memory("用户偏好:回答先给结论,再给分步说明")
-```
-
-## 5. KB + LTM 组合模式
-
-推荐顺序:
-
-1. 先用 KB 回答客观事实
-2. 再用 LTM 补用户偏好与历史决策
-3. 回复结束后保存本轮结论
-
-```mermaid
-flowchart LR
- classDef client fill:#dbeafe,stroke:#1d4ed8,stroke-width:2px,color:#1e3a8a;
- classDef data fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#166534;
- classDef runtime fill:#e2e8f0,stroke:#475569,stroke-width:2px,color:#1e293b;
-
- Question["用户问题"]:::client --> KB["search_knowledge"]:::data
- Question --> LTM["load_memory"]:::data
- KB --> Reason["LLM 组织答案"]:::runtime
- LTM --> Reason
- Reason --> Save["save_memory"]:::data
-```
-
-## 6. OpenClaw 场景
-
-OpenClaw deploy 当前支持:
-
-```bash
-agentengine openclaw deploy --memory-system openclaw_default
-agentengine openclaw deploy --memory-system mem0 --mem0-instance-id
-```
-
-说明:
-
-- `openclaw_default` 不需要 mem0 实例参数
-- `mem0` 由 server 注入环境变量并下发 manifest
-- runtime 只负责渲染 patch 和按需同步插件
-
-## 7. 建议验证入口
-
-- `tests/test_platform_memory_tools.py`
-- `tests/unit/memory/test_adk_memory_comprehensive.py`
-- `tests/test_runtime_common_memory_backend.py`
-- `tests/unit/knowledge_base/test_client_env.py`
-
-## 8. 相关文档
-
-- [记忆使用指南](./记忆使用指南.md)
-- [ksadk使用文档](./ksadk使用文档.md)
-- [OpenClaw一键部署指南](../reference/openclaw一键部署指南.md)
diff --git "a/docs/guides/\350\256\260\345\277\206\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/guides/\350\256\260\345\277\206\344\275\277\347\224\250\346\214\207\345\215\227.md"
deleted file mode 100644
index 46ac67e2..00000000
--- "a/docs/guides/\350\256\260\345\277\206\344\275\277\347\224\250\346\214\207\345\215\227.md"
+++ /dev/null
@@ -1,227 +0,0 @@
-# 记忆使用指南
-
-本文档说明 `ksadk-python` 当前代码里可直接使用的记忆能力,分为两条主线:
-
-1. 平台长期记忆工具:`load_memory` / `save_memory`
-2. OpenClaw runtime memory backend:`openclaw_default` / `mem0`
-
-## 1. 能力分层
-
-```mermaid
-flowchart TB
- classDef client fill:#dbeafe,stroke:#1d4ed8,stroke-width:2px,color:#1e3a8a;
- classDef control fill:#ede9fe,stroke:#7c3aed,stroke-width:2px,color:#581c87;
- classDef data fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#166534;
- classDef runtime fill:#e2e8f0,stroke:#475569,stroke-width:2px,color:#1e293b;
-
- Tool["load_memory / save_memory"]:::client --> Service["LongTermMemoryService.from_env()"]:::runtime
- ADK["LongTermMemory.from_env()"]:::client --> Service
- OpenClaw["MEMORY_BACKEND_MANIFEST"]:::control --> Backend["ksadk_runtime_common.memory_backend"]:::runtime
- Service --> BackendImpl["local / http / sdk backend"]:::data
- Backend --> Mem0["mem0 provider"]:::data
- Backend --> Default["openclaw_default provider"]:::data
-```
-
-## 2. 平台长期记忆工具
-
-### 2.1 入口
-
-文件:`ksadk/memory/tool.py`
-
-- `load_memory(query: str) -> str`
-- `save_memory(content: str) -> str`
-
-### 2.2 行为约束
-
-- 依赖运行时上下文 `platform_invocation_context`
-- 无上下文时不会盲写,而是返回诊断信息
-- 保存时会附带 `agent_id / user_id / session_id / runner_type` 等元数据
-
-### 2.3 使用示例
-
-```python
-from ksadk.memory.tool import load_memory, save_memory
-
-history = load_memory("用户之前提过哪些偏好")
-save_memory("用户偏好:先给结论,再给简短原因")
-```
-
-## 3. ADK 记忆服务
-
-文件:`ksadk/memory/adk/long_term_memory.py`
-
-核心入口:
-
-```python
-from ksadk.memory.adk.long_term_memory import LongTermMemory
-
-ltm = LongTermMemory.from_env(app_name="demo_app")
-```
-
-当前行为:
-
-- 只持久化用户事件
-- 会把检索结果转换成 ADK `MemoryEntry`
-- 支持 `local`、`http`、`sdk` 三类后端
-
-## 4. 环境变量
-
-### 4.1 通用 LTM 环境变量
-
-- `KSADK_LTM_BACKEND`:`local` / `http` / `sdk`
-- `KSADK_LTM_TOP_K`:默认 `5`
-- `KSADK_LTM_INDEX`
-- `KSADK_LTM_APP_NAME`
-
-### 4.2 `http` 后端
-
-- `KSADK_LTM_HTTP_URL`
-- `KSADK_LTM_HTTP_TOKEN`
-
-### 4.3 `sdk` 后端
-
-- `KSADK_LTM_ACCESS_KEY`
-- `KSADK_LTM_SECRET_KEY`
-- `KSADK_LTM_REGION`
-- `KSADK_LTM_ENDPOINT`
-- `KSADK_LTM_SCHEME`
-- `KSADK_LTM_NAMESPACE`:环境变量名保持不变,值对应新版 SDK 的 `MemoryCollectionId`
-- `KSADK_LTM_AGENT_ID`
-- `KSADK_LTM_SCENE_ID`:默认 `_sys_general`
-- `KSADK_LTM_AUTO_SAVE`:布尔开关。默认在 `KSADK_LTM_BACKEND=sdk` 且 `KSADK_LTM_NAMESPACE` 已设置时开启;设为 `false` / `0` / `off` 可关闭 runtime 每轮完成后的会话文本镜像。
-
-自动镜像只写 user/assistant 文本和附件摘要,metadata 会包含 `agent_id`、`session_id`、`invocation_id`、`model`、`runner_type`。图片、文件 base64 和二进制内容不会写入长期记忆;完整附件回显仍由 AgentEngine conversation 存储负责。
-
-!!! info "invocation_id 与账号边界从哪来(0.6.5 新增)"
- `load_memory` / `save_memory` 不接收显式的 user/session 参数,而是通过 `ksadk.runtime_context.get_current_invocation_context()` 读取当前运行时上下文,取其中的 `user_id` / `session_id` / `runner_type` 等字段写入记忆元数据,`invocation_id` 即来自该上下文。`account_id` 字段同样来自此上下文,用于在多账号共享同一记忆后端时做账号隔离。
-
- 业务侧若要在工具或自定义流程里直接拿到账号标识,可用 0.6.5 新增的便捷函数:
-
- ```python hl_lines="2"
- from ksadk.runtime_context import get_current_account_id
- account_id = get_current_account_id(default="")
- ```
-
- `get_current_account_id()` 同样基于 `get_current_invocation_context()`;上下文缺失时返回传入的 `default`(默认空串),不会抛异常。`get_current_user_id()` 是其姊妹函数,行为一致。
-
-## 5. OpenClaw 的记忆后端声明
-
-### 5.1 CLI 入口
-
-```bash
-agentengine openclaw deploy --memory-system openclaw_default
-agentengine openclaw deploy --memory-system mem0 --mem0-instance-id
-```
-
-### 5.2 当前支持的 backend
-
-| backend_type | 含义 |
-| --- | --- |
-| `openclaw_default` | 保持 OpenClaw 默认记忆模式 |
-| `mem0` | 使用 mem0 插件和平台注入的环境变量 |
-| `lancedb` | 进程内 LanceDB OpenClaw 记忆插件(plugin id `memory-lancedb`)。通过 `MEMORY_BACKEND_MANIFEST` 直接声明,不走 `agentengine openclaw deploy --memory-system` CLI 入口;manifest 可选覆盖 `dbPath`、embedding 配置和 storage 选项。 |
-
-### 5.2.1 `lancedb` manifest 示例
-
-`lancedb` 后端通过 `MEMORY_BACKEND_MANIFEST` 声明,manifest 经 JSON Schema 校验后由 `LanceDBProvider` 渲染成 `memory-lancedb` 插件配置。`config.dbPath` 可选,不传时使用插件默认路径:
-
-```json
-{
- "schema_version": "v1",
- "backend_type": "lancedb",
- "config": {
- "dbPath": "/home/node/.openclaw/memory/lancedb"
- }
-}
-```
-
-渲染后 `plugins.entries.memory-lancedb.config.dbPath` 会被原样透传,`plugins.slots.memory` 指向 `memory-lancedb`,并自动禁用 `openclaw-mem0` 插件。`config.embedding` 和 `config.storageOptions` 同样为可选覆盖项。
-
-### 5.3 manifest 契约
-
-`ksadk_runtime_common.memory_backend.manifest` 当前强制按 JSON Schema 校验。即使调用方直接传 `MemoryBackendManifest` 模型实例,也会先 `model_dump()` 再走 schema 校验。
-
-### 5.4 `mem0` provider 约束
-
-当前 `mem0` 渲染要求:
-
-- `config.mem0_instance_id` 必填
-- 运行时环境变量必须存在:
- - `MEM0_API_KEY`
- - `MEM0_USER_ID`
- - `MEM0_BASE_URL`
-
-渲染结果会生成:
-
-- `plugins.slots.memory = "openclaw-mem0"`
-- `plugins.entries.openclaw-mem0.enabled = true`
-- `plugins.entries.openclaw-mem0.config.mode = "platform"`
-
-## 6. 服务端与 runtime 的分工
-
-```mermaid
-sequenceDiagram
- autonumber
- participant CLI as agentengine openclaw deploy
- participant Server as agentengine-server
- participant Runtime as OpenClaw bootstrap
- participant Render as ksadk_runtime_common.memory_backend
-
- CLI->>Server: MemoryConfig
- Server->>Server: 查询 mem0 实例并生成 env + manifest
- Server-->>Runtime: MEMORY_BACKEND_MANIFEST + MEM0_*
- Runtime->>Render: render_to_json()
- Render-->>Runtime: config_patch + plugin_ids
- Runtime->>Runtime: 写入 openclaw.json 并按需同步插件
-```
-
-分工边界:
-
-- server 是 mem0 实例查询、校验和密钥拼装的事实源
-- runtime 只消费 manifest 和环境变量,不直接查询 mem0 控制面
-
-## 7. 常见排查
-
-### 7.1 `load_memory` 返回诊断文本
-
-通常表示当前调用不在受支持的运行时上下文里。
-
-### 7.2 `sdk` 后端初始化失败
-
-优先检查:
-
-- 依赖是否安装
-- AK/SK 是否齐全
-- `KSADK_LTM_ENDPOINT` 与网络环境是否匹配
-
-### 7.3 OpenClaw mem0 渲染失败
-
-当前渲染失败会直接指出缺失的环境变量,例如:
-
-- `mem0 backend requires environment variable 'MEM0_API_KEY'`
-
-### 7.4 OpenClaw 启动后配置校验失败
-
-优先检查:
-
-- `MEMORY_BACKEND_MANIFEST` 是否为合法 JSON
-- manifest 中 `mem0_instance_id` 是否满足 schema 要求
-- runtime 中的 mem0 插件是否按渲染结果被同步
-
-### 7.5 LanceDB `dbPath` 写入失败(0.6.7 新增)
-
-`lancedb` 后端在容器内初始化时,若指定的 `config.dbPath` 目录不存在或无写权限,`memory-lancedb` 插件启动会失败。优先检查:
-
-- `dbPath` 所在目录是否已挂载为可写卷,Pod 重启后路径是否仍持久化
-- 运行时进程对该路径是否有读写权限(OpenClaw runtime 通常以非 root 用户运行)
-- `MEMORY_BACKEND_MANIFEST` 是否为合法 JSON,且 `config.dbPath` 是绝对路径
-- 若不传 `dbPath`,确认插件默认路径在当前部署环境可写
-
-!!! warning "校验仍走 schema"
- 即使 `dbPath` 目录不存在,manifest 也会先通过 JSON Schema 校验(schema 只校验字段类型,不校验路径可达性)。路径/权限问题在 runtime 拉起插件时才暴露,排查时以 runtime 日志为准。
-
-## 8. 相关文档
-
-- [知识库与记忆示例](./知识库与记忆示例.md)
-- [ksadk技术设计](../reference/ksadk技术设计.md)
-- [OpenClaw一键部署指南](../reference/openclaw一键部署指南.md)
diff --git "a/docs/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" "b/docs/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md"
deleted file mode 100644
index 2206df11..00000000
--- "a/docs/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md"
+++ /dev/null
@@ -1,619 +0,0 @@
-# KSADK 环境变量参考
-
-本文档面向部署、运行、运维和 SDK 集成排障。它不是业务代码 `.env` 模板;业务方自己的变量,例如 `APP_ENV`、`DB_URL`、`CUSTOM_API_KEY`,只要不是 KsADK / 平台运行时读取的变量,都属于业务自定义变量,不在本文逐项维护。
-
-本文档基于当前 `feat/skill-runtime` 工作树和 `master` 分支源码扫描整理,覆盖 `ksadk/`、`deploy/`、`tests/` 中已经注册或常见可配置的运行时变量。测试专用变量、PID/marker/cache 等进程内部临时变量、镜像构建脚本内部常量不会逐项列入表格;如果要排查这些高级项,以对应脚本源码和模板 README 为准。
-
-## 1. 阅读规则
-
-| 字段 | 含义 |
-| --- | --- |
-| 变量 | 环境变量名。 |
-| 作用层级 | 主要读取方:CLI、本地运行时、云端 Runtime、Runner、Sandbox、Skill Runtime、平台服务等。 |
-| 是否必传 | `是` 表示该场景启用时必须设置;`条件必传` 表示只有选择某个 backend/能力时才必传;`否` 表示有默认值或可不启用。 |
-| 默认值 | 未设置时的 SDK 行为。`未设置` 表示没有默认值。`代码常量` 表示源码内部表名/依赖列表,不建议用户改。 |
-| 别名/兼容 | 可替代变量、旧变量或 fallback 链路。优先使用表中第一列变量。 |
-| 敏感 | 是否包含 token、secret、DSN、API key。敏感变量只能通过本地 shell、CI Secret、K8S Secret 或平台 Secret 注入。 |
-| 配置方/来源 | 一般由谁提供或注入。 |
-| 是否业务自定义 | `否` 表示 KsADK/平台读取;`是` 表示业务方可自由定义,本文只说明边界。 |
-| 说明 | 用途、取值、注意事项。 |
-
-## 2. 常见场景必传清单
-
-### 2.1 本地运行普通 Agent
-
-| 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 |
-| --- | --- | --- | --- | --- | --- |
-| `OPENAI_API_KEY` | 条件必传 | 部分 OpenAI 兼容实现也支持 `MODEL_API_KEY` | 是 | 开发者 / 模型网关 Secret | 使用 OpenAI 兼容模型时需要。 |
-| `OPENAI_BASE_URL` | 条件必传 | `OPENAI_API_BASE` | 否 | 开发者 / 模型网关 | OpenAI 兼容接口 base url。 |
-| `OPENAI_MODEL_NAME` | 条件必传 | `MODEL_NAME` | 否 | 开发者 | 默认模型名。 |
-| `KSYUN_REGION` | 否 | 无 | 否 | 开发者 / 平台 | 本地 CLI 默认 `cn-beijing-6`。跨环境建议显式设置。 |
-
-### 2.2 CLI 构建、发布、部署到金山云
-
-| 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 |
-| --- | --- | --- | --- | --- | --- |
-| `KSYUN_ACCESS_KEY` | 是 | `KS3_ACCESS_KEY` | 是 | 开发者 / CI Secret | 金山云 API / KS3 / KOP 签名 AK。 |
-| `KSYUN_SECRET_KEY` | 是 | `KS3_SECRET_KEY` | 是 | 开发者 / CI Secret | 金山云 API / KS3 / KOP 签名 SK。 |
-| `KSYUN_ACCOUNT_ID` | 条件必传 | 无 | 否 | 开发者 / 平台账号 | 创建/查询/删除资源、权限预检查、个人版 KCR 用户名兜底等场景需要。 |
-| `KSYUN_REGION` | 否 | 无 | 否 | 开发者 / 平台 | 默认 `cn-beijing-6`。 |
-| `AGENTENGINE_SERVER_URL` | 否 | 无 | 否 | 平台 / 开发者 | 覆盖 AgentEngine Server 地址。内部账号/内网环境建议 `http://aicp.inner.api.ksyun.com`;公网账号通常不设置或使用 `https://aicp.api.ksyun.com`。 |
-| `AGENTENGINE_API_VERSION` | 否 | 无 | 否 | 平台 / 开发者 | 覆盖 KOP API version。 |
-| `AGENTENGINE_SIGN_SERVICE` | 否 | 无 | 否 | 平台 / 开发者 | 覆盖 KOP signing service。 |
-| `KSADK_AICP_ENDPOINT_MODE` | 否 | 无 | 否 | 平台 / 开发者 | AICP endpoint 选择策略,支持 `auto/detect/internal/inner/public`。内网环境可显式设为 `inner`,跳过自动探测。 |
-
-### 2.3 ADK Runner 注入远端 MCP tools
-
-| 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 |
-| --- | --- | --- | --- | --- | --- |
-| `KSADK_ENABLE_MCP_TOOLS` | 否 | 无 | 否 | 开发者 / 平台 | 默认 `1`,设为 `0/false/no/off` 禁用自动注入。 |
-| `KSADK_MCP_SERVERS` | 条件必传 | 无 | 是 | 开发者 / 平台 Secret | JSON 数组,配置 MCP server url、api_key、tool_filter、tool_name_prefix。可能包含 token。 |
-
-### 2.4 Skill Runtime 本地模式
-
-| 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 |
-| --- | --- | --- | --- | --- | --- |
-| `KSADK_SKILLS_MODE` | 否 | 无 | 否 | 开发者 / Runner 环境 | `auto/local/sandbox`。本地调试可显式设为 `local`。 |
-| `KSADK_LOCAL_SKILLS_DIR` | 条件必传 | `KSADK_SKILL_CACHE_DIR` 可作为 fallback | 否 | 开发者 | 本地已解压 Skill 包目录;目录下每个 skill 应包含 `SKILL.md`。 |
-| `KSADK_SKILL_RUNTIME_BACKEND` | 否 | 无 | 否 | 开发者 | 本地进程模式设为 `local_process`。 |
-| `KSADK_SKILL_RUNTIME_AGENT_PATH` | 条件必传 | 默认使用 SDK 内置 agent | 否 | 开发者 | `local_process` backend 的 agent 入口。 |
-
-### 2.5 Skill Runtime 远程 Sandbox / E2B 模式
-
-| 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 |
-| --- | --- | --- | --- | --- | --- |
-| `KSADK_SANDBOX_TEMPLATE_ID` | 是 | `KSADK_SKILL_RUNTIME_TEMPLATE_ID` | 否 | 沙箱控制台 / 沙箱团队 | 新部署优先使用。AIO template 是 Skill Runtime 默认推荐。 |
-| `E2B_API_URL` | 是 | 无 | 否 | 沙箱团队 / Secret 配置 | E2B 兼容 manager endpoint。 |
-| `E2B_API_KEY` | 是 | 无 | 是 | 沙箱团队 / Secret 配置 | E2B SDK 原生 API key,不能写入代码、文档示例明文、测试 fixture 或日志。 |
-| `KSADK_SANDBOX_BACKEND` | 否 | 无 | 否 | 平台 / 开发者 | 默认 `e2b`。后续可扩展其他 backend。 |
-| `KSADK_SANDBOX_TYPE` | 否 | 无 | 否 | 平台 / 开发者 | `aio/code/browser/private`,默认 `aio`。 |
-| `KSADK_SANDBOX_TIMEOUT` | 否 | `KSADK_SKILL_RUNTIME_TIMEOUT` | 否 | 平台 / 开发者 | Sandbox 会话超时秒数,默认 `900`。 |
-| `KSADK_SANDBOX_ALLOW_INTERNET_ACCESS` | 否 | `KSADK_SKILL_RUNTIME_ALLOW_INTERNET_ACCESS` | 否 | 平台 / 开发者 | 是否允许 sandbox 出网,默认 `true`。 |
-| `KSADK_SKILLS_MODE` | 否 | 无 | 否 | Runner 环境 | `auto` 下检测到 sandbox backend/template 会注入 `execute_skills`;也可显式设为 `sandbox`。 |
-| `KSADK_SKILL_RUNTIME_BACKEND` | 否 | 无 | 否 | Runner 环境 | 显式设为 `e2b` 会走远程 backend;显式 `disabled` 会禁止 Skill Runtime 注入。未设置且存在 `KSADK_SANDBOX_TEMPLATE_ID` 时自动使用 `e2b`。 |
-
-### 2.6 Skill Center / Skill Service
-
-| 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 |
-| --- | --- | --- | --- | --- | --- |
-| `KSADK_SKILL_SERVICE_URL` | 条件必传 | 无 | 否 | 平台 / Skill Service | 配置后 Runtime agent 才会从 Skill Center 拉取 skill。直连 REST 可用 `/agentengine/skill/api/v1`,AICP KOP 可用 `http://aicp.inner.api.ksyun.com`。 |
-| `KSADK_SKILL_SERVICE_ENDPOINT` | 否 | 无 | 否 | 平台 / Skill Service | 未设置 `KSADK_SKILL_SERVICE_URL` 时的 AICP endpoint 覆盖,只写 host/path,不含 scheme。 |
-| `KSADK_SKILL_SERVICE_SCHEME` | 否 | 无 | 否 | 平台 / Skill Service | 未设置 `KSADK_SKILL_SERVICE_URL` 时的 AICP URL scheme 覆盖;内网 endpoint 默认会使用 `http`。 |
-| `KSADK_SKILL_SPACE_IDS` | 条件必传 | `SKILL_SPACE_ID` | 否 | Agent 创建/更新时注入 / Runner 环境 | 逗号分隔 space id;单 space 兼容变量为 `SKILL_SPACE_ID`。 |
-| `SKILL_SPACE_ID` | 条件必传 | `KSADK_SKILL_SPACE_IDS` | 否 | 兼容旧/单 space 注入 | 单个 Skill Space id。新部署优先 `KSADK_SKILL_SPACE_IDS`。 |
-| `KSADK_SKILL_SERVICE_ACCOUNT_ID` | 条件必传 | `KSYUN_ACCOUNT_ID` | 否 | 平台 / 租户上下文 | Skill Service 租户隔离 header。KOP 或直连 REST 租户视图通常需要。 |
-| `KSADK_SKILL_SERVICE_ACCESS_KEY` | 条件必传 | `KSYUN_ACCESS_KEY`、`KS3_ACCESS_KEY` | 是 | 平台 Secret | AICP KOP 签名 AK。直连 REST 或 bearer token 模式不需要。 |
-| `KSADK_SKILL_SERVICE_SECRET_KEY` | 条件必传 | `KSYUN_SECRET_KEY`、`KS3_SECRET_KEY` | 是 | 平台 Secret | AICP KOP 签名 SK。直连 REST 或 bearer token 模式不需要。 |
-| `KSADK_SKILL_SERVICE_TOKEN` | 条件必传 | 无 | 是 | 平台 Secret | Bearer token 模式使用;KOP 签名模式通常不使用。 |
-| `KSADK_SKILL_SERVICE_REGION` | 否 | `KSYUN_REGION` | 否 | 平台 / 开发者 | 默认 `cn-beijing-6`。 |
-| `KSADK_SKILL_SERVICE_API_VERSION` | 否 | 无 | 否 | 平台 / 开发者 | 默认 `2024-06-12`;不要复用 Sandbox KOP 的 `2026-04-01`。 |
-| `KSADK_SKILL_SERVICE_SIGN_SERVICE` | 否 | 无 | 否 | 平台 / 开发者 | 默认 `aicp`。 |
-| `KSADK_SKILL_MANIFEST_LIMIT` | 否 | 无 | 否 | 平台 / 开发者 | 外层 Agent instruction 最多注入的远端 skill manifest 数量,默认 `30`。 |
-| `KSADK_SKILL_MANIFEST_TIMEOUT` | 否 | 无 | 否 | 平台 / 开发者 | 拉取远端 skill manifest 的超时秒数,默认 `5`。 |
-| `KSADK_SELECTED_SKILL_NAMES` | 否 | 无 | 否 | Runner / Runtime agent | `execute_skills` 选中的 skill 名称列表,Runtime agent 优先按它下载;通常由 SDK 自动注入。 |
-| `KSADK_SKILL_CACHE_DIR` | 否 | 无 | 否 | Runtime agent | Skill archive 下载和解压缓存目录。 |
-| `KSADK_SKILL_WORKDIR` | 否 | 无 | 否 | Runtime agent | workflow 工作目录。 |
-| `KSADK_SKILL_ARTIFACT_PROJECT` | 否 | 无 | 否 | Runtime agent | 最小 artifact workflow 默认项目名,默认 `ksadk-artifact`。 |
-
-### 2.7 知识库、记忆库、会话存储
-
-| 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 |
-| --- | --- | --- | --- | --- | --- |
-| `KSADK_KB_DATASET_ID` | 条件必传 | 无 | 否 | 平台 / 开发者 | 配置后启用知识库检索。 |
-| `KSADK_KB_ACCESS_KEY` | 条件必传 | `KSYUN_ACCESS_KEY` | 是 | 平台 Secret | SDK 知识库 backend AK。 |
-| `KSADK_KB_SECRET_KEY` | 条件必传 | `KSYUN_SECRET_KEY` | 是 | 平台 Secret | SDK 知识库 backend SK。 |
-| `KSADK_KB_ENDPOINT` | 否 | 无 | 否 | 平台 / 开发者 | 默认 `aicp.api.ksyun.com`。 |
-| `KSADK_KB_REGION` | 否 | 无 | 否 | 平台 / 开发者 | 默认 `cn-beijing-6`。 |
-| `KSADK_KB_SCHEME` | 否 | 无 | 否 | 平台 / 开发者 | KB endpoint 协议。内网 endpoint 默认 `http`,其他默认 `https`。 |
-| `KSADK_KB_AMBIENT_POLICY` | 否 | 无 | 否 | 平台 / 开发者 | runtime 自动注入知识库上下文策略:`on_demand/always/disabled`。 |
-| `KSADK_LTM_BACKEND` | 否 | 无 | 否 | 开发者 | 长期记忆 backend,默认 `local`,可选 `http/sdk`。 |
-| `KSADK_LTM_HTTP_URL` | 条件必传 | 无 | 是 | 平台 Secret | `KSADK_LTM_BACKEND=http` 时需要。 |
-| `KSADK_LTM_HTTP_TOKEN` | 条件必传 | 无 | 是 | 平台 Secret | HTTP LTM 鉴权 token。 |
-| `KSADK_LTM_ACCESS_KEY` | 条件必传 | `KSYUN_ACCESS_KEY` | 是 | 平台 Secret | SDK LTM AK。 |
-| `KSADK_LTM_SECRET_KEY` | 条件必传 | `KSYUN_SECRET_KEY` | 是 | 平台 Secret | SDK LTM SK。 |
-| `KSADK_LTM_AMBIENT_POLICY` | 否 | 无 | 否 | 平台 / 开发者 | runtime 自动注入长期记忆上下文策略:`on_demand/always/disabled`。 |
-| `KSADK_MEMORY_BACKEND` | 否 | 无 | 否 | 开发者 | 轻量 KV/消息历史 MemoryManager backend,默认 `memory`。 |
-| `KSADK_MEMORY_URL` | 条件必传 | 无 | 是 | 开发者 / Secret | `KSADK_MEMORY_BACKEND=redis` 等远端 backend 连接 URL。 |
-| `KSADK_SESSION_BACKEND` | 否 | `AGENTENGINE_SESSION_BACKEND`、`KSADK_STM_BACKEND` | 否 | 平台 / 开发者 | 会话 backend,默认 `local`。ADK/STM 也会把它作为兜底。 |
-| `KSADK_SESSION_DSN` | 条件必传 | `KSADK_STM_URL`、`KSADK_STM_DB_URL`、`KSADK_ADK_SESSION_URL` | 是 | 平台 Secret | `postgres` / `database` backend 时必传。ADK/STM 也会把它作为兜底。 |
-| `KSADK_SESSION_PATH` | 否 | `KSADK_STM_PATH`、`KSADK_STM_DB_PATH` | 否 | 本地运行时 | 本地 SQLite 会话库路径。 |
-| `KSADK_SESSION_NAMESPACE` | 否 | `KSADK_WORKSPACE_ID`、`AGENTENGINE_WORKSPACE_ID`、`KSADK_TENANT_ID`、`AGENTENGINE_TENANT_ID` | 否 | 平台 / 开发者 | 会话命名空间。 |
-
-### 2.8 可观测性和 Langfuse
-
-| 变量 | 是否必传 | 别名/兼容 | 敏感 | 配置方/来源 | 说明 |
-| --- | --- | --- | --- | --- | --- |
-| `LANGFUSE_PUBLIC_KEY` | 条件必传 | 无 | 是 | 平台 Secret / 开发者 | 启用 Langfuse 时需要。 |
-| `LANGFUSE_SECRET_KEY` | 条件必传 | 无 | 是 | 平台 Secret / 开发者 | 启用 Langfuse 时需要。 |
-| `LANGFUSE_BASE_URL` | 否 | `LANGFUSE_HOST` | 否 | 平台 / 开发者 | Langfuse endpoint。 |
-| `LANGFUSE_USE_CALLBACK` | 否 | 无 | 否 | 开发者 | 控制是否启用 callback 集成。 |
-| `CLOUD_MONITOR_APP_KEY` | 条件必传 | 无 | 是 | 平台 Secret | 云监控 OTLP AppKey。 |
-| `CLOUD_MONITOR_OTLP_ENDPOINT` | 条件必传 | 无 | 否 | 平台 / 开发者 | CloudMonitor 通用 OTLP HTTP endpoint。 |
-| `OTEL_EXPORTER_OTLP_ENDPOINT` | 条件必传 | 无 | 否 | 平台 / 开发者 | OTel Collector endpoint;未设置 traces 专用 endpoint 时,KsADK 会派生 `/v1/traces`。 |
-| `OTEL_EXPORTER_OTLP_PROTOCOL` | 否 | 无 | 否 | 平台 / 开发者 | 通用 OTLP 协议;KsADK 自动 HTTP exporter 当前支持 `http/protobuf`。 |
-| `OTEL_EXPORTER_OTLP_HEADERS` | 否 | 无 | 是 | 平台 / 开发者 | 通用 OTLP headers,逗号分隔,值按 URL encoding;可能包含 `Authorization`。 |
-| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | 否 | 无 | 否 | 平台 / 开发者 | traces 专用 endpoint;设置后优先于通用 endpoint。 |
-| `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | 否 | 无 | 否 | 平台 / 开发者 | traces 专用 OTLP 协议;设置后优先于通用 protocol。 |
-| `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | 否 | 无 | 是 | 平台 / 开发者 | traces 专用 OTLP headers;设置后优先于通用 headers。 |
-| `OTEL_SERVICE_NAME` | 否 | 无 | 否 | 平台 / 开发者 | OTel service name。 |
-| `OTEL_RESOURCE_ATTRIBUTES` | 否 | 无 | 否 | 平台 / 开发者 | OTel resource attributes。 |
-
-## 3. 通用模型与 LLM 变量
-
-| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 |
-| --- | --- | --- | --- | --- | --- | --- | --- | --- |
-| `OPENAI_API_KEY` | 本地运行时 / Runtime 镜像 / OpenClaw / Hermes | 条件必传 | 未设置 | `LLM_API_KEY`、`MODEL_API_KEY`、部分 OpenClaw 场景使用 `OPENCLAW_MODEL_API_KEY` | 是 | 开发者 / Secret | 否 | OpenAI 兼容接口 API key。 |
-| `OPENAI_BASE_URL` | 本地运行时 / Runtime 镜像 / OpenClaw / Hermes | 条件必传 | 未设置 | `OPENAI_API_BASE`、`LLM_API_BASE`、`MODEL_API_BASE`、部分 OpenClaw 场景使用 `OPENCLAW_MODEL_BASE_URL` | 否 | 开发者 / 平台 | 否 | OpenAI 兼容接口 base url。 |
-| `OPENAI_MODEL_NAME` | 本地运行时 / Runtime 镜像 | 条件必传 | 未设置 | `LLM_MODEL`、`MODEL_NAME`、Hermes fallback 读取 `OPENAI_FALLBACK_MODEL_NAME` | 否 | 开发者 / 平台 | 否 | 默认模型名。 |
-| `OPENAI_CONTEXT_LENGTH` | Hermes / 模型配置 | 否 | 未设置 | `MODEL_CONTEXT_LENGTH`、`HERMES_CONTEXT_LENGTH` | 否 | 开发者 / 平台 | 否 | 模型上下文长度提示。 |
-| `OPENAI_FALLBACK_MODEL_NAME` | Hermes / 模型配置 | 否 | 未设置 | `HERMES_FALLBACK_MODEL` | 否 | 开发者 / 平台 | 否 | Hermes fallback 模型名 fallback。 |
-| `LLM_API_KEY` | Serverless / 兼容模型配置 | 条件必传 | 未设置 | `OPENAI_API_KEY`、`MODEL_API_KEY` | 是 | 平台 Secret / 开发者 | 否 | Serverless 平台兼容模型 API key。 |
-| `LLM_API_BASE` | Serverless / 兼容模型配置 | 条件必传 | 未设置 | `OPENAI_BASE_URL`、`MODEL_API_BASE` | 否 | 平台 / 开发者 | 否 | Serverless 平台兼容模型 endpoint。 |
-| `LLM_MODEL` | Serverless / OpenClaw | 条件必传 | 未设置 | `OPENAI_MODEL_NAME`、`MODEL_NAME` | 否 | 平台 / 开发者 | 否 | Serverless/OpenClaw 兼容模型名。 |
-| `MODEL_API_KEY` | OpenClaw / 兼容模型配置 | 条件必传 | 未设置 | `OPENAI_API_KEY` | 是 | 开发者 / Secret | 否 | 兼容 OpenClaw 模型配置。 |
-| `MODEL_API_BASE` | OpenClaw / 兼容模型配置 | 条件必传 | 未设置 | `OPENAI_BASE_URL` | 否 | 开发者 / 平台 | 否 | 兼容 OpenClaw 模型 endpoint。 |
-| `MODEL_BASE_URL` | CLI model / 兼容模型配置 | 否 | 未设置 | `OPENAI_BASE_URL`、`OPENAI_API_BASE`、`MODEL_API_BASE` | 否 | 开发者 / 平台 | 否 | 部分 CLI model 命令和历史配置读取的 base url。 |
-| `MODEL_NAME` | 本地运行时 / OpenClaw | 条件必传 | 未设置 | `OPENAI_MODEL_NAME` | 否 | 开发者 / 平台 | 否 | 旧版模型名变量。 |
-| `COZE_WORKLOAD_IDENTITY_API_KEY` | Coze 导出项目兼容 | 条件必传 | 未设置 | 未设置时可由 `OPENAI_API_KEY` 自动补齐 | 是 | 开发者 / Secret | 否 | 部分 Coze 导出项目依赖 `coze_coding_dev_sdk`,SDK 会尝试从 OpenAI 兼容配置补齐。 |
-| `COZE_INTEGRATION_BASE_URL` | Coze 导出项目兼容 | 条件必传 | 未设置 | 未设置时可由 `OPENAI_BASE_URL` 自动补齐 | 否 | 开发者 / 平台 | 否 | Coze integration endpoint。 |
-| `COZE_INTEGRATION_MODEL_BASE_URL` | Coze 导出项目兼容 | 条件必传 | 未设置 | 未设置时可由 `OPENAI_BASE_URL` 自动补齐 | 否 | 开发者 / 平台 | 否 | Coze model endpoint。 |
-| `COZE_MODEL_NAME` | Coze 导出项目兼容 | 条件必传 | 未设置 | 通常跟随业务导出项目 | 否 | 开发者 / 平台 | 否 | Coze 导出项目模型名。 |
-
-## 4. 金山云账号、KOP、KS3 与镜像仓库
-
-| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 |
-| --- | --- | --- | --- | --- | --- | --- | --- | --- |
-| `KSYUN_ACCESS_KEY` | CLI / KOP / KS3 / Skill Service fallback | 条件必传 | 未设置 | `KS3_ACCESS_KEY` | 是 | 开发者 / CI Secret / K8S Secret | 否 | 金山云 AK。启用云端资源操作、KS3、KOP 签名时需要。 |
-| `KSYUN_SECRET_KEY` | CLI / KOP / KS3 / Skill Service fallback | 条件必传 | 未设置 | `KS3_SECRET_KEY` | 是 | 开发者 / CI Secret / K8S Secret | 否 | 金山云 SK。 |
-| `KSYUN_ACCOUNT_ID` | CLI / KOP / 权限预检查 / Skill Service fallback | 条件必传 | 未设置 | 无 | 否 | 平台账号 / 开发者 | 否 | 账号 ID。资源管理、租户隔离、个人版 KCR 用户名兜底等场景需要。 |
-| `KSYUN_REGION` | CLI / KOP / KS3 / Skill Service fallback | 否 | `cn-beijing-6` | 无 | 否 | 开发者 / 平台 | 否 | 区域。跨环境、预发、生产联调建议显式设置。 |
-| `KS_ACCESS_KEY_ID` | 旧 KingsoftCloudConfig | 条件必传 | 未设置 | 建议迁移到 `KSYUN_ACCESS_KEY` | 是 | 兼容旧配置 | 否 | 早期 SDK settings 读取的 AK;不与 `KSYUN_ACCESS_KEY` 自动互通。 |
-| `KS_SECRET_ACCESS_KEY` | 旧 KingsoftCloudConfig | 条件必传 | 未设置 | 建议迁移到 `KSYUN_SECRET_KEY` | 是 | 兼容旧配置 | 否 | 早期 SDK settings 读取的 SK;不与 `KSYUN_SECRET_KEY` 自动互通。 |
-| `KS_REGION` | 旧 KingsoftCloudConfig | 否 | `cn-beijing-6` | 建议迁移到 `KSYUN_REGION` | 否 | 兼容旧配置 | 否 | 早期 SDK settings 读取的 region;不与 `KSYUN_REGION` 自动互通。 |
-| `KS3_ACCESS_KEY` | KS3 / 兼容 fallback | 条件必传 | 未设置 | `KSYUN_ACCESS_KEY` | 是 | 开发者 / Secret | 否 | KS3 专用 AK 兼容变量。 |
-| `KS3_SECRET_KEY` | KS3 / 兼容 fallback | 条件必传 | 未设置 | `KSYUN_SECRET_KEY` | 是 | 开发者 / Secret | 否 | KS3 专用 SK 兼容变量。 |
-| `KS3_BUCKET` | 构建上传 / 版本发布 | 条件必传 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | 自定义 KS3 bucket。 |
-| `KS3_ENDPOINT_MODE` | KS3 上传 | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | KS3 endpoint 选择策略。 |
-| `KS3_ENDPOINT_PROBE_TIMEOUT_SECONDS` | KS3 上传 | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | KS3 endpoint 探测超时。 |
-| `KS3_UPLOAD_TIMEOUT_SECONDS` | KS3 上传 | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | KS3 上传超时。 |
-| `KCR_REGISTRY` | 镜像构建 / MCP / Serverless | 条件必传 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | 镜像仓库地址,通常为 `/`,例如 `agenthzzqy-vpc.ksyunkcr.com/testagent-pub` 或第三方 registry/namespace。 |
-| `KCR_ENDPOINT` | 镜像构建 / MCP / Serverless | 否 | `hub.kce.ksyun.com` | 无 | 否 | 开发者 / 平台 | 否 | KCR endpoint。 |
-| `KCR_USERNAME` | 镜像构建 / MCP / Serverless | 条件必传 | 未设置 | 个人版 KCR 可回退 `KSYUN_ACCOUNT_ID` | 否 | 开发者 / 平台 | 否 | 镜像仓库访问凭证用户名。企业版 KCR 和第三方镜像仓库必须显式设置;个人版 KCR 可留空并使用 `KSYUN_ACCOUNT_ID` 作为用户名兜底。 |
-| `KCR_PASSWORD` | 镜像构建 / MCP / Serverless | 条件必传 | 未设置 | 无 | 是 | 开发者 / Secret | 否 | 镜像仓库访问凭证密码或 token。 |
-
-## 5. 通用 Sandbox Runtime
-
-| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 |
-| --- | --- | --- | --- | --- | --- | --- | --- | --- |
-| `KSADK_SANDBOX_BACKEND` | Sandbox backend factory | 否 | `e2b` | 无 | 否 | 平台 / 开发者 | 否 | 通用 sandbox backend。首版支持 `e2b`。 |
-| `KSADK_SANDBOX_TYPE` | Sandbox spec | 否 | `aio` | 无 | 否 | 沙箱控制台 / 平台 | 否 | `aio/code/browser/private`。Skill Runtime 默认推荐 `aio`。 |
-| `KSADK_SANDBOX_TEMPLATE_ID` | Sandbox spec / Skill Runtime E2B backend | 条件必传 | 未设置 | `KSADK_SKILL_RUNTIME_TEMPLATE_ID` | 否 | 沙箱控制台 / 沙箱团队 | 否 | 远程 sandbox 执行时必传。新部署优先使用。 |
-| `KSADK_SANDBOX_TIMEOUT` | Sandbox spec | 否 | `900` | `KSADK_SKILL_RUNTIME_TIMEOUT` | 否 | 平台 / 开发者 | 否 | Sandbox 会话超时秒数。 |
-| `KSADK_SANDBOX_ALLOW_INTERNET_ACCESS` | Sandbox spec | 否 | `true` | `KSADK_SKILL_RUNTIME_ALLOW_INTERNET_ACCESS` | 否 | 平台 / 开发者 | 否 | 是否允许 sandbox 出网。 |
-| `KSADK_SANDBOX_STARTUP_RETRY_ATTEMPTS` | E2B Sandbox backend | 否 | `6` | 无 | 否 | 平台 / 开发者 | 否 | 沙箱创建后 readiness 探测最大重试次数,用于兜底短暂 `NotFoundException` / `FileNotFoundException`。 |
-| `KSADK_SANDBOX_STARTUP_RETRY_DELAY` | E2B Sandbox backend | 否 | `0.2` | 无 | 否 | 平台 / 开发者 | 否 | 沙箱 readiness 首次重试间隔秒数,后续指数退避,单次 sleep 上限 1 秒。 |
-| `E2B_API_URL` | E2B SDK | 条件必传 | 未设置 | 无 | 否 | 沙箱团队 / Secret 配置 | 否 | E2B 兼容 manager endpoint。使用 E2B backend 时必传。 |
-| `E2B_API_KEY` | E2B SDK | 条件必传 | 未设置 | 无 | 是 | 沙箱团队 / Secret 配置 | 否 | E2B API key。严禁写入代码、文档明文、测试 fixture、日志。 |
-
-## 6. Skill Runtime 与 Skill Center
-
-| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 |
-| --- | --- | --- | --- | --- | --- | --- | --- | --- |
-| `KSADK_SKILLS_MODE` | ADK Runner | 否 | `auto` | 无 | 否 | 开发者 / 平台 | 否 | `auto/local/sandbox`。`auto` 会根据 sandbox template 或本地 skill 目录自动选择。 |
-| `KSADK_LOCAL_SKILLS_DIR` | ADK Runner / Runtime agent | 条件必传 | 未设置 | `KSADK_SKILL_CACHE_DIR` 可作为 Runner 本地扫描 fallback | 否 | 开发者 | 否 | 本地 skill 目录。 |
-| `KSADK_SKILL_RUNTIME_BACKEND` | Skill Runtime factory | 否 | `disabled`;未设置且存在 `KSADK_SANDBOX_TEMPLATE_ID` 时自动走 `e2b` | 无 | 否 | 开发者 / 平台 | 否 | `disabled/local_process/e2b`。显式 `disabled` 会阻止自动注入。 |
-| `KSADK_SKILL_RUNTIME_TEMPLATE_ID` | Skill Runtime E2B backend | 否 | 未设置 | `KSADK_SANDBOX_TEMPLATE_ID` | 否 | 旧部署 / 兼容 | 否 | 兼容变量。新部署不要优先使用。 |
-| `KSADK_SKILL_RUNTIME_TIMEOUT` | Skill Runtime command | 否 | `900` | `KSADK_SANDBOX_TIMEOUT` 在 E2B 会话层优先 | 否 | 开发者 / 平台 | 否 | workflow 命令超时秒数。 |
-| `KSADK_SKILL_RUNTIME_ALLOW_INTERNET_ACCESS` | Skill Runtime E2B backend | 否 | `true` | `KSADK_SANDBOX_ALLOW_INTERNET_ACCESS` 优先 | 否 | 旧部署 / 兼容 | 否 | 兼容变量。 |
-| `KSADK_SKILL_RUNTIME_AGENT_PATH` | local_process backend | 条件必传 | SDK 内置 `ksadk/skills/runtime/agent.py` | 无 | 否 | 开发者 | 否 | 本地进程 backend 的 agent 路径。 |
-| `KSADK_SKILL_SERVICE_URL` | Runtime agent / Skill Service client | 条件必传 | 未设置 | 无 | 否 | Skill Service / 平台 | 否 | 配置后从 Skill Center 拉取技能。支持直连 REST 和 AICP KOP endpoint。 |
-| `KSADK_SKILL_SERVICE_ENDPOINT` | Runtime agent / AICP resolver | 否 | 按 `KSADK_AICP_ENDPOINT_MODE` 自动选择 | 无 | 否 | Skill Service / 平台 | 否 | 未设置 `KSADK_SKILL_SERVICE_URL` 时覆盖 Skill Service AICP endpoint。 |
-| `KSADK_SKILL_SERVICE_SCHEME` | Runtime agent / AICP resolver | 否 | 内网 endpoint 为 `http`,公网默认 `https` | 无 | 否 | Skill Service / 平台 | 否 | 未设置 `KSADK_SKILL_SERVICE_URL` 时覆盖 Skill Service AICP URL scheme。 |
-| `KSADK_SKILL_SPACE_IDS` | Runner / Runtime agent | 条件必传 | 未设置 | `SKILL_SPACE_ID` | 否 | Agent 创建/更新 / 平台注入 | 否 | 逗号分隔 Skill Space id。 |
-| `KSADK_PUBLIC_SKILL_ALLOWLIST` | Runtime agent | 否 | 未设置 | 无 | 否 | 平台 / Skill Service | 否 | 逗号分隔 public skill 名称白名单;未设置时加载 public space 下全部 active skills。 |
-| `KSADK_PUBLIC_SKILL_SPACE_IDS` | Runner / Runtime agent | 否 | 未设置 | 无 | 否 | 平台 / Skill Service | 否 | 逗号分隔官方公共 Skill Space id,会追加在用户 space 之后。 |
-| `SKILL_SPACE_ID` | Runtime agent / 兼容 | 条件必传 | 未设置 | `KSADK_SKILL_SPACE_IDS` | 否 | 旧部署 / 单 space 注入 | 否 | 单 space 兼容变量。 |
-| `KSADK_SKILL_SERVICE_ACCOUNT_ID` | Skill Service client | 条件必传 | 未设置 | `KSYUN_ACCOUNT_ID` | 否 | 平台租户上下文 | 否 | 租户隔离 account id。 |
-| `KSADK_SKILL_SERVICE_ACCESS_KEY` | Skill Service KOP signing | 条件必传 | 未设置 | `KSYUN_ACCESS_KEY`、`KS3_ACCESS_KEY` | 是 | Secret | 否 | AICP KOP endpoint 签名 AK。 |
-| `KSADK_SKILL_SERVICE_SECRET_KEY` | Skill Service KOP signing | 条件必传 | 未设置 | `KSYUN_SECRET_KEY`、`KS3_SECRET_KEY` | 是 | Secret | 否 | AICP KOP endpoint 签名 SK。 |
-| `KSADK_SKILL_SERVICE_TOKEN` | Skill Service bearer auth | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | Bearer token 模式。 |
-| `KSADK_SKILL_SERVICE_REGION` | Skill Service KOP signing | 否 | `cn-beijing-6` | `KSYUN_REGION` | 否 | 平台 / 开发者 | 否 | KOP 签名 region。 |
-| `KSADK_SKILL_SERVICE_API_VERSION` | Skill Service KOP action | 否 | `2024-06-12` | 无 | 否 | Skill Service / 平台 | 否 | Skill Center KOP API 版本。 |
-| `KSADK_SKILL_SERVICE_SIGN_SERVICE` | Skill Service KOP signing | 否 | `aicp` | 无 | 否 | Skill Service / 平台 | 否 | KOP signing service。 |
-| `KSADK_SKILL_MANIFEST_LIMIT` | ADK Runner | 否 | `30` | 无 | 否 | 平台 / 开发者 | 否 | 外层 Agent instruction 最多注入的远端 skill manifest 数量。 |
-| `KSADK_SKILL_MANIFEST_TIMEOUT` | Skill Service client | 否 | `5` | 无 | 否 | 平台 / 开发者 | 否 | 拉取远端 skill manifest 的超时秒数。 |
-| `KSADK_SELECTED_SKILL_NAMES` | Runtime agent | 否 | 未设置 | 无 | 否 | Runner / Runtime agent | 否 | `execute_skills` 选中的 skill 名称列表,Runtime agent 优先按它下载。 |
-| `KSADK_SKILL_ALLOW_HASH_MISMATCH` | Runtime agent / PackageStore | 否 | `false` | 无 | 否 | 调试 / 兼容旧包 | 否 | 允许 ContentHash 校验失败后以 unverified cache 加载旧 skill 包;生产不建议开启。 |
-| `KSADK_SKILL_CACHE_DIR` | Runtime agent / PackageStore | 否 | 系统临时目录下 `ksadk-skill-cache` | 无 | 否 | Runtime agent | 否 | Skill archive 下载与解压缓存。 |
-| `KSADK_SKILL_WORKDIR` | Runtime agent | 否 | 系统临时目录下 `ksadk-skill-workflow` | 无 | 否 | Runtime agent | 否 | workflow 工作目录。 |
-| `KSADK_SKILL_OUTPUT_DIR` | Runtime agent workflow | 否 | `KSADK_SKILL_WORKDIR/artifacts` | 无 | 否 | Runtime agent | 否 | 传给本地 skill workflow 脚本的产物输出目录。 |
-| `KSADK_SKILL_ROOT_DIR` | Runtime agent workflow | 否 | 当前执行 skill 根目录 | 无 | 否 | Runtime agent | 否 | 传给本地 skill workflow 脚本的 skill 根目录。 |
-| `KSADK_SKILL_ARTIFACT_PROJECT` | Runtime agent | 否 | `ksadk-artifact` | 无 | 否 | Runtime agent | 否 | 最小 artifact workflow 项目目录名。 |
-| `KSADK_WORKFLOW_PROMPT` | Runtime agent workflow | 否 | 当前 workflow prompt | 无 | 否 | Runtime agent | 否 | 传给本地 skill workflow 脚本的用户请求文本。 |
-
-## 7. MCP Runtime
-
-| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 |
-| --- | --- | --- | --- | --- | --- | --- | --- | --- |
-| `KSADK_ENABLE_MCP_TOOLS` | ADK Runner | 否 | `1` | 无 | 否 | 开发者 / 平台 | 否 | 控制远端 MCP tools 自动注入。 |
-| `KSADK_MCP_SERVERS` | MCP runtime | 条件必传 | 未设置 | 无 | 是 | 开发者 / 平台 Secret | 否 | JSON 数组。可能包含 MCP server api_key。 |
-
-## 8. 会话、短期记忆和长期记忆
-
-| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 |
-| --- | --- | --- | --- | --- | --- | --- | --- | --- |
-| `KSADK_SESSION_BACKEND` | Sessions | 否 | `local` | `AGENTENGINE_SESSION_BACKEND`、`KSADK_STM_BACKEND` | 否 | 开发者 / 平台 | 否 | 会话存储 backend。ADK/STM 也会把它作为兜底。 |
-| `KSADK_SESSION_DSN` | Sessions | 条件必传 | 未设置 | `KSADK_STM_URL`、`KSADK_STM_DB_URL`、`KSADK_ADK_SESSION_URL` | 是 | Secret | 否 | PostgreSQL DSN。`postgres` / `database` backend 时必传。ADK/STM 也会把它作为兜底。 |
-| `KSADK_SESSION_PATH` | Sessions | 否 | 项目目录下本地 sqlite 路径 | `KSADK_STM_PATH`、`KSADK_STM_DB_PATH` | 否 | 开发者 / 本地运行时 | 否 | 本地 SQLite 会话路径。 |
-| `KSADK_SESSION_CONNECT_TIMEOUT` | Sessions | 否 | `5` | `KSADK_SESSION_PG_CONNECT_TIMEOUT` | 否 | 开发者 / 平台 | 否 | PostgreSQL 会话 backend 连接超时秒数。 |
-| `KSADK_SESSION_PG_CONNECT_TIMEOUT` | Sessions 旧兼容 | 否 | `5` | `KSADK_SESSION_CONNECT_TIMEOUT` | 否 | 兼容旧部署 | 否 | 旧 PostgreSQL session 连接超时变量。新部署优先 `KSADK_SESSION_CONNECT_TIMEOUT`。 |
-| `KSADK_SESSION_NAMESPACE` | Sessions | 否 | 未设置 | `KSADK_WORKSPACE_ID`、`AGENTENGINE_WORKSPACE_ID`、`KSADK_TENANT_ID`、`AGENTENGINE_TENANT_ID` | 否 | 平台 | 否 | 会话 namespace。 |
-| `KSADK_CHECKPOINT_BACKEND` | LangGraph checkpoint | 否 | `local` | `local` 等价本地 SQLite;也支持 `sqlite`、`memory`、`postgres` | 否 | 开发者 / 平台 | 否 | LangGraph checkpoint backend。`agentengine web` 本地调试默认优先使用 SQLite。 |
-| `KSADK_CHECKPOINT_PATH` | LangGraph checkpoint | 否 | 项目目录下 `.agentengine/ui/checkpoints.sqlite` | 无 | 否 | 开发者 / 本地运行时 | 否 | 本地 SQLite checkpoint 文件路径。 |
-| `KSADK_LANGGRAPH_CHECKPOINT_DSN` | LangGraph checkpoint | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | `KSADK_CHECKPOINT_BACKEND=postgres` 时的 LangGraph checkpointer PostgreSQL DSN。 |
-| `KSADK_TENANT_ID` | Sessions | 否 | 未设置 | `AGENTENGINE_TENANT_ID` | 否 | 平台 | 否 | 租户 id。 |
-| `KSADK_WORKSPACE_ID` | Sessions | 否 | 未设置 | `AGENTENGINE_WORKSPACE_ID` | 否 | 平台 | 否 | workspace id。 |
-| `KSADK_STM_BACKEND` | 旧 STM / Sessions fallback | 否 | 未设置 | `KSADK_SESSION_BACKEND` | 否 | 兼容旧部署 | 否 | 旧变量。新部署优先 `KSADK_SESSION_BACKEND`,但 ADK/STM 仍可读。 |
-| `KSADK_STM_PATH` | 旧 STM / Sessions fallback | 否 | 未设置 | `KSADK_SESSION_PATH` | 否 | 兼容旧部署 | 否 | 旧变量。 |
-| `KSADK_STM_DB_PATH` | 旧 STM / Sessions fallback | 否 | 未设置 | `KSADK_SESSION_PATH` | 否 | 兼容旧部署 | 否 | 旧变量。 |
-| `KSADK_STM_URL` | 旧 STM / Sessions fallback | 条件必传 | 未设置 | `KSADK_SESSION_DSN` | 是 | 兼容旧部署 | 否 | 旧变量。ADK/STM 仍可读。 |
-| `KSADK_STM_DB_URL` | 旧 STM / Sessions fallback | 条件必传 | 未设置 | `KSADK_SESSION_DSN` | 是 | 兼容旧部署 | 否 | 旧变量。ADK/STM 仍可读。 |
-| `KSADK_ADK_SESSION_BACKEND` | ADK Memory | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | ADK 原生 session backend。 |
-| `KSADK_ADK_SESSION_PATH` | ADK Memory | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | ADK 原生 session sqlite 路径。 |
-| `KSADK_ADK_SESSION_URL` | ADK Memory | 条件必传 | 未设置 | `KSADK_SESSION_DSN` | 是 | Secret | 否 | ADK 原生 session 数据库 URL。统一 session DSN 也可兜底。 |
-| `KSADK_MEMORY_BACKEND` | MemoryManager | 否 | `memory` | 无 | 否 | 开发者 / 平台 | 否 | 轻量 KV/消息历史 backend。当前内置 `memory`,注册 Redis backend 后可用 `redis`。 |
-| `KSADK_MEMORY_URL` | MemoryManager | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | 远端 MemoryManager backend 连接 URL,例如 Redis URL。 |
-| `KSADK_MEMORY_PREFIX` | MemoryManager | 否 | `ksadk:memory:` | 无 | 否 | 开发者 / 平台 | 否 | MemoryManager key prefix。 |
-| `KSADK_MEMORY_TTL` | MemoryManager | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | MemoryManager 默认 TTL 秒数。 |
-| `KSADK_LTM_BACKEND` | Long-term memory | 否 | `local` | 无 | 否 | 开发者 / 平台 | 否 | LTM backend。 |
-| `KSADK_LTM_HTTP_URL` | HTTP LTM | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | HTTP LTM URL。 |
-| `KSADK_LTM_HTTP_TOKEN` | HTTP LTM | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | HTTP LTM token。 |
-| `KSADK_LTM_ACCESS_KEY` | SDK LTM | 条件必传 | 未设置 | `KSYUN_ACCESS_KEY` | 是 | Secret | 否 | SDK LTM AK。 |
-| `KSADK_LTM_SECRET_KEY` | SDK LTM | 条件必传 | 未设置 | `KSYUN_SECRET_KEY` | 是 | Secret | 否 | SDK LTM SK。 |
-| `KSADK_LTM_REGION` | SDK LTM | 否 | `cn-beijing-6` | 无 | 否 | 平台 / 开发者 | 否 | SDK LTM region。 |
-| `KSADK_LTM_ENDPOINT` | SDK LTM | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | SDK LTM endpoint。 |
-| `KSADK_LTM_SCHEME` | SDK LTM | 否 | `https` | 无 | 否 | 平台 / 开发者 | 否 | SDK LTM scheme。 |
-| `KSADK_LTM_INDEX` | LTM | 否 | 未设置 | 无 | 否 | 开发者 | 否 | LTM index。 |
-| `KSADK_LTM_NAMESPACE` | LTM | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | LTM 记忆库 ID;环境变量名保持不变,请填写新版 SDK 的 `MemoryCollectionId`。 |
-| `KSADK_LTM_AGENT_ID` | LTM | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | LTM agent id。 |
-| `KSADK_LTM_SCENE_ID` | LTM | 否 | `_sys_general` | 无 | 否 | 平台 / 开发者 | 否 | LTM scene id;新版记忆库保存必传,未设置时使用通用场景 `_sys_general`。 |
-| `KSADK_LTM_APP_NAME` | LTM | 否 | 未设置 | 无 | 否 | 开发者 | 否 | LTM application name 覆盖。 |
-| `KSADK_LTM_TOP_K` | LTM | 否 | `5` | 无 | 否 | 开发者 | 否 | LTM 返回条数。 |
-| `KSADK_LTM_AUTO_SAVE` | Conversations runtime | 否 | SDK LTM 已绑定时为 `true` | 无 | 否 | 平台 / 开发者 | 否 | 是否在每轮完成后 best-effort 镜像 user/assistant 文本到记忆库。只接受布尔语义:`true/false`、`1/0`、`on/off`。 |
-| `KSADK_LTM_AMBIENT_ENABLED` | Conversations runtime | 否 | `true` | 无 | 否 | 平台 / 开发者 | 否 | 是否允许 runtime 自动加载长期记忆上下文。 |
-| `KSADK_LTM_AMBIENT_POLICY` | Conversations runtime | 否 | `on_demand` | 无 | 否 | 平台 / 开发者 | 否 | 长期记忆 ambient context 策略:`on_demand/always/disabled`。 |
-| `MEM0_API_KEY` | OpenClaw memory backend | 条件必传 | 未设置 | 无 | 是 | 平台 Secret | 否 | 选择 `mem0` memory backend manifest 时需要。 |
-| `MEM0_USER_ID` | OpenClaw memory backend | 条件必传 | 未设置 | 无 | 否 | 平台 / 用户上下文 | 否 | 选择 `mem0` memory backend manifest 时需要。 |
-| `MEM0_BASE_URL` | OpenClaw memory backend | 条件必传 | 未设置 | 无 | 否 | 平台 | 否 | 选择 `mem0` memory backend manifest 时需要。 |
-
-## 9. 知识库
-
-| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 |
-| --- | --- | --- | --- | --- | --- | --- | --- | --- |
-| `KSADK_KB` | Knowledge base | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | AICP knowledge-base 连接配置前缀。 |
-| `KSADK_KB_DATASET_ID` | Knowledge base | 条件必传 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 存在时启用知识库。 |
-| `KSADK_KB_ACCESS_KEY` | Knowledge base | 条件必传 | 未设置 | `KSYUN_ACCESS_KEY` | 是 | Secret | 否 | KB AK。 |
-| `KSADK_KB_SECRET_KEY` | Knowledge base | 条件必传 | 未设置 | `KSYUN_SECRET_KEY` | 是 | Secret | 否 | KB SK。 |
-| `KSADK_KB_ENDPOINT` | Knowledge base | 否 | `aicp.api.ksyun.com` | 无 | 否 | 平台 / 开发者 | 否 | KB endpoint。 |
-| `KSADK_KB_REGION` | Knowledge base | 否 | `cn-beijing-6` | 无 | 否 | 平台 / 开发者 | 否 | KB region。 |
-| `KSADK_KB_SCHEME` | Knowledge base | 否 | 内网 endpoint 默认 `http`,其他默认 `https` | 无 | 否 | 平台 / 开发者 | 否 | KB endpoint scheme。 |
-| `KSADK_KB_SEARCH_METHOD` | Knowledge base | 否 | `intelligence_search` | 无 | 否 | 开发者 | 否 | 检索方法。 |
-| `KSADK_KB_TOP_K` | Knowledge base | 否 | `5` | 无 | 否 | 开发者 | 否 | 返回条数。 |
-| `KSADK_KB_SCORE_THRESHOLD` | Knowledge base | 否 | `0.0` | 无 | 否 | 开发者 | 否 | 分数阈值。 |
-| `KSADK_KB_RERANKING_ENABLE` | Knowledge base | 否 | `false` | 无 | 否 | 开发者 | 否 | 是否启用 reranking。 |
-| `KSADK_KB_AMBIENT_ENABLED` | Conversations runtime | 否 | `true` | 无 | 否 | 平台 / 开发者 | 否 | 是否允许 runtime 自动加载知识库上下文。 |
-| `KSADK_KB_AMBIENT_POLICY` | Conversations runtime | 否 | `on_demand` | 无 | 否 | 平台 / 开发者 | 否 | 知识库 ambient context 策略:`on_demand/always/disabled`。 |
-| `KSYUN_SECRET_ID` | Knowledge base fallback | 否 | 未设置 | `KSYUN_ACCESS_KEY` | 是 | 兼容旧配置 | 否 | 代码中作为 KB AK 的旧 fallback,建议使用 `KSADK_KB_ACCESS_KEY` 或 `KSYUN_ACCESS_KEY`。 |
-
-## 10. CLI、构建、部署和 UI 行为
-
-| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 |
-| --- | --- | --- | --- | --- | --- | --- | --- | --- |
-| `AGENTENGINE_SERVER_URL` | CLI / API client | 否 | 自动探测:优先 `http://aicp.inner.api.ksyun.com`,不可达时回落 `https://aicp.api.ksyun.com` | 无 | 否 | 平台 / 开发者 | 否 | 覆盖 AgentEngine Server 地址。内部账号/内网环境建议显式设为 `http://aicp.inner.api.ksyun.com`;公网账号通常不设置或使用 `https://aicp.api.ksyun.com`。如果公网 AICP 返回 `InnerAccountCanOnlyAccessThroughIntranet`,客户端会自动切内网重试一次。 |
-| `AGENTENGINE_API_VERSION` | CLI / API client | 否 | 内置版本 | 无 | 否 | 平台 / 开发者 | 否 | 覆盖 AgentEngine API version。 |
-| `AGENTENGINE_PRE_CONTROL_REGION` | CLI / API client | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 预发控制面 region 覆盖。 |
-| `AGENTENGINE_PRE_CUSTOM_SOURCE` | CLI / API client | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 预发 custom source 覆盖。 |
-| `KSADK_AICP_ENDPOINT_MODE` | AICP resolver | 否 | `auto` | 无 | 否 | 平台 / 开发者 | 否 | AICP endpoint 选择策略,支持 `auto/detect/internal/inner/public`。内网环境可显式设为 `inner`,跳过自动探测。 |
-| `AGENTENGINE_MODEL_ALLOWLIST` | CLI model / OpenClaw | 否 | 未设置 | `OPENCLAW_MODEL_ALLOWLIST` | 否 | 平台 / 开发者 | 否 | 模型列表过滤。OpenClaw 场景优先使用 `OPENCLAW_MODEL_ALLOWLIST`。 |
-| `AGENTENGINE_UI_DIR` | 本地 Web UI / Sessions | 否 | 未设置 | 无 | 否 | 本地开发者 | 否 | 本地 UI 静态目录覆盖,主要用于 Web/文件上传本地调试。 |
-| `KSADK_UI_PROFILE` | 本地 Web UI / Runtime bootstrap | 否 | `builtin` | 无 | 否 | 开发者 / 平台 | 否 | Agent UI profile。`custom` 时 runtime 会暴露自定义 UI bootstrap 信息。 |
-| `KSADK_UI_PATH` | 本地 Web UI / Runtime bootstrap | 否 | `/` | 无 | 否 | 开发者 / 平台 | 否 | 自定义 UI 挂载路径,例如 `/research`。 |
-| `KSADK_UI_URL` | Runtime bootstrap | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 外部自定义 UI URL。 |
-| `KSADK_UI_BUNDLE_PATH` | Runtime bootstrap | 否 | 自动探测 `research-ui/dist` | 无 | 否 | 开发者 / 平台 | 否 | 自定义 UI 静态 bundle 相对项目路径。 |
-| `KSADK_WEB_VERSION` | Hosted Web UI static sync | 否 | `latest` | 可显式设置 `0.2.7` / `v0.2.7` | 否 | 构建环境 / 开发者 | 否 | `make sync-ksadk-web-static` 使用的 `@kingsoftcloud/ksadk-web` npm dist-tag 或版本,默认消费最新 release。 |
-| `KSADK_WEB_PACKAGE` | Hosted Web UI static sync | 否 | `@kingsoftcloud/ksadk-web` | 无 | 否 | 构建环境 / 开发者 | 否 | 本地 UI static 同步使用的 npm 包名。 |
-| `KSADK_WEB_TARBALL_NAME` | Hosted Web UI static sync | 否 | 根据 `KSADK_WEB_VERSION` 派生 | 无 | 否 | 构建环境 | 否 | 仅在设置 `KSADK_WEB_RELEASE_URL` 时作为下载保存文件名;npm pack 模式会使用 npm 返回的真实 tarball 文件名。 |
-| `KSADK_WEB_RELEASE_URL` | Hosted Web UI static sync | 否 | 未设置 | 无 | 否 | 构建环境 / 开发者 | 否 | 可选兼容兜底。设置后跳过 npm pack,改从该 tarball URL 下载。 |
-| `KSADK_WEB_CACHE_DIR` | Hosted Web UI static sync | 否 | `.cache/ksadk-web` | 无 | 否 | 构建环境 / 开发者 | 否 | KsADK Web 包解压缓存目录。 |
-| `KSADK_GLOBAL_CONFIG_ENV_KEYS` | CLI | 否 | 未设置 | 无 | 否 | CLI 内部 | 否 | CLI 启动时记录哪些环境变量由 `~/.agentengine/settings.json` 补入,用于区分用户显式环境变量和全局配置默认值。 |
-| `AGENTENGINE_LOCAL_RUNTIME_VENV_REEXEC` | 本地 runtime CLI | 否 | 自动判断 | 无 | 否 | 本地开发者 / 测试 | 否 | 控制本地 runtime 是否在虚拟环境中 re-exec。普通用户通常无需设置。 |
-| `AGENTENGINE_WEB_VENV_REEXEC` | 本地 Web CLI | 否 | 自动判断 | 无 | 否 | 本地开发者 / 测试 | 否 | 控制本地 Web 命令是否在虚拟环境中 re-exec。普通用户通常无需设置。 |
-| `AGENTENGINE_DEBUG` | CLI | 否 | 未设置 | 无 | 否 | 开发者 | 否 | 开启更详细错误输出。 |
-| `AGENTENGINE_GLOBAL_DRY_RUN` | CLI / API client | 否 | 未设置 | 无 | 否 | 开发者 / 测试 | 否 | 全局 dry-run 开关。 |
-| `AGENTENGINE_OUTPUT_MODE` | CLI | 否 | `pretty` | 无 | 否 | 开发者 / CI | 否 | 输出模式,影响 JSON/pretty 渲染。 |
-| `AGENTENGINE_NO_COLOR` | CLI | 否 | 未设置 | `NO_COLOR` | 否 | 开发者 / CI | 否 | 禁用彩色输出。 |
-| `SESSION_TITLE_MODEL` | Conversations runtime | 否 | 未设置 | 默认模型配置 | 否 | 开发者 / 平台 | 否 | 会话标题生成模型覆盖。 |
-| `COMPACTION_DISABLE_SEMANTIC` | Conversations runtime | 否 | `false` | 无 | 否 | 开发者 / 平台 | 否 | 禁用语义压缩摘要。 |
-| `COMPACTION_SUMMARY_TIMEOUT_MS` | Conversations runtime | 否 | `45000` | 无 | 否 | 开发者 / 平台 | 否 | 语义压缩摘要超时毫秒数。 |
-| `COMPACTION_SUMMARY_MAX_GROUPS` | Conversations runtime | 否 | `12` | 无 | 否 | 开发者 / 平台 | 否 | 单次语义压缩最大分组数。 |
-| `COMPACTION_SUMMARY_MODEL` | Conversations runtime | 否 | 默认模型配置 | 无 | 否 | 开发者 / 平台 | 否 | 语义压缩摘要模型覆盖。 |
-| `PORT` | Runtime image / Web | 否 | `8080` | `KSADK_RUNTIME_PORT` 在部分模板中转写 | 否 | 平台 / Runtime 镜像 | 否 | 容器监听端口。业务服务也可能读取同名变量;此时属于业务自定义。 |
-| `HOST` | MCP / Web runtime | 否 | `0.0.0.0` | 无 | 否 | Runtime 镜像 | 否 | MCP/Web 服务监听地址。 |
-| `LOG_LEVEL` | Runtime image | 否 | `INFO` | 无 | 否 | 开发者 / 平台 | 否 | 模板运行时日志级别。 |
-| `CODE_PATH` | Runtime image | 否 | `/app/code` | 无 | 否 | Runtime 镜像 | 否 | 代码包解压/挂载目录。 |
-| `PIP_INDEX_URL` | 构建 / Runtime image | 否 | pip 默认 | `UV_INDEX_URL` | 否 | 开发者 / 平台 | 否 | Python 依赖安装源。 |
-| `UV_INDEX_URL` | 构建 / Runtime image | 否 | uv 默认 | `PIP_INDEX_URL` | 否 | 开发者 / 平台 | 否 | uv 依赖安装源。 |
-| `KSADK_BUILD_PIP_INSTALL_TIMEOUT_SECONDS` | Code Builder | 否 | `2700` | 无 | 否 | 构建环境 / 开发者 | 否 | 源码构建时 `pip install` 总超时秒数。 |
-| `KSADK_BUILD_ENABLE_ATTACHMENT_OCR` | Code Builder / Container Builder | 否 | `false` | 无 | 否 | 构建环境 / 开发者 | 否 | 是否把平台本地 OCR 依赖打进代码包。不开启不影响多模态模型直接消费 `input_image`。 |
-| `KSADK_BUILD_ENABLE_MCP` | Code Builder / Container Builder | 否 | `false` | 无 | 否 | 构建环境 / 开发者 | 否 | 强制把 `mcp` / `langchain-mcp-adapters` 打进包。通常会根据项目 import 或非空 `KSADK_MCP_SERVERS` 自动启用;`[]` 不会启用。 |
-| `KSADK_BUILD_ENABLE_POSTGRES_SESSION` | Code Builder / Container Builder | 否 | `false` | 无 | 否 | 构建环境 / 开发者 | 否 | 强制把 `asyncpg` 打进包。通常会根据 `KSADK_SESSION_BACKEND=postgres`、`KSADK_SESSION_DSN` 或 PostgreSQL DSN 自动启用。 |
-| `KSADK_RUNTIME_PORT` | Runtime image / CLI | 否 | `8080` | 无 | 否 | 平台 | 否 | 模板运行时 HTTP 端口。 |
-| `KSADK_PROJECT_DIR` | Sessions / Web | 否 | 当前工作目录 | 无 | 否 | 本地运行时 | 否 | 本地 session/workspace 状态 project root。 |
-| `KSADK_RESPONSES_SESSION_HEADER` | RemoteRunner | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 远端 Responses session 透传 header 名称。 |
-| `KSADK_TERMINAL_EXEC_SUBCOMMAND_ALLOWLIST` | Terminal exec | 否 | 默认常见只读命令 | 无 | 否 | 平台 / 开发者 | 否 | 追加允许远程 terminal exec 透传的命令前缀,多个前缀用逗号、分号或换行分隔;例如 `config,openclaw config`。设置为 `*` 时允许全部远程 exec 命令。 |
-| `KSADK_TOOL_APPROVAL_MODE` | Built-in tools / Conversations runtime | 否 | `off` | 无 | 否 | 平台 / 开发者 | 否 | 内置工具审批模式;`strict` 时中高风险工具需要审批。 |
-| `KSADK_FEISHU_APP_ID` | OpenClaw diagnostics | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | 飞书辅助 app id。 |
-| `KSADK_FEISHU_RESULT_PATH` | OpenClaw diagnostics | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | 飞书辅助结果路径。 |
-| `KSADK_WORKSPACE_FILES_ENABLED` | Hermes/OpenClaw workspace files | 否 | 镜像内通常默认 `1` | `OPENCLAW_WORKSPACE_FILES_ENABLED` | 否 | Runtime 镜像 / 平台 | 否 | 工作区文件服务开关。 |
-| `KSADK_WORKSPACE_ROOT` | Hermes/OpenClaw workspace files | 否 | 镜像工作目录 | `OPENCLAW_WORKSPACE_DIR`、`HERMES_WORKDIR` | 否 | Runtime 镜像 / 平台 | 否 | 工作区根目录。 |
-
-## 11. 可观测性
-
-| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 |
-| --- | --- | --- | --- | --- | --- | --- | --- | --- |
-| `LANGFUSE_PUBLIC_KEY` | Tracing / Runtime | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | Langfuse public key。 |
-| `LANGFUSE_SECRET_KEY` | Tracing / Runtime | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | Langfuse secret key。 |
-| `LANGFUSE_BASE_URL` | Tracing / Runtime | 否 | 未设置 | `LANGFUSE_HOST` | 否 | 平台 / 开发者 | 否 | Langfuse endpoint。 |
-| `LANGFUSE_HOST` | Tracing / Runtime | 否 | 未设置 | `LANGFUSE_BASE_URL` | 否 | 兼容旧配置 | 否 | Langfuse endpoint 旧变量。 |
-| `LANGFUSE_PROJECT_ID` | Tracing | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | Langfuse project id。 |
-| `LANGFUSE_USE_CALLBACK` | Tracing | 否 | 未设置 | 无 | 否 | 开发者 | 否 | 是否启用 Langfuse callback。 |
-| `LANGCHAIN_TRACING_V2` | LangChain tracing | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | LangChain v2 tracing 开关。 |
-| `LANGCHAIN_VERBOSE` | Runtime image | 否 | `true` | 无 | 否 | 开发者 / 平台 | 否 | 模板运行时 LangChain verbose 开关。 |
-| `CLOUD_MONITOR_APP_KEY` | CloudMonitor tracing | 条件必传 | 未设置 | 无 | 是 | 平台 Secret | 否 | 云监控 OTLP AppKey;启用 CloudMonitor OTLP 上报时需要。 |
-| `CLOUD_MONITOR_OTLP_ENABLED` | CloudMonitor tracing | 否 | 自动判断 | 无 | 否 | 平台 / 开发者 | 否 | 显式启用或禁用 CloudMonitor OTLP exporter。 |
-| `CLOUD_MONITOR_OTLP_ENDPOINT` | CloudMonitor tracing | 条件必传 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | CloudMonitor 通用 OTLP HTTP endpoint;未设置 traces endpoint 时会派生 `/v1/traces`。 |
-| `CLOUD_MONITOR_OTLP_PROTOCOL` | CloudMonitor tracing | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | CloudMonitor 通用 OTLP 协议,当前支持 `http/protobuf`。 |
-| `CLOUD_MONITOR_OTLP_HEADERS` | CloudMonitor tracing | 否 | 未设置 | 无 | 是 | 平台 / 开发者 | 否 | CloudMonitor OTLP 附加 headers,逗号分隔且 URL encoded。 |
-| `CLOUD_MONITOR_OTLP_TRACES_ENDPOINT` | CloudMonitor tracing | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | CloudMonitor traces 专用 endpoint,优先于通用 endpoint。 |
-| `CLOUD_MONITOR_OTLP_TRACES_PROTOCOL` | CloudMonitor tracing | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | CloudMonitor traces 专用协议,优先于通用 protocol。 |
-| `CLOUD_MONITOR_LANGFUSE_ENABLED` | CloudMonitor Langfuse callback | 否 | 自动判断 | 无 | 否 | 平台 / 开发者 | 否 | 显式启用或禁用 CloudMonitor Langfuse SDK callback。 |
-| `CLOUD_MONITOR_LANGFUSE_HOST` | CloudMonitor Langfuse callback | 条件必传 | 未设置 | `CLOUD_MONITOR_OTLP_ENDPOINT` | 否 | 平台 / 开发者 | 否 | CloudMonitor AppMonitor Langfuse SDK host。 |
-| `CLOUD_MONITOR_LANGFUSE_PUBLIC_KEY` | CloudMonitor Langfuse callback | 条件必传 | 未设置 | 无 | 是 | 平台 Secret | 否 | CloudMonitor AppMonitor Langfuse public key。 |
-| `CLOUD_MONITOR_LANGFUSE_SECRET_KEY` | CloudMonitor Langfuse callback | 条件必传 | 未设置 | 无 | 是 | 平台 Secret | 否 | CloudMonitor AppMonitor Langfuse secret key。 |
-| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTel | 条件必传 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | OTel Collector endpoint;未设置 traces 专用 endpoint 时,KsADK 会派生 `/v1/traces`。 |
-| `OTEL_EXPORTER_OTLP_PROTOCOL` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | 通用 OTLP 协议;KsADK 自动 HTTP exporter 当前支持 `http/protobuf`。 |
-| `OTEL_EXPORTER_OTLP_HEADERS` | OTel | 否 | 未设置 | 无 | 是 | 平台 / 开发者 | 否 | 通用 OTLP headers,逗号分隔,值按 URL encoding;可能包含 `Authorization`。 |
-| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | traces 专用 endpoint;设置后优先于通用 endpoint。 |
-| `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | traces 专用 OTLP 协议;设置后优先于通用 protocol。 |
-| `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | OTel | 否 | 未设置 | 无 | 是 | 平台 / 开发者 | 否 | traces 专用 OTLP headers;设置后优先于通用 headers。 |
-| `OTEL_SERVICE_NAME` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | service name。 |
-| `OTEL_RESOURCE_ATTRIBUTES` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | resource attributes。 |
-
-## 12. Hermes 和 OpenClaw 常见运行时变量
-
-Hermes / OpenClaw 有大量镜像启动和安全策略变量,本文只列常见运行时可配置项。`*_PID`、`*_MARKER`、`*_CACHE_DIR`、`*_SPEC`、`*_PLUGIN_ID`、`*_PATCH_ROOTS`、`*_READY_STATUSES` 等主要是脚本内部状态或模板常量,未逐项列出。完整模板变量以 `deploy/hermes/`、`deploy/openclaw/`、`deploy/openclaw-user-template/` 内 README 和 bootstrap 脚本为准。
-
-| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 |
-| --- | --- | --- | --- | --- | --- | --- | --- | --- |
-| `HERMES_MODEL_PROVIDER` | Hermes | 否 | `custom` | 无 | 否 | 开发者 / 平台 | 否 | Hermes 模型 provider。 |
-| `HERMES_CONTEXT_LENGTH` | Hermes | 否 | `OPENAI_CONTEXT_LENGTH` / `MODEL_CONTEXT_LENGTH` | 无 | 否 | 开发者 / 平台 | 否 | 上下文长度。 |
-| `HERMES_COMPRESSION_MODEL` | Hermes | 否 | `OPENAI_MODEL_NAME` | 无 | 否 | 开发者 / 平台 | 否 | 压缩模型。 |
-| `HERMES_COMPRESSION_BASE_URL` | Hermes | 否 | `OPENAI_BASE_URL` | 无 | 否 | 开发者 / 平台 | 否 | 压缩模型 endpoint。 |
-| `HERMES_COMPRESSION_PROVIDER` | Hermes | 否 | `HERMES_MODEL_PROVIDER` | 无 | 否 | 开发者 / 平台 | 否 | 压缩模型 provider。 |
-| `HERMES_COMPRESSION_CONTEXT_LENGTH` | Hermes | 否 | `HERMES_CONTEXT_LENGTH` | 无 | 否 | 开发者 / 平台 | 否 | 压缩模型上下文长度。 |
-| `HERMES_COMPRESSION_TIMEOUT` | Hermes | 否 | `120` | 无 | 否 | 开发者 / 平台 | 否 | 压缩请求超时秒数。 |
-| `HERMES_FALLBACK_MODEL` | Hermes | 否 | `OPENAI_FALLBACK_MODEL_NAME` | 无 | 否 | 开发者 / 平台 | 否 | fallback 模型。 |
-| `HERMES_FALLBACK_BASE_URL` | Hermes | 否 | `OPENAI_BASE_URL` | 无 | 否 | 开发者 / 平台 | 否 | fallback endpoint。 |
-| `HERMES_FALLBACK_PROVIDER` | Hermes | 否 | `custom` | 无 | 否 | 开发者 / 平台 | 否 | fallback 模型 provider。 |
-| `HERMES_HOSTED_RUNTIME` | Hermes | 否 | `1` | 无 | 否 | Runtime 镜像 / 平台 | 否 | 标识 Hermes 以 hosted runtime 模式运行。 |
-| `HERMES_HOME` | Hermes | 否 | `${HERMES_STATE_DIR}` | 无 | 否 | Runtime 镜像 | 否 | Hermes 状态根目录。 |
-| `HERMES_STATE_DIR` | Hermes | 否 | `${HOME}/.hermes` | 无 | 否 | Runtime 镜像 | 否 | Hermes 状态目录。 |
-| `HERMES_WORKDIR` | Hermes | 否 | 镜像默认值 | 无 | 否 | Runtime 镜像 | 否 | Hermes 工作目录。 |
-| `HERMES_RUN_DIR` | Hermes | 否 | `${HERMES_HOME}/run` | 无 | 否 | Runtime 镜像 | 否 | Hermes 运行时 PID/socket 目录。 |
-| `HERMES_SESSION_DIR` | Hermes | 否 | `${HERMES_HOME}/sessions` | 无 | 否 | Runtime 镜像 | 否 | Hermes 会话目录。 |
-| `MCPORTER_HOME` | Hermes | 否 | `${HERMES_HOME}/mcporter` | 无 | 否 | Runtime 镜像 | 否 | MCPorter 状态目录。 |
-| `XDG_CONFIG_HOME` | Hermes | 否 | `${HERMES_HOME}/xdg/config` | 无 | 否 | Runtime 镜像 | 否 | XDG config 目录覆盖。 |
-| `XDG_CACHE_HOME` | Hermes | 否 | `${HERMES_HOME}/xdg/cache` | 无 | 否 | Runtime 镜像 | 否 | XDG cache 目录覆盖。 |
-| `XDG_STATE_HOME` | Hermes | 否 | `${HERMES_HOME}/xdg/state` | 无 | 否 | Runtime 镜像 | 否 | XDG state 目录覆盖。 |
-| `AGENT_BROWSER_HOME` | Hermes browser | 否 | `/usr/local/lib/node_modules/agent-browser` | 无 | 否 | Runtime 镜像 | 否 | browser agent 安装目录。 |
-| `AGENT_BROWSER_EXECUTABLE_PATH` | Hermes/OpenClaw browser | 否 | `/usr/bin/chromium` 或自动探测 | `OPENCLAW_BROWSER_EXECUTABLE_PATH` | 否 | Runtime 镜像 / 开发者 | 否 | 浏览器可执行文件路径覆盖。 |
-| `AGENT_BROWSER_STATE_DIR` | Hermes browser | 否 | `${HERMES_HOME}/browser` | 无 | 否 | Runtime 镜像 | 否 | browser agent 状态目录。 |
-| `AGENT_BROWSER_RUN_DIR` | Hermes browser | 否 | `${AGENT_BROWSER_STATE_DIR}/run` | 无 | 否 | Runtime 镜像 | 否 | browser agent 运行目录。 |
-| `AGENT_BROWSER_SESSION_DIR` | Hermes browser | 否 | `${AGENT_BROWSER_STATE_DIR}/sessions` | 无 | 否 | Runtime 镜像 | 否 | browser agent 会话目录。 |
-| `AGENT_BROWSER_SOCKET_DIR` | Hermes browser | 否 | `${AGENT_BROWSER_RUN_DIR}` | 无 | 否 | Runtime 镜像 | 否 | browser agent socket 目录。 |
-| `AGENT_BROWSER_ARTIFACTS_DIR` | Hermes browser | 否 | `${AGENT_BROWSER_STATE_DIR}/artifacts` | 无 | 否 | Runtime 镜像 | 否 | browser agent 产物目录。 |
-| `AGENT_BROWSER_LOG_DIR` | Hermes browser | 否 | `${AGENT_BROWSER_STATE_DIR}/logs` | 无 | 否 | Runtime 镜像 | 否 | browser agent 日志目录。 |
-| `API_SERVER_ENABLED` | Hermes API server | 否 | `true` | 无 | 否 | Runtime 镜像 / 开发者 | 否 | Hermes 内置 API server 开关。 |
-| `API_SERVER_HOST` | Hermes API server | 否 | `127.0.0.1` | 无 | 否 | Runtime 镜像 / 开发者 | 否 | Hermes 内置 API server host。 |
-| `API_SERVER_PORT` | Hermes API server | 否 | `8642` | 无 | 否 | Runtime 镜像 / 开发者 | 否 | Hermes 内置 API server port。 |
-| `API_SERVER_KEY` | Hermes API server | 条件必传 | 未设置 | `HERMES_API_SERVER_KEY` | 是 | Secret | 否 | Hermes API server 鉴权 key。 |
-| `TAVILY_API_KEY` | Hermes / OpenClaw web search | 条件必传 | 未设置 | `OPENCLAW_TAVILY_API_KEY` | 是 | Secret | 否 | Tavily 搜索 key。 |
-| `FIRECRAWL_API_KEY` | Hermes web/search skill | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | bundled web/search skill 使用 Firecrawl 时需要。 |
-| `EXA_API_KEY` | Hermes web/search skill | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | bundled web/search skill 使用 Exa 时需要。 |
-| `PARALLEL_API_KEY` | Hermes web/search skill | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | bundled web/search skill 使用 Parallel 时需要。 |
-| `BROWSERBASE_API_KEY` | Hermes browser skill | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | browser skill 使用 Browserbase 时需要。 |
-| `BROWSER_USE_API_KEY` | Hermes browser skill | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | browser-use 云服务模式需要。 |
-| `CAMOFOX_URL` | Hermes browser skill | 条件必传 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | browser skill 使用 Camofox 服务时的 endpoint。 |
-| `KDOCS_OPEN_BROWSER` | Hermes kdocs skill | 否 | `0` | 无 | 否 | 开发者 | 否 | kdocs token 获取脚本是否自动打开浏览器。 |
-| `HERMES_DASHBOARD_HOST` | Hermes | 否 | `127.0.0.1` | 无 | 否 | Runtime 镜像 / 开发者 | 否 | Dashboard 监听 host。 |
-| `HERMES_DASHBOARD_PORT` | Hermes | 否 | `9119` | 无 | 否 | Runtime 镜像 / 开发者 | 否 | Dashboard 端口。 |
-| `HERMES_UI_LOCALE` | Hermes | 否 | `zh` | `LANG`、`LC_ALL` | 否 | Runtime 镜像 / 开发者 | 否 | UI 语言。 |
-| `HERMES_API_SERVER_KEY` | Hermes CLI | 条件必传 | 未设置 | `API_SERVER_KEY` | 是 | Secret | 否 | Hermes API server 鉴权 key。 |
-| `HERMES_IMAGE` | Hermes CLI | 否 | CLI 内置镜像 | `HERMES_DOCKER_IMAGE` | 否 | 开发者 / CI | 否 | Hermes 镜像覆盖。 |
-| `HERMES_RESOURCE` | Hermes CLI | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | Hermes 资源规格覆盖。 |
-| `OPENCLAW_GATEWAY_AUTH_MODE` | OpenClaw | 条件必传 | 模板默认值 | 无 | 否 | 平台 / 开发者 | 否 | Gateway 鉴权模式。 |
-| `OPENCLAW_GATEWAY_TOKEN` | OpenClaw | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | token 模式鉴权 token。 |
-| `OPENCLAW_GATEWAY_PASSWORD` | OpenClaw | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | password 模式鉴权密码。 |
-| `OPENCLAW_GATEWAY_PORT` | OpenClaw | 否 | 模板默认值 | 无 | 否 | Runtime 镜像 / 平台 | 否 | Gateway 端口。 |
-| `OPENCLAW_GATEWAY_BIND` | OpenClaw | 否 | `lan` | 无 | 否 | Runtime 镜像 / 平台 | 否 | gateway 绑定模式。 |
-| `OPENCLAW_GATEWAY_TRUSTED_PROXY_USER_HEADER` | OpenClaw | 条件必传 | 模板默认值 | `OPENCLAW_TRUSTED_PROXY_USER_HEADER` | 否 | 平台 | 否 | trusted-proxy 用户 header。 |
-| `OPENCLAW_TRUSTED_PROXIES` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 | 否 | trusted-proxy 允许代理列表。 |
-| `OPENCLAW_INTERNAL_TRUSTED_PROXY_USER` | OpenClaw | 否 | `openclaw-backend` | 无 | 否 | Runtime 镜像 / 平台 | 否 | 内部 loopback 请求用户。 |
-| `OPENCLAW_INTERNAL_TRUSTED_PROXY_USER_HEADER` | OpenClaw | 否 | `OPENCLAW_TRUSTED_PROXY_USER_HEADER` | 无 | 否 | Runtime 镜像 / 平台 | 否 | 内部 loopback 用户 header。 |
-| `OPENCLAW_ALLOWED_ORIGINS` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 / 开发者 | 否 | CORS allowed origins,支持列表/JSON。 |
-| `OPENCLAW_ALLOW_INSECURE_AUTH` | OpenClaw | 否 | `false` | 无 | 否 | 开发者 / 测试 | 否 | 允许不安全鉴权配置,生产不要开启。 |
-| `OPENCLAW_DISABLE_DEVICE_AUTH` | OpenClaw | 否 | `false` | 无 | 否 | 开发者 / 测试 | 否 | 禁用设备鉴权。 |
-| `OPENCLAW_MODEL_API_KEY` | OpenClaw | 条件必传 | 未设置 | `OPENAI_API_KEY` / `MODEL_API_KEY` | 是 | Secret | 否 | OpenClaw 模型 API key。 |
-| `OPENCLAW_MODEL_BASE_URL` | OpenClaw | 条件必传 | 未设置 | `OPENAI_BASE_URL` / `MODEL_API_BASE` | 否 | 平台 / 开发者 | 否 | OpenClaw 模型 endpoint。 |
-| `OPENCLAW_DEFAULT_MODEL` | OpenClaw | 条件必传 | 未设置 | `OPENAI_MODEL_NAME` / `MODEL_NAME` | 否 | 平台 / 开发者 | 否 | OpenClaw 默认模型。 |
-| `OPENCLAW_MODEL_PROVIDER_ID` | OpenClaw | 否 | `ksyun` | 无 | 否 | 平台 / 开发者 | 否 | OpenClaw 模型 provider id。 |
-| `OPENCLAW_MODEL_API` | OpenClaw | 否 | `openai-completions` | 无 | 否 | 平台 / 开发者 | 否 | OpenClaw 模型 API 类型。 |
-| `OPENCLAW_MODEL_CATALOG_JSON` | OpenClaw | 否 | 自动生成 | 无 | 否 | 平台 / 开发者 | 否 | 覆盖模型 catalog。 |
-| `OPENCLAW_MODEL_ALLOWLIST` | OpenClaw | 否 | 未设置 | `AGENTENGINE_MODEL_ALLOWLIST` | 否 | 平台 / 开发者 | 否 | OpenClaw 模型白名单。 |
-| `OPENCLAW_MODEL_API_KEY_SECRET_SOURCE` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 | 否 | 模型 API key secret 来源,例如 env/file。 |
-| `OPENCLAW_MODEL_API_KEY_SECRET_PROVIDER` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 | 否 | 模型 API key secret provider 标识。 |
-| `OPENCLAW_MODEL_API_KEY_SECRET_ID` | OpenClaw / safe exec | 条件必传 | 未设置 | 无 | 是 | Secret 配置 | 否 | file/secret-provider 模式下的模型或 web-search key 引用 ID。 |
-| `OPENCLAW_MODEL_API_KEY_SECRET_FILE_PATH` | OpenClaw | 条件必传 | 模板默认值 | 无 | 是 | Secret 挂载 | 否 | file secret 模式下的 key 文件路径。 |
-| `OPENCLAW_BROWSER_ENABLED` | OpenClaw | 否 | 安全策略决定 | 无 | 否 | 平台 / 开发者 | 否 | 是否启用浏览器能力。 |
-| `OPENCLAW_BROWSER_NO_SANDBOX` | OpenClaw | 否 | `true` | 无 | 否 | Runtime 镜像 / 平台 | 否 | Chromium no-sandbox 开关。 |
-| `OPENCLAW_BROWSER_HEADLESS` | OpenClaw | 否 | `true` | 无 | 否 | Runtime 镜像 / 平台 | 否 | 浏览器 headless 开关。 |
-| `OPENCLAW_BROWSER_EXECUTABLE_PATH` | OpenClaw | 否 | 自动探测 | `OPENCLAW_BROWSER_EXECUTABLE` | 否 | Runtime 镜像 / 平台 | 否 | 浏览器可执行文件路径。 |
-| `OPENCLAW_BROWSER_SSRF_POLICY_JSON` | OpenClaw | 否 | 模板默认策略 | 无 | 否 | 平台 / 开发者 | 否 | 浏览器 SSRF 策略 JSON。 |
-| `OPENCLAW_WEB_FETCH_ENABLED` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 / 开发者 | 否 | web fetch 能力开关。 |
-| `OPENCLAW_WEB_SEARCH_PROVIDER` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 / 开发者 | 否 | web search provider。 |
-| `OPENCLAW_WEB_SEARCH_BASE_URL` | OpenClaw | 条件必传 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | web search endpoint。 |
-| `OPENCLAW_WEB_SEARCH_MODEL` | OpenClaw | 条件必传 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | web search 模型名。 |
-| `OPENCLAW_WEB_SEARCH_API_KEY` | OpenClaw | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | web search API key。 |
-| `OPENCLAW_WEB_SEARCH_API_KEY_SECRET_SOURCE` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 | 否 | web search key secret 来源。 |
-| `OPENCLAW_WEB_SEARCH_API_KEY_SECRET_PROVIDER` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 | 否 | web search key secret provider 标识。 |
-| `OPENCLAW_WEB_SEARCH_API_KEY_SECRET_ID` | OpenClaw | 条件必传 | 未设置 | 无 | 是 | Secret 配置 | 否 | web search key 引用 ID。 |
-| `OPENCLAW_TAVILY_API_KEY` | OpenClaw web search | 条件必传 | 未设置 | `TAVILY_API_KEY` | 是 | Secret | 否 | OpenClaw Tavily 搜索 key。 |
-| `OPENCLAW_WEB_SAFE_SEARCH_MODE` | OpenClaw safe exec | 否 | `bing` | 无 | 否 | 平台 / 开发者 | 否 | safe web search 模式,支持默认 Bing RSS 或模型搜索。 |
-| `OPENCLAW_WEB_SAFE_SEARCH_MODEL` | OpenClaw safe exec | 条件必传 | 未设置 | `OPENCLAW_WEB_SEARCH_MODEL` / 默认模型 | 否 | 平台 / 开发者 | 否 | safe web search 模型名覆盖。 |
-| `OPENCLAW_WEB_SAFE_SEARCH_BASE_URL` | OpenClaw safe exec | 条件必传 | 未设置 | `OPENCLAW_WEB_SEARCH_BASE_URL` / 模型 base url | 否 | 平台 / 开发者 | 否 | safe web search 模型 endpoint。 |
-| `OPENCLAW_WEB_SAFE_SEARCH_API` | OpenClaw safe exec | 否 | `OPENCLAW_MODEL_API` 或 `openai-completions` | 无 | 否 | 平台 / 开发者 | 否 | safe web search 模型 API 类型。 |
-| `OPENCLAW_WEB_SAFE_SEARCH_API_KEY` | OpenClaw safe exec | 条件必传 | 模型 key fallback | 无 | 是 | Secret | 否 | safe web search 专用 API key。 |
-| `OPENCLAW_WEB_SAFE_SEARCH_SECRET_SOURCE` | OpenClaw safe exec | 否 | `OPENCLAW_MODEL_API_KEY_SECRET_SOURCE` | 无 | 否 | 平台 | 否 | safe web search key secret 来源。 |
-| `OPENCLAW_WEB_SAFE_SEARCH_SECRET_FILE_PATH` | OpenClaw safe exec | 条件必传 | 未设置 | 无 | 是 | Secret 挂载 | 否 | safe web search file secret 路径。 |
-| `OPENCLAW_WEB_SAFE_SEARCH_SECRET_ID` | OpenClaw safe exec | 条件必传 | 未设置 | 无 | 是 | Secret 配置 | 否 | safe web search key 引用 ID。 |
-| `OPENCLAW_WEB_SAFE_SEARCH_ENDPOINT` | OpenClaw safe exec | 否 | `https://cn.bing.com/search?format=rss&q={query}` | 无 | 否 | 平台 / 开发者 | 否 | safe web search HTTP endpoint。 |
-| `OPENCLAW_WEB_SAFE_READER_ENDPOINT` | OpenClaw safe exec | 否 | `https://r.jina.ai/` | 无 | 否 | 平台 / 开发者 | 否 | safe web reader endpoint。 |
-| `OPENCLAW_WEB_SAFE_UNRESTRICTED` | OpenClaw safe exec | 否 | `false` | `OPENCLAW_EXEC_UNSAFE_MODE` 派生 | 否 | 开发者 / 测试 | 否 | 放宽 safe web SSRF 限制,生产不要开启。 |
-| `OPENCLAW_WORKSPACE_FILES_ENABLED` | OpenClaw | 否 | 模板默认值 | `KSADK_WORKSPACE_FILES_ENABLED` | 否 | Runtime 镜像 / 平台 | 否 | workspace files 服务开关。 |
-| `OPENCLAW_WORKSPACE_DIR` | OpenClaw | 否 | 模板默认值 | `KSADK_WORKSPACE_ROOT` | 否 | Runtime 镜像 / 平台 | 否 | workspace 目录。 |
-| `OPENCLAW_WORKSPACE_FILES_PORT` | OpenClaw workspace files | 否 | 模板默认值 | 无 | 否 | Runtime 镜像 / 平台 | 否 | workspace files 服务端口。 |
-| `OPENCLAW_WORKSPACE_FILES_PROXY_URL` | OpenClaw workspace files | 否 | 未设置 | 无 | 否 | Runtime 镜像 / 平台 | 否 | workspace files 代理地址。 |
-| `OPENCLAW_PRESET_SKILLS_DIR` | OpenClaw bootstrap | 否 | `/opt/openclaw/preset-skills` | 无 | 否 | Runtime 镜像 / 平台 | 否 | 预置 skills 目录。 |
-| `OPENCLAW_DEFAULT_EXTENSIONS_DIR` | OpenClaw bootstrap | 否 | `/opt/openclaw/default-extensions` | 无 | 否 | Runtime 镜像 / 平台 | 否 | 默认 extensions 目录。 |
-| `OPENCLAW_GATEWAY_INTERNAL_HOST` | OpenClaw runtime proxy | 否 | `127.0.0.1` | 无 | 否 | Runtime 镜像 / 平台 | 否 | runtime proxy 连接内部 gateway 的 host。 |
-| `OPENCLAW_GATEWAY_INTERNAL_PORT` | OpenClaw runtime proxy | 否 | `18080` | 无 | 否 | Runtime 镜像 / 平台 | 否 | runtime proxy 连接内部 gateway 的端口。 |
-| `OPENCLAW_GATEWAY_PROXY_BASE_URL` | OpenClaw runtime proxy | 否 | 自动生成 | 无 | 否 | Runtime 镜像 / 平台 | 否 | runtime proxy HTTP base url 覆盖。 |
-| `OPENCLAW_GATEWAY_PROXY_WS_URL` | OpenClaw runtime proxy | 否 | 自动生成 | 无 | 否 | Runtime 镜像 / 平台 | 否 | runtime proxy WebSocket url 覆盖。 |
-| `OPENCLAW_GATEWAY_HANDOFF_GRACE_SECONDS` | OpenClaw gateway supervisor | 否 | `5` | 无 | 否 | Runtime 镜像 / 平台 | 否 | gateway handoff 等待秒数。 |
-| `OPENCLAW_GATEWAY_LOCAL_RESTART_MAX` | OpenClaw gateway supervisor | 否 | `3` | 无 | 否 | Runtime 镜像 / 平台 | 否 | gateway 本地重启最大次数。 |
-| `OPENCLAW_GATEWAY_LOCAL_RESTART_WINDOW_SECONDS` | OpenClaw gateway supervisor | 否 | `120` | 无 | 否 | Runtime 镜像 / 平台 | 否 | gateway 本地重启计数窗口。 |
-| `OPENCLAW_GATEWAY_LOCAL_RESTART_BACKOFF_SECONDS` | OpenClaw gateway supervisor | 否 | `1` | 无 | 否 | Runtime 镜像 / 平台 | 否 | gateway 本地重启退避秒数。 |
-| `OPENCLAW_EXEC_STRICT_MODE` | OpenClaw | 否 | `false` | `OPENCLAW_EXEC_SAFE_MODE` | 否 | 平台 / 开发者 | 否 | 收紧 exec/fs 策略。 |
-| `OPENCLAW_EXEC_HOST` | OpenClaw | 否 | `gateway` | 无 | 否 | 平台 / 开发者 | 否 | exec tool host 策略。 |
-| `OPENCLAW_EXEC_SECURITY` | OpenClaw | 否 | `full` 或 profile 默认 | 无 | 否 | 平台 / 开发者 | 否 | exec 安全级别:`full/allowlist/deny` 等。 |
-| `OPENCLAW_EXEC_ASK` | OpenClaw | 否 | `off` | 无 | 否 | 平台 / 开发者 | 否 | exec 询问策略。 |
-| `OPENCLAW_EXEC_ASK_FALLBACK` | OpenClaw | 否 | profile 默认 | 无 | 否 | 平台 / 开发者 | 否 | 询问失败时的 fallback 策略。 |
-| `OPENCLAW_EXEC_AUTO_ALLOW_SKILLS` | OpenClaw | 否 | `false` | 无 | 否 | 平台 / 开发者 | 否 | 是否自动允许预置 skill 调用 exec。 |
-| `OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED` | OpenClaw | 否 | profile 默认 | 无 | 否 | 平台 / 开发者 | 否 | 是否启用默认 exec allowlist。 |
-| `OPENCLAW_EXEC_ALLOWLIST` | OpenClaw | 否 | 未设置 | `OPENCLAW_EXEC_DEFAULT_ALLOWLIST` | 否 | 平台 / 开发者 | 否 | exec allowlist 覆盖。 |
-| `OPENCLAW_FS_WORKSPACE_ONLY` | OpenClaw | 否 | profile 默认 | 无 | 否 | 平台 / 开发者 | 否 | 文件系统访问限制到 workspace。 |
-| `OPENCLAW_ELEVATED_ENABLED` | OpenClaw | 否 | `false` | 无 | 否 | 平台 / 开发者 | 否 | elevated tool 开关。 |
-| `OPENCLAW_PRESET_SKILLS_ALLOWLIST` | OpenClaw | 否 | 模板默认值 | 无 | 否 | Runtime 镜像 / 平台 | 否 | 预置 skills allowlist。 |
-| `OPENCLAW_PRESET_PLUGINS_ALLOWLIST` | OpenClaw | 否 | 模板默认值 | 无 | 否 | Runtime 镜像 / 平台 | 否 | 预置 plugins allowlist。 |
-| `OPENCLAW_RUNTIME_PROXY_ENABLED` | OpenClaw | 否 | 模板默认值 | 无 | 否 | Runtime 镜像 / 平台 | 否 | runtime proxy 开关。 |
-| `OPENCLAW_RESPONSES_API_ENABLED` | OpenClaw | 否 | 模板默认值 | 无 | 否 | 平台 / 开发者 | 否 | Responses API 兼容入口开关。 |
-| `OPENCLAW_THINKING_DEFAULT` | OpenClaw | 否 | `off` | 无 | 否 | 平台 / 开发者 | 否 | 默认 thinking effort。 |
-| `OPENCLAW_VERBOSE_DEFAULT` | OpenClaw | 否 | `off` | 无 | 否 | 平台 / 开发者 | 否 | 默认 verbose 行为。 |
-| `OPENCLAW_TYPING_MODE` | OpenClaw | 否 | `instant` | 无 | 否 | 平台 / 开发者 | 否 | UI typing 展示模式。 |
-| `OPENCLAW_UI_LOCALE` | OpenClaw | 否 | 模板默认值 | `LANG`、`LC_ALL` | 否 | Runtime 镜像 / 开发者 | 否 | OpenClaw UI 语言。 |
-| `OPENCLAW_CHANNEL_BOOTSTRAP_JSON` | OpenClaw | 否 | 未设置 | 无 | 是 | 平台 Secret / 部署配置 | 否 | channel 启动配置,可能包含登录/连接 token。 |
-| `OPENCLAW_CONFIG_PATCH_JSON` | OpenClaw | 否 | 未设置 | 无 | 是 | 平台 Secret / 部署配置 | 否 | openclaw 配置 patch,可能包含 secret。 |
-| `OPENCLAW_BOOTSTRAP_ONLY` | OpenClaw | 否 | `false` | 无 | 否 | Runtime 镜像 / 开发者 | 否 | 只执行 bootstrap,不启动 gateway。 |
-| `OPENCLAW_STATE_DIR` | OpenClaw | 否 | `/home/node/.openclaw` | 无 | 否 | Runtime 镜像 | 否 | OpenClaw 状态目录。 |
-| `OPENCLAW_TEMPLATE_DIR` | OpenClaw user template | 否 | `/opt/openclaw-template` | 无 | 否 | Runtime 镜像 | 否 | user template 根目录。 |
-| `OPENCLAW_TEMPLATE_ENV_STRICT` | OpenClaw user template | 否 | `1` | 无 | 否 | Runtime 镜像 / 开发者 | 否 | user bootstrap 是否严格校验环境变量。 |
-| `OPENCLAW_IMAGE` | OpenClaw CLI | 否 | CLI 内置镜像 | `OPENCLAW_DOCKER_IMAGE` | 否 | 开发者 / CI | 否 | OpenClaw 镜像覆盖。 |
-| `OPENCLAW_RESOURCE` | OpenClaw CLI | 否 | CLI 默认规格 | 无 | 否 | 开发者 / 平台 | 否 | OpenClaw 资源规格快捷配置。 |
-| `OPENCLAW_CPU` | OpenClaw CLI | 否 | CLI 默认规格 | 无 | 否 | 开发者 / 平台 | 否 | OpenClaw CPU 规格覆盖。 |
-| `OPENCLAW_MEMORY` | OpenClaw CLI | 否 | CLI 默认规格 | 无 | 否 | 开发者 / 平台 | 否 | OpenClaw memory 规格覆盖。 |
-| `OPENCLAW_RUNTIME_NPM_REGISTRY` | OpenClaw bootstrap | 否 | 镜像默认值 | 无 | 否 | Runtime 镜像 / 平台 | 否 | OpenClaw npm registry 覆盖。 |
-| `OPENCLAW_RUNTIME_PIP_INDEX_URL` | OpenClaw bootstrap | 否 | 镜像默认值 | 无 | 否 | Runtime 镜像 / 平台 | 否 | OpenClaw pip index 覆盖。 |
-| `OPENCLAW_RUNTIME_PIP_TRUSTED_HOST` | OpenClaw bootstrap | 否 | `mirrors.aliyun.com` | `PIP_TRUSTED_HOST` | 否 | Runtime 镜像 / 平台 | 否 | OpenClaw pip trusted-host 覆盖。 |
-| `OPENCLAW_RUNTIME_UV_INDEX_URL` | OpenClaw bootstrap | 否 | `OPENCLAW_RUNTIME_PIP_INDEX_URL` | 无 | 否 | Runtime 镜像 / 平台 | 否 | OpenClaw uv index 覆盖。 |
-| `OPENCLAW_RUNTIME_PLAYWRIGHT_DOWNLOAD_HOST` | OpenClaw bootstrap | 否 | `https://npmmirror.com/mirrors/playwright` | `PLAYWRIGHT_DOWNLOAD_HOST` | 否 | Runtime 镜像 / 平台 | 否 | Playwright 浏览器下载源覆盖。 |
-| `OPENCLAW_RUNTIME_PUPPETEER_DOWNLOAD_BASE_URL` | OpenClaw bootstrap | 否 | `https://npmmirror.com/mirrors/chrome-for-testing` | `PUPPETEER_DOWNLOAD_BASE_URL` | 否 | Runtime 镜像 / 平台 | 否 | Puppeteer chrome-for-testing 下载源覆盖。 |
-| `OPENCLAW_RUNTIME_PUPPETEER_DOWNLOAD_HOST` | OpenClaw bootstrap | 否 | `https://npmmirror.com/mirrors` | `PUPPETEER_DOWNLOAD_HOST` | 否 | Runtime 镜像 / 平台 | 否 | Puppeteer 下载 host 覆盖。 |
-| `OPENCLAW_RUNTIME_CLAWHUB_SITE` | OpenClaw bootstrap | 否 | `https://cn.clawhub-mirror.com` | `CLAWHUB_SITE` | 否 | Runtime 镜像 / 平台 | 否 | ClawHub 站点地址覆盖。 |
-| `OPENCLAW_RUNTIME_CLAWHUB_REGISTRY` | OpenClaw bootstrap | 否 | `CLAWHUB_SITE` 推导值 | `CLAWHUB_REGISTRY` | 否 | Runtime 镜像 / 平台 | 否 | ClawHub 插件仓库地址覆盖。 |
-| `OPENCLAW_NPM_REGISTRY` | OpenClaw user template examples | 否 | `https://registry.npmmirror.com` | `OPENCLAW_RUNTIME_NPM_REGISTRY` | 否 | 镜像构建 / 开发者 | 否 | user template 示例中安装插件依赖的 npm registry。 |
-| `PIP_TRUSTED_HOST` | Runtime image | 否 | 镜像或 bootstrap 设置 | `OPENCLAW_RUNTIME_PIP_TRUSTED_HOST` | 否 | Runtime 镜像 / 平台 | 否 | pip trusted-host。 |
-| `PLAYWRIGHT_DOWNLOAD_HOST` | Runtime image | 否 | 镜像或 bootstrap 设置 | `OPENCLAW_RUNTIME_PLAYWRIGHT_DOWNLOAD_HOST` | 否 | Runtime 镜像 / 平台 | 否 | Playwright 浏览器下载源。 |
-| `PUPPETEER_DOWNLOAD_BASE_URL` | Runtime image | 否 | 镜像或 bootstrap 设置 | `OPENCLAW_RUNTIME_PUPPETEER_DOWNLOAD_BASE_URL` | 否 | Runtime 镜像 / 平台 | 否 | Puppeteer 下载 base url。 |
-| `PUPPETEER_DOWNLOAD_HOST` | Runtime image | 否 | 镜像或 bootstrap 设置 | `OPENCLAW_RUNTIME_PUPPETEER_DOWNLOAD_HOST` | 否 | Runtime 镜像 / 平台 | 否 | Puppeteer 下载 host。 |
-| `KDOCS_TOKEN` | OpenClaw / Hermes kdocs skill | 条件必传 | 未设置 | 推荐迁移到 mcporter 配置 | 是 | 用户授权 / Secret | 否 | kdocs skill 运行态 token。Hermes 新流程优先 mcporter。 |
-| `KDOCS_SKILL_REPO` | Hermes/OpenClaw image build | 否 | `https://github.com/kdocs-app/kdocs-skill.git` | 无 | 否 | 镜像构建 / 开发者 | 否 | 构建镜像时覆盖 kdocs skill 源仓库。 |
-| `PLUGIN_API_KEY` | OpenClaw user template 示例 | 条件必传 | 未设置 | 无 | 是 | 业务扩展 Secret | 是 | user template 示例插件使用的业务 token,不属于 KsADK 标准契约。 |
-| `DEMO_CHANNEL_API_KEY` | OpenClaw user template 示例 | 条件必传 | 未设置 | 无 | 是 | 业务扩展 Secret | 是 | user template 示例 channel 使用的业务 token,不属于 KsADK 标准契约。 |
-
-## 13. 内部常量和表名
-
-这些变量名由源码作为常量导出或用于内部表名/依赖集合,一般不需要用户配置。
-
-| 变量 | 作用层级 | 是否必传 | 默认值 | 别名/兼容 | 敏感 | 配置方/来源 | 是否业务自定义 | 说明 |
-| --- | --- | --- | --- | --- | --- | --- | --- | --- |
-| `KSADK_ALLOWED_SUFFIXES` | builders | 否 | 代码常量 | 无 | 否 | SDK 内部 | 否 | 代码打包允许后缀集合。 |
-| `KSADK_ATTACHMENT_RUNTIME_REQUIREMENTS` | builders | 否 | 代码常量 | 无 | 否 | SDK 内部 | 否 | 附件运行时内置依赖集合。 |
-| `KSADK_ATTACHMENT_OCR_RUNTIME_REQUIREMENTS` | builders | 否 | 代码常量 | 无 | 否 | SDK 内部 | 否 | 附件 OCR 运行时内置依赖集合。 |
-| `KSADK_BUILD_ENABLE_ATTACHMENT_OCR` | builders | 否 | `false` | 无 | 否 | 构建环境 / 开发者 | 否 | 是否把平台本地 OCR 依赖打进代码包。 |
-| `KSADK_BUILD_ENABLE_MCP` | builders | 否 | `false` | 无 | 否 | 构建环境 / 开发者 | 否 | 强制加入 MCP adapter 构建依赖。 |
-| `KSADK_BUILD_PIP_INSTALL_TIMEOUT_SECONDS` | builders | 否 | `2700` | 无 | 否 | 构建环境 / 开发者 | 否 | 源码构建时 pip install 的超时秒数。 |
-| `KSADK_BUILD_ENABLE_POSTGRES_SESSION` | builders | 否 | `false` | 无 | 否 | 构建环境 / 开发者 | 否 | 强制加入 PostgreSQL session 构建依赖。 |
-| `KSADK_CORE_RUNTIME_REQUIREMENTS` | builders | 否 | 代码常量 | 无 | 否 | SDK 内部 | 否 | 核心运行时内置依赖集合。 |
-| `KSADK_MCP_RUNTIME_REQUIREMENTS` | builders | 否 | 代码常量 | 无 | 否 | SDK 内部 | 否 | MCP adapter 可选运行时内置依赖集合。 |
-| `KSADK_POSTGRES_SESSION_REQUIREMENTS` | builders | 否 | 代码常量 | 无 | 否 | SDK 内部 | 否 | PostgreSQL session 可选运行时内置依赖集合。 |
-| `KSADK_RUNTIME_REQUIREMENTS` | builders | 否 | 代码常量 | 无 | 否 | SDK 内部 | 否 | 完整运行时内置依赖集合。 |
-| `KSADK_SKILL_SERVICE` | skills | 否 | 代码调用前缀 | 无 | 否 | SDK 内部 | 否 | Skill Service AICP 连接配置前缀,用于解析 `KSADK_SKILL_SERVICE_ENDPOINT` / `KSADK_SKILL_SERVICE_SCHEME` / `KSADK_SKILL_SERVICE_REGION`;一般不需要用户单独设置。 |
-| `KSADK_EVENTS_TABLE` | sessions | 否 | `ksadk_events` | 无 | 否 | SDK 内部 | 否 | 本地 SQLite events 表名。 |
-| `KSADK_SESSIONS_TABLE` | sessions | 否 | `ksadk_sessions` | 无 | 否 | SDK 内部 | 否 | 本地 SQLite sessions 表名。 |
-| `KSADK_STATES_TABLE` | sessions | 否 | `ksadk_states` | 无 | 否 | SDK 内部 | 否 | 本地 SQLite states 表名。 |
-| `KSADK_PG_EVENTS_TABLE` | sessions | 否 | `ksadk_events` | 无 | 否 | SDK 内部 | 否 | PostgreSQL events 表名。 |
-| `KSADK_PG_SESSIONS_TABLE` | sessions | 否 | `ksadk_sessions` | 无 | 否 | SDK 内部 | 否 | PostgreSQL sessions 表名。 |
-| `KSADK_PG_STATES_TABLE` | sessions | 否 | `ksadk_states` | 无 | 否 | SDK 内部 | 否 | PostgreSQL states 表名。 |
-| `KSADK_UPDATED_AT` | configs | 否 | 写入部署环境时生成 | 无 | 否 | SDK 内部 | 否 | serverless 部署更新触发时间戳。 |
-| `KSADK_VERSION` | configs | 否 | 代码常量 | 无 | 否 | SDK 内部 | 否 | SDK version 导出名。 |
-
-## 14. 兼容、历史和不推荐变量
-
-| 变量 | 状态 | 替代变量 | 说明 |
-| --- | --- | --- | --- |
-| `KSADK_ENABLE_SANDBOX_TOOLS` | master 旧 sandbox tools 开关,当前 Skill Runtime 重构后不再推荐 | `KSADK_SKILLS_MODE` + `KSADK_SKILL_RUNTIME_BACKEND` | master 分支仍存在。新实现不再默认注入 `execute_python/execute_bash/execute_javascript`。 |
-| `KSADK_SANDBOX_TOOL_ID` | 早期 Skills 草案变量,不作为当前契约 | `KSADK_SANDBOX_TEMPLATE_ID` | 只保留在历史设计草案中。 |
-| `KSADK_SANDBOX_HOST` | 早期/草案变量,不作为当前实现契约 | `E2B_API_URL` 或未来 provider endpoint | 当前通用 sandbox E2B backend 不读取。 |
-| `KSADK_SANDBOX_REGION` | 早期/草案变量,不作为当前实现契约 | `KSADK_SANDBOX_TYPE` / provider 自身 region | 当前通用 sandbox E2B backend 不读取。 |
-| `KSADK_SKILLS_DIR` | 早期/草案变量,不作为当前实现契约 | `KSADK_LOCAL_SKILLS_DIR` 或 `KSADK_SKILL_CACHE_DIR` | 当前 Runner/agent 不读取。 |
-| `KSADK_SKILL_RUNTIME_ENDPOINT` | 早期/草案变量,不作为当前实现契约 | `E2B_API_URL` | E2B SDK 使用原生变量。 |
-| `KSADK_SKILL_RUNTIME_API_KEY` | 早期/草案变量,不作为当前实现契约 | `E2B_API_KEY` | E2B SDK 使用原生变量。 |
-| `KSADK_SKILL_RUNTIME_REGION` | 早期/草案变量,不作为当前实现契约 | 无 | 当前 E2B backend 不读取。 |
-| `KSADK_SKILL_RUNTIME_TEMPLATE_ID` | 兼容变量 | `KSADK_SANDBOX_TEMPLATE_ID` | 仍可用,但新部署优先通用 sandbox 变量。 |
-| `KSADK_SKILL_RUNTIME_ALLOW_INTERNET_ACCESS` | 兼容变量 | `KSADK_SANDBOX_ALLOW_INTERNET_ACCESS` | 通用 sandbox 变量优先。 |
-| `KSADK_STM_*` | 旧短期记忆变量 | `KSADK_SESSION_*` | 仍作为 fallback。 |
-| `AGENTENGINE_SESSION_BACKEND` / `AGENTENGINE_TENANT_ID` / `AGENTENGINE_WORKSPACE_ID` | 平台兼容变量 | `KSADK_SESSION_BACKEND` / `KSADK_TENANT_ID` / `KSADK_WORKSPACE_ID` | 仍作为 fallback。 |
-| `OPENAI_API_BASE` | OpenAI 旧变量 | `OPENAI_BASE_URL` | 仍作为兼容。 |
-| `MODEL_NAME` | 旧模型名变量 | `OPENAI_MODEL_NAME` | 仍作为兼容。 |
-| `MODEL_API_KEY` / `MODEL_API_BASE` | OpenClaw/模型兼容变量 | `OPENAI_API_KEY` / `OPENAI_BASE_URL` 或 OpenClaw 专用变量 | 按运行时模板选择。 |
-| `LLM_API_KEY` / `LLM_API_BASE` / `LLM_MODEL` | Serverless/OpenClaw 兼容变量 | `OPENAI_API_KEY` / `OPENAI_BASE_URL` / `OPENAI_MODEL_NAME` | 仍作为 fallback。 |
-| `KINGSOFT_DOCS_TOKEN` | Hermes kdocs 旧变量,不推荐 | mcporter 内的 kdocs token | 只允许一次性迁移到 mcporter,不再建议写入环境变量或 `.env`。 |
-
-## 15. 业务自定义变量边界
-
-| 类型 | 是否业务自定义 | 是否写入本文 | 说明 |
-| --- | --- | --- | --- |
-| 业务代码读取的变量,例如 `APP_ENV`、`DATABASE_URL`、`REDIS_URL`、`MY_SERVICE_TOKEN` | 是 | 否 | 由业务方自己定义,KsADK 不做含义约束。 |
-| Agent 依赖的第三方工具变量,例如某业务 API token | 是 | 否 | 可以通过部署环境注入,但不属于 KsADK 标准契约。 |
-| SDK/镜像内置扩展读取的第三方 token,例如 `TAVILY_API_KEY`、`FIRECRAWL_API_KEY`、`MEM0_API_KEY` | 否 | 部分写入 | 只有被 KsADK runtime、Hermes/OpenClaw 模板或内置 skill 明确读取的变量才列入本文。 |
-| 平台或 SDK 读取的变量,例如 `KSADK_*`、`KSYUN_*`、`E2B_*`、`OPENAI_*`、`LANGFUSE_*` | 否 | 是 | 本文维护常见和核心变量。 |
-| 镜像模板内部变量,例如大量 `OPENCLAW_*` / `HERMES_*` 高级开关 | 否 | 部分写入 | 本文只列常见运行时可配置项,完整列表以对应模板 README/bootstrap 为准。 |
-
-## 16. 配置建议
-
-- 新部署优先使用通用变量:`KSADK_SANDBOX_TEMPLATE_ID`、`KSADK_SANDBOX_TIMEOUT`、`KSADK_SANDBOX_ALLOW_INTERNET_ACCESS`。
-- Skill Runtime 兼容变量 `KSADK_SKILL_RUNTIME_TEMPLATE_ID` 仅用于迁移期。
-- E2B backend 必须使用 SDK 原生 `E2B_API_URL` / `E2B_API_KEY`。
-- Secret 不要写入代码、仓库文档、测试 fixture、日志、snapshot;使用 Secret 注入。
-- 平台注入 Skill Space 时优先用 `KSADK_SKILL_SPACE_IDS`,单 space 兼容才使用 `SKILL_SPACE_ID`。
-- `KSYUN_ACCESS_KEY` / `KSYUN_SECRET_KEY` 是多个服务的 fallback。生产 sandbox 中建议使用更窄权限的 `KSADK_SKILL_SERVICE_ACCESS_KEY` / `KSADK_SKILL_SERVICE_SECRET_KEY`。
diff --git a/docs/maintainer-approval-record.md b/docs/maintainer-approval-record.md
index 3c283f9c..fad17358 100644
--- a/docs/maintainer-approval-record.md
+++ b/docs/maintainer-approval-record.md
@@ -19,19 +19,19 @@ PyPI publication.
## Publication Strategy
-Record exactly one approved source publication strategy:
+Record exactly one approved source publication strategy.
| Strategy | Approved |
| --- | --- |
| Reviewed GitHub pull request | No |
-| Clean export from reviewed candidate | No |
-| Rewritten Git history after secret scan | Yes |
+| Clean export from reviewed candidate | Yes |
+| Rewritten Git history after secret scan | No |
The approved strategy must name the reviewed commit, tag, pull request, or
export archive used for:
-- `ksadk-python`: rewritten public `new-main` candidate `3bbf295e4f27a4f7f6a8b8cdf76a17227ad40033`, verified by `make public-preflight KSADK_WEB_VERSION=0.2.16` and public source/dist audits.
-- `ksadk-web`: npm package `@kingsoftcloud/ksadk-web@0.2.16` (`latest` on 2026-07-03), bundled from npm during `make public-preflight KSADK_WEB_VERSION=0.2.16`.
+- `ksadk-python`: clean export candidate from reviewed internal commit `1f3c0d844dc4df2630c8e1d410bcd9f7c9b595cc`; local candidate directory `/tmp/ksadk-python-export-candidate-0.6.9`; verified on 2026-07-08 with public source audit, registry-bundled `make public-preflight` in the public candidate worktree, Fumadocs static build, wheel/sdist build, twine check, and source/dist package audits.
+- `ksadk-web`: npm package `@kingsoftcloud/ksadk-web@0.2.18` from commit `24551d0f290e5a4efc5b5d60d02fa298cccd2efa`; Python candidate commit `1f3c0d844dc4df2630c8e1d410bcd9f7c9b595cc`; published by the trusted GitHub npm workflow on 2026-07-08 and consumed from the npm registry during `make public-preflight`.
Both approved source references must include the current commit SHA at approval
time. This prevents a stale approval record from passing after candidate
@@ -59,6 +59,6 @@ changes.
| Role | Name | Decision | Date |
| --- | --- | --- | --- |
-| Maintainer | xiayu | Approved for one-time public main rewrite | 2026-07-03 |
-| Security reviewer | automated public audit | Passed source, wheel, and sdist audits with 0 violations | 2026-07-03 |
-| Release owner | xiayu | Approved GitHub Release / PyPI Trusted Publishing for 0.6.9 | 2026-07-03 |
+| Maintainer | xiayu | Approved clean export candidate for ksadk 0.6.9 | 2026-07-08 |
+| Security reviewer | automated public audit | Passed source, wheel, and sdist audits with 0 violations | 2026-07-08 |
+| Release owner | xiayu | Approved trusted GitHub PyPI and Pages workflow for 0.6.9 | 2026-07-08 |
diff --git "a/docs/reference/ksadk\346\212\200\346\234\257\350\256\276\350\256\241.md" "b/docs/reference/ksadk\346\212\200\346\234\257\350\256\276\350\256\241.md"
deleted file mode 100644
index bcaf6d11..00000000
--- "a/docs/reference/ksadk\346\212\200\346\234\257\350\256\276\350\256\241.md"
+++ /dev/null
@@ -1,524 +0,0 @@
-# ksadk技术设计
-
-本文档描述 `ksadk-python` 当前主线的正式技术设计。写法采用技术设计文档结构,但内容只记录已经体现在代码、测试、CLI 帮助、Dockerfile 与默认常量中的行为。
-
-## 1. 目标与边界
-
-`ksadk-python` 负责三类职责:
-
-1. 开发者入口
- 提供 `agentengine` / `ksadk` CLI,本地开发、调试、构建和部署都从这里进入。
-2. 本地运行时
- 提供本地 Web UI、会话存储、本地 workspace 数据面以及开发态调用能力。
-3. 托管运行时资产
- 提供 Hermes / OpenClaw 共享镜像、bootstrap 脚本、共享 workspace_files 与 memory_backend 源码。
-4. Skill Runtime 与内置工具消费
- 提供 Skill Space 运行时消费、Skill 包校验与加载、sandbox backend 编排、内置 toolset 绑定、Tool Gateway 审批 envelope。
-
-不在本仓承担最终事实源的能力:
-
-- Agent 生命周期持久化
-- endpoint / api_key 写回
-- Hosted UI bootstrap
-- Workspace Files Hosted Action
-- OpenClaw `MEMORY_BACKEND_MANIFEST` 生成
-- Skill 注册、CRUD、版本治理和 marketplace
-- Sandbox template、instance、token 与网络生命周期
-
-这些分别由 `agentengine-server`、Skill Service、Sandbox Service 或平台控制面负责。
-
-## 2. 总体架构
-
-```mermaid
-flowchart LR
- classDef client fill:#dbeafe,stroke:#1d4ed8,stroke-width:2px,color:#1e3a8a;
- classDef control fill:#ede9fe,stroke:#7c3aed,stroke-width:2px,color:#581c87;
- classDef data fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#166534;
- classDef storage fill:#ffedd5,stroke:#ea580c,stroke-width:2px,color:#9a3412;
- classDef runtime fill:#e2e8f0,stroke:#475569,stroke-width:2px,color:#1e293b;
-
- subgraph Client["开发者入口"]
- CLI["agentengine CLI"]:::client
- Web["agentengine web"]:::client
- Invoke["agent invoke / files"]:::client
- end
-
- subgraph Repo["ksadk-python"]
- Dispatch["命令分发与框架识别"]:::runtime
- Local["ksadk.server.app"]:::runtime
- Common["ksadk_runtime_common"]:::runtime
- Toolsets["ksadk.toolsets + Tool Gateway"]:::runtime
- SkillRT["ksadk.skills.runtime"]:::runtime
- Sandbox["ksadk.sandbox"]:::runtime
- Assets["agentengine-images: deploy/hermes + deploy/openclaw"]:::runtime
- end
-
- subgraph Control["控制面"]
- Server["agentengine-server"]:::control
- end
-
- subgraph Runtime["运行时"]
- Generic["通用 runtime"]:::data
- Hermes["Hermes"]:::data
- OpenClaw["OpenClaw"]:::data
- end
-
- subgraph Storage["持久化"]
- LocalRoot[".agentengine/ui/workspace"]:::storage
- PVC["PVC / 挂盘目录"]:::storage
- end
-
- CLI --> Dispatch
- Web --> Local
- Invoke --> Server
- Dispatch --> Local
- Dispatch --> Server
- Dispatch --> Toolsets
- Toolsets --> SkillRT
- Toolsets --> Sandbox
- Common --> Local
- Common --> Assets
- Assets --> Hermes
- Assets --> OpenClaw
- Server --> Generic
- Server --> Hermes
- Server --> OpenClaw
- Local --> LocalRoot
- Generic --> PVC
- Hermes --> PVC
- OpenClaw --> PVC
-```
-
-## 3. 代码分层
-
-### 3.1 CLI 层
-
-入口在 `pyproject.toml`:
-
-```toml
-agentengine = "ksadk.cli:main"
-```
-
-CLI 层负责:
-
-- framework 检测
-- 本地运行与调试
-- 构建产物准备
-- 调用 `agentengine-server`
-- `files` 与 `agent invoke` 的传输选择
-- framework 级默认存储参数
-
-### 3.2 本地运行时
-
-本地运行时的核心是 `ksadk.server.app`,负责:
-
-- 本地会话
-- 本地统一 Web UI
-- 本地附件上传
-- 本地 workspace files 路由
-
-当前本地目录约定:
-
-- UI 根目录:`/.agentengine/ui`
-- 本地会话:`/.agentengine/ui/sessions.sqlite`
-- 本地 workspace:`/.agentengine/ui/workspace`
-
-### 3.3 托管运行时资产
-
-Hermes / OpenClaw 运行时镜像资产曾在本仓库 `deploy/hermes/`、`deploy/openclaw/`、`deploy/openclaw-user-template/` 下维护,现已迁出至 `agentengine-images` 仓库。这些目录不仅是模板,还包含线上运行时镜像的事实约定,例如:
-
-- Hermes 的 `entrypoint.sh`
-- OpenClaw 的 `bootstrap.sh`
-- 共享 workspace sidecar 与 memory backend 渲染入口
-
-### 3.4 Skill Runtime、Sandbox 与 Toolsets
-
-`0.6.2` 起,SDK 侧新增三层运行时消费抽象:
-
-- `ksadk.skills.runtime`:负责 workflow 请求解析、Skill 选择、远端 Skill 包下载、`sha256` 校验、安全解压、runtime agent 执行和 artifacts 汇总。
-- `ksadk.sandbox`:通用 Sandbox Runtime 底座,当前首个 backend 是 E2B-compatible sandbox;Skill Runtime 和 sandbox direct tools 共用这层,不把 sandbox 语义写死为 Skill 专用。
-- `ksadk.toolsets`:给 LangGraph、LangChain、DeepAgents、ADK 或自定义 runner 暴露内置工具,包括 Skill、Workspace、Platform、Sandbox 四组工具,以及聚合入口 `get_agentengine_tools()`。
-
-推荐绑定方式是显式渐进式披露:
-
-```python
-from ksadk.toolsets import get_agentengine_tools
-
-tools = get_agentengine_tools(include=["focused", "agentengine_tool_dispatcher"])
-```
-
-`get_agentengine_tools()` 无参保持全量工具兼容。`focused/core` profile 只直接暴露 Skill 发现/加载、Workspace 状态/搜索/片段编辑/lint、组件状态和 sandbox 状态;`execute_skills`、`run_command`、`run_code`、Workspace 写入/删除等低频或高风险工具通过 `agentengine_tool_dispatcher` 按需 `list` / `describe` / `call`。
-
-Tool Gateway 位于实际工具执行前,负责风险策略和人工确认 envelope。strict 模式下,中高风险工具返回 `approval_required`,由 Hosted/local UI 或调用方回传批准后继续;dispatcher 调用真实工具对象,不绕过 Tool Gateway。
-
-```mermaid
-flowchart LR
- classDef agent fill:#dbeafe,stroke:#1d4ed8,stroke-width:2px,color:#1e3a8a;
- classDef tool fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#166534;
- classDef runtime fill:#e2e8f0,stroke:#475569,stroke-width:2px,color:#1e293b;
- classDef service fill:#ede9fe,stroke:#7c3aed,stroke-width:2px,color:#581c87;
-
- Agent["LangGraph / LangChain / ADK Agent"]:::agent --> Focused["focused tools"]:::tool
- Agent --> Dispatcher["agentengine_tool_dispatcher"]:::tool
- Focused --> Gateway["Tool Gateway"]:::runtime
- Dispatcher --> Gateway
- Gateway --> Skill["Skill tools / execute_skills"]:::tool
- Gateway --> Workspace["Workspace tools"]:::tool
- Gateway --> SandboxTools["Sandbox direct tools"]:::tool
- Skill --> SkillService["Skill Service"]:::service
- Skill --> SkillRuntime["Skill Runtime backend"]:::runtime
- SkillRuntime --> Sandbox["E2B / Sandbox backend"]:::runtime
- SandboxTools --> Sandbox
-```
-
-## 4. `ksadk_runtime_common` 同仓共享源码
-
-这是当前主线的核心去重点。
-
-```mermaid
-flowchart TB
- classDef runtime fill:#e2e8f0,stroke:#475569,stroke-width:2px,color:#1e293b;
- classDef data fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#166534;
- classDef control fill:#ede9fe,stroke:#7c3aed,stroke-width:2px,color:#581c87;
-
- Common["ksadk_runtime_common"]:::runtime
- WF["workspace_files"]:::data
- MB["memory_backend"]:::control
- Local["ksadk.server.app"]:::runtime
- Hermes["agentengine-images: deploy/hermes/runtime/app.py"]:::runtime
- OpenClaw["agentengine-images: deploy/openclaw/bootstrap.sh + workspace_files_app.py"]:::runtime
-
- Common --> WF
- Common --> MB
- WF --> Local
- WF --> Hermes
- WF --> OpenClaw
- MB --> OpenClaw
-```
-
-当前共享源码包含两块:
-
-### 4.1 `workspace_files`
-
-职责:
-
-- 统一 runtime 路由前缀
-- 统一 Hosted bootstrap payload
-- 统一路径逃逸拦截
-- 统一上传大小上限和动作常量
-
-关键常量:
-
-- `WORKSPACE_ENTRY_ACTION = "ListWorkspaceFiles"`
-- `WORKSPACE_UPLOAD_ACTION = "AddWorkspaceFile"`
-- `WORKSPACE_CONTENT_PATH = "/agentengine/api/v1/GetWorkspaceFileContent"`
-- `DEFAULT_WORKSPACE_MAX_UPLOAD_BYTES = 100MB`
-
-### 4.2 `memory_backend`
-
-职责:
-
-- 解析并校验 `MEMORY_BACKEND_MANIFEST`
-- 基于 provider 渲染 OpenClaw 需要的配置 patch
-- 返回需要同步的插件 ID 列表
-
-当前 provider:
-
-- `openclaw_default`
-- `mem0`
-
-当前 `mem0` 渲染所要求的环境变量:
-
-- `MEM0_API_KEY`
-- `MEM0_USER_ID`
-- `MEM0_BASE_URL`
-
-## 5. 存储与 workspace 设计
-
-`ksadk/cli/storage.py` 统一定义了 framework 级默认值。
-
-### 5.1 容量约束
-
-- 默认:`20Gi`
-- 最小:`20Gi`
-- 最大:`500Gi`
-
-### 5.2 默认挂载目录
-
-| Framework | 默认挂载目录 |
-| --- | --- |
-| `adk` | `/home/node/.agentengine` |
-| `langchain` | `/home/node/.agentengine` |
-| `langgraph` | `/home/node/.agentengine` |
-| `deepagents` | `/home/node/.agentengine` |
-| `hermes` | `/home/node/.hermes` |
-| `openclaw` | `/home/node/.openclaw` |
-
-### 5.3 workspace 对外语义
-
-- CLI 和 Hosted UI 统一把根目录表示为 `workspace:/`
-- Hermes 明确把 `KSADK_WORKSPACE_ROOT` 绑定到 `HERMES_WORKDIR`
-- OpenClaw 明确把 `KSADK_WORKSPACE_ROOT` 绑定到 `${OPENCLAW_STATE_DIR}/workspace`
-
-## 6. 文件访问与传输选择
-
-```mermaid
-sequenceDiagram
- autonumber
- participant U as 用户
- participant CLI as agentengine files
- participant Client as AgentEngineClient
- participant Server as agentengine-server
- participant Runtime as runtime data plane
-
- U->>CLI: files list / upload / push
- CLI->>Client: 规范化 agent_ref 与路径
- alt 常规 agent
- Client->>Runtime: 直连 /_ksadk/workspace/v1/*
- Runtime-->>Client: JSON / 文件流
- else OpenClaw 或 Hosted 场景
- Client->>Server: ListWorkspaceFiles / AddWorkspaceFile
- Server->>Runtime: 代发请求
- Runtime-->>Server: JSON / 文件流
- Server-->>Client: ActionResponse
- end
- Client-->>CLI: pretty / json 输出
-```
-
-当前策略重点:
-
-- 常规 agent 有 endpoint + api_key 时,优先 `runtime_direct`
-- OpenClaw 默认优先 `action_proxy`
-- CLI 会把逻辑路径和真实路径同时渲染到输出中
-
-## 7. `agentengine agent invoke` 与本地目录同步
-
-`agentengine agent invoke` 是远端交互入口;其中 Hermes native 模式额外接通了本地目录同步。
-
-同步前会做四件事:
-
-1. 递归扫描本地目录
-2. 校验目录非空
-3. 校验任一单文件不超过 `MaxUploadBytes`
-4. 校验目录总大小不超过同一个上限
-
-如果不传 `--remote-workspace-path`,会默认使用本地目录名作为远端子目录名。
-
-## 8. Hermes 运行时设计要点
-
-Hermes runtime 的关键事实:
-
-- Docker 构建时从仓根复制 `ksadk_runtime_common`
-- `PYTHONPATH=/opt`
-- `HERMES_HOME=/home/node/.hermes`
-- `HERMES_WORKDIR=/home/node/.hermes/workspace`
-- `KSADK_WORKSPACE_ROOT` 默认跟随 `HERMES_WORKDIR`
-
-Hermes 既承载 dashboard,又承载:
-
-- `/v1/*`
-- `/_ksadk/terminal/ws`
-- `/_ksadk/workspace/v1/*`
-
-## 9. OpenClaw 运行时设计要点
-
-OpenClaw runtime 的关键事实:
-
-- Docker 构建时从仓根复制 `ksadk_runtime_common`
-- `PYTHONPATH=/opt`
-- bootstrap 会启动 workspace files sidecar
-- sidecar 默认监听 `127.0.0.1:8091`
-- gateway 内部通过 `OPENCLAW_WORKSPACE_FILES_PROXY_URL` 转发到 sidecar
-
-### 9.1 memory backend 主链路
-
-```mermaid
-flowchart LR
- classDef control fill:#ede9fe,stroke:#7c3aed,stroke-width:2px,color:#581c87;
- classDef data fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#166534;
- classDef runtime fill:#e2e8f0,stroke:#475569,stroke-width:2px,color:#1e293b;
-
- Server["agentengine-server"]:::control --> Manifest["MEMORY_BACKEND_MANIFEST"]:::control
- Manifest --> Render["python -m ksadk_runtime_common.memory_backend.render"]:::runtime
- Render --> Patch["memory patch JSON + plugin_ids"]:::data
- Patch --> Bootstrap["bootstrap.sh"]:::runtime
- Bootstrap --> Config["openclaw.json"]:::data
- Bootstrap --> Extensions["按需同步 default extension"]:::data
-```
-
-当前链路特征:
-
-- manifest 由控制面生成
-- 渲染在 runtime 内完成
-- `mem0` 需要环境变量齐全,否则 bootstrap 直接失败
-- 插件不是无条件落盘,而是由渲染结果驱动按需同步
-- `lancedb`(`backend_type=lancedb`)走同一 manifest→render 链路,但不依赖 mem0 环境变量;渲染产出 `memory-lancedb` 插件 entry,同时把 `openclaw-mem0` 加入 `disabled_plugin_ids`,避免两个 memory 插件同时生效。manifest 可选 `config.dbPath` / `config.embedding` / `config.storageOptions` 三个 LanceDB 专属字段,由 schema 校验,缺省时插件使用内置默认。
-
-!!! info "0.6.7 新增 backend"
- LanceDB 作为进程内向量存储 backend,给 OpenClaw 提供无需外部 mem0 实例的长期记忆能力;当 `backend_type=lancedb` 时,`secrets_env` 可留空。
-
-## 10. Docker 构建与根上下文
-
-当前 Hermes / OpenClaw 镜像都采用同仓共享源码 + 根上下文构建:
-
-- `COPY ksadk_runtime_common /opt/ksadk_runtime_common`
-- `PYTHONPATH=/opt`
-- 根目录 `.dockerignore` 负责排除 `.git`、`dist`、`build`、缓存目录和本地产物
-
-收益:
-
-- 不依赖额外 wheel 仓发布
-- 共享源码与 runtime 资产同仓演进
-- Docker 构建可直接消费最新共享模块
-
-## 11. 与服务端的协作边界
-
-| 能力 | `ksadk-python` | `agentengine-server` |
-| --- | --- | --- |
-| CLI / 本地开发 | 负责 | 不负责 |
-| Agent 生命周期 | 调用方 | 真相源 |
-| Hosted UI bootstrap | 消费方 | 负责 |
-| Workspace Files runtime data plane | 负责 | 不负责 |
-| Workspace Files Hosted Action | 消费方 | 负责 |
-| Memory manifest 渲染 | 负责(含 `openclaw_default` / `mem0` / `lancedb` 三种 backend_type) | 不负责 |
-| Memory manifest 生成 | 不负责 | 负责 |
-| endpoint / api_key 写回 | 不负责 | 负责 |
-
-## 12. 平台上下文与 invocation_id
-
-!!! new "0.6.5 新增"
- 平台调用上下文(`PlatformInvocationContext`)与 `invocation_id` 贯穿 runner payload 与 OpenAI 兼容接口,为 Skill / Workspace / Sandbox / Memory 工具提供统一的账号边界读取入口。
-
-### 12.1 PlatformInvocationContext
-
-`ksadk.runtime_context.PlatformInvocationContext` 在 runner 执行前由 conversation runtime 注入到 `ContextVar`,携带 `agent_id` / `user_id` / `session_id` / `account_id` 等字段。工具实现优先读取当前调用上下文,而不是裸环境变量。
-
-```python
-from ksadk.runtime_context import (
- get_current_invocation_context_or_default,
- get_current_user_id,
- get_current_account_id,
-)
-
-ctx = get_current_invocation_context_or_default()
-user_id = get_current_user_id() # ctx.user_id
-account_id = get_current_account_id() # ctx.account_id,未注入时为空串
-```
-
-`PlatformInvocationContext.account_id` 是 0.6.5 新增字段,用于把控制面透传的账号边界下沉到工具层。
-
-### 12.2 invocation_id 与 account_id 透传
-
-`invocation_id` 作为单次调用的稳定标识,由 runner payload 与 OpenAI 兼容接口共同透传:
-
-- runner payload 携带 `invocation_id`,由 conversation runtime 进入 `platform_invocation_scope` / `tool_execution_scope`。
-- `/v1/responses`、`/v1/chat/completions` 与 `RunAgent` action 都接收并透传 `account_id`,进入 `PlatformInvocationContext`。
-- 后台 stream(`Background=true`)使用 `invocation_id` 作为 detached stream 的索引键,供 `SubscribeRunEvents` 拉起始态。
-
-```python hl_lines="3"
-# RunAgent action 透传示例(伪代码)
-result = await conversation.invoke_conversation_once(
- runner=active_runner,
- agent_id=agent_id,
- user_id=run_user_id,
- account_id=account_id, # 控制面透传的账号边界
- invocation_id=invocation_id, # 单次调用稳定标识
- session_id=resolved_session_id,
-)
-```
-
-### 12.3 工具按账号边界读取当前调用上下文
-
-Skill / Workspace / Sandbox / Memory 工具在执行时通过 `get_current_invocation_context_or_default()` 读取当前 `account_id` / `user_id` / `session_id`,使同一 runner 进程内的多账号调用互不串扰:
-
-- Memory 工具使用 `context.user_id` / `context.session_id` 限定长期记忆的读写范围。
-- Skill 工具通过 `KSYUN_ACCOUNT_ID` / `KSADK_SKILL_SERVICE_ACCOUNT_ID` 把账号写入 Skill Service 请求头 `X-Ksc-Account-Id`。
-- Workspace / Sandbox 工具通过 `tool_execution_scope` 读取 `session_id` / `run_id` / `invocation_id`,把执行范围绑定到当前调用。
-
-!!! tip "工具实现建议"
- 自定义工具优先调用 `get_current_invocation_context_or_default()`,不要直接读 `os.environ` 里的账号信息;前者反映当前调用边界,后者只反映进程启动环境。
-
-## 13. Hosted/本地附件统一解析
-
-!!! new "0.6.6 新增"
- 附件 URI 统一为两种 scheme:本地 `ksadk-upload://` 与 Hosted `ae-upload://`。runtime 侧统一解析、按需下载并恢复本地 cache。
-
-### 13.1 双 scheme 解析
-
-`ksadk.conversations.attachment_storage` 同时识别两种 scheme:
-
-| Scheme | 来源 | 解析动作 |
-| --- | --- | --- |
-| `ksadk-upload://` | 本地上传或 KS3 回填 | 直接读本地 cache 或 KS3 object |
-| `ae-upload://` | Hosted 控制面下发 | 走 KOP Action `AttachmentContent` 下载 |
-
-`parse_file_id()` 对两种 scheme 都返回去掉前缀后的 `file_id`;`is_runtime_upload_uri()` / `is_hosted_upload_uri()` 用于分支判断。
-
-### 13.2 KOP Action 下载
-
-Hosted 附件通过 `AgentEngineClient.download_attachment_content(file_uri)` 走签名后的 KOP Action API 拉取字节流,返回 `AttachmentContent`(`data` / `content_type` / `display_name`)。下载失败时返回 `None`,由上层决定是否降级。
-
-### 13.3 本地 cache 恢复链路
-
-`AttachmentStorageService.read()` 按以下顺序恢复附件字节:
-
-1. `ae-upload://` → 调用 KOP Action 下载,写入本地 cache 并落 `.meta.json`。
-2. `ksadk-upload://` 且 metadata 标记 `backend=ks3` → 读 KS3 object,失败时回退本地 cache。
-3. metadata 里有 `local_path` → 直接读本地文件。
-4. 上述都缺失 → 尝试 legacy 本地路径兜底。
-
-`_restore_local_cache()` 负责把下载字节落盘到 session files 目录并回填 `local_path`,保证同一 `invocation_id` 内的重复读取不反复走网络。
-
-## 14. 会话与事件分页
-
-!!! new "0.6.6 新增"
- `ListSessions` / `ListSessionEvents` 增加 `count_sessions` / `count_events`,返回 `Total` 供 UI 分页。
-
-会话与事件查询在原有 `offset` / `limit` 基础上新增总数统计:
-
-| Action | 入参 | 出参新增 |
-| --- | --- | --- |
-| `ListSessions` | `Page` / `PageSize`(agent_id + user_id) | `Total`(`count_sessions`) |
-| `ListSessionEvents` | `Offset` / `Limit`(session_id) | `Total`(`count_events`) |
-
-`SessionService` 基类与 `LocalSessionService` / `PostgresSessionService` 实现统一提供 `count_sessions` / `count_events`,保证本地 SQLite 与托管 PG 行为一致。
-
-## 15. Workspace 导出 facade
-
-!!! new "0.6.6 新增"
- `ExportWorkspaceZip` 作为统一 facade,把 workspace 目录打包成 zip 流式下载。
-
-`GET /agentengine/api/v1/ExportWorkspaceZip?path=` 由 `ksadk_runtime_common.workspace_files.router` 提供,本地 `ksadk.server.app` 与 OpenClaw sidecar 共用同一 handler。`path` 默认为 `.`(workspace 根),导出前做路径逃逸拦截,符号链接逃逸会被拒绝。
-
-## 16. Custom UI 配置体系与 checkpoint/resume
-
-!!! new "0.6.7 新增"
- Custom UI profile 与 LangGraph checkpoint/resume 能力层在 0.6.7 稳定。
-
-### 16.1 Custom UI 配置体系
-
-`ksadk.ui_config.resolve_ui_config()` 按「CLI 参数 → `.agentengine.state` → framework 默认 → 全局默认」优先级合并出最终 `UIConfig`(`profile` / `path` / `url`)。`ui_profile=custom` 时:
-
-- `path` 默认 `/`(不复用 `/chat`)。
-- 本地 `agentengine web` / `agentengine dashboard` 通过 `_configure_custom_ui_env()` 解析 custom bundle 目录并挂载静态资源。
-- Hosted 侧由控制面把 `ui_profile` / `ui_path` / `ui_url` 写入 state,runtime 读取后路由到 custom bundle。
-
-支持 profile 列表:`auto` / `adk` / `langchain` / `openclaw` / `hermes` / `custom`。
-
-### 16.2 LangGraph checkpoint/resume 能力层
-
-`LangGraphRunner` 暴露 checkpoint 描述与恢复能力:
-
-- `describe_checkpoint_capability()` 返回 `Supported` / `Backend` / `Scope` / `Durable` / `Reason`,供 UI 判断是否可 resume。
-- `_latest_checkpoint_metadata()` 从 `aget_state(config)` 提取 `thread_id` / `checkpoint_ns` / `checkpoint_id` / `next_node`,标注 `is_terminal` / `is_resumable`。
-- resume 时 `_apply_checkpoint_resume_config()` 把 `thread_id` / `checkpoint_ns` / `checkpoint_id` 写入 `configurable`,保留 `checkpoint_ns` 以命中正确的子图状态。
-
-!!! warning "checkpoint_ns 必须保留"
- LangGraph 的 checkpoint 在子图(subgraph)场景下按 `thread_id` + `checkpoint_ns` + `checkpoint_id` 定位;丢掉 `checkpoint_ns` 会错误恢复到父图状态。`_checkpoint_ref_from_state()` 只在 `checkpoint_ns` 非空时写入 `framework_ref.langgraph.checkpoint_ns`。
-
-## 17. 文档索引
-
-- [ksadk使用文档](../guides/ksadk使用文档.md)
-- [工作区文件技术设计](../internal/工作区文件技术设计.md)
-- [记忆使用指南](../guides/记忆使用指南.md)
-- [OpenClaw一键部署指南](./openclaw一键部署指南.md)
diff --git "a/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" "b/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md"
index ac6c26b3..8228fe4e 100644
--- "a/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md"
+++ "b/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md"
@@ -138,6 +138,7 @@
| `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | 否 | 无 | 是 | 平台 / 开发者 | traces 专用 OTLP headers;设置后优先于通用 headers。 |
| `OTEL_SERVICE_NAME` | 否 | 无 | 否 | 平台 / 开发者 | OTel service name。 |
| `OTEL_RESOURCE_ATTRIBUTES` | 否 | 无 | 否 | 平台 / 开发者 | OTel resource attributes。 |
+| `KSADK_OTLP_MAX_EXPORT_BATCH_SIZE` | 否 | 无 | 否 | 平台 / 开发者 | OTLP 单次 export 最大 span 数,默认 `64`,用于避免 collector 请求过大。 |
## 3. 通用模型与 LLM 变量
@@ -342,6 +343,7 @@
| `KSADK_WEB_RELEASE_URL` | Hosted Web UI static sync | 否 | 未设置 | 无 | 否 | 构建环境 / 开发者 | 否 | 可选兼容兜底。设置后跳过 npm pack,改从该 tarball URL 下载。 |
| `KSADK_WEB_CACHE_DIR` | Hosted Web UI static sync | 否 | `.cache/ksadk-web` | 无 | 否 | 构建环境 / 开发者 | 否 | KsADK Web 包解压缓存目录。 |
| `KSADK_GLOBAL_CONFIG_ENV_KEYS` | CLI | 否 | 未设置 | 无 | 否 | CLI 内部 | 否 | CLI 启动时记录哪些环境变量由 `~/.agentengine/settings.json` 补入,用于区分用户显式环境变量和全局配置默认值。 |
+| `KSYUN_IAM_URL` | 身份反查 | 否 | `https://iam.api.ksyun.com` | 无 | 否 | CLI | 否 | 覆盖 IAM endpoint,用于 AK/SK 反查子账号 user uuid。内部账号 AK 公网访问被拒时,CLI 自动 fallback 到 `http://iam.inner.api.ksyun.com`。 |
| `AGENTENGINE_LOCAL_RUNTIME_VENV_REEXEC` | 本地 runtime CLI | 否 | 自动判断 | 无 | 否 | 本地开发者 / 测试 | 否 | 控制本地 runtime 是否在虚拟环境中 re-exec。普通用户通常无需设置。 |
| `AGENTENGINE_WEB_VENV_REEXEC` | 本地 Web CLI | 否 | 自动判断 | 无 | 否 | 本地开发者 / 测试 | 否 | 控制本地 Web 命令是否在虚拟环境中 re-exec。普通用户通常无需设置。 |
| `AGENTENGINE_DEBUG` | CLI | 否 | 未设置 | 无 | 否 | 开发者 | 否 | 开启更详细错误输出。 |
@@ -428,9 +430,9 @@
| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | traces 专用 endpoint;设置后优先于通用 endpoint。 |
| `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | traces 专用 OTLP 协议;设置后优先于通用 protocol。 |
| `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | OTel | 否 | 未设置 | 无 | 是 | 平台 / 开发者 | 否 | traces 专用 OTLP headers;设置后优先于通用 headers。 |
-| `KSADK_OTLP_MAX_EXPORT_BATCH_SIZE` | OTel | 否 | `64` | 无 | 否 | 平台 / 开发者 | 否 | 单次 OTLP export 的最大 span 数,降低 collector 413 风险。 |
| `OTEL_SERVICE_NAME` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | service name。 |
| `OTEL_RESOURCE_ATTRIBUTES` | OTel | 否 | 未设置 | 无 | 否 | 平台 / 开发者 | 否 | resource attributes。 |
+| `KSADK_OTLP_MAX_EXPORT_BATCH_SIZE` | OTel | 否 | `64` | 无 | 否 | 平台 / 开发者 | 否 | OTLP 单次 export 最大 span 数,用于避免 collector 请求过大。 |
## 12. Hermes 和 OpenClaw 常见运行时变量
diff --git "a/docs/reference/\350\277\234\347\250\213Agent\350\277\220\350\241\214\346\227\266\346\216\245\345\217\243\350\257\264\346\230\216.md" "b/docs/reference/\350\277\234\347\250\213Agent\350\277\220\350\241\214\346\227\266\346\216\245\345\217\243\350\257\264\346\230\216.md"
deleted file mode 100644
index 251e4781..00000000
--- "a/docs/reference/\350\277\234\347\250\213Agent\350\277\220\350\241\214\346\227\266\346\216\245\345\217\243\350\257\264\346\230\216.md"
+++ /dev/null
@@ -1,2168 +0,0 @@
-# 远程Agent运行时接口说明
-
-本文档基于当前 `master` 分支的真实代码实现整理,目标是说明:
-
-- Agent 部署到远程 K8s / Serverless Pod 之后,最终通过 `PublicEndpoint` 对外暴露哪些接口
-- 不同运行时类型的接口差异:通用 Agent、Hermes、OpenClaw
-- 公共鉴权、公共 Header、流式行为、WebSocket 约束
-- 各接口的请求体 / 响应体 shape
-
-本文档只把当前代码里可以确认的 contract 写出来;对仓库中未完整定义、但依赖上游项目的 OpenClaw 原生接口,不做超出代码证据的推断。
-
-## 1. 事实来源
-
-本文档主要依据以下代码与文档:
-
-- `agentengine-server/app/api/v1/actions/agent_actions.py`
-- `agentengine-server/app/api/v1/actions/chat_actions.py`
-- `agentengine-server/app/api/v1/actions/feedback_actions.py`
-- `agentengine-server/app/gateway/api.py`
-- `agentengine-server/app/gateway/router_service.py`
-- `agentengine-server/docs/技术设计.md`
-- `agentengine-server/docs/网关鉴权说明.md`
-- `ksadk-python/ksadk/server/app.py`
-- `ksadk-python/ksadk/server/api_models.py`
-- `ksadk-python/ksadk/conversations/runtime.py`
-- `ksadk-python/ksadk_runtime_common/workspace_files/*.py`
-- Hermes / OpenClaw 运行时镜像资产(`runtime/app.py`、`README.md`、`bootstrap.sh`、用户镜像 `Dockerfile` 等)已迁出至 `agentengine-images` 仓库,原 `deploy/hermes/`、`deploy/openclaw/`、`deploy/openclaw-user-template/` 路径不再存在于本仓库
-
-## 2. 入口模型
-
-### 2.1 公网入口
-
-远程 Agent 部署成功后,控制面 `GetAgent` 会返回:
-
-- `QuickAccess.PublicEndpoint`
-
-这个地址就是外部调用运行时接口时应使用的根地址。例如:
-
-```text
-http://ar-20260506162108-d30283cd.agent-pre.kspmas.ksyun.com
-```
-
-说明:
-
-- 对外看到的是 `PublicEndpoint`
-- 实际请求先进入 Ingress / Gateway,再由 `agentengine-server` 的 router 做鉴权和转发
-- 因此“部署后暴露的接口”应以公网入口经过网关后可访问的路径为准,而不是简单把 Pod 内部监听端口当成外部 contract
-
-### 2.2 内网入口
-
-`GetAgent` 也可能返回:
-
-- `QuickAccess.PrivateEndpoint`
-
-这类地址用于内网访问,不作为本文主线。本文默认描述通过 `PublicEndpoint` 暴露的接口。
-
-## 3. 鉴权与公共 Header
-
-## 3.1 外部访问鉴权
-
-当前数据面统一通过网关校验,外部调用主要有两种认证方式:
-
-1. `Authorization: Bearer `
-2. `ae_ui_session` Cookie
-
-其中:
-
-- API/SDK/CLI 直连运行时接口时,使用 `Authorization: Bearer `
-- 浏览器经 dashboard share link 或 hosted UI 访问时,通常使用 `ae_ui_session` Cookie
-
-代码证据:
-
-- `agentengine-server/docs/网关鉴权说明.md`
-- `agentengine-server/app/gateway/api.py`
-
-### 3.1.1 Bearer Token 的含义
-
-Bearer Token 有两种来源:
-
-1. AgentEngine 为该 Agent 签发的 API Key,通常是 `ak-...` 或 `sk-...`
-2. OpenClaw 在 `token` 模式下使用的 shared secret
-
-对绝大多数自动化调用,推荐理解为:
-
-```http
-Authorization: Bearer
-```
-
-### 3.1.2 Cookie 会话的适用场景
-
-`ae_ui_session` 主要用于:
-
-- `https:///chat`
-- `https:///`
-- share link 跳转后的浏览器会话
-
-它不是给通用脚本调用运行时 API 设计的主接口。
-
-## 3.2 公共请求 Header
-
-### 3.2.1 通用 HTTP Header
-
-建议按以下方式构造:
-
-| Header | 是否必填 | 说明 |
-| --- | --- | --- |
-| `Authorization: Bearer ` | 外部 API 调用必填 | 由网关校验 |
-| `Content-Type: application/json` | JSON 请求推荐 | `POST /v1/*`、`POST /agentengine/api/v1/*` 常用 |
-| `Accept: application/json` | 非流式请求推荐 | 返回 JSON |
-| `Accept: text/event-stream` | 流式请求推荐 | `stream=true` 时推荐显式声明 |
-
-说明:
-
-- 对于 `multipart/form-data` 上传,如 `UploadFile` / `AddWorkspaceFile`,`Content-Type` 由客户端自动生成 boundary
-- 运行时应用本身没有在 `ksadk.server.app` 内显式校验 Bearer;鉴权发生在网关层
-
-### 3.2.2 WebSocket Header
-
-Hermes 终端 WebSocket 额外要求:
-
-| Header | 是否必填 | 说明 |
-| --- | --- | --- |
-| `Authorization: Bearer ` | 公网访问建议携带 | 网关鉴权 |
-| `Sec-WebSocket-Protocol: ks-terminal.v1` | 必填 | Hermes 终端子协议 |
-
-如果缺少 `ks-terminal.v1`,Hermes runtime 会直接拒绝连接。
-
-## 3.3 内部 Header 与外部调用边界
-
-以下 Header 会在网关和运行时之间使用,但**不应由外部调用方手工构造**:
-
-| Header | 用途 |
-| --- | --- |
-| `X-Auth-Agent-Id` | 网关鉴权后注入的 Agent ID |
-| `X-Auth-Account-Id` | 网关鉴权后注入的账号 ID |
-| `X-Auth-Framework` | 网关鉴权后注入的 framework |
-| `X-Auth-Openclaw-Gateway-Mode` | OpenClaw 模式透传 |
-| `X-Forwarded-Host` | 原始 Host 透传 |
-| `x-forwarded-user` | OpenClaw trusted-proxy / workspace 代理链路使用 |
-| `X-Hermes-Session-Token` | Hermes dashboard 内部 fetch shim 使用 |
-
-外部用户应只关心:
-
-- Bearer API Key
-- Cookie Session
-- WebSocket 子协议
-
-## 4. 运行时类型矩阵
-
-当前主线下,公网可见接口按运行时分为三类:
-
-| 运行时类型 | 典型 framework | 主入口实现 | 对外特征 |
-| --- | --- | --- | --- |
-| 通用 Agent 运行时 | `adk` / `langchain` / `langgraph` / `deepagents` | `ksadk.server.app` | `/v1/*` + workspace files;公网 `/chat` 由独立 hosted UI 服务承载并调用 Hosted UI action 接口 |
-| Hermes 托管运行时 | `hermes` | `agentengine-images` 仓库内 `deploy/hermes/runtime/app.py` 外层 wrapper | `/` dashboard、`/v1/*`、`/_ksadk/terminal/ws`、workspace files;公网 `/chat` 同样由独立 hosted UI 服务承载 |
-| OpenClaw 托管运行时 | `openclaw` | OpenClaw gateway + ksadk 补丁 | 以 OpenClaw gateway 为主,平台额外挂出 workspace files |
-
-## 5. 公网暴露范围总览
-
-### 5.1 通用 Agent 运行时
-
-公网入口可确认的主路径:
-
-- `GET /health`
-- `POST /v1/responses`
-- `POST /v1/chat/completions`
-- `GET /chat`
-- `GET /build`
-- `GET /deploy`
-- `GET /agentengine/api/v1/AttachmentContent`
-- `GET /agentengine/api/v1/GetWorkspaceFileContent`
-- `POST /agentengine/api/v1/GetAgentUiBootstrap`
-- `POST /agentengine/api/v1/CreateSession`
-- `POST /agentengine/api/v1/GetSession`
-- `POST /agentengine/api/v1/ListSessions`
-- `POST /agentengine/api/v1/DeleteSession`
-- `POST /agentengine/api/v1/ListSessionEvents`
-- `GET /agentengine/api/v1/SubscribeRunEvents`
-- `POST /agentengine/api/v1/RunAgent`
-- `POST /agentengine/api/v1/ListSessionCheckpoints`
-- `POST /agentengine/api/v1/GetCheckpointResumePreview`
-- `POST /agentengine/api/v1/ListToolReceipts`
-- `POST /agentengine/api/v1/ResumeRun`
-- `POST /agentengine/api/v1/CancelRun`
-- `POST /agentengine/api/v1/UploadFile`
-- `POST /agentengine/api/v1/ListWorkspaceFiles`
-- `POST /agentengine/api/v1/AddWorkspaceFile`
-- `POST /agentengine/api/v1/DeleteWorkspaceFile`
-- `POST /agentengine/api/v1/ListAgentModels`
-- `GET /agentengine/api/v1/ExportWorkspaceZip`
-- `POST /run_sse`
-- `GET/POST/DELETE /apps/{app_name}/users/{user_id}/sessions*`
-
-注意:
-
-- 并不是所有 `/agentengine/api/v1/*` 都会通过公网数据面暴露
-- 网关只放行 Hosted UI 所需的那一小组 action
-- 对 `PublicEndpoint` 而言,`POST /agentengine/api/v1/*` 这组 Hosted UI action 实际会被 router 代理回 `agentengine-server`,不是直接命中 runtime pod 的本地同名路由
-
-### 5.2 Hermes 运行时
-
-公网入口可确认的主路径:
-
-- `GET /`
-- `GET /health`
-- `GET/POST/PUT/PATCH/DELETE/OPTIONS /v1/{path}`
-- `GET/POST/PUT/PATCH/DELETE/OPTIONS /{path}`
- 这部分本质是 Hermes dashboard 与其 API 的代理入口
-- `GET/HEAD/POST/DELETE /_ksadk/workspace/v1/*`
-- `WS /_ksadk/terminal/ws`
-- `GET /chat`
-
-### 5.3 OpenClaw 运行时
-
-当前代码中可以**准确确认**的平台追加 contract 只有:
-
-- `/_ksadk/workspace/v1/*`:通过 ksadk sidecar / proxy 增加的文件接口
-
-此外还可以确认:
-
-- OpenClaw gateway 默认跑在 `8080`
-- 鉴权模式支持 `trusted-proxy | token | none`
-- 健康检查使用的是上游 gateway 的 `/healthz`
-
-但 OpenClaw gateway 原生完整 API 面不是本仓当前代码独立定义的,因此本文不把其所有原生端点逐条列为平台 contract。
-
-## 6. 通用 Agent 运行时详细接口
-
-本节适用于原始 runtime 服务本身:
-
-- `adk`
-- `langchain`
-- `langgraph`
-- `deepagents`
-
-底层实现:`ksadk-python/ksadk/server/app.py`
-
-重要边界:
-
-- 本节里的 `/v1/*`、`/health`、`/run_sse`、`/apps/.../sessions*` 是 runtime pod 自身实现
-- 但对公网 `PublicEndpoint` 来说,`/agentengine/api/v1/*` Hosted UI action 以 `agentengine-server` facade 为准
-- 因此本文后续会把“runtime 原始接口”和“公网 Hosted facade”拆开写
-
-## 6.1 健康检查
-
-### `GET /health`
-
-用途:
-
-- 检查运行时是否启动
-- 返回当前 runner 识别出的 framework 和 agent 名
-
-请求示例:
-
-```bash
-curl -H "Authorization: Bearer " \
- "https:///health"
-```
-
-响应示例:
-
-```json
-{
- "status": "ok",
- "framework": "langgraph",
- "agent": "demo-agent"
-}
-```
-
-## 6.2 OpenAI Responses 兼容接口
-
-### `POST /v1/responses`
-
-说明:
-
-- 非流式返回 OpenAI Responses 风格 JSON
-- 流式返回 `text/event-stream`
-
-请求体字段:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `input` | `string | array` | 是 | 用户输入;字符串或 KOP 风格消息数组 |
-| `model` | `string` | 否 | 本次调用显式模型 |
-| `model_metadata` | `object` | 否 | 模型元数据 |
-| `instructions` | `string` | 否 | 额外系统指令 |
-| `metadata` | `object` | 否 | 请求级 metadata |
-| `conversation` | `string | object` | 否 | OpenAI Responses 会话绑定字段;可传 `"conv_xxx"` 或 `{ "id": "conv_xxx" }`,runtime 会映射为内部会话 ID |
-| `previous_response_id` | `string` | 否 | OpenAI Responses 上一轮 response id;不能和 `conversation` 同时使用 |
-| `safety_identifier` | `string` | 否 | OpenAI 推荐的最终用户稳定标识;runtime 会映射为内部 user id 和 Langfuse UserID,建议传 hash 后值 |
-| `prompt_cache_key` | `string` | 否 | OpenAI prompt cache 路由提示;runtime 当前保留到请求 metadata,不作为用户身份 |
-| `user` | `string` | 否 | OpenAI deprecated 用户字段;仅在未传 `safety_identifier` 时作为兼容兜底 |
-| `store` | `boolean` | 否 | OpenAI Responses 存储开关;runtime 当前保留到请求 metadata |
-| `stream` | `boolean` | 否 | 是否流式 |
-| `session_id` | `string` | 否 | ksadk legacy extension;兼容旧客户端。新接入应优先使用 `conversation` |
-| `account_id` | `string` | 否 | 0.6.7 新增。账号 ID 透传;写入 `PlatformInvocationContext`,用于多租户隔离与审计 |
-
-最小请求示例:
-
-```json
-{
- "input": "你好",
- "stream": false
-}
-```
-
-带会话与模型示例:
-
-```json
-{
- "input": [
- {
- "role": "user",
- "content": [
- {
- "text": "请总结一下这份设计"
- }
- ]
- }
- ],
- "model": "glm-5.2",
- "stream": true,
- "conversation": "conv_customer_001",
- "safety_identifier": "hash_user_001"
-}
-```
-
-会话字段边界:
-
-- 官方兼容路径:连续对话传 `conversation`;最终用户标识传 `safety_identifier`。
-- `previous_response_id` 只表达 Responses 链式上下文,不能和 `conversation` 同时使用。
-- `session_id` 是 ksadk 早期扩展字段,仅为旧客户端保留;不要在新代码中把它当作 OpenAI 官方字段。
-- 不要通过 `metadata.user_id`、`metadata.session_id` 或其他私有 metadata 约定传用户身份和会话身份。
-
-推荐请求示例:
-
-```json
-{
- "model": "deepseek-v4-pro",
- "input": "帮我分析这张账单",
- "conversation": "conv_bill_20260525_001",
- "safety_identifier": "user_hash_001",
- "stream": false
-}
-```
-
-图片与附件输入:
-
-推荐写法:
-
-- `/v1/responses` 推荐使用 OpenAI Responses content blocks:`input_text` / `input_image` / `input_file`
-- runner 业务代码推荐读取 `payload["input_content"]` / `payload["input_messages"]`,这是 KsADK 默认 canonical 输入
-- 判断当前轮是否传了图片或文件,推荐使用 `payload["has_current_files"]` 和 `payload["current_attachments"]`
-- 读取当前轮 OCR、文档抽取、压缩包摘要,推荐使用 `payload["current_attachment_results"]`
-
-兼容写法:
-
-- 老客户端仍可使用 KsADK 兼容扩展 part 数组:`text` / `inlineData` / `fileData`
-- runner 里仍保留 `payload["input_parts"]`,用于兼容已有 `text / inlineData / fileData` 业务代码
-- `payload["attachments"]` / `payload["attachment_results"]` 仍保留,但语义是最近有效附件上下文,可能来自历史 fallback;不要用它判断当前最新 user turn 是否上传了文件
-- `/v1/chat/completions` 对外仍保持 Chat Completions 语义,官方图片块使用 `text` / `image_url`;`inlineData` / `fileData` 在 Chat 入口只属于 KsADK 兼容扩展,不是 OpenAI Chat 官方能力
-
-字段细节:
-
-- `input_image.image_url` 支持远程图片 URL 或 `data:image/...;base64,...`,运行时会归一化为内部附件上下文
-- `input_file.file_data` 会归一化为内部 `inlineData`;`input_file.file_url` / `input_file.file_id` 会归一化为内部 `fileData` 引用
-- `inlineData` 适合旧客户端直接内联 base64 内容
-- `fileData` 适合旧客户端先调用 `UploadFile`,再引用返回的 `ksadk-upload://...`
-- 远程图片 URL 会作为引用保留,并可在支持原生图片输入的 LangGraph 路径下继续传给模型;KsADK 不会主动拉取远程图片或远程文件做 OCR / 文本提取。需要平台提取、OCR 或本地附件内容时,请使用 data URL、`file_data`、`inlineData` 或 `fileData`
-
-图片示例(OpenAI Responses 风格 data URL):
-
-```json
-{
- "input": [
- {
- "role": "user",
- "content": [
- {
- "type": "input_text",
- "text": "请分析这张图片"
- },
- {
- "type": "input_image",
- "image_url": "data:image/png;base64,"
- }
- ]
- }
- ],
- "model": "glm-5.2",
- "stream": false
-}
-```
-
-业务代码获取图片信息:
-
-```python
-def ksadk_prepare_input(payload, session_context):
- # 当前轮是否真的上传了图片/文件。不要用 attachments 判断当前轮,
- # attachments 可能是历史最近一次有效附件上下文。
- has_current_files = payload.get("has_current_files", False)
- current_attachments = payload.get("current_attachments", [])
-
- images = [
- item
- for item in current_attachments
- if str(item.get("mime_type", "")).startswith("image/")
- ]
-
- # OpenAI Responses canonical content,适合直接转给支持原生多模态的模型。
- input_content = payload.get("input_content", [])
- image_blocks = [
- block
- for block in input_content
- if block.get("type") == "input_image"
- ]
-
- return {
- "input": payload.get("input", ""),
- "images": images,
- "image_blocks": image_blocks,
- }
-```
-
-如果业务 agent 使用 LangGraph / LangChain 并且模型支持原生多模态,优先从 `input_content` 或 `input_messages` 读取 `input_image`,按底层模型 SDK 需要的消息格式继续传递;如果需要读取平台归一化后的附件元信息、OCR / 文档抽取结果,则读取 `current_attachments` 和 `current_attachment_results`。`input_parts`、`inlineData`、`fileData` 是 legacy/internal 兼容输入,仍可作为老客户端兜底。
-
-多模态模型“看图”和平台 OCR 是两条不同链路:推荐让支持图片的模型直接消费 `input_image` / `input_content`,这样不需要在代码包里安装本地 OCR 依赖。平台本地 OCR 只用于需要把图片预先转成 `current_attachment_results[*].text` 的场景;源码构建默认不打包 OCR 二进制栈,如需启用请在构建环境设置 `KSADK_BUILD_ENABLE_ATTACHMENT_OCR=true`,或在项目 `requirements.txt` 中显式加入 OCR 相关依赖。
-
-图片 data URL 或 `inlineData.data` 本身就是 base64 字符串,payload 可能很大,这是内联传图时的正常现象。业务日志不要直接打印完整 `payload`、`input_content`、`input_parts` 或 `current_attachments`;建议只记录字段摘要,例如文件名、MIME、大小、transport、data URL 前缀和长度:
-
-```python
-def summarize_attachment(item):
- data = item.get("data") or ""
- return {
- "display_name": item.get("display_name"),
- "mime_type": item.get("mime_type"),
- "transport": item.get("transport"),
- "file_uri": item.get("file_uri"),
- "size_bytes": item.get("size_bytes"),
- "has_inline_data": bool(data),
- "inline_data_length": len(data),
- }
-
-logger.info(
- "ksadk_prepare_state attachments=%s has_current_files=%s",
- [summarize_attachment(item) for item in payload.get("current_attachments", [])],
- payload.get("has_current_files", False),
-)
-```
-
-旧客户端图片示例(先上传,再引用):
-
-```json
-{
- "input": [
- {
- "role": "user",
- "content": [
- {
- "text": "请分析这张图片"
- },
- {
- "fileData": {
- "fileUri": "ksadk-upload://abc123.png",
- "displayName": "diagram.png",
- "mimeType": "image/png"
- }
- }
- ]
- }
- ],
- "model": "glm-5.2",
- "stream": false
-}
-```
-
-旧客户端图片示例(直接内联):
-
-```json
-{
- "input": [
- {
- "role": "user",
- "content": [
- {
- "text": "请分析这张图片"
- },
- {
- "inlineData": {
- "data": "",
- "displayName": "diagram.png",
- "mimeType": "image/png"
- }
- }
- ]
- }
- ]
-}
-```
-
-当前附件类型支持矩阵:
-
-| 类型 | 典型扩展名 / MIME | 传输支持 | 平台提取支持 | 原生多模态直通 |
-| --- | --- | --- | --- | --- |
-| 文本 | `.txt` `.md` `.json` `.yaml` `.yml` `.csv` `.tsv` `.log` | 支持 | 支持 | 不适用 |
-| 文档 | `.pdf` `.docx` `.pptx` `.xlsx` `.html` `.htm` | 支持 | 部分支持:文本提取 / OCR | 不适用 |
-| 图片 | `.png` `.jpg` `.jpeg` `.webp` / `image/*` | 支持 | 元信息提取默认支持;OCR 需构建时显式启用 | 部分支持,见下方框架差异 |
-| 压缩包 | `.zip` | 支持 | 支持:目录/可读文件抽样提取 | 不适用 |
-| 其他二进制 | 其他后缀或 `application/octet-stream` | 支持 | 通常仅保留为附件引用 | 不支持 |
-
-框架差异:
-
-- `ADK`
- - 图片附件会优先以 bytes 形式构造成底层 SDK `Part`
- - 若底层模型支持原生多模态,可直接消费图片
-- `LangGraph`
- - 简化输入路径下,若模型支持图片输入,图片附件会自动转换为多模态 `HumanMessage.content` blocks
- - 非图片附件仍保留为普通附件上下文
-- `LangChain`
- - 当前没有对所有 agent 统一做“自动图片直通”
- - 如需原生多模态,建议在 `ksadk_prepare_input(payload, session_context)` 中优先消费 `input_content / input_messages`,必要时再兼容 `input_parts / current_attachments / attachments`
- - 判断当前轮是否传文件用 KsADK runner payload 扩展字段 `has_current_files`;该字段不是 OpenAI Responses API 官方字段
-
-模型能力判断优先级:
-
-1. 请求里显式传入的 `model_metadata`
-2. runtime 通过 `OPENAI_BASE_URL` / `OPENAI_API_KEY` 查询上游 `/v1/models` 返回的 `architecture.input_modalities`
-3. 本地默认兜底(按文本模型处理)
-
-多轮会话历史:
-
-- `/v1/responses` 本身不要求客户端每轮重传完整历史
-- 新客户端应持续传同一个 `conversation`,runtime 会从服务端会话存储里恢复该会话的历史 transcript
-- 旧客户端只传 `session_id` 时仍可恢复同一会话,但这是 ksadk legacy extension
-- 进入 runner 前,`ksadk` 会把历史、附件上下文、知识库上下文和长期记忆上下文统一重建成标准运行输入
-- `safety_identifier` 会作为内部 user id,并用于 Langfuse UserID;未传时 deprecated `user` 字段可作为兜底
-- `previous_response_id` 按 OpenAI Responses 语义接收并保留;当使用 `conversation` 时不要同时传 `previous_response_id`
-
-### Responses approval / interrupt 恢复
-
-如果流式执行遇到工具审批或人工确认,runtime 不会把本轮包装成 completed,而是返回 incomplete:
-
-- `status`: `incomplete`
-- `incomplete_details.reason`: `approval_required`
-- MCP/tool approval 场景会输出 `mcp_approval_request`
-- 非 MCP 的通用 interrupt 会输出 `response.ksadk.approval_request`
-
-#### MCP approval 恢复
-
-MCP/tool approval 场景按 OpenAI Responses 标准语义恢复。客户端应传同一个 `conversation` 或 legacy `session_id`,并把 `input` 写成 `mcp_approval_response`:
-
-```json
-{
- "conversation": "conv_customer_001",
- "input": [
- {
- "type": "mcp_approval_response",
- "id": "mcprsp_123",
- "approval_request_id": "appr_123",
- "approve": true,
- "reason": "approved by user"
- }
- ],
- "stream": true
-}
-```
-
-运行时处理方式:
-
-- 记录一条 `approval_response` 会话事件
-- 向 runner 传入 `resume=True`
-- `input` 原样保留为 `mcp_approval_response`
-- LangGraphRunner 在内部转换成 `Command(resume=...)`
-
-调用方不需要、也不应该直接传 Python `Command`。
-
-#### 通用 interrupt 恢复
-
-如果 interrupt 不是 MCP/tool approval,而是普通人工确认、补充信息或业务分支选择,客户端可以使用平台扩展 `ksadk_resume`:
-
-```json
-{
- "conversation": "conv_customer_001",
- "input": [
- {
- "type": "ksadk_resume",
- "interrupt_id": "intr_123",
- "value": {
- "approved": true,
- "answer": "继续"
- }
- }
- ],
- "stream": true
-}
-```
-
-这类事件属于 `ksadk` 扩展,不伪装成 OpenAI MCP approval。
-
-### Agent 开发者如何在业务代码中拿到上下文
-
-这部分不属于远程 API 调用 contract。不同框架的业务代码接入方式已经内化到框架专属文档:
-
-- LangGraph: [LangGraph开发最佳实践](../guides/LangGraph开发最佳实践.md)
-- 平台公共上下文总览: [Agent 开发者上下文接入指南](../guides/Agent 开发者上下文接入指南.md)
-
-调用方只需要理解:
-
-- `/v1/responses` 不要求每轮重传完整历史
-- 同一会话应持续传同一个 `conversation`;旧客户端传 `session_id` 也能继续兼容
-- runtime 会在进入 runner 前重建历史、附件、知识库和长期记忆上下文
-- 框架业务代码如何消费这些上下文,由对应框架最佳实践文档说明
-
-### 历史压缩(compaction)是怎么做的
-
-长会话不会无限把所有历史原样塞进模型。
-
-当前策略是:
-
-1. transcript 按 API round / `invocation_id` 分组
-2. 保留最近若干轮原始消息
-3. 把更早历史压成一条 `context_checkpoint`
-4. 后续模型看到的是:
- - 一条 `Earlier conversation summary: ...`
- - 最近若干轮原始 user / assistant 消息
-
-重要特性:
-
-- 原始事件不会物理删除,compaction 是 append-only
-- 工具调用、审批请求、附件引用等关键信息不会简单丢弃,会以 summary 或占位文本形式保留
-- 压缩阈值会结合 `model_metadata` 的上下文窗口能力自动调整
-
-非流式响应字段:
-
-| 字段 | 说明 |
-| --- | --- |
-| `id` | response ID |
-| `object` | 固定 `response` |
-| `created_at` | Unix 时间戳 |
-| `status` | 默认 `completed` |
-| `model` | 模型名 |
-| `output` | 输出条目数组 |
-| `output_text` | 文本聚合结果 |
-| `usage` | 简化 token 统计 |
-| `session_id` | ksadk 返回的内部会话 ID;当请求传了 `conversation` 时与其 id 一致 |
-
-非流式响应示例:
-
-```json
-{
- "id": "resp_123",
- "object": "response",
- "created_at": 1710000000,
- "status": "completed",
- "error": null,
- "incomplete_details": null,
- "instructions": null,
- "metadata": {},
- "model": "glm-5.2",
- "parallel_tool_calls": true,
- "temperature": null,
- "top_p": null,
- "tools": [],
- "output": [
- {
- "id": "msg_abc",
- "type": "message",
- "status": "completed",
- "role": "assistant",
- "content": [
- {
- "type": "output_text",
- "text": "你好,我可以帮你分析代码。"
- }
- ]
- }
- ],
- "output_text": "你好,我可以帮你分析代码。",
- "usage": {
- "input_tokens": 0,
- "output_tokens": 12,
- "total_tokens": 12
- },
- "session_id": "conv_customer_001"
-}
-```
-
-流式行为:
-
-- `Content-Type: text/event-stream`
-- 每个事件格式为:
-
-```text
-event:
-data:
-
-```
-
-当前可能出现的主要事件:
-
-- `response.created`
-- `response.in_progress`
-- `response.output_text.delta`
-- `response.reasoning.delta`
-- `response.tool_call`
-- `response.tool_result`
-- `response.output_item.added` / `response.output_item.done`:MCP approval request 等结构化 output item
-- `response.ksadk.approval_request`:非 MCP 的通用 interrupt 扩展事件
-- `response.compaction.start`
-- `response.compaction.done`
-- `response.incomplete`
-- `response.completed`
-
-## 6.3 OpenAI Chat Completions 兼容接口
-
-### `POST /v1/chat/completions`
-
-请求体字段:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `messages` | `array` | 是 | OpenAI 风格消息数组 |
-| `model` | `string` | 否 | 模型名 |
-| `model_metadata` | `object` | 否 | 模型元数据 |
-| `stream` | `boolean` | 否 | 是否流式 |
-| `session_id` | `string` | 否 | 会话 ID |
-| `temperature` | `number` | 否 | 当前代码接受,但不保证下游一定使用 |
-| `max_tokens` | `integer` | 否 | 当前代码接受,但不保证下游一定使用 |
-| `account_id` | `string` | 否 | 0.6.7 新增。账号 ID 透传;写入 `PlatformInvocationContext`,用于多租户隔离与审计 |
-
-`messages[].content` 支持:
-
-1. 字符串
-2. OpenAI Chat content parts:`text` / `image_url`
-3. KsADK 兼容扩展 part 数组:`text` / `inlineData` / `fileData`
-
-OpenAI Chat 图片块示例:
-
-```json
-[
- {
- "role": "user",
- "content": [
- {
- "type": "text",
- "text": "请分析这张图片"
- },
- {
- "type": "image_url",
- "image_url": {
- "url": "data:image/png;base64,"
- }
- }
- ]
- }
-]
-```
-
-KsADK 兼容扩展附件示例:
-
-```json
-[
- {
- "role": "user",
- "content": [
- {
- "text": "请分析附件"
- },
- {
- "fileData": {
- "fileUri": "ksadk-upload://abc123.txt",
- "displayName": "report.txt",
- "mimeType": "text/plain"
- }
- }
- ]
- }
-]
-```
-
-非流式响应示例:
-
-```json
-{
- "id": "chatcmpl-123",
- "object": "chat.completion",
- "created": 1710000000,
- "model": "glm-5.2",
- "choices": [
- {
- "index": 0,
- "message": {
- "role": "assistant",
- "content": "这是分析结果。"
- },
- "finish_reason": "stop"
- }
- ],
- "usage": {
- "prompt_tokens": 0,
- "completion_tokens": 6,
- "total_tokens": 6
- },
- "session_id": "sess-123"
-}
-```
-
-内部转换规则:
-
-- 字符串消息会转换为 runner `input_content: [{ "type": "input_text", ... }]`
-- Chat 官方 `text` / `image_url` 会转换为 runner `input_text` / `input_image`
-- `inlineData` / `fileData` 只作为 KsADK 兼容扩展处理,不声明为 OpenAI Chat 官方能力
-- 响应对象仍保持 Chat Completions 语义,非流式 `object` 为 `chat.completion`
-
-KsADK 扩展图片引用示例:
-
-```json
-[
- {
- "role": "user",
- "content": [
- {
- "text": "请分析这张图片"
- },
- {
- "fileData": {
- "fileUri": "ksadk-upload://abc123.png",
- "displayName": "diagram.png",
- "mimeType": "image/png"
- }
- }
- ]
- }
-]
-```
-
-流式说明:
-
-- 返回仍然是 SSE
-- 事件名沿用 ksadk 统一事件,不是 OpenAI 官方 `chat.completion.chunk`
-- 因此客户端若按 OpenAI 官方 chunk parser 逐字节兼容,需要先确认是否接受该事件形态
-
-## 6.4 公网 Hosted UI Facade 说明
-
-通过 `PublicEndpoint` 访问 `POST /agentengine/api/v1/*` 时,应以 `agentengine-server` 的 facade 为准,而不是以 runtime pod 本地 `ksadk.server.app` 的同名实现为准。
-
-当前网关公开放行的 Hosted UI action 白名单包括:
-
-- `GetAgentUiBootstrap`
-- `CreateSession`
-- `GetSession`
-- `ListSessions`
-- `DeleteSession`
-- `ListSessionEvents`
-- `SubscribeRunEvents`
-- `GetResponseFeedback`
-- `UpsertResponseFeedback`
-- `DeleteResponseFeedback`
-- `RunAgent`
-- `ListSessionCheckpoints`
-- `GetCheckpointResumePreview`
-- `ListToolReceipts`
-- `ResumeRun`
-- `CancelRun`
-- `UploadFile`
-- `ListWorkspaceFiles`
-- `AddWorkspaceFile`
-- `DeleteWorkspaceFile`
-- `ListAgentModels`
-
-另外两个 GET 下载路径也会通过 Hosted/UI 侧转发:
-
-- `GET /agentengine/api/v1/AttachmentContent`
-- `GET /agentengine/api/v1/GetWorkspaceFileContent`
-
-本地 runtime 还提供 `ExportWorkspaceZip`、`/agentengine/api/v1/ws/{agent_id}/{file_path}` 等 UI 辅助接口。公网 `PublicEndpoint` 是否放行这些接口,以 `agentengine-gateway` 的 Hosted UI 白名单和独立 facade 实现为准;不要把任意 runtime 本地路由都当成公网稳定 contract。
-
-长任务恢复相关 action 的公网链路是:
-
-`agentengine-hosted-ui / ksadk-web -> agentengine-gateway 白名单 -> agentengine-server Hosted facade -> runtime/router -> runtime 本地同名 action`
-
-因此,公网 contract 以 gateway 白名单和 `agentengine-server` facade 为准;runtime 本地实现是最终执行方,但不是浏览器直接依赖的入口。
-
-能力门控以 `GetAgentUiBootstrap.Data.Capabilities.RunLifecycle` 为准。`RunLifecycle.Resume` 只表示普通运行生命周期可继续交互;checkpoint 恢复必须同时看到 `RunLifecycle.Checkpoints=true` 和 `RunLifecycle.CheckpointResume=true`。控制台应优先读取 `RuntimeCapabilities.ResumeRun.ResumeMode`:`time_travel` 表示可选择历史 checkpoint 回档,`forward_only` 表示只能沿框架原生事件或 invocation 连续性继续,`none` 表示没有框架级恢复能力。当前 `adk`、`langchain`、`langgraph`、`deepagents` 可声明 checkpoint lifecycle;`hermes` 虽然有 Hosted Chat、原生 dashboard 和 terminal,但其 Hermes runtime 壳只代理 `/v1/*` 与原生管理路由,不提供 `ListSessionCheckpoints` / `ResumeRun` / `CancelRun` 本地同名 action,因此不应默认点亮 checkpoint 恢复能力。
-
-## 6.5 Hosted UI Bootstrap
-
-### `POST /agentengine/api/v1/GetAgentUiBootstrap`
-
-说明:
-
-- 这是 hosted chat / hosted workbench 初始化时的核心 bootstrap 接口
-- 对公网数据面,这个 action 会被网关显式放行
-
-请求体字段:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 否 | 与 `Name` 二选一,优先使用 |
-| `Name` | `string` | 否 | Agent 名称 |
-| `SessionId` | `string` | 否 | 当前会话 ID |
-
-响应外层统一包裹:
-
-```json
-{
- "Code": 0,
- "Message": "Success",
- "RequestId": "req-xxxxxxxxxxxx",
- "Action": "GetAgentUiBootstrap",
- "Data": { "...": "..." }
-}
-```
-
-`Data` 关键字段:
-
-| 字段 | 说明 |
-| --- | --- |
-| `Agent.AgentId` | Agent ID |
-| `Agent.Name` | Agent 名 |
-| `Agent.Framework` | framework 名 |
-| `Modules` | 当前固定 `["Chat","Build","Deploy"]` |
-| `Capabilities.Attachments` | 固定 `true` |
-| `Capabilities.WorkspaceFiles` | 是否开启 workspace |
-| `Capabilities.Approval` | 当前公网 Hosted facade 为 `false` |
-| `Capabilities.Thinking` | 固定 `true` |
-| `Capabilities.HostedRuntime` | 当前公网 Hosted facade 为 `true` |
-| `Capabilities.SlashCommands` | 当前固定 `["/new","/clear","/stop","/help","/attach"]` |
-| `Capabilities.RuntimeCapabilities.Checkpoint` | 0.6.7 新增。是否支持 checkpoint 列表 / 预览 |
-| `Capabilities.RuntimeCapabilities.ResumeRun` | 0.6.7 新增。是否支持从 checkpoint 恢复运行;`ResumeMode` 取值 `time_travel` / `forward_only` / `none` |
-| `Capabilities.RuntimeCapabilities.CancelRun` | 0.6.7 新增。是否支持运行取消 |
-| `Capabilities.CheckpointResumeCapability.Supported` | 0.6.7 新增。是否整体支持 checkpoint 恢复链路 |
-| `Capabilities.CheckpointResumeCapability.Checkpoint` | 0.6.7 新增。是否支持 checkpoint 列表 / 预览 |
-| `Capabilities.CheckpointResumeCapability.ResumeRun` | 0.6.7 新增。是否支持从 checkpoint 恢复运行 |
-| `Capabilities.CustomUI.Enabled` | 0.6.7 新增。是否启用自定义 UI |
-| `Capabilities.CustomUI.Profile` | 0.6.7 新增。自定义 UI profile 标识 |
-| `Capabilities.CustomUI.Path` | 0.6.7 新增。自定义 UI 本地静态资源相对路径 |
-| `Capabilities.CustomUI.Url` | 0.6.7 新增。自定义 UI 远程 URL(与 `Path` 二选一) |
-| `Capabilities.CustomUI.BundlePath` | 0.6.7 新增。自定义 UI 打包产物路径 |
-| `WorkspaceFiles` | 工作区能力描述 |
-| `AccessMode` | `Owner / Private / Share` |
-| `SharePermissions.DefaultPath` | 默认 UI 路径;通常为 `/chat`,Hermes 管理页可为 `/` |
-| `SharePermissions.SharePath` | 分享默认路径 |
-| `ApiFormats` | `hermes` 为 `["chat_completions"]`,其余通常为 `["responses","chat_completions"]` |
-| `Stream` | 当前固定 `true` |
-| `SessionId` | 请求传入的会话 ID |
-| `HostedRuntime` | runtime 摘要对象,可能为 `null` |
-| `Model` | 当前模型摘要,可能为 `null` |
-
-`WorkspaceFiles` 字段在启用时结构为:
-
-```json
-{
- "Enabled": true,
- "MaxUploadBytes": 104857600,
- "SupportsDelete": true,
- "RootLabel": "workspace",
- "EntryAction": "ListWorkspaceFiles",
- "UploadAction": "AddWorkspaceFile",
- "ContentPath": "/agentengine/api/v1/GetWorkspaceFileContent"
-}
-```
-
-重要限制:
-
-- share link 场景下,`WorkspaceFiles.Enabled` 会被关闭
-- 当前服务端只对 `adk / langchain / langgraph / deepagents / hermes` 开启 workspace files
-
-## 6.6 会话 Action 接口
-
-### `POST /agentengine/api/v1/CreateSession`
-
-请求体:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 是 | Agent ID |
-| `UserId` | `string` | 否 | 可选用户 ID |
-| `SessionId` | `string` | 否 | 显式指定 session ID |
-| `ExpiresHours` | `integer` | 否 | 兼容旧字段,当前忽略 |
-
-### `POST /agentengine/api/v1/ListSessions`
-
-请求体:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 是 | Agent ID |
-| `UserId` | `string` | 否 | 可选用户 ID |
-| `Page` | `integer` | 否 | 默认 `1` |
-| `PageSize` | `integer` | 否 | 默认 `20`,最大 `200` |
-
-### `POST /agentengine/api/v1/GetSession`
-
-请求体:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `SessionId` | `string` | 条件 | 与 `Id` 二选一 |
-| `Id` | `string` | 条件 | 兼容旧字段 |
-
-### `POST /agentengine/api/v1/DeleteSession`
-
-请求体:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `SessionId` | `string` | 条件 | 与 `Id` 二选一 |
-| `Id` | `string` | 条件 | 兼容旧字段 |
-
-### `POST /agentengine/api/v1/ListSessionEvents`
-
-请求体:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `SessionId` | `string` | 是 | Session ID |
-| `Offset` | `integer` | 否 | 起始偏移,`>= 0` |
-| `Limit` | `integer` | 否 | 返回条数,`>= 1`;0.6.7 新增:无上限,可一次性拉取全量事件 |
-
-会话响应中 `Session` 的主要字段:
-
-| 字段 | 说明 |
-| --- | --- |
-| `SessionId` | 会话 ID |
-| `AgentId` | Agent ID |
-| `UserId` | 用户 ID |
-| `Title` | 当前标题 |
-| `TitleSource` | 标题来源 |
-| `Summary` | 摘要 |
-| `FirstPrompt` | 第一条 prompt |
-| `LastPrompt` | 最近一条 prompt |
-| `State` | 会话状态字典 |
-| `CreatedAt` | 创建时间 |
-| `UpdatedAt` | 更新时间 |
-| `Version` | 版本号 |
-
-事件响应中 `Events[]` 的主要字段:
-
-| 字段 | 说明 |
-| --- | --- |
-| `EventId` | 事件 ID |
-| `SessionId` | 会话 ID |
-| `Author` | 作者 |
-| `EventType` | 事件类型 |
-| `Content` | 事件内容 |
-| `Timestamp` | 时间戳 |
-| `SeqId` | 序号 |
-| `Metadata` | 元数据 |
-| `InvocationId` | 可选,本轮运行 ID |
-
-分页返回补充:
-
-- `ListSessions` 的 `Data` 额外包含 `Total`
-- `ListSessions` 的 `Data` 还会包含服务端回显的 `Page` 和 `PageSize`
-- `ListSessionEvents` 的 `Data` 额外包含请求透传的 `Offset` 和 `Limit`
-- `ListSessionEvents` 的 `Data` 还会包含 `Total`,便于客户端按需回加载更早的事件窗口
-
-### `GET /agentengine/api/v1/SubscribeRunEvents`
-
-说明:
-
-- 这是 AgentEngine Hosted UI / 本地 Web UI 的运行生命周期扩展接口,用于刷新页面、SSE 断开或切换会话后,按同一个 `SessionId + InvocationId` 继续订阅已经持久化的运行事件
-- 它不是 OpenAI Responses API 或 Chat Completions 官方接口,不改变 `/v1/responses`、`/v1/chat/completions` 的对外协议语义
-- 订阅返回的是 SSE,事件内容与 `ListSessionEvents.Events[]` 的事件 payload 形态一致
-- 当前本地 runtime 订阅窗口为 5 分钟;如果订阅期间看到 terminal `run_status`,服务端会发送 `data: [DONE]` 并结束流
-
-查询参数:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `SessionId` | `string` | 是 | 会话 ID |
-| `InvocationId` | `string` | 是 | 本轮运行 ID,通常来自已回放事件的 `InvocationId` |
-| `AfterSeqId` | `integer` | 否 | 只推送 `SeqId > AfterSeqId` 且 `InvocationId` 匹配的事件,默认 `0` |
-
-请求示例:
-
-```http
-GET /agentengine/api/v1/SubscribeRunEvents?SessionId=sess-123&InvocationId=inv-abc&AfterSeqId=12
-Accept: text/event-stream
-```
-
-SSE 数据示例:
-
-```text
-data: {"EventId":"evt-13","SessionId":"sess-123","EventType":"assistant_delta","SeqId":13,"InvocationId":"inv-abc","Content":{"text":"继续输出"}}
-
-data: {"EventId":"evt-14","SessionId":"sess-123","EventType":"run_status","SeqId":14,"InvocationId":"inv-abc","Content":{"status":"completed"}}
-
-data: [DONE]
-```
-
-重连与保护超时:
-
-- **5 分钟保护超时**:单次订阅最长 5 分钟;超时后服务端会结束 SSE 流,客户端应使用最后一次收到的 `SeqId` 重新发起订阅续接。
-- **`AfterSeqId` 重连**:重连时传入已确认消费的最大 `SeqId`,服务端只推送 `SeqId > AfterSeqId` 且 `InvocationId` 匹配的事件,避免重复回放。
-- **terminal 后自动结束**:看到 terminal `run_status` 后,服务端会发送 `data: [DONE]` 并结束流,无需客户端再重连。
-
-!!! tip "断线重连推荐做法"
- 客户端应在本地维护“最近确认消费的 `SeqId`”,断线后用该值作为 `AfterSeqId` 重连;不要用时间戳或事件计数估算偏移。
-
-## 6.7 文件上传与附件内容
-
-### `POST /agentengine/api/v1/UploadFile`
-
-请求:
-
-- `multipart/form-data`
-- 表单字段:`file`
-
-响应示例:
-
-```json
-{
- "Code": 0,
- "Message": "Success",
- "RequestId": "req-xxxx",
- "Action": "UploadFile",
- "Data": {
- "FileData": {
- "fileUri": "ksadk-upload://abc123.txt",
- "displayName": "report.txt",
- "mimeType": "text/plain",
- "sizeBytes": 1024
- }
- }
-}
-```
-
-### `GET /agentengine/api/v1/AttachmentContent?FileUri=`
-
-请求参数:
-
-| 参数 | 必填 | 说明 |
-| --- | --- | --- |
-| `FileUri` | 是 | `UploadFile` 返回的 `ksadk-upload://...` URI,或 Hosted/runtime 持久化的 `ae-upload://...` URI |
-
-返回:
-
-- 原始文件内容
-- `Content-Type` 依据文件类型推断
-- `Content-Disposition: inline`
-- 当 `FileUri` 是 `ae-upload://...` 时,服务端会先解析 Hosted 上传元数据,再返回原始文件内容
-- 0.6.7 新增:下载 `ae-upload://...` 内容时,服务端会将其写回本地 cache,后续同 URI 读取可直接命中本地 cache,减少对 Hosted 存储的重复拉取
-
-!!! info "0.6.7 新增:`ksadk-upload://` 与 `ae-upload://` 区别"
- - `ksadk-upload://`:由本地 runtime `UploadFile` 生成,文件落在 runtime 本地 workspace 附件区,生命周期随 session/runtime。
- - `ae-upload://`:由 Hosted 上传链路生成,文件落在平台 Hosted 存储,跨 runtime 实例可见;下载时由服务端解析 Hosted 上传元数据并写回本地 cache。
- - 两者都可作为 `AttachmentContent` 的 `FileUri`;客户端不需要感知差异,但应理解 `ae-upload://` 在首次下载后会被本地 cache 命中加速。
-
-## 6.8 Workspace Files Action 接口
-
-这组接口是对 runtime 内部 `/_ksadk/workspace/v1/*` 的 action 包装。
-
-重要限制:
-
-- share link 场景下,这组接口会被拒绝,返回 `403`
-- 这些接口会先根据 `AgentId` 或 `Name` 解析目标 Agent,再由 `agentengine-server` 代理到对应 runtime
-
-### `POST /agentengine/api/v1/ListWorkspaceFiles`
-
-请求体:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 否 | 与 `Name` 二选一,优先用于解析 Agent |
-| `Name` | `string` | 否 | 与 `AgentId` 二选一 |
-| `Path` | `string` | 否 | 默认 `"."` |
-| `Recursive` | `boolean` | 否 | 默认 `false` |
-
-响应 `Data` 示例:
-
-```json
-{
- "Root": "workspace",
- "Path": ".",
- "Entries": [
- {
- "Name": "outputs",
- "Path": "outputs",
- "Type": "directory",
- "SizeBytes": null,
- "MimeType": null,
- "ModifiedAt": "2026-04-27T10:00:00Z"
- }
- ]
-}
-```
-
-### `POST /agentengine/api/v1/AddWorkspaceFile`
-
-请求:
-
-- `multipart/form-data`
-- 表单字段:
- - `file`
- - `Path`
- - `AgentId`(可选)
- - `Name`(可选)
-
-成功响应 `Data` 示例:
-
-```json
-{
- "Entry": {
- "Name": "report.txt",
- "Path": "uploads/report.txt",
- "Type": "file",
- "SizeBytes": 1024,
- "MimeType": "text/plain",
- "ModifiedAt": "2026-04-27T10:00:00Z"
- }
-}
-```
-
-### `POST /agentengine/api/v1/DeleteWorkspaceFile`
-
-请求体:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 否 | 与 `Name` 二选一 |
-| `Name` | `string` | 否 | 与 `AgentId` 二选一 |
-| `Path` | `string` | 是 | 待删除文件相对路径 |
-
-响应:
-
-```json
-{
- "Deleted": true
-}
-```
-
-### `GET /agentengine/api/v1/ExportWorkspaceZip?Path=&AgentId=`
-
-说明:
-
-- 这是本地 Web UI / Workspace 面板使用的目录导出辅助接口
-- 它会读取指定 workspace 目录及其子文件,并返回 zip 文件
-- share link 场景和公网数据面是否可用,以 Hosted UI facade / gateway 白名单为准
-- 0.6.7 新增:服务端会对 `Path` 做安全过滤,拒绝绝对路径、包含 `..` 的相对路径,并跳过 symlink,避免越权读取 workspace 外的文件
-
-请求参数:
-
-| 参数 | 必填 | 说明 |
-| --- | --- | --- |
-| `Path` | 否 | 待导出的 workspace 相对目录,默认 `"."` |
-| `AgentId` | 否 | 与 `Name` 二选一 |
-| `Name` | 否 | 与 `AgentId` 二选一 |
-
-返回:
-
-- `application/zip`
-- 文件名通常为 `workspace.zip`
-
-### `GET /agentengine/api/v1/GetWorkspaceFileContent?FilePath=&AgentId=`
-
-请求参数:
-
-| 参数 | 必填 | 说明 |
-| --- | --- | --- |
-| `FilePath` | 是 | 文件相对路径 |
-| `AgentId` | 否 | 与 `Name` 二选一 |
-| `Name` | 否 | 与 `AgentId` 二选一 |
-
-返回:
-
-- 原始文件内容
-- 透传上游 runtime 的响应 Header(会过滤掉 `content-encoding` / `transfer-encoding` / `connection` / `content-length`)
-- `Content-Type` 透传自 runtime
-
-### `GET /agentengine/api/v1/ws/{agent_id}/{file_path}`
-
-说明:
-
-- 这是 Workspace HTML 预览和相对资源解析使用的本地辅助路径,不是 WebSocket
-- HTML 文件会注入预览运行所需的 base href / CSP,便于页面内相对 CSS、JS、图片资源继续从 workspace 读取
-- 它不建议作为业务 API 直接依赖;公网可用性以 Hosted UI facade / gateway 白名单为准
-
-## 6.9 模型目录
-
-### `POST /agentengine/api/v1/ListAgentModels`
-
-请求体:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 否 | 与 `Name` 二选一 |
-| `Name` | `string` | 否 | 与 `AgentId` 二选一 |
-
-响应 `Data` 结构:
-
-```json
-{
- "Models": [
- {
- "id": "glm-5.2",
- "display_name": "glm-5.2"
- }
- ],
- "Current": "glm-5.2",
- "Source": "OPENAI_MODEL_NAME"
-}
-```
-
-说明:
-
-- 服务端会优先尝试请求 runtime 侧模型目录 `GET /v1/models`
-- 若失败,则回退到当前 Agent 的模型配置推断结果
-
-## 6.10 响应反馈 Action 接口
-
-这组接口用于 hosted UI 或自研 WebUI 对某条 assistant 输出做通用点赞 / 点踩反馈。
-
-重要边界:
-
-- 当前正式 contract 是 Hosted Action,不是 runtime 原生 `POST /v1/responses/{response_id}/feedback`
-- 调用地址为 `https:///agentengine/api/v1/`
-- 主反馈事实源是平台的 `response_feedback` 表
-- Langfuse score 是异步镜像链路,不能作为业务主存储或业务主键
-- 客户端不需要也不应该持有 Langfuse key
-
-### 如何绑定一次回复
-
-自研 WebUI 调用 Agent 后,需要保存同一轮回复的两个字段:
-
-| 字段 | 来源 | 说明 |
-| --- | --- | --- |
-| `SessionId` | `/v1/responses` 请求中传入的 `conversation` 或 legacy `session_id`,或响应中返回的 `session_id`;`RunAgent` 则使用 `SessionId` | 会话 ID。连续对话和反馈查询都应使用同一个值 |
-| `ResponseId` | Responses payload 的 `id` | assistant 回复对应的 `resp_xxx` |
-
-不同入口的取值方式:
-
-- 直接调用 `/v1/responses`
- - 非流式:使用响应 JSON 顶层 `id` 和 `session_id`
- - 流式:从 `response.created` 或 `response.completed` 事件的 `data.id` 取 `ResponseId`;`SessionId` 使用请求里传入的 `conversation` 或 legacy `session_id`
-- 调用 `RunAgent`
- - 建议 `ApiFormat=responses`
- - 非流式:外层是 `ActionResponse`,使用 `Data.id` / `Data.session_id`
- - 流式:解析 Responses 风格 SSE,使用事件里的 `data.id`;`SessionId` 使用请求里传入的 `SessionId`
-
-只有已落库的 assistant message 才能反馈。服务端会校验:
-
-- `SessionId` 属于当前账号和 `AgentId`
-- `ResponseId` 能匹配该会话里的 assistant event metadata `response_id`
-- 如传入 `EventId`,还会校验该 event 与 `ResponseId` 一致
-
-### `POST /agentengine/api/v1/UpsertResponseFeedback`
-
-创建或更新当前 response 的反馈。
-
-请求体字段:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 是 | Agent ID |
-| `SessionId` | `string` | 是 | 会话 ID |
-| `ResponseId` | `string` | 是 | `/v1/responses` 的 response ID,通常为 `resp_xxx` |
-| `Rating` | `string` | 是 | `up` 或 `down` |
-| `Comment` | `string` | 否 | 文字反馈,点踩时建议填写 |
-| `EventId` | `string` | 否 | 内部 assistant event ID;通常不用传 |
-| `TraceId` | `string` | 否 | 可选 trace 覆盖值;通常不用传 |
-| `RootSpanId` | `string` | 否 | 可选 root span 覆盖值;通常不用传 |
-
-请求示例:
-
-```bash
-curl -X POST "https:///agentengine/api/v1/UpsertResponseFeedback" \
- -H "Authorization: Bearer " \
- -H "Content-Type: application/json" \
- -d '{
- "AgentId": "ar-demo",
- "SessionId": "sess-123",
- "ResponseId": "resp_123",
- "Rating": "down",
- "Comment": "太啰嗦"
- }'
-```
-
-成功响应:
-
-```json
-{
- "Code": 0,
- "Message": "Success",
- "RequestId": "req-xxxx",
- "Action": "UpsertResponseFeedback",
- "Data": {
- "Feedback": {
- "AgentId": "ar-demo",
- "SessionId": "sess-123",
- "ResponseId": "resp_123",
- "EventId": "evt-123",
- "Rating": "down",
- "Comment": "太啰嗦",
- "TraceId": "79b770fc81ad583640721b288462f1bd",
- "RootSpanId": "",
- "CreatedAt": "2026-05-08T10:00:00Z",
- "UpdatedAt": "2026-05-08T10:00:00Z"
- }
- }
-}
-```
-
-说明:
-
-- 再次提交同一个 `AgentId + SessionId + ResponseId` 会覆盖原反馈
-- 点赞可以不传 `Comment`
-- 点踩建议传 `Comment`
-- 如果该回复已有 trace metadata,服务端会 best-effort 写入 Langfuse `hosted_ui_feedback` score
-- 如果 trace 还不可用,反馈仍会先落平台表;服务端日志会记录 score 镜像跳过或失败原因
-
-### `POST /agentengine/api/v1/GetResponseFeedback`
-
-查询某条 response 当前反馈。
-
-请求体字段:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 是 | Agent ID |
-| `SessionId` | `string` | 是 | 会话 ID |
-| `ResponseId` | `string` | 是 | response ID |
-
-请求示例:
-
-```bash
-curl -X POST "https:///agentengine/api/v1/GetResponseFeedback" \
- -H "Authorization: Bearer " \
- -H "Content-Type: application/json" \
- -d '{
- "AgentId": "ar-demo",
- "SessionId": "sess-123",
- "ResponseId": "resp_123"
- }'
-```
-
-返回:
-
-- `Data.Feedback` 为反馈对象
-- 没有反馈时 `Data.Feedback` 为 `null`
-
-### `POST /agentengine/api/v1/DeleteResponseFeedback`
-
-删除某条 response 的反馈。
-
-请求体字段:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 是 | Agent ID |
-| `SessionId` | `string` | 是 | 会话 ID |
-| `ResponseId` | `string` | 是 | response ID |
-
-请求示例:
-
-```bash
-curl -X POST "https:///agentengine/api/v1/DeleteResponseFeedback" \
- -H "Authorization: Bearer " \
- -H "Content-Type: application/json" \
- -d '{
- "AgentId": "ar-demo",
- "SessionId": "sess-123",
- "ResponseId": "resp_123"
- }'
-```
-
-成功响应:
-
-```json
-{
- "Code": 0,
- "Message": "Success",
- "RequestId": "req-xxxx",
- "Action": "DeleteResponseFeedback",
- "Data": {
- "Deleted": true
- }
-}
-```
-
-### 自研 WebUI 推荐调用顺序
-
-1. 创建或复用一个 `SessionId`
-2. 调用 `/v1/responses`,或调用 `RunAgent` 且设置 `ApiFormat=responses`
-3. 从本轮 assistant 回复拿到 `ResponseId`
-4. 渲染点赞 / 点踩按钮
-5. 页面刷新或历史回放时,对每条 assistant 回复调用 `GetResponseFeedback` 回显状态
-6. 用户点赞或点踩时调用 `UpsertResponseFeedback`
-7. 用户取消反馈时调用 `DeleteResponseFeedback`
-
-## 6.11 Hosted 运行入口
-
-### `POST /agentengine/api/v1/RunAgent`
-
-说明:
-
-- 这是 hosted UI 直接调用的运行入口
-- 它内部会根据 `ApiFormat` 转到:
- - `responses`
- - `chat_completions`
-
-请求体字段:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 是 | Agent ID |
-| `Messages` | `array` | 否 | 兼容旧 UI / 旧客户端的消息数组 |
-| `ResponsesInput` | `string \| array` | 否 | `ApiFormat=responses` 时优先使用的 OpenAI Responses 风格输入;Hosted UI 默认使用它 |
-| `SessionId` | `string` | 否 | 会话 ID |
-| `ApiFormat` | `string` | 否 | 默认 `responses`;可选 `responses` / `chat_completions` |
-| `Stream` | `boolean` | 否 | 是否流式 |
-| `Model` | `string` | 否 | 本次显式模型 |
-| `ModelMetadata` | `object` | 否 | 模型元数据;可包含 `reasoning`(推理控制)、`multimodal_input_image`(多模态图片输入能力)、`context_window_tokens`(上下文窗口 token 数),runtime 会据此调整压缩阈值与能力判断 |
-| `Background` | `boolean` | 否 | 0.6.7 新增。是否后台运行;`true` 时不等待完整响应,配合 `InvocationId` + `SubscribeRunEvents` 异步消费 |
-| `InvocationId` | `string` | 否 | 0.6.7 新增。本轮运行 ID;用于 `SubscribeRunEvents` / `CancelRun` 续接与取消 |
-| `AccountId` | `string` | 否 | 0.6.7 新增。账号 ID 透传;写入 `PlatformInvocationContext`,用于多租户隔离与审计 |
-| `PreviousResponseId` | `string` | 否 | 0.6.7 新增。OpenAI Responses 上一轮 response id;用于链式上下文,不能和 `conversation`/`SessionId` 语义混用 |
-| `ModelOptions` | `object` | 否 | 0.6.7 新增。模型调用参数,例如 `temperature` / `max_tokens` 等 |
-
-请求示例:
-
-```json
-{
- "AgentId": "ar-demo",
- "SessionId": "sess-123",
- "ApiFormat": "responses",
- "Stream": true,
- "ResponsesInput": [
- {
- "role": "user",
- "content": [
- {
- "type": "input_text",
- "text": "帮我总结今天的变更"
- }
- ]
- }
- ],
- "Messages": [
- {
- "role": "user",
- "content": [
- {
- "type": "input_text",
- "text": "帮我总结今天的变更"
- }
- ]
- }
- ]
-}
-```
-
-流式返回:
-
-- `ApiFormat=responses` 时:Responses 风格 SSE
-- `ApiFormat=chat_completions` 时:透传 runtime 的流式返回,实践中通常仍是 ksadk 统一 SSE 事件
-
-## 6.12 长任务恢复与运行时取消 Action
-
-这组接口用于 Hosted UI / 本地 Web UI 展示 checkpoint、预览恢复、恢复运行和取消运行。公网 `PublicEndpoint` 调用时,请求先经过 `agentengine-gateway` Hosted UI action 白名单,再由 `agentengine-server` 按 `AgentId` 解析目标 runtime 并代理到 runtime/router。前端是否展示入口必须依赖 bootstrap capability,不要仅凭 action 是否在白名单内判断可用性。
-
-公网链路验收应使用 `scripts/validate_hosted_long_task_e2e.py`,而不是只跑本地 runtime / ASGI 脚本。该脚本不需要 PG DSN,只访问 `PublicEndpoint`:
-
-```bash
-python scripts/validate_hosted_long_task_e2e.py \
- --endpoint "https://" \
- --agent-id "" \
- --api-key "$AGENTENGINE_RUNTIME_API_KEY"
-```
-
-如果通过 private/share 短链接打开 Hosted UI,也可以传入 `--cookie "ae_ui_session="`。脚本默认覆盖 bootstrap capability、`RunAgent`、`ListSessionCheckpoints`、`ResumeRun(Stream=true)` 和 `ListSessionEvents`;运行时取消可用 `--mode cancel-active --session-id --invocation-id ` 对仍活跃的流式 run 验证 `CancelRun`。
-
-### `POST /agentengine/api/v1/ListSessionCheckpoints`
-
-说明:
-
-- 控制台使用该接口展示指定 session 的 checkpoint 列表。
-- 该接口在 0.6.7 起支持分页、可恢复性过滤和框架过滤。
-
-请求体:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 是 | Agent ID |
-| `SessionId` | `string` | 是 | 会话 ID |
-| `RunId` | `string` | 否 | 只返回指定 run 的 checkpoint |
-| `OnlyResumable` | `boolean` | 否 | 只返回可恢复 checkpoint |
-| `Framework` | `string` | 否 | 按框架过滤,例如 `langgraph` |
-| `Offset` | `integer` | 否 | 分页起始偏移 |
-| `Limit` | `integer` | 否 | 分页大小,最大 `500` |
-
-响应 `Data.Checkpoints` 为 checkpoint 列表。checkpoint 来自 runtime session event 中的 `run_checkpoint`,不是客户端传入的状态。响应还包含 `Total`、`Offset` 和 `Limit`。
-
-每个 checkpoint descriptor 至少包含:
-
-| 字段 | 说明 |
-| --- | --- |
-| `CheckpointId` / `RunId` | 恢复点和运行 ID,传给 `GetCheckpointResumePreview` / `ResumeRun` |
-| `Framework` / `FrameworkRef` | 框架与原生 checkpoint 引用 |
-| `IsResumable` / `ResumeStatus` / `ResumeDisabledReason` | 是否可恢复、恢复状态和禁用原因 |
-| `IsTerminal` / `NextNode` | 是否终态、恢复后预期进入的下一个节点 |
-| `StageKey` / `StageName` / `StageIndex` / `TotalStages` | 控制台展示阶段和进度 |
-| `Backend` / `Scope` / `Durable` | checkpoint 后端、作用域和持久化能力 |
-| `CreatedAt` / `ExpiresAt` | 创建时间和过期时间 |
-| `LastResumedAt` / `ResumeCount` | 最近恢复时间和累计恢复次数 |
-| `ReplayAllowed` | 是否允许重复从该 checkpoint 发起恢复 |
-| `CheckpointStatus` | 当前状态,例如 `active`、`resumed`、`expired`、`disabled`、`terminal` |
-| `ArtifactPreview` | 产物摘要或缩略信息 |
-
-`ListSessionCheckpoints` 会基于同 session 内的 `run_resume` 事件聚合 `LastResumedAt` 与 `ResumeCount`。若 `ExpiresAt` 已过期,或 `ReplayAllowed=false` 且该 checkpoint 已恢复过,服务端会将 `IsResumable=false` 并填充 `ResumeDisabledReason`,前端不需要重复推导这些禁用规则。
-
-!!! info "0.6.7 状态机"
- `CheckpointStatus` 取值为 `active` / `resumed` / `expired` / `disabled` / `terminal`。`ResumeCount` 与 `LastResumedAt` 由服务端从 `run_resume` 事件聚合,不是客户端传入的状态。
-
-```mermaid
-stateDiagram-v2
- [*] --> active: checkpoint 创建
- active --> resumed: ResumeRun 成功
- active --> expired: ExpiresAt 过期
- active --> disabled: ReplayAllowed=false 且已恢复过
- resumed --> active: 允许重放
- resumed --> terminal: 进入终态节点
- expired --> [*]
- disabled --> [*]
- terminal --> [*]
-```
-
-### `POST /agentengine/api/v1/GetCheckpointResumePreview`
-
-请求体:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 是 | Agent ID |
-| `SessionId` | `string` | 是 | 会话 ID |
-| `RunId` | `string` | 是 | 原 run ID |
-| `CheckpointId` | `string` | 是 | 要恢复的 checkpoint ID |
-
-响应 `Data.Preview` 返回恢复预览信息,用于 UI 在真正恢复前展示将从哪个 checkpoint 继续、可能涉及哪些 tool receipt。
-
-### `POST /agentengine/api/v1/ListToolReceipts`
-
-请求体:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 是 | Agent ID |
-| `SessionId` | `string` | 是 | 会话 ID |
-| `RunId` | `string` | 否 | 只返回指定 run 的 tool receipt |
-| `CheckpointId` | `string` | 否 | 只返回指定 checkpoint 关联的 tool receipt |
-
-响应 `Data.ToolReceipts` 为已记录的工具执行 receipt,用于恢复时展示和幂等治理。
-
-### `POST /agentengine/api/v1/ResumeRun`
-
-请求体:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 是 | Agent ID |
-| `SessionId` | `string` | 是 | 会话 ID |
-| `RunId` | `string` | 是 | 原 run ID。恢复语义是同一 run 续跑,不是新建 run |
-| `CheckpointId` | `string` | 是 | 要恢复的 checkpoint ID |
-| `ResumeAttemptId` | `string` | 否 | 本次恢复尝试 ID;不传由 runtime 生成 |
-| `InvocationId` | `string` | 否 | 本次流式恢复的 invocation ID;用于 `SubscribeRunEvents` / `CancelRun` |
-| `Stream` | `boolean` | 否 | 是否流式返回 |
-| `Model` | `string` | 否 | 可选模型名 |
-| `ModelMetadata` | `object` | 否 | 可选模型 metadata |
-| `ModelOptions` | `object` | 否 | 可选模型调用参数 |
-| `ResumeInstructionEnabled` | `boolean` | 否 | 0.6.7 新增。是否启用恢复指令注入;默认 `false` |
-| `ResumeInstruction` | `string` | 否 | 0.6.7 新增。仅 `ResumeInstructionEnabled=true` 时生效;作为恢复后的指令追加给 runner |
-
-`Stream=true` 时返回 SSE,gateway 和 server 都按流式代理处理。runtime 只信任服务端已保存的 checkpoint 事件来解析 `framework_ref`,不会信任客户端传入的 framework 状态。
-
-#### 禁用规则与错误码
-
-!!! info "0.6.7 新增"
- `ResumeRun` 引入了显式的可恢复性判断与互斥保护,前端不需要再自行推导禁用规则。
-
-可恢复性判断以服务端 checkpoint 事件的 `IsResumable` 与 `ResumeStatus` 为准:
-
-- **非终态且不可恢复**:checkpoint `IsResumable=false` 且 `IsTerminal=false` 时,返回 `409 checkpoint_not_resumable`。响应体包含:
- - `reason`:禁用原因
- - `checkpoint_id`:触发的 checkpoint ID
- - `run_id`:原 run ID
- - `resume_status`:当前恢复状态
- - `is_terminal`:是否终态(此处为 `false`)
-- **终态 checkpoint**:`IsTerminal=true` 时不再报错,返回 `200 noop`。响应 `Data` 包含:
- - `Reason`:`terminal_noop`
- - `CheckpointId`
- - `RunId`
- - `ResumeAttemptId`:本次恢复尝试 ID
-- **同 `(SessionId, RunId)` detached resume 互斥**:若同一 session+run 已有一个 detached resume 在进行中,重复发起返回 `409 resume_already_running`,响应体包含 `reason`、`checkpoint_id`、`run_id` 与当前活跃的 `resume_attempt_id`。
-
-!!! warning "前端实现提示"
- 终态 `noop` 不是错误;前端应按正常完成态收敛 UI,不要把 `200 noop` 当作失败重试。`409` 系列错误不要自动无限重试,应引导用户选择其他 checkpoint 或重新发起 run。
-
-### `POST /agentengine/api/v1/CancelRun`
-
-说明:
-
-- 这是 Hosted UI / 本地 Web UI 的运行取消接口
-- 公网 `PublicEndpoint` 调用时由 gateway 放行到 `agentengine-server`,再代理到 runtime 本地同名 action
-- runtime 会尝试调用当前 active runner 的 `request_cancel(InvocationId)`,并取消 detached streaming task
-- 如果 runner 不支持真正取消,接口仍可能返回 `Cancelled=true`,语义是“已请求取消”;前端仍应以后续 `run_status` 或事件流终态为准
-
-请求体:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 否 | 为兼容旧客户端可选;runtime 以 `InvocationId` 为取消主键 |
-| `InvocationId` | `string` | 是 | 需要取消的运行 ID |
-
-响应 `Data` 关键字段:
-
-| 字段 | 说明 |
-| --- | --- |
-| `Cancelled` | 是否已请求取消;语义是“已请求取消”,不代表运行已真正终止 |
-| `Found` | 是否找到对应的 active / detached 运行 |
-| `Status` | 取消请求后的运行状态摘要,例如 `cancelling` / `cancelled` / `not_found` |
-| `RunnerCancelStatus` | runner 层返回的细粒度取消状态;runner 不支持真正取消时可能为 `not_supported`,但 `Cancelled` 仍可能为 `true` |
-
-响应示例:
-
-```json
-{
- "Code": 0,
- "Message": "Success",
- "RequestId": "req-xxxx",
- "Action": "CancelRun",
- "Data": {
- "Cancelled": true,
- "Found": true,
- "Status": "cancelling",
- "RunnerCancelStatus": "requested"
- }
-}
-```
-
-非流式返回:
-
-- 外层仍是 `ActionResponse`
-- `Data` 直接放 runtime 返回的 payload
-- 服务端会补齐 `session_id`
-
-!!! warning "终态以前端事件流为准"
- 如果 runner 不支持真正取消,接口仍可能返回 `Cancelled=true`。前端不应仅凭 `Cancelled` 判断运行是否已终止,仍应以后续 `run_status` 或事件流终态为准。
-
-## 6.13 Legacy ADK Web 兼容接口
-
-### `POST /run_sse`
-
-请求体模型来自 `ksadk/server/api_models.py`:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `appName` | `string` | 是 | app 名 |
-| `userId` | `string` | 是 | 用户 ID |
-| `sessionId` | `string` | 否 | 会话 ID |
-| `newMessage` | `object` | 是 | 新消息 |
-| `streaming` | `boolean` | 否 | 是否流式 |
-| `invocationId` | `string` | 否 | 调用 ID |
-| `stateDelta` | `object` | 否 | 状态增量 |
-| `functionCallEventId` | `string` | 否 | 函数调用事件 ID |
-| `model` | `string` | 否 | 模型 |
-
-`newMessage` 结构:
-
-```json
-{
- "role": "user",
- "parts": [
- {
- "text": "hello"
- }
- ]
-}
-```
-
-### `/apps/{app_name}/users/{user_id}/sessions*`
-
-这组接口是 legacy session 兼容层,主要面向 ADK Web。
-
-## 6.14 Runtime 本地前端壳路径
-
-### `GET /chat`
-
-- 在 SDK 本地 `agentengine web` 或 runtime 镜像内置静态文件场景下,返回统一 Agent UI 的 `index.html`
-- 生产公网 `PublicEndpoint` 的 `/chat` 不再由 `agentengine-server` 或 runtime 本地静态文件承载;Ingress 会优先路由到独立 `agentengine-hosted-ui` Service
-- 前端仍通过 `/agentengine/api/v1/*` 调用 `agentengine-server` 的 Hosted UI action 接口
-
-### `GET /build`
-
-- SDK 本地前端壳路径,返回同一前端壳
-
-### `GET /deploy`
-
-- SDK 本地前端壳路径,返回同一前端壳
-
-### `GET /`
-
-- 当静态资源存在时,挂载整个静态目录
-
-说明:
-
-- 这些路径只有在 runtime 镜像内静态资源已构建并同步时才可用
-- 生产 hosted UI 的源码、镜像和发布节奏归属 `agentengine-hosted-ui` 独立仓库;`ksadk-python` 中的静态资源只作为 SDK 本地 UI 副本保留
-
-## 6.15 账号边界与调用上下文
-
-!!! info "0.6.7 新增"
- 三个运行入口统一透传 `account_id`,写入 `PlatformInvocationContext`,用于多租户隔离、审计与配额。
-
-三入口 `account_id` 透传:
-
-| 入口 | 字段 | 说明 |
-| --- | --- | --- |
-| `POST /v1/responses` | `account_id` | 0.6.7 新增请求体字段 |
-| `POST /v1/chat/completions` | `account_id` | 0.6.7 新增请求体字段 |
-| `POST /agentengine/api/v1/RunAgent` | `AccountId` | 0.6.7 新增请求体字段 |
-
-写入与保留语义:
-
-- `account_id` 会写入 `PlatformInvocationContext`,作为本轮调用的账号上下文;runtime 在 runner、tool、审批、附件等环节统一从该上下文读取账号
-- 对 LangGraph 运行时,`account_id` 同时映射为 LangGraph checkpoint namespace(`checkpoint_ns`)的一部分,保证不同账号的 checkpoint 状态彼此隔离,不会被跨账号串读
-- 公网 `PublicEndpoint` 调用时,网关鉴权后注入的 `X-Auth-Account-Id` 与请求体 `account_id` 应一致;不一致以网关注入值为准,请求体 `account_id` 仅作为客户端提示,不能越权覆盖
-- `account_id` 不参与 OpenAI Responses / Chat Completions 官方语义,仅作为 ksadk 平台扩展透传
-
-## 7. Hermes 运行时详细接口
-
-底层实现:`agentengine-images` 仓库内 `deploy/hermes/runtime/app.py`
-
-Hermes 不是直接把 `ksadk.server.app` 暴露出去,而是在容器内再包一层 wrapper:
-
-- `/v1/*` 代理到内部 API server
-- `/` 代理到内部 dashboard
-- `/_ksadk/terminal/ws` 由 wrapper 自己实现
-- workspace files 由 wrapper 直接挂载
-
-## 7.1 路径总览
-
-| 路径 | 方法 | 说明 |
-| --- | --- | --- |
-| `/` | GET 等 | Hermes dashboard 管理 UI |
-| `/chat` | GET | AgentEngine hosted chat UI |
-| `/health` | GET | wrapper 健康检查 |
-| `/v1/{path}` | 全方法 | OpenAI-compatible API 透传 |
-| `/_ksadk/workspace/v1/*` | GET/HEAD/POST/DELETE | workspace files |
-| `/_ksadk/terminal/ws` | WebSocket | 终端 / connect / exec / pairing |
-
-## 7.2 健康检查
-
-### `GET /health`
-
-响应示例:
-
-```json
-{
- "ok": true,
- "checks": {
- "api": {
- "name": "api",
- "ok": true,
- "status_code": 200,
- "url": "http://127.0.0.1:8642/health"
- },
- "dashboard": {
- "name": "dashboard",
- "ok": true,
- "status_code": 200,
- "url": "http://127.0.0.1:9119/"
- }
- }
-}
-```
-
-## 7.3 `/v1/*`
-
-Hermes 外层 wrapper 对外暴露整个 `/v1/{path}`,本质是透传到内部 `API_SERVER_PORT=8642`。
-
-文档上应理解为:
-
-- 至少提供 `/v1/chat/completions`
-- 其余 `/v1/*` 只要内部 API server 存在,也会通过 wrapper 暴露
-
-SSE 要点:
-
-- wrapper 明确要求对 `/v1/*` 保持真流式转发
-- 不应把上游流读取完后再一次性回包
-
-## 7.4 Workspace Files
-
-Hermes 直接复用通用的 `/_ksadk/workspace/v1/*` contract。
-
-可用路径与通用 runtime 完全一致:
-
-- `GET /_ksadk/workspace/v1/healthz`
-- `GET /_ksadk/workspace/v1/entries`
-- `HEAD /_ksadk/workspace/v1/files/{path}`
-- `GET /_ksadk/workspace/v1/files/{path}`
-- `POST /_ksadk/workspace/v1/files/{path}`
-- `DELETE /_ksadk/workspace/v1/files/{path}`
-
-## 7.5 终端 WebSocket
-
-### `WS /_ksadk/terminal/ws`
-
-连接要求:
-
-- 必须带 `Sec-WebSocket-Protocol: ks-terminal.v1`
-- 公网访问时应带 `Authorization: Bearer `
-
-建立连接后,客户端首帧必须是 JSON 文本:
-
-```json
-{
- "type": "start",
- "mode": "tui",
- "argv": [],
- "cwd": ".",
- "rows": 24,
- "cols": 80
-}
-```
-
-`mode` 支持:
-
-- `tui`
-- `exec`
-- `pairing`
-- `connect`
-
-其中:
-
-- `tui` 会执行 `hermes chat`
-- `exec` 走只读命令白名单
-- `pairing` 走 `hermes pairing`
-- `connect` 走 `hermes gateway setup`
-
-服务端可能返回的文本消息:
-
-```json
-{"type":"ready"}
-```
-
-```json
-{"type":"exit","code":0}
-```
-
-```json
-{"type":"error","message":"..."}
-```
-
-控制帧示例:
-
-```json
-{"type":"resize","rows":40,"cols":120}
-```
-
-```json
-{"type":"signal","signal":"SIGINT"}
-```
-
-```json
-{"type":"stdin_eof"}
-```
-
-另外:
-
-- PTY 输出主要通过 WebSocket binary frame 回传
-- 如果首帧不是 `type=start`,服务端会报错
-
-## 8. OpenClaw 运行时可确认接口
-
-当前主线代码里,对 OpenClaw 可以准确写入文档的只有“平台补充 contract”,不要把上游 OpenClaw 原生全部接口误写成 ksadk/AgentEngine contract。
-
-## 8.1 运行模式
-
-OpenClaw gateway 主要有三种鉴权模式:
-
-- `trusted-proxy`
-- `token`
-- `none`
-
-默认建议模式:
-
-- `trusted-proxy`
-
-说明:
-
-- 公网经 AgentEngine 网关访问时,主路径仍是 trusted-proxy 设计
-- 自管或本地直连示例里,也支持 `token` 模式
-
-## 8.2 健康检查
-
-OpenClaw 运行镜像健康探针使用:
-
-- `GET /healthz`
-
-但这属于 OpenClaw gateway 原生健康接口,不是 ksadk 额外实现。
-
-## 8.3 Workspace Files 平台补充接口
-
-OpenClaw 会额外起一个本地 `workspace_files_app` sidecar,然后由 gateway 代理:
-
-- `/_ksadk/workspace/v1/*`
-
-可确认的外部 contract 与通用 runtime 一致:
-
-- `GET /_ksadk/workspace/v1/healthz`
-- `GET /_ksadk/workspace/v1/entries`
-- `HEAD /_ksadk/workspace/v1/files/{path}`
-- `GET /_ksadk/workspace/v1/files/{path}`
-- `POST /_ksadk/workspace/v1/files/{path}`
-- `DELETE /_ksadk/workspace/v1/files/{path}`
-
-说明:
-
-- sidecar 自身监听 `127.0.0.1:${WORKSPACE_FILES_PORT}`
-- 公网访问时看到的是经 OpenClaw gateway 代理后的同一路径
-
-## 9. 哪些接口能通过公网数据面直接访问
-
-这个点很容易误判,这里单独说明。
-
-### 9.1 一定可作为公网 contract 使用的接口
-
-- `/v1/responses`
-- `/v1/chat/completions`
-- `/chat`
-- `/_ksadk/workspace/v1/*`
-- Hermes 的 `/_ksadk/terminal/ws`
-- `GET /agentengine/api/v1/AttachmentContent`
-- `GET /agentengine/api/v1/GetWorkspaceFileContent`
-- Hosted UI action 白名单:
- - `GetAgentUiBootstrap`
- - `CreateSession`
- - `GetSession`
- - `ListSessions`
- - `DeleteSession`
- - `ListSessionEvents`
- - `SubscribeRunEvents`
- - `RunAgent`
- - `ListSessionCheckpoints`
- - `GetCheckpointResumePreview`
- - `ListToolReceipts`
- - `ResumeRun`
- - `CancelRun`
- - `GetResponseFeedback`
- - `UpsertResponseFeedback`
- - `DeleteResponseFeedback`
- - `UploadFile`
- - `ListWorkspaceFiles`
- - `AddWorkspaceFile`
- - `DeleteWorkspaceFile`
- - `ListAgentModels`
-
-### 9.2 不应假设公网可调用的接口
-
-不要假设下列内容一定是公网 contract:
-
-- 任意 `/agentengine/api/v1/*` 路径
-- runtime 本地存在但未进入 Hosted UI action 白名单的 UI 辅助路径,例如 `ExportWorkspaceZip`、Workspace HTML 预览路径
-- `/debug/*`、`/builder/*`、`/traces`、`eval_sets`、`eval_results` 等开发 / 调试 / 内部辅助入口
-- 任意 Pod 内部监听端口
-- OpenClaw 上游项目的全部原生 API
-- Hermes dashboard 内部 `/api/*` 的所有未文档化子路径
-
-## 10. 调用示例
-
-## 10.1 通用 Agent:调用 `/v1/chat/completions`
-
-```bash
-curl -X POST "https:///v1/chat/completions" \
- -H "Authorization: Bearer " \
- -H "Content-Type: application/json" \
- -H "Accept: application/json" \
- -d '{
- "messages": [
- {
- "role": "user",
- "content": "你好"
- }
- ],
- "stream": false
- }'
-```
-
-## 10.2 Hosted UI:调用 `RunAgent`
-
-```bash
-curl -X POST "https:///agentengine/api/v1/RunAgent" \
- -H "Authorization: Bearer " \
- -H "Content-Type: application/json" \
- -H "Accept: text/event-stream" \
- -d '{
- "AgentId": "ar-demo",
- "SessionId": "sess-123",
- "ApiFormat": "responses",
- "Stream": true,
- "Messages": [
- {
- "role": "user",
- "content": [
- {
- "type": "input_text",
- "text": "继续"
- }
- ]
- }
- ]
- }'
-```
-
-## 10.3 Workspace:列目录
-
-```bash
-curl "https:///_ksadk/workspace/v1/entries?path=.&recursive=false" \
- -H "Authorization: Bearer "
-```
-
-## 10.4 Hermes:连接终端
-
-```bash
-wscat \
- -H "Authorization: Bearer " \
- -s "ks-terminal.v1" \
- -c "wss:///_ksadk/terminal/ws"
-```
-
-首帧:
-
-```json
-{"type":"start","mode":"tui","rows":24,"cols":80}
-```
-
-## 11. 结论
-
-当前 `master` 下可以稳定对外承诺的核心运行时 contract 是:
-
-### 通用 Agent
-
-- `/v1/responses`
-- `/v1/chat/completions`
-- 公网 `/chat` 入口由 `agentengine-hosted-ui` 承载;runtime 本地 `/chat` 只用于 SDK 本地 UI 或内置静态资源场景
-- `/_ksadk/workspace/v1/*`
-- Hosted UI action 白名单
-
-### Hermes
-
-- `/`
-- 公网 `/chat` 入口由 `agentengine-hosted-ui` 承载
-- `/v1/*`
-- `/_ksadk/terminal/ws`
-- `/_ksadk/workspace/v1/*`
-- `/health`
-
-### OpenClaw
-
-- OpenClaw gateway 原生入口
-- 平台额外挂出的 `/_ksadk/workspace/v1/*`
-- 可配置的 `trusted-proxy | token | none` 鉴权模式
-
-如果后续要继续扩展文档,建议按两个方向增量补充:
-
-1. 基于真实镜像再验证 OpenClaw 原生 gateway 的稳定可见路由
-2. 为 Hosted UI action 补充逐接口完整示例响应
diff --git "a/docs/\350\277\234\347\250\213Agent\350\277\220\350\241\214\346\227\266\346\216\245\345\217\243\350\257\264\346\230\216.md" "b/docs/\350\277\234\347\250\213Agent\350\277\220\350\241\214\346\227\266\346\216\245\345\217\243\350\257\264\346\230\216.md"
deleted file mode 100644
index 322c880a..00000000
--- "a/docs/\350\277\234\347\250\213Agent\350\277\220\350\241\214\346\227\266\346\216\245\345\217\243\350\257\264\346\230\216.md"
+++ /dev/null
@@ -1,2061 +0,0 @@
-# 远程Agent运行时接口说明
-
-本文档基于当前 `master` 分支的真实代码实现整理,目标是说明:
-
-- Agent 部署到远程 K8s / Serverless Pod 之后,最终通过 `PublicEndpoint` 对外暴露哪些接口
-- 不同运行时类型的接口差异:通用 Agent、Hermes、OpenClaw
-- 公共鉴权、公共 Header、流式行为、WebSocket 约束
-- 各接口的请求体 / 响应体 shape
-
-本文档只把当前代码里可以确认的 contract 写出来;对仓库中未完整定义、但依赖上游项目的 OpenClaw 原生接口,不做超出代码证据的推断。
-
-## 1. 事实来源
-
-本文档主要依据以下代码与文档:
-
-- `agentengine-server/app/api/v1/actions/agent_actions.py`
-- `agentengine-server/app/api/v1/actions/chat_actions.py`
-- `agentengine-server/app/api/v1/actions/feedback_actions.py`
-- `agentengine-server/app/gateway/api.py`
-- `agentengine-server/app/gateway/router_service.py`
-- `agentengine-server/docs/技术设计.md`
-- `agentengine-server/docs/网关鉴权说明.md`
-- `ksadk-python/ksadk/server/app.py`
-- `ksadk-python/ksadk/server/api_models.py`
-- `ksadk-python/ksadk/conversations/runtime.py`
-- `ksadk-python/ksadk_runtime_common/workspace_files/*.py`
-- `ksadk-python/deploy/hermes/runtime/app.py`
-- `ksadk-python/deploy/hermes/README.md`
-- `ksadk-python/deploy/openclaw/bootstrap.sh`
-- `ksadk-python/deploy/openclaw-user-template/Dockerfile`
-
-## 2. 入口模型
-
-### 2.1 公网入口
-
-远程 Agent 部署成功后,控制面 `GetAgent` 会返回:
-
-- `QuickAccess.PublicEndpoint`
-
-这个地址就是外部调用运行时接口时应使用的根地址。例如:
-
-```text
-http://ar-20260506162108-d30283cd.agent-pre.kspmas.ksyun.com
-```
-
-说明:
-
-- 对外看到的是 `PublicEndpoint`
-- 实际请求先进入 Ingress / Gateway,再由 `agentengine-server` 的 router 做鉴权和转发
-- 因此“部署后暴露的接口”应以公网入口经过网关后可访问的路径为准,而不是简单把 Pod 内部监听端口当成外部 contract
-
-### 2.2 内网入口
-
-`GetAgent` 也可能返回:
-
-- `QuickAccess.PrivateEndpoint`
-
-这类地址用于内网访问,不作为本文主线。本文默认描述通过 `PublicEndpoint` 暴露的接口。
-
-## 3. 鉴权与公共 Header
-
-## 3.1 外部访问鉴权
-
-当前数据面统一通过网关校验,外部调用主要有两种认证方式:
-
-1. `Authorization: Bearer `
-2. `ae_ui_session` Cookie
-
-其中:
-
-- API/SDK/CLI 直连运行时接口时,使用 `Authorization: Bearer `
-- 浏览器经 dashboard share link 或 hosted UI 访问时,通常使用 `ae_ui_session` Cookie
-
-代码证据:
-
-- `agentengine-server/docs/网关鉴权说明.md`
-- `agentengine-server/app/gateway/api.py`
-
-### 3.1.1 Bearer Token 的含义
-
-Bearer Token 有两种来源:
-
-1. AgentEngine 为该 Agent 签发的 API Key,通常是 `ak-...` 或 `sk-...`
-2. OpenClaw 在 `token` 模式下使用的 shared secret
-
-对绝大多数自动化调用,推荐理解为:
-
-```http
-Authorization: Bearer
-```
-
-### 3.1.2 Cookie 会话的适用场景
-
-`ae_ui_session` 主要用于:
-
-- `https:///chat`
-- `https:///`
-- share link 跳转后的浏览器会话
-
-它不是给通用脚本调用运行时 API 设计的主接口。
-
-## 3.2 公共请求 Header
-
-### 3.2.1 通用 HTTP Header
-
-建议按以下方式构造:
-
-| Header | 是否必填 | 说明 |
-| --- | --- | --- |
-| `Authorization: Bearer ` | 外部 API 调用必填 | 由网关校验 |
-| `Content-Type: application/json` | JSON 请求推荐 | `POST /v1/*`、`POST /agentengine/api/v1/*` 常用 |
-| `Accept: application/json` | 非流式请求推荐 | 返回 JSON |
-| `Accept: text/event-stream` | 流式请求推荐 | `stream=true` 时推荐显式声明 |
-
-说明:
-
-- 对于 `multipart/form-data` 上传,如 `UploadFile` / `AddWorkspaceFile`,`Content-Type` 由客户端自动生成 boundary
-- 运行时应用本身没有在 `ksadk.server.app` 内显式校验 Bearer;鉴权发生在网关层
-
-### 3.2.2 WebSocket Header
-
-Hermes 终端 WebSocket 额外要求:
-
-| Header | 是否必填 | 说明 |
-| --- | --- | --- |
-| `Authorization: Bearer ` | 公网访问建议携带 | 网关鉴权 |
-| `Sec-WebSocket-Protocol: ks-terminal.v1` | 必填 | Hermes 终端子协议 |
-
-如果缺少 `ks-terminal.v1`,Hermes runtime 会直接拒绝连接。
-
-## 3.3 内部 Header 与外部调用边界
-
-以下 Header 会在网关和运行时之间使用,但**不应由外部调用方手工构造**:
-
-| Header | 用途 |
-| --- | --- |
-| `X-Auth-Agent-Id` | 网关鉴权后注入的 Agent ID |
-| `X-Auth-Account-Id` | 网关鉴权后注入的账号 ID |
-| `X-Auth-Framework` | 网关鉴权后注入的 framework |
-| `X-Auth-Openclaw-Gateway-Mode` | OpenClaw 模式透传 |
-| `X-Forwarded-Host` | 原始 Host 透传 |
-| `x-forwarded-user` | OpenClaw trusted-proxy / workspace 代理链路使用 |
-| `X-Hermes-Session-Token` | Hermes dashboard 内部 fetch shim 使用 |
-
-外部用户应只关心:
-
-- Bearer API Key
-- Cookie Session
-- WebSocket 子协议
-
-## 4. 运行时类型矩阵
-
-当前主线下,公网可见接口按运行时分为三类:
-
-| 运行时类型 | 典型 framework | 主入口实现 | 对外特征 |
-| --- | --- | --- | --- |
-| 通用 Agent 运行时 | `adk` / `langchain` / `langgraph` / `deepagents` | `ksadk.server.app` | `/v1/*` + workspace files;公网 `/chat` 由独立 hosted UI 服务承载并调用 Hosted UI action 接口 |
-| Hermes 托管运行时 | `hermes` | `deploy/hermes/runtime/app.py` 外层 wrapper | `/` dashboard、`/v1/*`、`/_ksadk/terminal/ws`、workspace files;公网 `/chat` 同样由独立 hosted UI 服务承载 |
-| OpenClaw 托管运行时 | `openclaw` | OpenClaw gateway + ksadk 补丁 | 以 OpenClaw gateway 为主,平台额外挂出 workspace files |
-
-## 5. 公网暴露范围总览
-
-### 5.1 通用 Agent 运行时
-
-公网入口可确认的主路径:
-
-- `GET /health`
-- `POST /v1/responses`
-- `POST /v1/chat/completions`
-- `GET /chat`
-- `GET /build`
-- `GET /deploy`
-- `GET /agentengine/api/v1/AttachmentContent`
-- `GET /agentengine/api/v1/GetWorkspaceFileContent`
-- `POST /agentengine/api/v1/GetAgentUiBootstrap`
-- `POST /agentengine/api/v1/CreateSession`
-- `POST /agentengine/api/v1/GetSession`
-- `POST /agentengine/api/v1/ListSessions`
-- `POST /agentengine/api/v1/DeleteSession`
-- `POST /agentengine/api/v1/ListSessionEvents`
-- `GET /agentengine/api/v1/SubscribeRunEvents`
-- `POST /agentengine/api/v1/RunAgent`
-- `POST /agentengine/api/v1/ListSessionCheckpoints`
-- `POST /agentengine/api/v1/GetCheckpointResumePreview`
-- `POST /agentengine/api/v1/ListToolReceipts`
-- `POST /agentengine/api/v1/ResumeRun`
-- `POST /agentengine/api/v1/CancelRun`
-- `POST /agentengine/api/v1/UploadFile`
-- `POST /agentengine/api/v1/ListWorkspaceFiles`
-- `POST /agentengine/api/v1/AddWorkspaceFile`
-- `POST /agentengine/api/v1/DeleteWorkspaceFile`
-- `POST /agentengine/api/v1/ListAgentModels`
-- `GET /agentengine/api/v1/ExportWorkspaceZip`
-- `POST /run_sse`
-- `GET/POST/DELETE /apps/{app_name}/users/{user_id}/sessions*`
-
-注意:
-
-- 并不是所有 `/agentengine/api/v1/*` 都会通过公网数据面暴露
-- 网关只放行 Hosted UI 所需的那一小组 action
-- 对 `PublicEndpoint` 而言,`POST /agentengine/api/v1/*` 这组 Hosted UI action 实际会被 router 代理回 `agentengine-server`,不是直接命中 runtime pod 的本地同名路由
-
-### 5.2 Hermes 运行时
-
-公网入口可确认的主路径:
-
-- `GET /`
-- `GET /health`
-- `GET/POST/PUT/PATCH/DELETE/OPTIONS /v1/{path}`
-- `GET/POST/PUT/PATCH/DELETE/OPTIONS /{path}`
- 这部分本质是 Hermes dashboard 与其 API 的代理入口
-- `GET/HEAD/POST/DELETE /_ksadk/workspace/v1/*`
-- `WS /_ksadk/terminal/ws`
-- `GET /chat`
-
-### 5.3 OpenClaw 运行时
-
-当前代码中可以**准确确认**的平台追加 contract 只有:
-
-- `/_ksadk/workspace/v1/*`:通过 ksadk sidecar / proxy 增加的文件接口
-
-此外还可以确认:
-
-- OpenClaw gateway 默认跑在 `8080`
-- 鉴权模式支持 `trusted-proxy | token | none`
-- 健康检查使用的是上游 gateway 的 `/healthz`
-
-但 OpenClaw gateway 原生完整 API 面不是本仓当前代码独立定义的,因此本文不把其所有原生端点逐条列为平台 contract。
-
-## 6. 通用 Agent 运行时详细接口
-
-本节适用于原始 runtime 服务本身:
-
-- `adk`
-- `langchain`
-- `langgraph`
-- `deepagents`
-
-底层实现:`ksadk-python/ksadk/server/app.py`
-
-重要边界:
-
-- 本节里的 `/v1/*`、`/health`、`/run_sse`、`/apps/.../sessions*` 是 runtime pod 自身实现
-- 但对公网 `PublicEndpoint` 来说,`/agentengine/api/v1/*` Hosted UI action 以 `agentengine-server` facade 为准
-- 因此本文后续会把“runtime 原始接口”和“公网 Hosted facade”拆开写
-
-## 6.1 健康检查
-
-### `GET /health`
-
-用途:
-
-- 检查运行时是否启动
-- 返回当前 runner 识别出的 framework 和 agent 名
-
-请求示例:
-
-```bash
-curl -H "Authorization: Bearer " \
- "https:///health"
-```
-
-响应示例:
-
-```json
-{
- "status": "ok",
- "framework": "langgraph",
- "agent": "demo-agent"
-}
-```
-
-## 6.2 OpenAI Responses 兼容接口
-
-### `POST /v1/responses`
-
-说明:
-
-- 非流式返回 OpenAI Responses 风格 JSON
-- 流式返回 `text/event-stream`
-
-请求体字段:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `input` | `string | array` | 是 | 用户输入;字符串或 KOP 风格消息数组 |
-| `model` | `string` | 否 | 本次调用显式模型 |
-| `model_metadata` | `object` | 否 | 模型元数据 |
-| `instructions` | `string` | 否 | 额外系统指令 |
-| `metadata` | `object` | 否 | 请求级 metadata |
-| `conversation` | `string | object` | 否 | OpenAI Responses 会话绑定字段;可传 `"conv_xxx"` 或 `{ "id": "conv_xxx" }`,runtime 会映射为内部会话 ID |
-| `previous_response_id` | `string` | 否 | OpenAI Responses 上一轮 response id;不能和 `conversation` 同时使用 |
-| `safety_identifier` | `string` | 否 | OpenAI 推荐的最终用户稳定标识;runtime 会映射为内部 user id 和 Langfuse UserID,建议传 hash 后值 |
-| `prompt_cache_key` | `string` | 否 | OpenAI prompt cache 路由提示;runtime 当前保留到请求 metadata,不作为用户身份 |
-| `user` | `string` | 否 | OpenAI deprecated 用户字段;仅在未传 `safety_identifier` 时作为兼容兜底 |
-| `store` | `boolean` | 否 | OpenAI Responses 存储开关;runtime 当前保留到请求 metadata |
-| `stream` | `boolean` | 否 | 是否流式 |
-| `session_id` | `string` | 否 | ksadk legacy extension;兼容旧客户端。新接入应优先使用 `conversation` |
-
-最小请求示例:
-
-```json
-{
- "input": "你好",
- "stream": false
-}
-```
-
-带会话与模型示例:
-
-```json
-{
- "input": [
- {
- "role": "user",
- "content": [
- {
- "text": "请总结一下这份设计"
- }
- ]
- }
- ],
- "model": "glm-5.1",
- "stream": true,
- "conversation": "conv_customer_001",
- "safety_identifier": "hash_user_001"
-}
-```
-
-会话字段边界:
-
-- 官方兼容路径:连续对话传 `conversation`;最终用户标识传 `safety_identifier`。
-- `previous_response_id` 只表达 Responses 链式上下文,不能和 `conversation` 同时使用。
-- `session_id` 是 ksadk 早期扩展字段,仅为旧客户端保留;不要在新代码中把它当作 OpenAI 官方字段。
-- 不要通过 `metadata.user_id`、`metadata.session_id` 或其他私有 metadata 约定传用户身份和会话身份。
-
-推荐请求示例:
-
-```json
-{
- "model": "deepseek-v4-pro",
- "input": "帮我分析这张账单",
- "conversation": "conv_bill_20260525_001",
- "safety_identifier": "user_hash_001",
- "stream": false
-}
-```
-
-图片与附件输入:
-
-推荐写法:
-
-- `/v1/responses` 推荐使用 OpenAI Responses content blocks:`input_text` / `input_image` / `input_file`
-- runner 业务代码推荐读取 `payload["input_content"]` / `payload["input_messages"]`,这是 KsADK 默认 canonical 输入
-- 判断当前轮是否传了图片或文件,推荐使用 `payload["has_current_files"]` 和 `payload["current_attachments"]`
-- 读取当前轮 OCR、文档抽取、压缩包摘要,推荐使用 `payload["current_attachment_results"]`
-
-兼容写法:
-
-- 老客户端仍可使用 KsADK 兼容扩展 part 数组:`text` / `inlineData` / `fileData`
-- runner 里仍保留 `payload["input_parts"]`,用于兼容已有 `text / inlineData / fileData` 业务代码
-- `payload["attachments"]` / `payload["attachment_results"]` 仍保留,但语义是最近有效附件上下文,可能来自历史 fallback;不要用它判断当前最新 user turn 是否上传了文件
-- `/v1/chat/completions` 对外仍保持 Chat Completions 语义,官方图片块使用 `text` / `image_url`;`inlineData` / `fileData` 在 Chat 入口只属于 KsADK 兼容扩展,不是 OpenAI Chat 官方能力
-
-字段细节:
-
-- `input_image.image_url` 支持远程图片 URL 或 `data:image/...;base64,...`,运行时会归一化为内部附件上下文
-- `input_file.file_data` 会归一化为内部 `inlineData`;`input_file.file_url` / `input_file.file_id` 会归一化为内部 `fileData` 引用
-- `inlineData` 适合旧客户端直接内联 base64 内容
-- `fileData` 适合旧客户端先调用 `UploadFile`,再引用返回的 `ksadk-upload://...`
-- 远程图片 URL 会作为引用保留,并可在支持原生图片输入的 LangGraph 路径下继续传给模型;KsADK 不会主动拉取远程图片或远程文件做 OCR / 文本提取。需要平台提取、OCR 或本地附件内容时,请使用 data URL、`file_data`、`inlineData` 或 `fileData`
-
-图片示例(OpenAI Responses 风格 data URL):
-
-```json
-{
- "input": [
- {
- "role": "user",
- "content": [
- {
- "type": "input_text",
- "text": "请分析这张图片"
- },
- {
- "type": "input_image",
- "image_url": "data:image/png;base64,"
- }
- ]
- }
- ],
- "model": "glm-5.1",
- "stream": false
-}
-```
-
-业务代码获取图片信息:
-
-```python
-def ksadk_prepare_input(payload, session_context):
- # 当前轮是否真的上传了图片/文件。不要用 attachments 判断当前轮,
- # attachments 可能是历史最近一次有效附件上下文。
- has_current_files = payload.get("has_current_files", False)
- current_attachments = payload.get("current_attachments", [])
-
- images = [
- item
- for item in current_attachments
- if str(item.get("mime_type", "")).startswith("image/")
- ]
-
- # OpenAI Responses canonical content,适合直接转给支持原生多模态的模型。
- input_content = payload.get("input_content", [])
- image_blocks = [
- block
- for block in input_content
- if block.get("type") == "input_image"
- ]
-
- return {
- "input": payload.get("input", ""),
- "images": images,
- "image_blocks": image_blocks,
- }
-```
-
-如果业务 agent 使用 LangGraph / LangChain 并且模型支持原生多模态,优先从 `input_content` 或 `input_messages` 读取 `input_image`,按底层模型 SDK 需要的消息格式继续传递;如果需要读取平台归一化后的附件元信息、OCR / 文档抽取结果,则读取 `current_attachments` 和 `current_attachment_results`。`input_parts`、`inlineData`、`fileData` 是 legacy/internal 兼容输入,仍可作为老客户端兜底。
-
-多模态模型“看图”和平台 OCR 是两条不同链路:推荐让支持图片的模型直接消费 `input_image` / `input_content`,这样不需要在代码包里安装本地 OCR 依赖。平台本地 OCR 只用于需要把图片预先转成 `current_attachment_results[*].text` 的场景;源码构建默认不打包 OCR 二进制栈,如需启用请在构建环境设置 `KSADK_BUILD_ENABLE_ATTACHMENT_OCR=true`,或在项目 `requirements.txt` 中显式加入 OCR 相关依赖。
-
-图片 data URL 或 `inlineData.data` 本身就是 base64 字符串,payload 可能很大,这是内联传图时的正常现象。业务日志不要直接打印完整 `payload`、`input_content`、`input_parts` 或 `current_attachments`;建议只记录字段摘要,例如文件名、MIME、大小、transport、data URL 前缀和长度:
-
-```python
-def summarize_attachment(item):
- data = item.get("data") or ""
- return {
- "display_name": item.get("display_name"),
- "mime_type": item.get("mime_type"),
- "transport": item.get("transport"),
- "file_uri": item.get("file_uri"),
- "size_bytes": item.get("size_bytes"),
- "has_inline_data": bool(data),
- "inline_data_length": len(data),
- }
-
-logger.info(
- "ksadk_prepare_state attachments=%s has_current_files=%s",
- [summarize_attachment(item) for item in payload.get("current_attachments", [])],
- payload.get("has_current_files", False),
-)
-```
-
-旧客户端图片示例(先上传,再引用):
-
-```json
-{
- "input": [
- {
- "role": "user",
- "content": [
- {
- "text": "请分析这张图片"
- },
- {
- "fileData": {
- "fileUri": "ksadk-upload://abc123.png",
- "displayName": "diagram.png",
- "mimeType": "image/png"
- }
- }
- ]
- }
- ],
- "model": "glm-5.1",
- "stream": false
-}
-```
-
-旧客户端图片示例(直接内联):
-
-```json
-{
- "input": [
- {
- "role": "user",
- "content": [
- {
- "text": "请分析这张图片"
- },
- {
- "inlineData": {
- "data": "",
- "displayName": "diagram.png",
- "mimeType": "image/png"
- }
- }
- ]
- }
- ]
-}
-```
-
-当前附件类型支持矩阵:
-
-| 类型 | 典型扩展名 / MIME | 传输支持 | 平台提取支持 | 原生多模态直通 |
-| --- | --- | --- | --- | --- |
-| 文本 | `.txt` `.md` `.json` `.yaml` `.yml` `.csv` `.tsv` `.log` | 支持 | 支持 | 不适用 |
-| 文档 | `.pdf` `.docx` `.pptx` `.xlsx` `.html` `.htm` | 支持 | 部分支持:文本提取 / OCR | 不适用 |
-| 图片 | `.png` `.jpg` `.jpeg` `.webp` / `image/*` | 支持 | 元信息提取默认支持;OCR 需构建时显式启用 | 部分支持,见下方框架差异 |
-| 压缩包 | `.zip` | 支持 | 支持:目录/可读文件抽样提取 | 不适用 |
-| 其他二进制 | 其他后缀或 `application/octet-stream` | 支持 | 通常仅保留为附件引用 | 不支持 |
-
-框架差异:
-
-- `ADK`
- - 图片附件会优先以 bytes 形式构造成底层 SDK `Part`
- - 若底层模型支持原生多模态,可直接消费图片
-- `LangGraph`
- - 简化输入路径下,若模型支持图片输入,图片附件会自动转换为多模态 `HumanMessage.content` blocks
- - 非图片附件仍保留为普通附件上下文
-- `LangChain`
- - 当前没有对所有 agent 统一做“自动图片直通”
- - 如需原生多模态,建议在 `ksadk_prepare_input(payload, session_context)` 中优先消费 `input_content / input_messages`,必要时再兼容 `input_parts / current_attachments / attachments`
- - 判断当前轮是否传文件用 KsADK runner payload 扩展字段 `has_current_files`;该字段不是 OpenAI Responses API 官方字段
-
-模型能力判断优先级:
-
-1. 请求里显式传入的 `model_metadata`
-2. runtime 通过 `OPENAI_BASE_URL` / `OPENAI_API_KEY` 查询上游 `/v1/models` 返回的 `architecture.input_modalities`
-3. 本地默认兜底(按文本模型处理)
-
-多轮会话历史:
-
-- `/v1/responses` 本身不要求客户端每轮重传完整历史
-- 新客户端应持续传同一个 `conversation`,runtime 会从服务端会话存储里恢复该会话的历史 transcript
-- 旧客户端只传 `session_id` 时仍可恢复同一会话,但这是 ksadk legacy extension
-- 进入 runner 前,`ksadk` 会把历史、附件上下文、知识库上下文和长期记忆上下文统一重建成标准运行输入
-- `safety_identifier` 会作为内部 user id,并用于 Langfuse UserID;未传时 deprecated `user` 字段可作为兜底
-- `previous_response_id` 按 OpenAI Responses 语义接收并保留;当使用 `conversation` 时不要同时传 `previous_response_id`
-
-### Responses approval / interrupt 恢复
-
-如果流式执行遇到工具审批或人工确认,runtime 不会把本轮包装成 completed,而是返回 incomplete:
-
-- `status`: `incomplete`
-- `incomplete_details.reason`: `approval_required`
-- MCP/tool approval 场景会输出 `mcp_approval_request`
-- 非 MCP 的通用 interrupt 会输出 `response.ksadk.approval_request`
-
-#### MCP approval 恢复
-
-MCP/tool approval 场景按 OpenAI Responses 标准语义恢复。客户端应传同一个 `conversation` 或 legacy `session_id`,并把 `input` 写成 `mcp_approval_response`:
-
-```json
-{
- "conversation": "conv_customer_001",
- "input": [
- {
- "type": "mcp_approval_response",
- "id": "mcprsp_123",
- "approval_request_id": "appr_123",
- "approve": true,
- "reason": "approved by user"
- }
- ],
- "stream": true
-}
-```
-
-运行时处理方式:
-
-- 记录一条 `approval_response` 会话事件
-- 向 runner 传入 `resume=True`
-- `input` 原样保留为 `mcp_approval_response`
-- LangGraphRunner 在内部转换成 `Command(resume=...)`
-
-调用方不需要、也不应该直接传 Python `Command`。
-
-#### 通用 interrupt 恢复
-
-如果 interrupt 不是 MCP/tool approval,而是普通人工确认、补充信息或业务分支选择,客户端可以使用平台扩展 `ksadk_resume`:
-
-```json
-{
- "conversation": "conv_customer_001",
- "input": [
- {
- "type": "ksadk_resume",
- "interrupt_id": "intr_123",
- "value": {
- "approved": true,
- "answer": "继续"
- }
- }
- ],
- "stream": true
-}
-```
-
-这类事件属于 `ksadk` 扩展,不伪装成 OpenAI MCP approval。
-
-### Agent 开发者如何在业务代码中拿到上下文
-
-这部分不属于远程 API 调用 contract。不同框架的业务代码接入方式已经内化到框架专属文档:
-
-- LangGraph: [LangGraph开发最佳实践](./frameworks/LangGraph开发最佳实践.md)
-- 平台公共上下文总览: [Agent 开发者上下文接入指南](./Agent 开发者上下文接入指南.md)
-
-调用方只需要理解:
-
-- `/v1/responses` 不要求每轮重传完整历史
-- 同一会话应持续传同一个 `conversation`;旧客户端传 `session_id` 也能继续兼容
-- runtime 会在进入 runner 前重建历史、附件、知识库和长期记忆上下文
-- 框架业务代码如何消费这些上下文,由对应框架最佳实践文档说明
-
-### 历史压缩(compaction)是怎么做的
-
-长会话不会无限把所有历史原样塞进模型。
-
-当前策略是:
-
-1. transcript 按 API round / `invocation_id` 分组
-2. 保留最近若干轮原始消息
-3. 把更早历史压成一条 `context_checkpoint`
-4. 后续模型看到的是:
- - 一条 `Earlier conversation summary: ...`
- - 最近若干轮原始 user / assistant 消息
-
-重要特性:
-
-- 原始事件不会物理删除,compaction 是 append-only
-- 工具调用、审批请求、附件引用等关键信息不会简单丢弃,会以 summary 或占位文本形式保留
-- 压缩阈值会结合 `model_metadata` 的上下文窗口能力自动调整
-
-非流式响应字段:
-
-| 字段 | 说明 |
-| --- | --- |
-| `id` | response ID |
-| `object` | 固定 `response` |
-| `created_at` | Unix 时间戳 |
-| `status` | 默认 `completed` |
-| `model` | 模型名 |
-| `output` | 输出条目数组 |
-| `output_text` | 文本聚合结果 |
-| `usage` | 简化 token 统计 |
-| `session_id` | ksadk 返回的内部会话 ID;当请求传了 `conversation` 时与其 id 一致 |
-
-非流式响应示例:
-
-```json
-{
- "id": "resp_123",
- "object": "response",
- "created_at": 1710000000,
- "status": "completed",
- "error": null,
- "incomplete_details": null,
- "instructions": null,
- "metadata": {},
- "model": "glm-5.1",
- "parallel_tool_calls": true,
- "temperature": null,
- "top_p": null,
- "tools": [],
- "output": [
- {
- "id": "msg_abc",
- "type": "message",
- "status": "completed",
- "role": "assistant",
- "content": [
- {
- "type": "output_text",
- "text": "你好,我可以帮你分析代码。"
- }
- ]
- }
- ],
- "output_text": "你好,我可以帮你分析代码。",
- "usage": {
- "input_tokens": 0,
- "output_tokens": 12,
- "total_tokens": 12
- },
- "session_id": "conv_customer_001"
-}
-```
-
-流式行为:
-
-- `Content-Type: text/event-stream`
-- 每个事件格式为:
-
-```text
-event:
-data:
-
-```
-
-当前可能出现的主要事件:
-
-- `response.created`
-- `response.in_progress`
-- `response.output_text.delta`
-- `response.reasoning.delta`
-- `response.tool_call`
-- `response.tool_result`
-- `response.output_item.added` / `response.output_item.done`:MCP approval request 等结构化 output item
-- `response.ksadk.approval_request`:非 MCP 的通用 interrupt 扩展事件
-- `response.compaction.start`
-- `response.compaction.done`
-- `response.incomplete`
-- `response.completed`
-
-## 6.3 OpenAI Chat Completions 兼容接口
-
-### `POST /v1/chat/completions`
-
-请求体字段:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `messages` | `array` | 是 | OpenAI 风格消息数组 |
-| `model` | `string` | 否 | 模型名 |
-| `model_metadata` | `object` | 否 | 模型元数据 |
-| `stream` | `boolean` | 否 | 是否流式 |
-| `session_id` | `string` | 否 | 会话 ID |
-| `temperature` | `number` | 否 | 当前代码接受,但不保证下游一定使用 |
-| `max_tokens` | `integer` | 否 | 当前代码接受,但不保证下游一定使用 |
-
-`messages[].content` 支持:
-
-1. 字符串
-2. OpenAI Chat content parts:`text` / `image_url`
-3. KsADK 兼容扩展 part 数组:`text` / `inlineData` / `fileData`
-
-OpenAI Chat 图片块示例:
-
-```json
-[
- {
- "role": "user",
- "content": [
- {
- "type": "text",
- "text": "请分析这张图片"
- },
- {
- "type": "image_url",
- "image_url": {
- "url": "data:image/png;base64,"
- }
- }
- ]
- }
-]
-```
-
-KsADK 兼容扩展附件示例:
-
-```json
-[
- {
- "role": "user",
- "content": [
- {
- "text": "请分析附件"
- },
- {
- "fileData": {
- "fileUri": "ksadk-upload://abc123.txt",
- "displayName": "report.txt",
- "mimeType": "text/plain"
- }
- }
- ]
- }
-]
-```
-
-非流式响应示例:
-
-```json
-{
- "id": "chatcmpl-123",
- "object": "chat.completion",
- "created": 1710000000,
- "model": "glm-5.1",
- "choices": [
- {
- "index": 0,
- "message": {
- "role": "assistant",
- "content": "这是分析结果。"
- },
- "finish_reason": "stop"
- }
- ],
- "usage": {
- "prompt_tokens": 0,
- "completion_tokens": 6,
- "total_tokens": 6
- },
- "session_id": "sess-123"
-}
-```
-
-内部转换规则:
-
-- 字符串消息会转换为 runner `input_content: [{ "type": "input_text", ... }]`
-- Chat 官方 `text` / `image_url` 会转换为 runner `input_text` / `input_image`
-- `inlineData` / `fileData` 只作为 KsADK 兼容扩展处理,不声明为 OpenAI Chat 官方能力
-- 响应对象仍保持 Chat Completions 语义,非流式 `object` 为 `chat.completion`
-
-KsADK 扩展图片引用示例:
-
-```json
-[
- {
- "role": "user",
- "content": [
- {
- "text": "请分析这张图片"
- },
- {
- "fileData": {
- "fileUri": "ksadk-upload://abc123.png",
- "displayName": "diagram.png",
- "mimeType": "image/png"
- }
- }
- ]
- }
-]
-```
-
-流式说明:
-
-- 返回仍然是 SSE
-- 事件名沿用 ksadk 统一事件,不是 OpenAI 官方 `chat.completion.chunk`
-- 因此客户端若按 OpenAI 官方 chunk parser 逐字节兼容,需要先确认是否接受该事件形态
-
-## 6.4 公网 Hosted UI Facade 说明
-
-通过 `PublicEndpoint` 访问 `POST /agentengine/api/v1/*` 时,应以 `agentengine-server` 的 facade 为准,而不是以 runtime pod 本地 `ksadk.server.app` 的同名实现为准。
-
-当前网关公开放行的 Hosted UI action 白名单包括:
-
-- `GetAgentUiBootstrap`
-- `CreateSession`
-- `GetSession`
-- `ListSessions`
-- `DeleteSession`
-- `ListSessionEvents`
-- `SubscribeRunEvents`
-- `GetResponseFeedback`
-- `UpsertResponseFeedback`
-- `DeleteResponseFeedback`
-- `RunAgent`
-- `ListSessionCheckpoints`
-- `GetCheckpointResumePreview`
-- `ListToolReceipts`
-- `ResumeRun`
-- `CancelRun`
-- `UploadFile`
-- `ListWorkspaceFiles`
-- `AddWorkspaceFile`
-- `DeleteWorkspaceFile`
-- `ListAgentModels`
-
-另外两个 GET 下载路径也会通过 Hosted/UI 侧转发:
-
-- `GET /agentengine/api/v1/AttachmentContent`
-- `GET /agentengine/api/v1/GetWorkspaceFileContent`
-
-本地 runtime 还提供 `ExportWorkspaceZip`、`/agentengine/api/v1/ws/{agent_id}/{file_path}` 等 UI 辅助接口。公网 `PublicEndpoint` 是否放行这些接口,以 `agentengine-gateway` 的 Hosted UI 白名单和独立 facade 实现为准;不要把任意 runtime 本地路由都当成公网稳定 contract。
-
-长任务恢复相关 action 的公网链路是:
-
-`agentengine-hosted-ui / ksadk-web -> agentengine-gateway 白名单 -> agentengine-server Hosted facade -> runtime/router -> runtime 本地同名 action`
-
-因此,公网 contract 以 gateway 白名单和 `agentengine-server` facade 为准;runtime 本地实现是最终执行方,但不是浏览器直接依赖的入口。
-
-能力门控以 `GetAgentUiBootstrap.Data.Capabilities.RunLifecycle` 为准。`RunLifecycle.Resume` 只表示普通运行生命周期可继续交互;checkpoint 恢复必须同时看到 `RunLifecycle.Checkpoints=true` 和 `RunLifecycle.CheckpointResume=true`。控制台应优先读取 `RuntimeCapabilities.ResumeRun.ResumeMode`:`time_travel` 表示可选择历史 checkpoint 回档,`forward_only` 表示只能沿框架原生事件或 invocation 连续性继续,`none` 表示没有框架级恢复能力。当前 `adk`、`langchain`、`langgraph`、`deepagents` 可声明 checkpoint lifecycle;`hermes` 虽然有 Hosted Chat、原生 dashboard 和 terminal,但其 Hermes runtime 壳只代理 `/v1/*` 与原生管理路由,不提供 `ListSessionCheckpoints` / `ResumeRun` / `CancelRun` 本地同名 action,因此不应默认点亮 checkpoint 恢复能力。
-
-## 6.5 Hosted UI Bootstrap
-
-### `POST /agentengine/api/v1/GetAgentUiBootstrap`
-
-说明:
-
-- 这是 hosted chat / hosted workbench 初始化时的核心 bootstrap 接口
-- 对公网数据面,这个 action 会被网关显式放行
-
-请求体字段:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 否 | 与 `Name` 二选一,优先使用 |
-| `Name` | `string` | 否 | Agent 名称 |
-| `SessionId` | `string` | 否 | 当前会话 ID |
-
-响应外层统一包裹:
-
-```json
-{
- "Code": 0,
- "Message": "Success",
- "RequestId": "req-xxxxxxxxxxxx",
- "Action": "GetAgentUiBootstrap",
- "Data": { "...": "..." }
-}
-```
-
-`Data` 关键字段:
-
-| 字段 | 说明 |
-| --- | --- |
-| `Agent.AgentId` | Agent ID |
-| `Agent.Name` | Agent 名 |
-| `Agent.Framework` | framework 名 |
-| `Modules` | 当前固定 `["Chat","Build","Deploy"]` |
-| `Capabilities.Attachments` | 固定 `true` |
-| `Capabilities.WorkspaceFiles` | 是否开启 workspace |
-| `Capabilities.Approval` | 当前公网 Hosted facade 为 `false` |
-| `Capabilities.Thinking` | 固定 `true` |
-| `Capabilities.HostedRuntime` | 当前公网 Hosted facade 为 `true` |
-| `Capabilities.SlashCommands` | 当前固定 `["/new","/clear","/stop","/help","/attach"]` |
-| `WorkspaceFiles` | 工作区能力描述 |
-| `AccessMode` | `Owner / Private / Share` |
-| `SharePermissions.DefaultPath` | 默认 UI 路径;通常为 `/chat`,Hermes 管理页可为 `/` |
-| `SharePermissions.SharePath` | 分享默认路径 |
-| `ApiFormats` | `hermes` 为 `["chat_completions"]`,其余通常为 `["responses","chat_completions"]` |
-| `Stream` | 当前固定 `true` |
-| `SessionId` | 请求传入的会话 ID |
-| `HostedRuntime` | runtime 摘要对象,可能为 `null` |
-| `Model` | 当前模型摘要,可能为 `null` |
-
-`WorkspaceFiles` 字段在启用时结构为:
-
-```json
-{
- "Enabled": true,
- "MaxUploadBytes": 104857600,
- "SupportsDelete": true,
- "RootLabel": "workspace",
- "EntryAction": "ListWorkspaceFiles",
- "UploadAction": "AddWorkspaceFile",
- "ContentPath": "/agentengine/api/v1/GetWorkspaceFileContent"
-}
-```
-
-重要限制:
-
-- share link 场景下,`WorkspaceFiles.Enabled` 会被关闭
-- 当前服务端只对 `adk / langchain / langgraph / deepagents / hermes` 开启 workspace files
-
-## 6.6 会话 Action 接口
-
-### `POST /agentengine/api/v1/CreateSession`
-
-请求体:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 是 | Agent ID |
-| `UserId` | `string` | 否 | 可选用户 ID |
-| `SessionId` | `string` | 否 | 显式指定 session ID |
-| `ExpiresHours` | `integer` | 否 | 兼容旧字段,当前忽略 |
-
-### `POST /agentengine/api/v1/ListSessions`
-
-请求体:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 是 | Agent ID |
-| `UserId` | `string` | 否 | 可选用户 ID |
-| `Page` | `integer` | 否 | 默认 `1` |
-| `PageSize` | `integer` | 否 | 默认 `20`,最大 `200` |
-
-### `POST /agentengine/api/v1/GetSession`
-
-请求体:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `SessionId` | `string` | 条件 | 与 `Id` 二选一 |
-| `Id` | `string` | 条件 | 兼容旧字段 |
-
-### `POST /agentengine/api/v1/DeleteSession`
-
-请求体:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `SessionId` | `string` | 条件 | 与 `Id` 二选一 |
-| `Id` | `string` | 条件 | 兼容旧字段 |
-
-### `POST /agentengine/api/v1/ListSessionEvents`
-
-请求体:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `SessionId` | `string` | 是 | Session ID |
-| `Offset` | `integer` | 否 | 起始偏移,`>= 0` |
-| `Limit` | `integer` | 否 | 返回条数,`>= 1` |
-
-会话响应中 `Session` 的主要字段:
-
-| 字段 | 说明 |
-| --- | --- |
-| `SessionId` | 会话 ID |
-| `AgentId` | Agent ID |
-| `UserId` | 用户 ID |
-| `Title` | 当前标题 |
-| `TitleSource` | 标题来源 |
-| `Summary` | 摘要 |
-| `FirstPrompt` | 第一条 prompt |
-| `LastPrompt` | 最近一条 prompt |
-| `State` | 会话状态字典 |
-| `CreatedAt` | 创建时间 |
-| `UpdatedAt` | 更新时间 |
-| `Version` | 版本号 |
-
-事件响应中 `Events[]` 的主要字段:
-
-| 字段 | 说明 |
-| --- | --- |
-| `EventId` | 事件 ID |
-| `SessionId` | 会话 ID |
-| `Author` | 作者 |
-| `EventType` | 事件类型 |
-| `Content` | 事件内容 |
-| `Timestamp` | 时间戳 |
-| `SeqId` | 序号 |
-| `Metadata` | 元数据 |
-| `InvocationId` | 可选,本轮运行 ID |
-
-分页返回补充:
-
-- `ListSessions` 的 `Data` 额外包含 `Total`
-- `ListSessions` 的 `Data` 还会包含服务端回显的 `Page` 和 `PageSize`
-- `ListSessionEvents` 的 `Data` 额外包含请求透传的 `Offset` 和 `Limit`
-- `ListSessionEvents` 的 `Data` 还会包含 `Total`,便于客户端按需回加载更早的事件窗口
-
-### `GET /agentengine/api/v1/SubscribeRunEvents`
-
-说明:
-
-- 这是 AgentEngine Hosted UI / 本地 Web UI 的运行生命周期扩展接口,用于刷新页面、SSE 断开或切换会话后,按同一个 `SessionId + InvocationId` 继续订阅已经持久化的运行事件
-- 它不是 OpenAI Responses API 或 Chat Completions 官方接口,不改变 `/v1/responses`、`/v1/chat/completions` 的对外协议语义
-- 订阅返回的是 SSE,事件内容与 `ListSessionEvents.Events[]` 的事件 payload 形态一致
-- 当前本地 runtime 订阅窗口为 5 分钟;如果订阅期间看到 terminal `run_status`,服务端会发送 `data: [DONE]` 并结束流
-
-查询参数:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `SessionId` | `string` | 是 | 会话 ID |
-| `InvocationId` | `string` | 是 | 本轮运行 ID,通常来自已回放事件的 `InvocationId` |
-| `AfterSeqId` | `integer` | 否 | 只推送 `SeqId > AfterSeqId` 且 `InvocationId` 匹配的事件,默认 `0` |
-
-请求示例:
-
-```http
-GET /agentengine/api/v1/SubscribeRunEvents?SessionId=sess-123&InvocationId=inv-abc&AfterSeqId=12
-Accept: text/event-stream
-```
-
-SSE 数据示例:
-
-```text
-data: {"EventId":"evt-13","SessionId":"sess-123","EventType":"assistant_delta","SeqId":13,"InvocationId":"inv-abc","Content":{"text":"继续输出"}}
-
-data: {"EventId":"evt-14","SessionId":"sess-123","EventType":"run_status","SeqId":14,"InvocationId":"inv-abc","Content":{"status":"completed"}}
-
-data: [DONE]
-```
-
-## 6.7 文件上传与附件内容
-
-### `POST /agentengine/api/v1/UploadFile`
-
-请求:
-
-- `multipart/form-data`
-- 表单字段:`file`
-
-响应示例:
-
-```json
-{
- "Code": 0,
- "Message": "Success",
- "RequestId": "req-xxxx",
- "Action": "UploadFile",
- "Data": {
- "FileData": {
- "fileUri": "ksadk-upload://abc123.txt",
- "displayName": "report.txt",
- "mimeType": "text/plain",
- "sizeBytes": 1024
- }
- }
-}
-```
-
-### `GET /agentengine/api/v1/AttachmentContent?FileUri=`
-
-请求参数:
-
-| 参数 | 必填 | 说明 |
-| --- | --- | --- |
-| `FileUri` | 是 | `UploadFile` 返回的 `ksadk-upload://...` URI,或 Hosted/runtime 持久化的 `ae-upload://...` URI |
-
-返回:
-
-- 原始文件内容
-- `Content-Type` 依据文件类型推断
-- `Content-Disposition: inline`
-- 当 `FileUri` 是 `ae-upload://...` 时,服务端会先解析 Hosted 上传元数据,再返回原始文件内容
-
-## 6.8 Workspace Files Action 接口
-
-这组接口是对 runtime 内部 `/_ksadk/workspace/v1/*` 的 action 包装。
-
-重要限制:
-
-- share link 场景下,这组接口会被拒绝,返回 `403`
-- 这些接口会先根据 `AgentId` 或 `Name` 解析目标 Agent,再由 `agentengine-server` 代理到对应 runtime
-
-### `POST /agentengine/api/v1/ListWorkspaceFiles`
-
-请求体:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 否 | 与 `Name` 二选一,优先用于解析 Agent |
-| `Name` | `string` | 否 | 与 `AgentId` 二选一 |
-| `Path` | `string` | 否 | 默认 `"."` |
-| `Recursive` | `boolean` | 否 | 默认 `false` |
-
-响应 `Data` 示例:
-
-```json
-{
- "Root": "workspace",
- "Path": ".",
- "Entries": [
- {
- "Name": "outputs",
- "Path": "outputs",
- "Type": "directory",
- "SizeBytes": null,
- "MimeType": null,
- "ModifiedAt": "2026-04-27T10:00:00Z"
- }
- ]
-}
-```
-
-### `POST /agentengine/api/v1/AddWorkspaceFile`
-
-请求:
-
-- `multipart/form-data`
-- 表单字段:
- - `file`
- - `Path`
- - `AgentId`(可选)
- - `Name`(可选)
-
-成功响应 `Data` 示例:
-
-```json
-{
- "Entry": {
- "Name": "report.txt",
- "Path": "uploads/report.txt",
- "Type": "file",
- "SizeBytes": 1024,
- "MimeType": "text/plain",
- "ModifiedAt": "2026-04-27T10:00:00Z"
- }
-}
-```
-
-### `POST /agentengine/api/v1/DeleteWorkspaceFile`
-
-请求体:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 否 | 与 `Name` 二选一 |
-| `Name` | `string` | 否 | 与 `AgentId` 二选一 |
-| `Path` | `string` | 是 | 待删除文件相对路径 |
-
-响应:
-
-```json
-{
- "Deleted": true
-}
-```
-
-### `GET /agentengine/api/v1/ExportWorkspaceZip?Path=&AgentId=`
-
-说明:
-
-- 这是本地 Web UI / Workspace 面板使用的目录导出辅助接口
-- 它会读取指定 workspace 目录及其子文件,并返回 zip 文件
-- share link 场景和公网数据面是否可用,以 Hosted UI facade / gateway 白名单为准
-
-请求参数:
-
-| 参数 | 必填 | 说明 |
-| --- | --- | --- |
-| `Path` | 否 | 待导出的 workspace 相对目录,默认 `"."` |
-| `AgentId` | 否 | 与 `Name` 二选一 |
-| `Name` | 否 | 与 `AgentId` 二选一 |
-
-返回:
-
-- `application/zip`
-- 文件名通常为 `workspace.zip`
-
-### `GET /agentengine/api/v1/GetWorkspaceFileContent?FilePath=&AgentId=`
-
-请求参数:
-
-| 参数 | 必填 | 说明 |
-| --- | --- | --- |
-| `FilePath` | 是 | 文件相对路径 |
-| `AgentId` | 否 | 与 `Name` 二选一 |
-| `Name` | 否 | 与 `AgentId` 二选一 |
-
-返回:
-
-- 原始文件内容
-- 透传上游 runtime 的响应 Header(会过滤掉 `content-encoding` / `transfer-encoding` / `connection` / `content-length`)
-- `Content-Type` 透传自 runtime
-
-### `GET /agentengine/api/v1/ws/{agent_id}/{file_path}`
-
-说明:
-
-- 这是 Workspace HTML 预览和相对资源解析使用的本地辅助路径,不是 WebSocket
-- HTML 文件会注入预览运行所需的 base href / CSP,便于页面内相对 CSS、JS、图片资源继续从 workspace 读取
-- 它不建议作为业务 API 直接依赖;公网可用性以 Hosted UI facade / gateway 白名单为准
-
-## 6.9 模型目录
-
-### `POST /agentengine/api/v1/ListAgentModels`
-
-请求体:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 否 | 与 `Name` 二选一 |
-| `Name` | `string` | 否 | 与 `AgentId` 二选一 |
-
-响应 `Data` 结构:
-
-```json
-{
- "Models": [
- {
- "id": "glm-5.1",
- "display_name": "glm-5.1"
- }
- ],
- "Current": "glm-5.1",
- "Source": "OPENAI_MODEL_NAME"
-}
-```
-
-说明:
-
-- 服务端会优先尝试请求 runtime 侧模型目录 `GET /v1/models`
-- 若失败,则回退到当前 Agent 的模型配置推断结果
-
-## 6.10 响应反馈 Action 接口
-
-这组接口用于 hosted UI 或自研 WebUI 对某条 assistant 输出做通用点赞 / 点踩反馈。
-
-重要边界:
-
-- 当前正式 contract 是 Hosted Action,不是 runtime 原生 `POST /v1/responses/{response_id}/feedback`
-- 调用地址为 `https:///agentengine/api/v1/`
-- 主反馈事实源是平台的 `response_feedback` 表
-- Langfuse score 是异步镜像链路,不能作为业务主存储或业务主键
-- 客户端不需要也不应该持有 Langfuse key
-
-### 如何绑定一次回复
-
-自研 WebUI 调用 Agent 后,需要保存同一轮回复的两个字段:
-
-| 字段 | 来源 | 说明 |
-| --- | --- | --- |
-| `SessionId` | `/v1/responses` 请求中传入的 `conversation` 或 legacy `session_id`,或响应中返回的 `session_id`;`RunAgent` 则使用 `SessionId` | 会话 ID。连续对话和反馈查询都应使用同一个值 |
-| `ResponseId` | Responses payload 的 `id` | assistant 回复对应的 `resp_xxx` |
-
-不同入口的取值方式:
-
-- 直接调用 `/v1/responses`
- - 非流式:使用响应 JSON 顶层 `id` 和 `session_id`
- - 流式:从 `response.created` 或 `response.completed` 事件的 `data.id` 取 `ResponseId`;`SessionId` 使用请求里传入的 `conversation` 或 legacy `session_id`
-- 调用 `RunAgent`
- - 建议 `ApiFormat=responses`
- - 非流式:外层是 `ActionResponse`,使用 `Data.id` / `Data.session_id`
- - 流式:解析 Responses 风格 SSE,使用事件里的 `data.id`;`SessionId` 使用请求里传入的 `SessionId`
-
-只有已落库的 assistant message 才能反馈。服务端会校验:
-
-- `SessionId` 属于当前账号和 `AgentId`
-- `ResponseId` 能匹配该会话里的 assistant event metadata `response_id`
-- 如传入 `EventId`,还会校验该 event 与 `ResponseId` 一致
-
-### `POST /agentengine/api/v1/UpsertResponseFeedback`
-
-创建或更新当前 response 的反馈。
-
-请求体字段:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 是 | Agent ID |
-| `SessionId` | `string` | 是 | 会话 ID |
-| `ResponseId` | `string` | 是 | `/v1/responses` 的 response ID,通常为 `resp_xxx` |
-| `Rating` | `string` | 是 | `up` 或 `down` |
-| `Comment` | `string` | 否 | 文字反馈,点踩时建议填写 |
-| `EventId` | `string` | 否 | 内部 assistant event ID;通常不用传 |
-| `TraceId` | `string` | 否 | 可选 trace 覆盖值;通常不用传 |
-| `RootSpanId` | `string` | 否 | 可选 root span 覆盖值;通常不用传 |
-
-请求示例:
-
-```bash
-curl -X POST "https:///agentengine/api/v1/UpsertResponseFeedback" \
- -H "Authorization: Bearer " \
- -H "Content-Type: application/json" \
- -d '{
- "AgentId": "ar-demo",
- "SessionId": "sess-123",
- "ResponseId": "resp_123",
- "Rating": "down",
- "Comment": "太啰嗦"
- }'
-```
-
-成功响应:
-
-```json
-{
- "Code": 0,
- "Message": "Success",
- "RequestId": "req-xxxx",
- "Action": "UpsertResponseFeedback",
- "Data": {
- "Feedback": {
- "AgentId": "ar-demo",
- "SessionId": "sess-123",
- "ResponseId": "resp_123",
- "EventId": "evt-123",
- "Rating": "down",
- "Comment": "太啰嗦",
- "TraceId": "79b770fc81ad583640721b288462f1bd",
- "RootSpanId": "",
- "CreatedAt": "2026-05-08T10:00:00Z",
- "UpdatedAt": "2026-05-08T10:00:00Z"
- }
- }
-}
-```
-
-说明:
-
-- 再次提交同一个 `AgentId + SessionId + ResponseId` 会覆盖原反馈
-- 点赞可以不传 `Comment`
-- 点踩建议传 `Comment`
-- 如果该回复已有 trace metadata,服务端会 best-effort 写入 Langfuse `hosted_ui_feedback` score
-- 如果 trace 还不可用,反馈仍会先落平台表;服务端日志会记录 score 镜像跳过或失败原因
-
-### `POST /agentengine/api/v1/GetResponseFeedback`
-
-查询某条 response 当前反馈。
-
-请求体字段:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 是 | Agent ID |
-| `SessionId` | `string` | 是 | 会话 ID |
-| `ResponseId` | `string` | 是 | response ID |
-
-请求示例:
-
-```bash
-curl -X POST "https:///agentengine/api/v1/GetResponseFeedback" \
- -H "Authorization: Bearer " \
- -H "Content-Type: application/json" \
- -d '{
- "AgentId": "ar-demo",
- "SessionId": "sess-123",
- "ResponseId": "resp_123"
- }'
-```
-
-返回:
-
-- `Data.Feedback` 为反馈对象
-- 没有反馈时 `Data.Feedback` 为 `null`
-
-### `POST /agentengine/api/v1/DeleteResponseFeedback`
-
-删除某条 response 的反馈。
-
-请求体字段:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 是 | Agent ID |
-| `SessionId` | `string` | 是 | 会话 ID |
-| `ResponseId` | `string` | 是 | response ID |
-
-请求示例:
-
-```bash
-curl -X POST "https:///agentengine/api/v1/DeleteResponseFeedback" \
- -H "Authorization: Bearer " \
- -H "Content-Type: application/json" \
- -d '{
- "AgentId": "ar-demo",
- "SessionId": "sess-123",
- "ResponseId": "resp_123"
- }'
-```
-
-成功响应:
-
-```json
-{
- "Code": 0,
- "Message": "Success",
- "RequestId": "req-xxxx",
- "Action": "DeleteResponseFeedback",
- "Data": {
- "Deleted": true
- }
-}
-```
-
-### 自研 WebUI 推荐调用顺序
-
-1. 创建或复用一个 `SessionId`
-2. 调用 `/v1/responses`,或调用 `RunAgent` 且设置 `ApiFormat=responses`
-3. 从本轮 assistant 回复拿到 `ResponseId`
-4. 渲染点赞 / 点踩按钮
-5. 页面刷新或历史回放时,对每条 assistant 回复调用 `GetResponseFeedback` 回显状态
-6. 用户点赞或点踩时调用 `UpsertResponseFeedback`
-7. 用户取消反馈时调用 `DeleteResponseFeedback`
-
-## 6.11 Hosted 运行入口
-
-### `POST /agentengine/api/v1/RunAgent`
-
-说明:
-
-- 这是 hosted UI 直接调用的运行入口
-- 它内部会根据 `ApiFormat` 转到:
- - `responses`
- - `chat_completions`
-
-请求体字段:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 是 | Agent ID |
-| `Messages` | `array` | 否 | 兼容旧 UI / 旧客户端的消息数组 |
-| `ResponsesInput` | `string \| array` | 否 | `ApiFormat=responses` 时优先使用的 OpenAI Responses 风格输入;Hosted UI 默认使用它 |
-| `SessionId` | `string` | 否 | 会话 ID |
-| `ApiFormat` | `string` | 否 | 默认 `responses`;可选 `responses` / `chat_completions` |
-| `Stream` | `boolean` | 否 | 是否流式 |
-| `Model` | `string` | 否 | 本次显式模型 |
-| `ModelMetadata` | `object` | 否 | 模型元数据 |
-
-请求示例:
-
-```json
-{
- "AgentId": "ar-demo",
- "SessionId": "sess-123",
- "ApiFormat": "responses",
- "Stream": true,
- "ResponsesInput": [
- {
- "role": "user",
- "content": [
- {
- "type": "input_text",
- "text": "帮我总结今天的变更"
- }
- ]
- }
- ],
- "Messages": [
- {
- "role": "user",
- "content": [
- {
- "type": "input_text",
- "text": "帮我总结今天的变更"
- }
- ]
- }
- ]
-}
-```
-
-流式返回:
-
-- `ApiFormat=responses` 时:Responses 风格 SSE
-- `ApiFormat=chat_completions` 时:透传 runtime 的流式返回,实践中通常仍是 ksadk 统一 SSE 事件
-
-## 6.12 长任务恢复与运行时取消 Action
-
-这组接口用于 Hosted UI / 本地 Web UI 展示 checkpoint、预览恢复、恢复运行和取消运行。公网 `PublicEndpoint` 调用时,请求先经过 `agentengine-gateway` Hosted UI action 白名单,再由 `agentengine-server` 按 `AgentId` 解析目标 runtime 并代理到 runtime/router。前端是否展示入口必须依赖 bootstrap capability,不要仅凭 action 是否在白名单内判断可用性。
-
-公网链路验收应使用 `scripts/validate_hosted_long_task_e2e.py`,而不是只跑本地 runtime / ASGI 脚本。该脚本不需要 PG DSN,只访问 `PublicEndpoint`:
-
-```bash
-python scripts/validate_hosted_long_task_e2e.py \
- --endpoint "https://" \
- --agent-id "" \
- --api-key "$AGENTENGINE_RUNTIME_API_KEY"
-```
-
-如果通过 private/share 短链接打开 Hosted UI,也可以传入 `--cookie "ae_ui_session="`。脚本默认覆盖 bootstrap capability、`RunAgent`、`ListSessionCheckpoints`、`ResumeRun(Stream=true)` 和 `ListSessionEvents`;运行时取消可用 `--mode cancel-active --session-id --invocation-id ` 对仍活跃的流式 run 验证 `CancelRun`。
-
-### `POST /agentengine/api/v1/ListSessionCheckpoints`
-
-说明:
-
-- 控制台使用该接口展示指定 session 的 checkpoint 列表。
-- 该接口在 0.6.7 起支持分页、可恢复性过滤和框架过滤。
-
-请求体:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 是 | Agent ID |
-| `SessionId` | `string` | 是 | 会话 ID |
-| `RunId` | `string` | 否 | 只返回指定 run 的 checkpoint |
-| `OnlyResumable` | `boolean` | 否 | 只返回可恢复 checkpoint |
-| `Framework` | `string` | 否 | 按框架过滤,例如 `langgraph` |
-| `Offset` | `integer` | 否 | 分页起始偏移 |
-| `Limit` | `integer` | 否 | 分页大小,最大 `500` |
-
-响应 `Data.Checkpoints` 为 checkpoint 列表。checkpoint 来自 runtime session event 中的 `run_checkpoint`,不是客户端传入的状态。响应还包含 `Total`、`Offset` 和 `Limit`。
-
-每个 checkpoint descriptor 至少包含:
-
-| 字段 | 说明 |
-| --- | --- |
-| `CheckpointId` / `RunId` | 恢复点和运行 ID,传给 `GetCheckpointResumePreview` / `ResumeRun` |
-| `Framework` / `FrameworkRef` | 框架与原生 checkpoint 引用 |
-| `IsResumable` / `ResumeStatus` / `ResumeDisabledReason` | 是否可恢复、恢复状态和禁用原因 |
-| `IsTerminal` / `NextNode` | 是否终态、恢复后预期进入的下一个节点 |
-| `StageKey` / `StageName` / `StageIndex` / `TotalStages` | 控制台展示阶段和进度 |
-| `Backend` / `Scope` / `Durable` | checkpoint 后端、作用域和持久化能力 |
-| `CreatedAt` / `ExpiresAt` | 创建时间和过期时间 |
-| `LastResumedAt` / `ResumeCount` | 最近恢复时间和累计恢复次数 |
-| `ReplayAllowed` | 是否允许重复从该 checkpoint 发起恢复 |
-| `CheckpointStatus` | 当前状态,例如 `active`、`resumed`、`expired`、`disabled`、`terminal` |
-| `ArtifactPreview` | 产物摘要或缩略信息 |
-
-`ListSessionCheckpoints` 会基于同 session 内的 `run_resume` 事件聚合 `LastResumedAt` 与 `ResumeCount`。若 `ExpiresAt` 已过期,或 `ReplayAllowed=false` 且该 checkpoint 已恢复过,服务端会将 `IsResumable=false` 并填充 `ResumeDisabledReason`,前端不需要重复推导这些禁用规则。
-
-### `POST /agentengine/api/v1/GetCheckpointResumePreview`
-
-请求体:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 是 | Agent ID |
-| `SessionId` | `string` | 是 | 会话 ID |
-| `RunId` | `string` | 是 | 原 run ID |
-| `CheckpointId` | `string` | 是 | 要恢复的 checkpoint ID |
-
-响应 `Data.Preview` 返回恢复预览信息,用于 UI 在真正恢复前展示将从哪个 checkpoint 继续、可能涉及哪些 tool receipt。
-
-### `POST /agentengine/api/v1/ListToolReceipts`
-
-请求体:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 是 | Agent ID |
-| `SessionId` | `string` | 是 | 会话 ID |
-| `RunId` | `string` | 否 | 只返回指定 run 的 tool receipt |
-| `CheckpointId` | `string` | 否 | 只返回指定 checkpoint 关联的 tool receipt |
-
-响应 `Data.ToolReceipts` 为已记录的工具执行 receipt,用于恢复时展示和幂等治理。
-
-### `POST /agentengine/api/v1/ResumeRun`
-
-请求体:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 是 | Agent ID |
-| `SessionId` | `string` | 是 | 会话 ID |
-| `RunId` | `string` | 是 | 原 run ID。恢复语义是同一 run 续跑,不是新建 run |
-| `CheckpointId` | `string` | 是 | 要恢复的 checkpoint ID |
-| `ResumeAttemptId` | `string` | 否 | 本次恢复尝试 ID;不传由 runtime 生成 |
-| `InvocationId` | `string` | 否 | 本次流式恢复的 invocation ID;用于 `SubscribeRunEvents` / `CancelRun` |
-| `Stream` | `boolean` | 否 | 是否流式返回 |
-| `Model` | `string` | 否 | 可选模型名 |
-| `ModelMetadata` | `object` | 否 | 可选模型 metadata |
-| `ModelOptions` | `object` | 否 | 可选模型调用参数 |
-
-`Stream=true` 时返回 SSE,gateway 和 server 都按流式代理处理。runtime 只信任服务端已保存的 checkpoint 事件来解析 `framework_ref`,不会信任客户端传入的 framework 状态。
-
-### `POST /agentengine/api/v1/CancelRun`
-
-说明:
-
-- 这是 Hosted UI / 本地 Web UI 的运行取消接口
-- 公网 `PublicEndpoint` 调用时由 gateway 放行到 `agentengine-server`,再代理到 runtime 本地同名 action
-- runtime 会尝试调用当前 active runner 的 `request_cancel(InvocationId)`,并取消 detached streaming task
-- 如果 runner 不支持真正取消,接口仍可能返回 `Cancelled=true`,语义是“已请求取消”;前端仍应以后续 `run_status` 或事件流终态为准
-
-请求体:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `AgentId` | `string` | 是 | Agent ID |
-| `InvocationId` | `string` | 是 | 需要取消的运行 ID |
-
-响应示例:
-
-```json
-{
- "Code": 0,
- "Message": "Success",
- "RequestId": "req-xxxx",
- "Action": "CancelRun",
- "Data": {
- "Cancelled": true
- }
-}
-```
-
-非流式返回:
-
-- 外层仍是 `ActionResponse`
-- `Data` 直接放 runtime 返回的 payload
-- 服务端会补齐 `session_id`
-
-## 6.13 Legacy ADK Web 兼容接口
-
-### `POST /run_sse`
-
-请求体模型来自 `ksadk/server/api_models.py`:
-
-| 字段 | 类型 | 必填 | 说明 |
-| --- | --- | --- | --- |
-| `appName` | `string` | 是 | app 名 |
-| `userId` | `string` | 是 | 用户 ID |
-| `sessionId` | `string` | 否 | 会话 ID |
-| `newMessage` | `object` | 是 | 新消息 |
-| `streaming` | `boolean` | 否 | 是否流式 |
-| `invocationId` | `string` | 否 | 调用 ID |
-| `stateDelta` | `object` | 否 | 状态增量 |
-| `functionCallEventId` | `string` | 否 | 函数调用事件 ID |
-| `model` | `string` | 否 | 模型 |
-
-`newMessage` 结构:
-
-```json
-{
- "role": "user",
- "parts": [
- {
- "text": "hello"
- }
- ]
-}
-```
-
-### `/apps/{app_name}/users/{user_id}/sessions*`
-
-这组接口是 legacy session 兼容层,主要面向 ADK Web。
-
-## 6.14 Runtime 本地前端壳路径
-
-### `GET /chat`
-
-- 在 SDK 本地 `agentengine web` 或 runtime 镜像内置静态文件场景下,返回统一 Agent UI 的 `index.html`
-- 生产公网 `PublicEndpoint` 的 `/chat` 不再由 `agentengine-server` 或 runtime 本地静态文件承载;Ingress 会优先路由到独立 `agentengine-hosted-ui` Service
-- 前端仍通过 `/agentengine/api/v1/*` 调用 `agentengine-server` 的 Hosted UI action 接口
-
-### `GET /build`
-
-- SDK 本地前端壳路径,返回同一前端壳
-
-### `GET /deploy`
-
-- SDK 本地前端壳路径,返回同一前端壳
-
-### `GET /`
-
-- 当静态资源存在时,挂载整个静态目录
-
-说明:
-
-- 这些路径只有在 runtime 镜像内静态资源已构建并同步时才可用
-- 生产 hosted UI 的源码、镜像和发布节奏归属 `agentengine-hosted-ui` 独立仓库;`ksadk-python` 中的静态资源只作为 SDK 本地 UI 副本保留
-
-## 7. Hermes 运行时详细接口
-
-底层实现:`ksadk-python/deploy/hermes/runtime/app.py`
-
-Hermes 不是直接把 `ksadk.server.app` 暴露出去,而是在容器内再包一层 wrapper:
-
-- `/v1/*` 代理到内部 API server
-- `/` 代理到内部 dashboard
-- `/_ksadk/terminal/ws` 由 wrapper 自己实现
-- workspace files 由 wrapper 直接挂载
-
-## 7.1 路径总览
-
-| 路径 | 方法 | 说明 |
-| --- | --- | --- |
-| `/` | GET 等 | Hermes dashboard 管理 UI |
-| `/chat` | GET | AgentEngine hosted chat UI |
-| `/health` | GET | wrapper 健康检查 |
-| `/v1/{path}` | 全方法 | OpenAI-compatible API 透传 |
-| `/_ksadk/workspace/v1/*` | GET/HEAD/POST/DELETE | workspace files |
-| `/_ksadk/terminal/ws` | WebSocket | 终端 / connect / exec / pairing |
-
-## 7.2 健康检查
-
-### `GET /health`
-
-响应示例:
-
-```json
-{
- "ok": true,
- "checks": {
- "api": {
- "name": "api",
- "ok": true,
- "status_code": 200,
- "url": "http://127.0.0.1:8642/health"
- },
- "dashboard": {
- "name": "dashboard",
- "ok": true,
- "status_code": 200,
- "url": "http://127.0.0.1:9119/"
- }
- }
-}
-```
-
-## 7.3 `/v1/*`
-
-Hermes 外层 wrapper 对外暴露整个 `/v1/{path}`,本质是透传到内部 `API_SERVER_PORT=8642`。
-
-文档上应理解为:
-
-- 至少提供 `/v1/chat/completions`
-- 其余 `/v1/*` 只要内部 API server 存在,也会通过 wrapper 暴露
-
-SSE 要点:
-
-- wrapper 明确要求对 `/v1/*` 保持真流式转发
-- 不应把上游流读取完后再一次性回包
-
-## 7.4 Workspace Files
-
-Hermes 直接复用通用的 `/_ksadk/workspace/v1/*` contract。
-
-可用路径与通用 runtime 完全一致:
-
-- `GET /_ksadk/workspace/v1/healthz`
-- `GET /_ksadk/workspace/v1/entries`
-- `HEAD /_ksadk/workspace/v1/files/{path}`
-- `GET /_ksadk/workspace/v1/files/{path}`
-- `POST /_ksadk/workspace/v1/files/{path}`
-- `DELETE /_ksadk/workspace/v1/files/{path}`
-
-## 7.5 终端 WebSocket
-
-### `WS /_ksadk/terminal/ws`
-
-连接要求:
-
-- 必须带 `Sec-WebSocket-Protocol: ks-terminal.v1`
-- 公网访问时应带 `Authorization: Bearer `
-
-建立连接后,客户端首帧必须是 JSON 文本:
-
-```json
-{
- "type": "start",
- "mode": "tui",
- "argv": [],
- "cwd": ".",
- "rows": 24,
- "cols": 80
-}
-```
-
-`mode` 支持:
-
-- `tui`
-- `exec`
-- `pairing`
-- `connect`
-
-其中:
-
-- `tui` 会执行 `hermes chat`
-- `exec` 走只读命令白名单
-- `pairing` 走 `hermes pairing`
-- `connect` 走 `hermes gateway setup`
-
-服务端可能返回的文本消息:
-
-```json
-{"type":"ready"}
-```
-
-```json
-{"type":"exit","code":0}
-```
-
-```json
-{"type":"error","message":"..."}
-```
-
-控制帧示例:
-
-```json
-{"type":"resize","rows":40,"cols":120}
-```
-
-```json
-{"type":"signal","signal":"SIGINT"}
-```
-
-```json
-{"type":"stdin_eof"}
-```
-
-另外:
-
-- PTY 输出主要通过 WebSocket binary frame 回传
-- 如果首帧不是 `type=start`,服务端会报错
-
-## 8. OpenClaw 运行时可确认接口
-
-当前主线代码里,对 OpenClaw 可以准确写入文档的只有“平台补充 contract”,不要把上游 OpenClaw 原生全部接口误写成 ksadk/AgentEngine contract。
-
-## 8.1 运行模式
-
-OpenClaw gateway 主要有三种鉴权模式:
-
-- `trusted-proxy`
-- `token`
-- `none`
-
-默认建议模式:
-
-- `trusted-proxy`
-
-说明:
-
-- 公网经 AgentEngine 网关访问时,主路径仍是 trusted-proxy 设计
-- 自管或本地直连示例里,也支持 `token` 模式
-
-## 8.2 健康检查
-
-OpenClaw 运行镜像健康探针使用:
-
-- `GET /healthz`
-
-但这属于 OpenClaw gateway 原生健康接口,不是 ksadk 额外实现。
-
-## 8.3 Workspace Files 平台补充接口
-
-OpenClaw 会额外起一个本地 `workspace_files_app` sidecar,然后由 gateway 代理:
-
-- `/_ksadk/workspace/v1/*`
-
-可确认的外部 contract 与通用 runtime 一致:
-
-- `GET /_ksadk/workspace/v1/healthz`
-- `GET /_ksadk/workspace/v1/entries`
-- `HEAD /_ksadk/workspace/v1/files/{path}`
-- `GET /_ksadk/workspace/v1/files/{path}`
-- `POST /_ksadk/workspace/v1/files/{path}`
-- `DELETE /_ksadk/workspace/v1/files/{path}`
-
-说明:
-
-- sidecar 自身监听 `127.0.0.1:${WORKSPACE_FILES_PORT}`
-- 公网访问时看到的是经 OpenClaw gateway 代理后的同一路径
-
-## 9. 哪些接口能通过公网数据面直接访问
-
-这个点很容易误判,这里单独说明。
-
-### 9.1 一定可作为公网 contract 使用的接口
-
-- `/v1/responses`
-- `/v1/chat/completions`
-- `/chat`
-- `/_ksadk/workspace/v1/*`
-- Hermes 的 `/_ksadk/terminal/ws`
-- `GET /agentengine/api/v1/AttachmentContent`
-- `GET /agentengine/api/v1/GetWorkspaceFileContent`
-- Hosted UI action 白名单:
- - `GetAgentUiBootstrap`
- - `CreateSession`
- - `GetSession`
- - `ListSessions`
- - `DeleteSession`
- - `ListSessionEvents`
- - `SubscribeRunEvents`
- - `RunAgent`
- - `ListSessionCheckpoints`
- - `GetCheckpointResumePreview`
- - `ListToolReceipts`
- - `ResumeRun`
- - `CancelRun`
- - `GetResponseFeedback`
- - `UpsertResponseFeedback`
- - `DeleteResponseFeedback`
- - `UploadFile`
- - `ListWorkspaceFiles`
- - `AddWorkspaceFile`
- - `DeleteWorkspaceFile`
- - `ListAgentModels`
-
-### 9.2 不应假设公网可调用的接口
-
-不要假设下列内容一定是公网 contract:
-
-- 任意 `/agentengine/api/v1/*` 路径
-- runtime 本地存在但未进入 Hosted UI action 白名单的 UI 辅助路径,例如 `ExportWorkspaceZip`、Workspace HTML 预览路径
-- `/debug/*`、`/builder/*`、`/traces`、`eval_sets`、`eval_results` 等开发 / 调试 / 内部辅助入口
-- 任意 Pod 内部监听端口
-- OpenClaw 上游项目的全部原生 API
-- Hermes dashboard 内部 `/api/*` 的所有未文档化子路径
-
-## 10. 调用示例
-
-## 10.1 通用 Agent:调用 `/v1/chat/completions`
-
-```bash
-curl -X POST "https:///v1/chat/completions" \
- -H "Authorization: Bearer " \
- -H "Content-Type: application/json" \
- -H "Accept: application/json" \
- -d '{
- "messages": [
- {
- "role": "user",
- "content": "你好"
- }
- ],
- "stream": false
- }'
-```
-
-## 10.2 Hosted UI:调用 `RunAgent`
-
-```bash
-curl -X POST "https:///agentengine/api/v1/RunAgent" \
- -H "Authorization: Bearer " \
- -H "Content-Type: application/json" \
- -H "Accept: text/event-stream" \
- -d '{
- "AgentId": "ar-demo",
- "SessionId": "sess-123",
- "ApiFormat": "responses",
- "Stream": true,
- "Messages": [
- {
- "role": "user",
- "content": [
- {
- "type": "input_text",
- "text": "继续"
- }
- ]
- }
- ]
- }'
-```
-
-## 10.3 Workspace:列目录
-
-```bash
-curl "https:///_ksadk/workspace/v1/entries?path=.&recursive=false" \
- -H "Authorization: Bearer "
-```
-
-## 10.4 Hermes:连接终端
-
-```bash
-wscat \
- -H "Authorization: Bearer " \
- -s "ks-terminal.v1" \
- -c "wss:///_ksadk/terminal/ws"
-```
-
-首帧:
-
-```json
-{"type":"start","mode":"tui","rows":24,"cols":80}
-```
-
-## 11. 结论
-
-当前 `master` 下可以稳定对外承诺的核心运行时 contract 是:
-
-### 通用 Agent
-
-- `/v1/responses`
-- `/v1/chat/completions`
-- 公网 `/chat` 入口由 `agentengine-hosted-ui` 承载;runtime 本地 `/chat` 只用于 SDK 本地 UI 或内置静态资源场景
-- `/_ksadk/workspace/v1/*`
-- Hosted UI action 白名单
-
-### Hermes
-
-- `/`
-- 公网 `/chat` 入口由 `agentengine-hosted-ui` 承载
-- `/v1/*`
-- `/_ksadk/terminal/ws`
-- `/_ksadk/workspace/v1/*`
-- `/health`
-
-### OpenClaw
-
-- OpenClaw gateway 原生入口
-- 平台额外挂出的 `/_ksadk/workspace/v1/*`
-- 可配置的 `trusted-proxy | token | none` 鉴权模式
-
-如果后续要继续扩展文档,建议按两个方向增量补充:
-
-1. 基于真实镜像再验证 OpenClaw 原生 gateway 的稳定可见路由
-2. 为 Hosted UI action 补充逐接口完整示例响应
diff --git a/export-manifest.json b/export-manifest.json
index 4e9d46b2..dd7da282 100644
--- a/export-manifest.json
+++ b/export-manifest.json
@@ -1,477 +1,97 @@
{
- "generatedAt": "2026-05-28T12:01:15.291017+00:00",
+ "generatedAt": "2026-07-08T03:54:48.013854+00:00",
"targetRepository": "https://github.com/kingsoftcloud/ksadk-python",
"documentation": "https://kingsoftcloud.github.io/ksadk-python/",
- "exportPathCount": 574,
- "excludedPathCount": 574,
+ "exportPathCount": 554,
+ "excludedPathCount": 202,
"excludedPaths": [
- "docs/ksadk-web-export-plan.md",
+ ".gitleaks.toml",
+ "docs/Agent 开发者上下文接入指南.md",
+ "docs/DeepAgents说明.md",
+ "docs/archive/kb-memory/knowledge_base_integration_plan.md",
+ "docs/archive/kb-memory/knowledge_base_test_report.md",
+ "docs/archive/kb-memory/memory_adk_test_practice.md",
+ "docs/archive/kb-memory/memory_sdk_integration_plan.md",
+ "docs/archive/kb-memory/memory_test_report.md",
+ "docs/archive/versions/hermes-agent-v2026.4.16_本地安装配置与ksadk接入流程.md",
+ "docs/archive/workspace/agentengine-runtime-common-设计方案.md",
+ "docs/archive/workspace/openclaw_通用_memory_backend_bootstrap_设计.md",
+ "docs/archive/workspace/workspace_files_v1_实施说明.md",
+ "docs/archive/workspace/workspace_files_去重改造方案比较稿.md",
+ "docs/frameworks/LangGraph开发最佳实践.md",
+ "docs/guides/Agent 开发者上下文接入指南.md",
+ "docs/guides/DeepAgents说明.md",
+ "docs/guides/LangGraph开发最佳实践.md",
+ "docs/guides/ksadk使用文档.md",
+ "docs/guides/知识库与记忆示例.md",
+ "docs/guides/记忆使用指南.md",
+ "docs/hosted-ui-refactor-plan.md",
+ "docs/internal/Runner_Approval_Architecture.md",
+ "docs/internal/coding-agent-p0-tooling.md",
+ "docs/internal/dry_run_refactor.md",
+ "docs/internal/ksadk-skills-analysis-and-design.md",
+ "docs/internal/sandbox-runtime-design.md",
+ "docs/internal/skill-runtime-e2e.md",
+ "docs/ksadk-iteration-plan-condensed.md",
+ "docs/ksadk使用文档.md",
"docs/ksadk开源准备计划.md",
- "docs/maintainer-approval-record.md",
- "docs/open-source-approval-request.md",
- "docs/open-source-final-blockers.md",
- "docs/open-source-requirements-traceability.md",
- "docs/open-source-review-packet.md",
- "docs/post-approval-commands.md",
- "docs/publication-drafts.md",
- "docs/release-checklist.md",
- "ksadk/server/web-ui/.gitignore",
- "ksadk/server/web-ui/README.md",
- "ksadk/server/web-ui/components.json",
- "ksadk/server/web-ui/dist-hosted/assets/ArtifactsPanel-DcF96dyy.js",
- "ksadk/server/web-ui/dist-hosted/assets/CodeBlock-DmGYvtG5.js",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_AMS-Regular-DMm9YOAa.woff",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_AMS-Regular-DRggAlZN.ttf",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Fraktur-Regular-CB_wures.ttf",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Main-Bold-Cx986IdX.woff2",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Main-Bold-Jm3AIy58.woff",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Main-Bold-waoOVXN0.ttf",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Main-Italic-3WenGoN9.ttf",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Main-Italic-BMLOBm91.woff",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Main-Regular-B22Nviop.woff2",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Main-Regular-Dr94JaBh.woff",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Main-Regular-ypZvNtVU.ttf",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Math-Italic-DA0__PXp.woff",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Math-Italic-flOr_0UB.ttf",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Math-Italic-t53AETM-.woff2",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Script-Regular-C5JkGWo-.ttf",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Script-Regular-D3wIWfF6.woff2",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Script-Regular-D5yQViql.woff",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Size1-Regular-C195tn64.woff",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Size2-Regular-oD1tc_U0.woff",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Size3-Regular-CTq5MqoE.woff",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Size4-Regular-BF-4gkZK.woff",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Size4-Regular-DWFBv043.ttf",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2",
- "ksadk/server/web-ui/dist-hosted/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf",
- "ksadk/server/web-ui/dist-hosted/assets/MathMessageMarkdown-Bv7ZTOOa.css",
- "ksadk/server/web-ui/dist-hosted/assets/MathMessageMarkdown-CEcBwtMM.js",
- "ksadk/server/web-ui/dist-hosted/assets/MermaidBlock-DkpacQXs.js",
- "ksadk/server/web-ui/dist-hosted/assets/NativeTerminalPanel-Bv4PsVQf.js",
- "ksadk/server/web-ui/dist-hosted/assets/NativeTerminalPanel-CHOd7Rch.css",
- "ksadk/server/web-ui/dist-hosted/assets/_basePickBy-Dyh6BT9q.js",
- "ksadk/server/web-ui/dist-hosted/assets/_baseUniq-CI5m3e9k.js",
- "ksadk/server/web-ui/dist-hosted/assets/addon-fit-D4rkLGFF.js",
- "ksadk/server/web-ui/dist-hosted/assets/apl-BHCK7eTz.js",
- "ksadk/server/web-ui/dist-hosted/assets/arc--eqaPxsr.js",
- "ksadk/server/web-ui/dist-hosted/assets/architecture-PBZL5I3N-ifKrC9e8.js",
- "ksadk/server/web-ui/dist-hosted/assets/architectureDiagram-2XIMDMQ5-Da-OVsVt.js",
- "ksadk/server/web-ui/dist-hosted/assets/array-C5d_RYS0.js",
- "ksadk/server/web-ui/dist-hosted/assets/asciiarmor-CKsFaQWn.js",
- "ksadk/server/web-ui/dist-hosted/assets/asn1-KmlMb7Fb.js",
- "ksadk/server/web-ui/dist-hosted/assets/asterisk-hsgVlmhh.js",
- "ksadk/server/web-ui/dist-hosted/assets/blockDiagram-WCTKOSBZ-CS6N8tYT.js",
- "ksadk/server/web-ui/dist-hosted/assets/brainfuck-yyj_k-Sc.js",
- "ksadk/server/web-ui/dist-hosted/assets/c4Diagram-IC4MRINW-7odRJTNp.js",
- "ksadk/server/web-ui/dist-hosted/assets/channel-B-DGJ4mZ.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-4BX2VUAB-C4REt2TD.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-55IACEB6-cT-BqZ0Y.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-7E7YKBS2-JMp1w35e.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-7R4GIKGN-Air9KZTr.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-C72U2L5F-PugnqXWF.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-CFjPhJqf.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-EGIJ26TM-DVHFvd-V.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-FMBD7UC4-DqqYxQOA.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-GEFDOKGD-5Yqg5d2s.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-GLR3WWYH-Dc6eCAmt.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-HHEYEP7N-CCiu6w2l.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-JSJVCQXG-Dq62NZ4F.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-KX2RTZJC-CCfxhk0o.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-KYZI473N-nWo7KLkb.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-L3YUKLVL-D56E_4dD.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-MX3YWQON-CyHya8ER.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-NQ4KR5QH-Bih7QuSt.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-O4XLMI2P-CP9jZjUR.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-OZEHJAEY-D7lrydD-.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-PQ6SQG4A-Z_QhQQla.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-PU5JKC2W-K1O2sVZ3.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-QZHKN3VN-B7yd4p4v.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-R5LLSJPH-BvUpKHhe.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-WL4C6EOR-DVwKonqO.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-XIRO2GV7-DT6hxxCu.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-XPW4576I-KV4DQO27.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-XZSTWKYB-B8wkT7VK.js",
- "ksadk/server/web-ui/dist-hosted/assets/chunk-YBOYWFTD-DN2Gj4VJ.js",
- "ksadk/server/web-ui/dist-hosted/assets/classDiagram-VBA2DB6C-3hv093K8.js",
- "ksadk/server/web-ui/dist-hosted/assets/classDiagram-v2-RAHNMMFH-Y3XV6rOG.js",
- "ksadk/server/web-ui/dist-hosted/assets/clike-ckx3YDb4.js",
- "ksadk/server/web-ui/dist-hosted/assets/clipboard-CkO48gWZ.js",
- "ksadk/server/web-ui/dist-hosted/assets/clojure-B4a1fekk.js",
- "ksadk/server/web-ui/dist-hosted/assets/clone-5dGAc1NI.js",
- "ksadk/server/web-ui/dist-hosted/assets/cmake-DOhLV2DE.js",
- "ksadk/server/web-ui/dist-hosted/assets/cobol-zzTfSC0N.js",
- "ksadk/server/web-ui/dist-hosted/assets/coffeescript-DAuaYAn2.js",
- "ksadk/server/web-ui/dist-hosted/assets/commonlisp-nhcvZtb3.js",
- "ksadk/server/web-ui/dist-hosted/assets/cose-bilkent-S5V4N54A-D0qw9djq.js",
- "ksadk/server/web-ui/dist-hosted/assets/crystal-DkNzLZCu.js",
- "ksadk/server/web-ui/dist-hosted/assets/css-CPAB6T5R.js",
- "ksadk/server/web-ui/dist-hosted/assets/cypher-DMnb2BhJ.js",
- "ksadk/server/web-ui/dist-hosted/assets/cytoscape.esm-GJAx1ET2.js",
- "ksadk/server/web-ui/dist-hosted/assets/d-B8nwzSFl.js",
- "ksadk/server/web-ui/dist-hosted/assets/dagre-KLK3FWXG-2uyh2Zqc.js",
- "ksadk/server/web-ui/dist-hosted/assets/dagre-rKcAh6Ox.js",
- "ksadk/server/web-ui/dist-hosted/assets/defaultLocale-CCuQb4WR.js",
- "ksadk/server/web-ui/dist-hosted/assets/diagram-E7M64L7V-C0RQZ8fT.js",
- "ksadk/server/web-ui/dist-hosted/assets/diagram-IFDJBPK2-BRg2mIY7.js",
- "ksadk/server/web-ui/dist-hosted/assets/diagram-P4PSJMXO-CTTZjSH7.js",
- "ksadk/server/web-ui/dist-hosted/assets/diff-D0_HBszs.js",
- "ksadk/server/web-ui/dist-hosted/assets/dist-25djM0d3.js",
- "ksadk/server/web-ui/dist-hosted/assets/dist-B7OnrKrq.js",
- "ksadk/server/web-ui/dist-hosted/assets/dist-BE59Ppag.js",
- "ksadk/server/web-ui/dist-hosted/assets/dist-BF-u2EEc.js",
- "ksadk/server/web-ui/dist-hosted/assets/dist-BkzWsnxG.js",
- "ksadk/server/web-ui/dist-hosted/assets/dist-BwT8q_F2.js",
- "ksadk/server/web-ui/dist-hosted/assets/dist-C29qgn-6.js",
- "ksadk/server/web-ui/dist-hosted/assets/dist-C7FgEVHm.js",
- "ksadk/server/web-ui/dist-hosted/assets/dist-C7MGALiH.js",
- "ksadk/server/web-ui/dist-hosted/assets/dist-CE9Q03aT.js",
- "ksadk/server/web-ui/dist-hosted/assets/dist-CPaG3mhO.js",
- "ksadk/server/web-ui/dist-hosted/assets/dist-D0IJChqR.js",
- "ksadk/server/web-ui/dist-hosted/assets/dist-D0Smw0lF.js",
- "ksadk/server/web-ui/dist-hosted/assets/dist-DALepkMR.js",
- "ksadk/server/web-ui/dist-hosted/assets/dist-DD9A32xs.js",
- "ksadk/server/web-ui/dist-hosted/assets/dist-DDi2AtwY.js",
- "ksadk/server/web-ui/dist-hosted/assets/dist-DNMI0KpH.js",
- "ksadk/server/web-ui/dist-hosted/assets/dist-DSxrMaGc.js",
- "ksadk/server/web-ui/dist-hosted/assets/dist-DTGw-62V.js",
- "ksadk/server/web-ui/dist-hosted/assets/dist-Dvmk6Zeb.js",
- "ksadk/server/web-ui/dist-hosted/assets/dist-Wc3tKjhh.js",
- "ksadk/server/web-ui/dist-hosted/assets/dist-arlyGqE5.js",
- "ksadk/server/web-ui/dist-hosted/assets/dist-kJW2hJYm.js",
- "ksadk/server/web-ui/dist-hosted/assets/dist-sXtNp4E2.js",
- "ksadk/server/web-ui/dist-hosted/assets/dockerfile-BVWBOak5.js",
- "ksadk/server/web-ui/dist-hosted/assets/dtd-CJAp6eHX.js",
- "ksadk/server/web-ui/dist-hosted/assets/dylan-BI42aXIQ.js",
- "ksadk/server/web-ui/dist-hosted/assets/ebnf-DM2tJvW9.js",
- "ksadk/server/web-ui/dist-hosted/assets/ecl-C9osow-d.js",
- "ksadk/server/web-ui/dist-hosted/assets/eiffel-DtelNSiF.js",
- "ksadk/server/web-ui/dist-hosted/assets/elm-CX2Qvijm.js",
- "ksadk/server/web-ui/dist-hosted/assets/erDiagram-INFDFZHY-PIxMm2X-.js",
- "ksadk/server/web-ui/dist-hosted/assets/erlang-CKlrHK__.js",
- "ksadk/server/web-ui/dist-hosted/assets/factor-aLlwLDKr.js",
- "ksadk/server/web-ui/dist-hosted/assets/fcl-D6xm6kZS.js",
- "ksadk/server/web-ui/dist-hosted/assets/flowDiagram-PKNHOUZH-BJgcCXMq.js",
- "ksadk/server/web-ui/dist-hosted/assets/forth-httATuxy.js",
- "ksadk/server/web-ui/dist-hosted/assets/fortran-BbTsNbWF.js",
- "ksadk/server/web-ui/dist-hosted/assets/ganttDiagram-A5KZAMGK-ycuFYq_r.js",
- "ksadk/server/web-ui/dist-hosted/assets/gas-iZxQduJ2.js",
- "ksadk/server/web-ui/dist-hosted/assets/gherkin-B9XMQqDv.js",
- "ksadk/server/web-ui/dist-hosted/assets/gitGraph-HDMCJU4V-B8YNcsGe.js",
- "ksadk/server/web-ui/dist-hosted/assets/gitGraphDiagram-K3NZZRJ6-L3FJ4ob5.js",
- "ksadk/server/web-ui/dist-hosted/assets/graphlib-BrgJZMdK.js",
- "ksadk/server/web-ui/dist-hosted/assets/groovy-JJ86l90I.js",
- "ksadk/server/web-ui/dist-hosted/assets/haskell-BS2dtm18.js",
- "ksadk/server/web-ui/dist-hosted/assets/haxe-CqNcsArX.js",
- "ksadk/server/web-ui/dist-hosted/assets/http-C184nxV5.js",
- "ksadk/server/web-ui/dist-hosted/assets/idl-Cpn8Vp6F.js",
- "ksadk/server/web-ui/dist-hosted/assets/index-CITidnqf.js",
- "ksadk/server/web-ui/dist-hosted/assets/index-Dlt7i3uL.css",
- "ksadk/server/web-ui/dist-hosted/assets/info-3K5VOQVL-B8oDQZLy.js",
- "ksadk/server/web-ui/dist-hosted/assets/infoDiagram-LFFYTUFH-BtCiMY-S.js",
- "ksadk/server/web-ui/dist-hosted/assets/init-B2r4ykR3.js",
- "ksadk/server/web-ui/dist-hosted/assets/isArrayLikeObject-B2qkpvU2.js",
- "ksadk/server/web-ui/dist-hosted/assets/isEmpty-9ZVSfg_R.js",
- "ksadk/server/web-ui/dist-hosted/assets/ishikawaDiagram-PHBUUO56-DfT5mbSs.js",
- "ksadk/server/web-ui/dist-hosted/assets/javascript-C0eIISeX.js",
- "ksadk/server/web-ui/dist-hosted/assets/journeyDiagram-4ABVD52K-Bb_hfB_u.js",
- "ksadk/server/web-ui/dist-hosted/assets/jsx-runtime-QQLjLlGf.js",
- "ksadk/server/web-ui/dist-hosted/assets/julia-BTZidP6S.js",
- "ksadk/server/web-ui/dist-hosted/assets/kanban-definition-K7BYSVSG-Ba2sdHd4.js",
- "ksadk/server/web-ui/dist-hosted/assets/katex-REXy7PdG.js",
- "ksadk/server/web-ui/dist-hosted/assets/line-BTNWGJWR.js",
- "ksadk/server/web-ui/dist-hosted/assets/linear-zQ1VAIxs.js",
- "ksadk/server/web-ui/dist-hosted/assets/livescript-DOF7XUnL.js",
- "ksadk/server/web-ui/dist-hosted/assets/lua-DgJYygpl.js",
- "ksadk/server/web-ui/dist-hosted/assets/math-DGSp8ro7.js",
- "ksadk/server/web-ui/dist-hosted/assets/mathematica-C4sYzthQ.js",
- "ksadk/server/web-ui/dist-hosted/assets/mbox-DmHIMCol.js",
- "ksadk/server/web-ui/dist-hosted/assets/mermaid-parser.core-DgTF6uqg.js",
- "ksadk/server/web-ui/dist-hosted/assets/mindmap-definition-YRQLILUH-BI_zWPaX.js",
- "ksadk/server/web-ui/dist-hosted/assets/mirc-B4EYunMl.js",
- "ksadk/server/web-ui/dist-hosted/assets/mllike-BDEz3sCm.js",
- "ksadk/server/web-ui/dist-hosted/assets/modelica-BrnLS-wO.js",
- "ksadk/server/web-ui/dist-hosted/assets/mscgen-DXoyi3vj.js",
- "ksadk/server/web-ui/dist-hosted/assets/mumps-BnqKiOwF.js",
- "ksadk/server/web-ui/dist-hosted/assets/nginx-3pYsPdp4.js",
- "ksadk/server/web-ui/dist-hosted/assets/nsis-ZmBaQWtb.js",
- "ksadk/server/web-ui/dist-hosted/assets/ntriples-BShxd_u8.js",
- "ksadk/server/web-ui/dist-hosted/assets/octave-CMcOSszE.js",
- "ksadk/server/web-ui/dist-hosted/assets/ordinal-jw163_Ud.js",
- "ksadk/server/web-ui/dist-hosted/assets/oz-EGqN2mWx.js",
- "ksadk/server/web-ui/dist-hosted/assets/packet-RMMSAZCW-C7OKhhXW.js",
- "ksadk/server/web-ui/dist-hosted/assets/pascal-Do5RfXpW.js",
- "ksadk/server/web-ui/dist-hosted/assets/path-BQp9Hq4z.js",
- "ksadk/server/web-ui/dist-hosted/assets/perl-C_vcTXmu.js",
- "ksadk/server/web-ui/dist-hosted/assets/pie-UPGHQEXC-Bbb6Wwdg.js",
- "ksadk/server/web-ui/dist-hosted/assets/pieDiagram-SKSYHLDU-BNKvWD9T.js",
- "ksadk/server/web-ui/dist-hosted/assets/pig-B9o6QnZc.js",
- "ksadk/server/web-ui/dist-hosted/assets/powershell-Cd06g2Jm.js",
- "ksadk/server/web-ui/dist-hosted/assets/properties-B2zjNYKR.js",
- "ksadk/server/web-ui/dist-hosted/assets/protobuf-IwqMH2ly.js",
- "ksadk/server/web-ui/dist-hosted/assets/pug-DOWcp-za.js",
- "ksadk/server/web-ui/dist-hosted/assets/puppet-q98PbnCT.js",
- "ksadk/server/web-ui/dist-hosted/assets/python-BNGi4pCU.js",
- "ksadk/server/web-ui/dist-hosted/assets/q-BpMcxOIp.js",
- "ksadk/server/web-ui/dist-hosted/assets/quadrantDiagram-337W2JSQ-slSGNumW.js",
- "ksadk/server/web-ui/dist-hosted/assets/r-DCrVCh6t.js",
- "ksadk/server/web-ui/dist-hosted/assets/radar-KQ55EAFF-ZwTAp0CD.js",
- "ksadk/server/web-ui/dist-hosted/assets/requirementDiagram-Z7DCOOCP-BYzK1C_O.js",
- "ksadk/server/web-ui/dist-hosted/assets/rough.esm-Cq1ZV0Dh.js",
- "ksadk/server/web-ui/dist-hosted/assets/rpm--ZNB-3J4.js",
- "ksadk/server/web-ui/dist-hosted/assets/ruby-Dx0I8uwI.js",
- "ksadk/server/web-ui/dist-hosted/assets/sankeyDiagram-WA2Y5GQK-DgoIo_l4.js",
- "ksadk/server/web-ui/dist-hosted/assets/sas-XEk3XXAE.js",
- "ksadk/server/web-ui/dist-hosted/assets/scheme-D1b_80b3.js",
- "ksadk/server/web-ui/dist-hosted/assets/sequenceDiagram-2WXFIKYE-BqkMKOpC.js",
- "ksadk/server/web-ui/dist-hosted/assets/shell-BxPETWmT.js",
- "ksadk/server/web-ui/dist-hosted/assets/sieve-D462Os_N.js",
- "ksadk/server/web-ui/dist-hosted/assets/simple-mode-C5ZsRGe_.js",
- "ksadk/server/web-ui/dist-hosted/assets/smalltalk-DWg-QJ88.js",
- "ksadk/server/web-ui/dist-hosted/assets/solr-DirJfI87.js",
- "ksadk/server/web-ui/dist-hosted/assets/sparql-C5f2gvL-.js",
- "ksadk/server/web-ui/dist-hosted/assets/spreadsheet-BrMjukBr.js",
- "ksadk/server/web-ui/dist-hosted/assets/sql-DbzescZo.js",
- "ksadk/server/web-ui/dist-hosted/assets/src-Clx42DGz.js",
- "ksadk/server/web-ui/dist-hosted/assets/stateDiagram-RAJIS63D-D_Ss5Ihh.js",
- "ksadk/server/web-ui/dist-hosted/assets/stateDiagram-v2-FVOUBMTO-DfZ87Vqg.js",
- "ksadk/server/web-ui/dist-hosted/assets/stex-CG80BKzl.js",
- "ksadk/server/web-ui/dist-hosted/assets/stylus-omwnNXCW.js",
- "ksadk/server/web-ui/dist-hosted/assets/swift-D289Orrh.js",
- "ksadk/server/web-ui/dist-hosted/assets/tcl-BUjUwtPX.js",
- "ksadk/server/web-ui/dist-hosted/assets/textile-CJ0u1GQx.js",
- "ksadk/server/web-ui/dist-hosted/assets/tiddlywiki-BUfjyoYH.js",
- "ksadk/server/web-ui/dist-hosted/assets/tiki-DxhKxrrK.js",
- "ksadk/server/web-ui/dist-hosted/assets/timeline-definition-YZTLITO2-CNVzm-L1.js",
- "ksadk/server/web-ui/dist-hosted/assets/toml-Bg2104qZ.js",
- "ksadk/server/web-ui/dist-hosted/assets/treemap-KZPCXAKY-DWASSxAz.js",
- "ksadk/server/web-ui/dist-hosted/assets/troff-Cs3PIRsT.js",
- "ksadk/server/web-ui/dist-hosted/assets/ttcn-DZJ1Gz3y.js",
- "ksadk/server/web-ui/dist-hosted/assets/ttcn-cfg-CHGoGicC.js",
- "ksadk/server/web-ui/dist-hosted/assets/turtle-BiP2VmFo.js",
- "ksadk/server/web-ui/dist-hosted/assets/vb-B-xEPVKJ.js",
- "ksadk/server/web-ui/dist-hosted/assets/vbscript-DAJc-0Lz.js",
- "ksadk/server/web-ui/dist-hosted/assets/velocity-D_e2TK0S.js",
- "ksadk/server/web-ui/dist-hosted/assets/vennDiagram-LZ73GAT5-Dj45e83d.js",
- "ksadk/server/web-ui/dist-hosted/assets/verilog-CkkvRlTm.js",
- "ksadk/server/web-ui/dist-hosted/assets/vhdl-DSuVfsLS.js",
- "ksadk/server/web-ui/dist-hosted/assets/webidl-COsQYX1v.js",
- "ksadk/server/web-ui/dist-hosted/assets/x-BGAZi46_.js",
- "ksadk/server/web-ui/dist-hosted/assets/xquery-avYAEakU.js",
- "ksadk/server/web-ui/dist-hosted/assets/xterm-B8gQdswI.js",
- "ksadk/server/web-ui/dist-hosted/assets/xychartDiagram-JWTSCODW-C_v35sT1.js",
- "ksadk/server/web-ui/dist-hosted/assets/yacas-DH2Lk764.js",
- "ksadk/server/web-ui/dist-hosted/assets/z80-wmjXpkcV.js",
- "ksadk/server/web-ui/dist-hosted/favicon.svg",
- "ksadk/server/web-ui/dist-hosted/icons.svg",
- "ksadk/server/web-ui/dist-hosted/index.html",
- "ksadk/server/web-ui/eslint.config.js",
- "ksadk/server/web-ui/index.html",
- "ksadk/server/web-ui/package-lock.json",
- "ksadk/server/web-ui/package.json",
- "ksadk/server/web-ui/postcss.config.js",
- "ksadk/server/web-ui/public/favicon.svg",
- "ksadk/server/web-ui/public/icons.svg",
- "ksadk/server/web-ui/sandbox-poc.html",
- "ksadk/server/web-ui/scripts/sync-static.mjs",
- "ksadk/server/web-ui/src/App.css",
- "ksadk/server/web-ui/src/App.tsx",
- "ksadk/server/web-ui/src/__tests__/api-facade.test.ts",
- "ksadk/server/web-ui/src/__tests__/composer-contract.test.ts",
- "ksadk/server/web-ui/src/__tests__/error-boundary.test.ts",
- "ksadk/server/web-ui/src/__tests__/native-terminal-panel.test.ts",
- "ksadk/server/web-ui/src/__tests__/plugin-registry.test.ts",
- "ksadk/server/web-ui/src/__tests__/run-engine.test.ts",
- "ksadk/server/web-ui/src/__tests__/sandbox.test.ts",
- "ksadk/server/web-ui/src/__tests__/sse-parser.test.ts",
- "ksadk/server/web-ui/src/__tests__/stream-protocol.test.ts",
- "ksadk/server/web-ui/src/__tests__/workspace.test.ts",
- "ksadk/server/web-ui/src/api/bootstrap.ts",
- "ksadk/server/web-ui/src/api/client.ts",
- "ksadk/server/web-ui/src/api/errors.ts",
- "ksadk/server/web-ui/src/api/events.ts",
- "ksadk/server/web-ui/src/api/feedback.ts",
- "ksadk/server/web-ui/src/api/model.ts",
- "ksadk/server/web-ui/src/api/run.ts",
- "ksadk/server/web-ui/src/api/session.ts",
- "ksadk/server/web-ui/src/api/terminal.ts",
- "ksadk/server/web-ui/src/api/upload.ts",
- "ksadk/server/web-ui/src/api/workspace.ts",
- "ksadk/server/web-ui/src/assets/hero.png",
- "ksadk/server/web-ui/src/assets/react.svg",
- "ksadk/server/web-ui/src/assets/vite.svg",
- "ksadk/server/web-ui/src/components/ErrorBoundary.tsx",
- "ksadk/server/web-ui/src/components/MessageMarkdown.tsx",
- "ksadk/server/web-ui/src/components/ToastContainer.tsx",
- "ksadk/server/web-ui/src/components/artifacts/ArtifactsPanel.tsx",
- "ksadk/server/web-ui/src/components/chat/AttachmentPreview.tsx",
- "ksadk/server/web-ui/src/components/chat/ChatComposer.tsx",
- "ksadk/server/web-ui/src/components/chat/ChatHeader.tsx",
- "ksadk/server/web-ui/src/components/chat/ChatMessageList.tsx",
- "ksadk/server/web-ui/src/components/chat/ChatSidebar.tsx",
- "ksadk/server/web-ui/src/components/chat/ConnectedComposer.tsx",
- "ksadk/server/web-ui/src/components/chat/ConnectedMessageList.tsx",
- "ksadk/server/web-ui/src/components/chat/ConnectedSidebar.tsx",
- "ksadk/server/web-ui/src/components/chat/types.ts",
- "ksadk/server/web-ui/src/components/markdown/CodeBlock.tsx",
- "ksadk/server/web-ui/src/components/markdown/MathMessageMarkdown.tsx",
- "ksadk/server/web-ui/src/components/markdown/MermaidBlock.tsx",
- "ksadk/server/web-ui/src/components/native/NativeRuntimeLauncher.tsx",
- "ksadk/server/web-ui/src/components/native/NativeTerminalPanel.tsx",
- "ksadk/server/web-ui/src/components/ui/accordion.tsx",
- "ksadk/server/web-ui/src/components/ui/alert.tsx",
- "ksadk/server/web-ui/src/components/ui/avatar.tsx",
- "ksadk/server/web-ui/src/components/ui/badge.tsx",
- "ksadk/server/web-ui/src/components/ui/button.tsx",
- "ksadk/server/web-ui/src/components/ui/card.tsx",
- "ksadk/server/web-ui/src/components/ui/dialog.tsx",
- "ksadk/server/web-ui/src/components/ui/input.tsx",
- "ksadk/server/web-ui/src/components/ui/navigation-menu.tsx",
- "ksadk/server/web-ui/src/components/ui/scroll-area.tsx",
- "ksadk/server/web-ui/src/components/ui/separator.tsx",
- "ksadk/server/web-ui/src/components/ui/sheet.tsx",
- "ksadk/server/web-ui/src/components/ui/tabs.tsx",
- "ksadk/server/web-ui/src/components/ui/textarea.tsx",
- "ksadk/server/web-ui/src/components/workspace/FileEditor.tsx",
- "ksadk/server/web-ui/src/components/workspace/FilePreview.tsx",
- "ksadk/server/web-ui/src/components/workspace/HtmlPreview.tsx",
- "ksadk/server/web-ui/src/components/workspace/MarkdownPreview.tsx",
- "ksadk/server/web-ui/src/components/workspace/WorkspacePanel.tsx",
- "ksadk/server/web-ui/src/components/workspace/WorkspacePanelContainer.tsx",
- "ksadk/server/web-ui/src/core/api/facade.ts",
- "ksadk/server/web-ui/src/core/api/index.ts",
- "ksadk/server/web-ui/src/core/api/types.ts",
- "ksadk/server/web-ui/src/core/capability/index.ts",
- "ksadk/server/web-ui/src/core/capability/registry.ts",
- "ksadk/server/web-ui/src/core/capability/types.ts",
- "ksadk/server/web-ui/src/core/run/dispatcher.ts",
- "ksadk/server/web-ui/src/core/run/engine.ts",
- "ksadk/server/web-ui/src/core/run/index.ts",
- "ksadk/server/web-ui/src/core/run/types.ts",
- "ksadk/server/web-ui/src/core/stream/chat-completions-protocol.ts",
- "ksadk/server/web-ui/src/core/stream/index.ts",
- "ksadk/server/web-ui/src/core/stream/responses-protocol.ts",
- "ksadk/server/web-ui/src/core/stream/types.ts",
- "ksadk/server/web-ui/src/core/transport/index.ts",
- "ksadk/server/web-ui/src/core/transport/sse-parser.ts",
- "ksadk/server/web-ui/src/core/transport/sse-transport.ts",
- "ksadk/server/web-ui/src/core/transport/types.ts",
- "ksadk/server/web-ui/src/hooks/useBootstrap.ts",
- "ksadk/server/web-ui/src/hooks/useFeedback.ts",
- "ksadk/server/web-ui/src/hooks/useResponsiveViewport.ts",
- "ksadk/server/web-ui/src/hooks/useRunAgent.ts",
- "ksadk/server/web-ui/src/hooks/useSessionLifecycle.ts",
- "ksadk/server/web-ui/src/index.css",
- "ksadk/server/web-ui/src/lib/utils.ts",
- "ksadk/server/web-ui/src/main.tsx",
- "ksadk/server/web-ui/src/plugins/artifacts-plugin.ts",
- "ksadk/server/web-ui/src/plugins/launcher-plugin.tsx",
- "ksadk/server/web-ui/src/plugins/terminal-plugin.tsx",
- "ksadk/server/web-ui/src/plugins/workspace-plugin.tsx",
- "ksadk/server/web-ui/src/stores/artifact.ts",
- "ksadk/server/web-ui/src/stores/bootstrap.ts",
- "ksadk/server/web-ui/src/stores/message.ts",
- "ksadk/server/web-ui/src/stores/model.ts",
- "ksadk/server/web-ui/src/stores/session.ts",
- "ksadk/server/web-ui/src/stores/streaming.ts",
- "ksadk/server/web-ui/src/stores/ui.ts",
- "ksadk/server/web-ui/src/stores/workspace.ts",
- "ksadk/server/web-ui/src/types/api.ts",
- "ksadk/server/web-ui/src/types/bootstrap.ts",
- "ksadk/server/web-ui/src/types/capabilities.ts",
- "ksadk/server/web-ui/src/types/input.ts",
- "ksadk/server/web-ui/src/types/session-events.ts",
- "ksadk/server/web-ui/src/utils/attachment.ts",
- "ksadk/server/web-ui/src/utils/capabilities.js",
- "ksadk/server/web-ui/src/utils/clipboard.js",
- "ksadk/server/web-ui/src/utils/context.js",
- "ksadk/server/web-ui/src/utils/error.ts",
- "ksadk/server/web-ui/src/utils/feedback.js",
- "ksadk/server/web-ui/src/utils/layout-constants.ts",
- "ksadk/server/web-ui/src/utils/markdown.js",
- "ksadk/server/web-ui/src/utils/mobile-layout.js",
- "ksadk/server/web-ui/src/utils/model-options.js",
- "ksadk/server/web-ui/src/utils/native-platform.js",
- "ksadk/server/web-ui/src/utils/responses-stream.js",
- "ksadk/server/web-ui/src/utils/run-state.js",
- "ksadk/server/web-ui/src/utils/sandbox.ts",
- "ksadk/server/web-ui/src/utils/session-events.js",
- "ksadk/server/web-ui/src/utils/session-helpers.ts",
- "ksadk/server/web-ui/src/utils/session-list.js",
- "ksadk/server/web-ui/src/utils/session.js",
- "ksadk/server/web-ui/src/utils/stream-control.js",
- "ksadk/server/web-ui/src/utils/stream-parsing.ts",
- "ksadk/server/web-ui/src/utils/terminal-session.js",
- "ksadk/server/web-ui/src/utils/tool-display.js",
- "ksadk/server/web-ui/src/utils/workspace.js",
- "ksadk/server/web-ui/tailwind.config.ts",
- "ksadk/server/web-ui/tests/capabilities.test.mjs",
- "ksadk/server/web-ui/tests/feedback-utils.test.mjs",
- "ksadk/server/web-ui/tests/hosted-ui-sync.test.mjs",
- "ksadk/server/web-ui/tests/mobile-layout.test.mjs",
- "ksadk/server/web-ui/tests/native-platform.test.mjs",
- "ksadk/server/web-ui/tests/responses-stream.test.mjs",
- "ksadk/server/web-ui/tests/run-state.test.mjs",
- "ksadk/server/web-ui/tests/session-events.test.mjs",
- "ksadk/server/web-ui/tests/session-list.test.mjs",
- "ksadk/server/web-ui/tests/session-persistence.test.mjs",
- "ksadk/server/web-ui/tests/sidebar-contract.test.mjs",
- "ksadk/server/web-ui/tests/stream-control.test.mjs",
- "ksadk/server/web-ui/tests/sync-static.test.mjs",
- "ksadk/server/web-ui/tests/terminal-session.test.mjs",
- "ksadk/server/web-ui/tests/tool-display.test.mjs",
- "ksadk/server/web-ui/tests/ui-utils.test.mjs",
- "ksadk/server/web-ui/tests/workspace-panel-contract.test.mjs",
- "ksadk/server/web-ui/tests/workspace-utils.test.mjs",
- "ksadk/server/web-ui/tsconfig.app.json",
- "ksadk/server/web-ui/tsconfig.json",
- "ksadk/server/web-ui/tsconfig.node.json",
- "ksadk/server/web-ui/tsconfig.tsbuildinfo",
- "ksadk/server/web-ui/vite.config.ts",
- "scripts/audit_public_history_paths.py",
- "scripts/check_approval_record.py",
- "scripts/check_publication_state.py",
+ "docs/ksadk技术设计.md",
+ "docs/ksadk环境变量参考.md",
+ "docs/openclaw_client_one_click_deploy.html",
+ "docs/openclaw_gateway_channel_flow.svg",
+ "docs/openclaw_gateway_channel_flow_hd.png",
+ "docs/openclaw_gateway_technical.html",
+ "docs/openclaw一键部署指南.md",
+ "docs/preview/cli-demo/cli_real_terminal_demo.png",
+ "docs/preview/cli-demo/cli_screenshot_demo.sh",
+ "docs/preview/images/claw_logo.png",
+ "docs/preview/images/claw_robot.png",
+ "docs/preview/images/wps_support_group.jpg",
+ "docs/prompt-driven-agent-creation-draft.md",
+ "docs/public-release-workflow.md",
+ "docs/reference/ksadk技术设计.md",
+ "docs/reference/远程Agent运行时接口说明.md",
+ "docs/superpowers/plans/2026-04-16-hosted-hermes-gateway.md",
+ "docs/superpowers/plans/2026-04-20-workspace-files-pvc-implementation.md",
+ "docs/superpowers/plans/2026-05-07-thinking-user-control-and-e2e-plan.md",
+ "docs/superpowers/plans/2026-06-04-ksadk-0.6.2-public-candidate-audit.md",
+ "docs/superpowers/plans/2026-06-04-otel-first-observability-release.md",
+ "docs/superpowers/specs/2026-04-18-ksadk-support-model-design.md",
+ "docs/superpowers/specs/2026-04-19-agent-workspace-file-service-design.md",
+ "docs/veadk-benchmark-and-iteration-plan.md",
+ "docs/工作区文件技术设计.md",
+ "docs/平台可观测与用户反馈设计方案.md",
+ "docs/知识库与记忆示例.md",
+ "docs/记忆使用指南.md",
+ "docs/远程Agent运行时接口说明.md",
"scripts/ci-frontend-check.sh",
"scripts/debug_aicp_memory.py",
- "scripts/plan_github_publication.py",
- "scripts/prepare_open_source_review_bundle.py",
- "scripts/prepare_zread_source_snapshot.py",
"scripts/test_ks3_upload.py",
- "scripts/verify_open_source_review_bundle.py",
+ "scripts/validate_checkpoint_resume_e2e.py",
+ "scripts/validate_hosted_long_task_e2e.py",
+ "scripts/validate_long_task_pilot.py",
+ "skills/agentengine-cli-ops/SKILL.md",
+ "skills/agentengine-cli-ops/agents/openai.yaml",
+ "skills/agentengine-cli-ops/references/prerequisites.md",
+ "skills/agentengine-cli-ops/references/routing.md",
+ "skills/agentengine-cli-ops/references/shared-defaults.md",
+ "skills/agentengine-cluster-debug/SKILL.md",
+ "skills/agentengine-cluster-debug/references/commands.md",
+ "skills/agentengine-hermes-lifecycle/SKILL.md",
+ "skills/agentengine-hermes-lifecycle/agents/openai.yaml",
+ "skills/agentengine-hermes-lifecycle/references/dashboard-links.md",
+ "skills/agentengine-hermes-lifecycle/references/hermes-lifecycle.md",
+ "skills/agentengine-hermes-lifecycle/references/troubleshooting.md",
+ "skills/agentengine-openclaw-oneclick-deploy/SKILL.md",
+ "skills/agentengine-openclaw-oneclick-deploy/agents/openai.yaml",
+ "skills/agentengine-openclaw-oneclick-deploy/references/channel-connect.md",
+ "skills/agentengine-openclaw-oneclick-deploy/references/dashboard-links.md",
+ "skills/agentengine-openclaw-oneclick-deploy/references/openclaw-lifecycle.md",
+ "skills/agentengine-openclaw-oneclick-deploy/references/troubleshooting.md",
+ "tests/long_task/__init__.py",
+ "tests/long_task/test_checkpoint_resume.py",
+ "tests/long_task/test_runtime_cancel.py",
+ "tests/long_task/test_tool_idempotency.py",
"tests/skills/__init__.py",
"tests/skills/test_adk_runner_skill_runtime.py",
"tests/skills/test_loader_and_tools.py",
@@ -489,12 +109,13 @@
"tests/test_a2a_integration.py",
"tests/test_agent.py",
"tests/test_agent_access.py",
+ "tests/test_agentengine_toolsets.py",
"tests/test_aicp_env.py",
"tests/test_attachment_pipeline.py",
+ "tests/test_attachment_storage.py",
+ "tests/test_background_run.py",
"tests/test_builder_requirements_merge.py",
"tests/test_builder_runtime_requirements.py",
- "tests/test_check_approval_record.py",
- "tests/test_check_publication_state.py",
"tests/test_cli_dry_run.py",
"tests/test_cli_global_options.py",
"tests/test_cli_platform_refactor.py",
@@ -504,6 +125,7 @@
"tests/test_client_http_error_logging.py",
"tests/test_client_mcp_payloads.py",
"tests/test_client_permission_precheck.py",
+ "tests/test_client_user_uuid_header.py",
"tests/test_client_workspace_files.py",
"tests/test_cmd_build_upload_urls.py",
"tests/test_cmd_completion.py",
@@ -521,47 +143,49 @@
"tests/test_code_builder_pip_indexes.py",
"tests/test_code_builder_rebuild_fingerprint.py",
"tests/test_code_builder_static_assets.py",
- "tests/test_config_env_registry.py",
+ "tests/test_compaction_pipeline.py",
"tests/test_config_root_visibility.py",
+ "tests/test_container_registry_credentials.py",
"tests/test_conversation_runtime.py",
"tests/test_deepagents_integration.py",
+ "tests/test_deepagents_runner_skill_runtime.py",
"tests/test_deploy_integration.py",
"tests/test_error_utils_hints.py",
"tests/test_help_snapshots.py",
"tests/test_hermes_container_builder.py",
- "tests/test_hermes_dockerfile.py",
- "tests/test_hermes_kdocs_skill.py",
- "tests/test_hermes_runtime_template.py",
"tests/test_hermes_terminal.py",
"tests/test_hermes_terminal_e2e.py",
+ "tests/test_identity_resolver.py",
"tests/test_json_contracts.py",
"tests/test_ks3_uploader_urls.py",
"tests/test_langchain_runner_session_continuity.py",
"tests/test_langfuse_exporter.py",
"tests/test_langfuse_runner_utils.py",
"tests/test_langgraph_runner_resume.py",
+ "tests/test_langgraph_runner_skill_runtime.py",
"tests/test_local_runtime_reexec.py",
+ "tests/test_long_task_pilot_validation.py",
"tests/test_mcp_runtime.py",
- "tests/test_open_source_docs_contract.py",
+ "tests/test_model_policy.py",
"tests/test_openai_protocol_e2e.py",
- "tests/test_openclaw_bootstrap_secretref.py",
"tests/test_openclaw_env_vars.py",
- "tests/test_openclaw_runtime_proxy.py",
- "tests/test_openclaw_safe_exec.py",
- "tests/test_openclaw_workspace_files_gating.py",
+ "tests/test_openclaw_gateway.py",
"tests/test_orchestration_agents.py",
"tests/test_patch_langchain.py",
- "tests/test_plan_github_publication.py",
"tests/test_platform_memory_tools.py",
"tests/test_postgres_session_service.py",
+ "tests/test_prepare_ksadk_python_export.py",
"tests/test_remote_runner.py",
"tests/test_resource_output_snapshots.py",
"tests/test_runner.py",
+ "tests/test_runner_langfuse_callbacks.py",
"tests/test_runtime_common_memory_backend.py",
"tests/test_sandbox_backend.py",
+ "tests/test_semantic_circuit_breaker.py",
"tests/test_server_app_fastapi_compat.py",
"tests/test_server_file_upload_parsing.py",
"tests/test_server_session_app.py",
+ "tests/test_server_terminal_sessions.py",
"tests/test_server_workspace_preview_security.py",
"tests/test_session_continuity.py",
"tests/test_session_title.py",
@@ -569,12 +193,16 @@
"tests/test_setup_environment.py",
"tests/test_stm_config.py",
"tests/test_storage_defaults.py",
- "tests/test_tracing_setup_otlp.py",
+ "tests/test_tool_gateway.py",
+ "tests/test_tool_result_budget.py",
+ "tests/test_tracing_cloud_monitor_e2e.py",
"tests/test_tui_app.py",
"tests/test_tui_clipboard.py",
"tests/test_ui_config_resolution.py",
"tests/test_unified_agent_ui_local.py",
- "tests/test_verify_open_source_review_bundle.py",
+ "tests/test_usage_accumulator.py",
+ "tests/test_validate_hosted_long_task_e2e.py",
+ "tests/test_web_toolset.py",
"tests/test_workflow_common.py",
"tests/test_workflow_help_snapshots.py",
"tests/unit/knowledge_base/test_client_env.py",
@@ -584,6 +212,7 @@
"rootFiles": [
".dockerignore",
".gitattributes",
+ ".github/BRANCH_PROTECTION.md",
".github/ISSUE_TEMPLATE/bug_report.md",
".github/ISSUE_TEMPLATE/feature_request.md",
".github/dependabot.yml",
@@ -591,6 +220,7 @@
".github/workflows/ci.yml",
".github/workflows/codeql.yml",
".github/workflows/pages.yml",
+ ".github/workflows/publish-pypi.yml",
".github/workflows/release-check.yml",
".github/workflows/secret-patterns.yml",
".gitignore",
@@ -605,28 +235,41 @@
"README.md",
"README.zh-CN.md",
"SECURITY.md",
- "mkdocs.yml",
"pyproject.toml",
"uv.lock"
],
"prefixes": [
+ "docs-site/",
"ksadk/",
- "ksadk_runtime_common/",
- "public-docs/"
+ "ksadk_runtime_common/"
+ ],
+ "curatedDocs": [
+ "docs/maintainer-approval-record.md"
+ ],
+ "curatedReferenceDocs": [
+ "docs/reference/ksadk环境变量参考.md"
],
- "curatedDocs": [],
"scripts": [
"scripts/audit_release_artifacts.py",
+ "scripts/check_approval_record.py",
+ "scripts/check_publication_state.py",
+ "scripts/check_release_version.py",
+ "scripts/generate_public_assets.py",
"scripts/open_source_audit.py",
"scripts/prepare_ksadk_python_export.py",
- "scripts/prepare_ksadk_web_export.py"
+ "scripts/prepare_ksadk_web_export.py",
+ "scripts/public_secret_audit.py"
],
"tests": [
"tests/conftest.py",
+ "tests/test_check_approval_record.py",
+ "tests/test_check_publication_state.py",
+ "tests/test_config_env_registry.py",
+ "tests/test_markdown_repair.py",
"tests/test_open_source_audit.py",
- "tests/test_prepare_ksadk_python_export.py",
- "tests/test_prepare_ksadk_web_export.py",
- "tests/test_runtime_common_packaging.py"
+ "tests/test_public_release_positioning.py",
+ "tests/test_runtime_common_packaging.py",
+ "tests/test_tracing_setup_otlp.py"
]
},
"notes": [
diff --git a/ksadk/configs/env_registry.py b/ksadk/configs/env_registry.py
index 6873a968..5f39aeb2 100644
--- a/ksadk/configs/env_registry.py
+++ b/ksadk/configs/env_registry.py
@@ -263,12 +263,6 @@ class EnvVarSpec:
EnvVarSpec("KSADK_USER_BACKEND_URL", "web", "User-facing backend URL used by hosted UI integrations."),
EnvVarSpec("KSADK_WORKFLOW_PROMPT", "skills", "Prompt text exposed to local Skill workflow scripts."),
EnvVarSpec("KSADK_WORKSPACE_ID", "sessions", "Workspace id used for session namespace scoping."),
- EnvVarSpec(
- "KSADK_OTLP_MAX_EXPORT_BATCH_SIZE",
- "tracing",
- "Maximum spans exported per OTLP batch; defaults to 64 to avoid collector request-size limits.",
- "64",
- ),
EnvVarSpec("CLOUD_MONITOR_APP_KEY", "tracing", "CloudMonitor AppKey for optional OTLP ingestion.", sensitive=True),
EnvVarSpec(
"CLOUD_MONITOR_LANGFUSE_ENABLED",
@@ -331,6 +325,12 @@ class EnvVarSpec:
EnvVarSpec("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", "tracing", "OTLP traces protocol; takes precedence over the generic protocol."),
EnvVarSpec("OTEL_RESOURCE_ATTRIBUTES", "tracing", "OpenTelemetry resource attributes in key=value comma-separated form."),
EnvVarSpec("OTEL_SERVICE_NAME", "tracing", "OpenTelemetry service name."),
+ EnvVarSpec(
+ "KSADK_OTLP_MAX_EXPORT_BATCH_SIZE",
+ "tracing",
+ "Maximum spans exported per OTLP batch to avoid oversized collector requests.",
+ "64",
+ ),
)
ENV_VAR_REGISTRY: tuple[EnvVarSpec, ...] = tuple(
diff --git a/ksadk/identity/resolver.py b/ksadk/identity/resolver.py
index 50eda0d0..15f55c23 100644
--- a/ksadk/identity/resolver.py
+++ b/ksadk/identity/resolver.py
@@ -77,6 +77,21 @@ def _resolve_iam_endpoint() -> tuple[str, str]:
return host, scheme
+def _resolve_iam_intranet_endpoint() -> tuple[str, str] | None:
+ """解析显式配置的 IAM 内网 endpoint。未配置时不启用内网 fallback。"""
+ raw = (os.getenv("KSYUN_IAM_INTRANET_URL") or os.getenv("IAM_INTRANET_URL") or "").strip()
+ if not raw:
+ return None
+ if "://" not in raw:
+ raw = "http://" + raw
+ parsed = urlparse(raw)
+ host = (parsed.netloc or parsed.path or "").strip()
+ if not host:
+ return None
+ scheme = (parsed.scheme or "http").strip().lower() or "http"
+ return host, scheme
+
+
def _should_retry_intranet(error: Exception | None) -> bool:
"""判断是否应回退到内网 endpoint(内部账号只能内网访问时)。"""
if error is None:
@@ -256,16 +271,18 @@ def resolve_identity(
ak_fingerprint=fingerprint,
)
- # 2. 调 IAM 反查(公网失败时自动 fallback 内网,处理 InnerAccountCanOnlyAccessThroughIntranet)
+ # 2. 调 IAM 反查。公网失败时 fallback 内网 endpoint(内部账号只能内网访问时)。
+ # 内网地址优先用显式配置的 IAM_INTRANET_URL,未配置时用默认 iam.inner.api.ksyun.com。
+ # 该地址外部不可访问(内网专属),open_source_audit 白名单已放行,内部账号开箱即用。
sdk_parts = _import_iam_sdk()
if sdk_parts is None:
return None
host, scheme = _resolve_iam_endpoint()
- # 候选 endpoint 列表:(host, scheme, is_intranet);首个用解析出的默认,失败再试内网
candidates = [(host, scheme)]
- if host != "iam.inner.api.ksyun.com":
- candidates.append(("iam.inner.api.ksyun.com", "http"))
+ intranet_endpoint = _resolve_iam_intranet_endpoint() or ("iam.inner.api.ksyun.com", "http")
+ if intranet_endpoint not in candidates:
+ candidates.append(intranet_endpoint)
user_name: Optional[str] = None
user: dict = {}
diff --git a/ksadk_runtime_common/schemas/memory_backend_manifest.schema.json b/ksadk_runtime_common/schemas/memory_backend_manifest.schema.json
index 7a83d280..a81058e0 100644
--- a/ksadk_runtime_common/schemas/memory_backend_manifest.schema.json
+++ b/ksadk_runtime_common/schemas/memory_backend_manifest.schema.json
@@ -101,10 +101,7 @@
"description": "S3-compatible storage secret access key. Mapped into storageOptions.secretAccessKey."
},
"storage_allow_http": {
- "type": [
- "boolean",
- "string"
- ],
+ "type": ["boolean", "string"],
"description": "Allow plain HTTP for S3-compatible storage. Mapped into storageOptions.allowHttp as a string (\"true\"/\"false\")."
}
}
@@ -135,20 +132,14 @@
{
"if": {
"properties": {
- "backend_type": {
- "const": "mem0"
- }
+ "backend_type": { "const": "mem0" }
}
},
"then": {
- "required": [
- "config"
- ],
+ "required": ["config"],
"properties": {
"config": {
- "required": [
- "mem0_instance_id"
- ]
+ "required": ["mem0_instance_id"]
}
}
}
diff --git a/scripts/check_publication_state.py b/scripts/check_publication_state.py
index 02b97dbf..9e1f1579 100644
--- a/scripts/check_publication_state.py
+++ b/scripts/check_publication_state.py
@@ -48,6 +48,30 @@ def _expect_http_ok(name: str, url: str) -> None:
print(f"{name}: HTTP {status}")
+def _normalize_url(url: str) -> str:
+ parsed = urllib.parse.urlparse(url)
+ path = parsed.path or "/"
+ if not path.endswith("/"):
+ path = f"{path}/"
+ return urllib.parse.urlunparse((parsed.scheme, parsed.netloc, path, "", "", ""))
+
+
+def _expect_github_repo_homepage(url: str, expected_docs_url: str) -> None:
+ status, body = _open(url)
+ if status != 200:
+ raise RuntimeError(f"github repo: 期望 HTTP 200,实际 {status}: {url}")
+ data = json.loads(body)
+ if not isinstance(data, dict):
+ raise RuntimeError("github repo: 响应不是 repo object")
+ homepage = str(data.get("homepage") or "")
+ if _normalize_url(homepage) != _normalize_url(expected_docs_url):
+ raise RuntimeError(
+ "github repo homepage 与文档地址不一致: "
+ f"homepage={homepage or ''}, expected={expected_docs_url}"
+ )
+ print(f"github repo homepage: {homepage}")
+
+
def _pypi_project_version(project: str) -> str | None:
url = f"https://pypi.org/pypi/{project}/json"
try:
@@ -152,6 +176,10 @@ def main() -> int:
parser.add_argument("--project", default="ksadk")
parser.add_argument("--alias-project", default="agentengine-sdk-python")
parser.add_argument("--docs-url", default="https://kingsoftcloud.github.io/ksadk-python/")
+ parser.add_argument(
+ "--github-repo-url",
+ default="https://api.github.com/repos/kingsoftcloud/ksadk-python",
+ )
parser.add_argument(
"--github-releases-url",
default="https://api.github.com/repos/kingsoftcloud/ksadk-python/releases?per_page=100",
@@ -164,6 +192,7 @@ def main() -> int:
args = parser.parse_args()
_expect_http_ok("docs", args.docs_url)
+ _expect_github_repo_homepage(args.github_repo_url, args.docs_url)
required_tags = [tag.strip() for tag in args.required_release_tags.split(",") if tag.strip()]
_expect_release_history(args.github_releases_url, required_tags)
diff --git a/scripts/ci-frontend-check.sh b/scripts/ci-frontend-check.sh
deleted file mode 100755
index 37dec95c..00000000
--- a/scripts/ci-frontend-check.sh
+++ /dev/null
@@ -1,24 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-repo_root="$(cd "$(dirname "$0")/.." && pwd)"
-cd "$repo_root"
-
-make sync-ksadk-web-static
-
-if [ ! -f ksadk/server/static/index.html ]; then
- echo "FAIL: ksadk/server/static/index.html missing"
- exit 1
-fi
-
-if ! ls ksadk/server/static/assets/*.js >/dev/null 2>&1; then
- echo "FAIL: synced static bundle is missing JS assets"
- exit 1
-fi
-
-if ! ls ksadk/server/static/assets/*.css >/dev/null 2>&1; then
- echo "FAIL: synced static bundle is missing CSS assets"
- exit 1
-fi
-
-echo "PASS: KsADK Web static sync check OK"
diff --git a/scripts/debug_aicp_memory.py b/scripts/debug_aicp_memory.py
deleted file mode 100644
index f3a36e8d..00000000
--- a/scripts/debug_aicp_memory.py
+++ /dev/null
@@ -1,353 +0,0 @@
-#!/usr/bin/env python3
-"""Debug AICP memory collection resources and SDK read/write behavior.
-
-Examples:
- python ksadk-python/scripts/debug_aicp_memory.py service-status
- python ksadk-python/scripts/debug_aicp_memory.py list
- python ksadk-python/scripts/debug_aicp_memory.py get --memory-id mem-xxx
- python ksadk-python/scripts/debug_aicp_memory.py create --name demo --description "debug"
- python ksadk-python/scripts/debug_aicp_memory.py write \
- --memory-id mem-xxx --user-id debug-user --text ping
- python ksadk-python/scripts/debug_aicp_memory.py query \
- --memory-id mem-xxx --user-id debug-user --query ping
- python ksadk-python/scripts/debug_aicp_memory.py list-sessions \
- --memory-id mem-xxx --user-id debug-user
- python ksadk-python/scripts/debug_aicp_memory.py session-memories \
- --memory-id mem-xxx --session-id sess-xxx
- python ksadk-python/scripts/debug_aicp_memory.py metrics --memory-id mem-xxx
-"""
-
-from __future__ import annotations
-
-import argparse
-import copy
-import json
-import os
-import sys
-import time
-import uuid
-from pathlib import Path
-from typing import Any
-
-
-def load_simple_dotenv(env_file: str | None = None) -> None:
- """Load .env files without requiring python-dotenv."""
- candidates = []
- if env_file:
- candidates.append(Path(env_file).expanduser())
- candidates.extend(
- [
- Path.cwd() / ".env",
- Path.cwd().parent / ".env",
- Path(__file__).resolve().parents[1] / ".env",
- Path(__file__).resolve().parents[2] / ".env",
- Path(__file__).resolve().parents[3] / ".env",
- ]
- )
-
- for env_path in candidates:
- if not env_path.exists():
- continue
- for raw_line in env_path.read_text(encoding="utf-8").splitlines():
- line = raw_line.strip()
- if not line or line.startswith("#") or "=" not in line:
- continue
- key, value = line.split("=", 1)
- key = key.strip()
- value = value.strip().strip("'").strip('"')
- os.environ.setdefault(key, value)
-
-
-def build_client():
- from ksyun.client.aicp.v20251114 import client
- from ksyun.common import credential
- from ksyun.common.profile.client_profile import ClientProfile
- from ksyun.common.profile.http_profile import HttpProfile
-
- access_key = os.getenv("KSADK_LTM_ACCESS_KEY") or os.getenv("KSYUN_ACCESS_KEY")
- secret_key = os.getenv("KSADK_LTM_SECRET_KEY") or os.getenv("KSYUN_SECRET_KEY")
- region = os.getenv("KSADK_LTM_REGION", "cn-beijing-6")
- endpoint = os.getenv("KSADK_LTM_ENDPOINT", "aicp.api.ksyun.com")
- scheme = os.getenv("KSADK_LTM_SCHEME", "https")
-
- if not access_key or not secret_key:
- raise SystemExit(
- "Missing AK/SK. Set KSADK_LTM_ACCESS_KEY/SECRET_KEY "
- "or KSYUN_ACCESS_KEY/SECRET_KEY."
- )
-
- cred = credential.Credential(access_key, secret_key)
- http = HttpProfile()
- http.endpoint = endpoint
- http.reqMethod = "POST"
- http.reqTimeout = 60
- http.scheme = scheme
-
- profile = ClientProfile()
- profile.httpProfile = http
-
- cli = client.AicpClient(cred, region, profile=profile)
- return cli, {
- "region": region,
- "endpoint": endpoint,
- "scheme": scheme,
- "access_key_tail": access_key[-6:],
- }
-
-
-def print_json(data: Any) -> None:
- print(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True))
-
-
-def safe_call(label: str, func, request=None):
- try:
- body = func(request) if request is not None else func()
- parsed = json.loads(body) if isinstance(body, str) else body
- return {"ok": True, "action": label, "response": parsed}
- except Exception as exc: # noqa: BLE001
- return {
- "ok": False,
- "action": label,
- "error_type": type(exc).__name__,
- "error": str(exc),
- }
-
-
-def cmd_service_status(cli):
- from ksyun.client.aicp.v20251114 import models
-
- req = models.GetMemoryBaseServiceRequest()
- return safe_call("GetMemoryBaseService", cli.GetMemoryBaseService, req)
-
-
-def cmd_list(cli, args):
- from ksyun.client.aicp.v20251114 import models
-
- req = models.ListMemoryCollectionsRequest()
- if args.name_keyword:
- req.NameKeyword = args.name_keyword
- if args.name:
- req.Name = args.name
- if args.memory_id:
- req.MemoryCollectionId = args.memory_id
- if args.status:
- req.Status = args.status
- req.Marker = args.marker
- req.MaxResults = args.max_results
- return safe_call("ListMemoryCollections", cli.ListMemoryCollections, req)
-
-
-def cmd_get(cli, args):
- from ksyun.client.aicp.v20251114 import models
-
- req = models.GetMemoryCollectionRequest()
- req.MemoryCollectionId = args.memory_id
- return safe_call("GetMemoryCollection", cli.GetMemoryCollection, req)
-
-
-def cmd_create(cli, args):
- from ksyun.client.aicp.v20251114 import models
-
- req = models.CreateMemoryCollectionRequest()
- req.Name = args.name
- if args.description:
- req.Description = args.description
- return safe_call("CreateMemoryCollection", cli.CreateMemoryCollection, req)
-
-
-def raw_sdk_call(cli, action: str, params: dict[str, Any]) -> dict[str, Any]:
- safe_params = copy.deepcopy(params)
- if "Accesskey" in safe_params and isinstance(safe_params["Accesskey"], str):
- ak = safe_params["Accesskey"]
- safe_params["Accesskey"] = f"{ak[:4]}...{ak[-4:]}"
- try:
- body = cli.call(action, params, options={"IsPostJson": True})
- parsed = json.loads(body) if isinstance(body, str) else body
- return {"ok": True, "action": action, "params": safe_params, "response": parsed}
- except Exception as exc: # noqa: BLE001
- return {
- "ok": False,
- "action": action,
- "params": safe_params,
- "error_type": type(exc).__name__,
- "error": str(exc),
- }
-
-
-def cmd_write(cli, args):
- params = {
- "MemoryCollectionId": args.memory_id,
- "AgentUserId": args.user_id,
- "SceneId": args.scene_id,
- "Data": {
- "Conversation": [
- {
- "Role": args.role,
- "CreatedAt": int(time.time() * 1000),
- "MessageId": str(uuid.uuid4()),
- "Content": [{"Type": "input_text", "Text": args.text}],
- }
- ]
- },
- }
- if args.agent_id:
- params["AgentId"] = args.agent_id
- if args.session_id:
- params["SessionId"] = args.session_id
- return raw_sdk_call(cli, "CreateMemorySdk", params)
-
-
-def cmd_query(cli, args):
- params = {
- "MemoryCollectionId": args.memory_id,
- "AgentUserId": args.user_id,
- "SceneId": args.scene_id,
- "Query": args.query,
- "Limit": args.limit,
- }
- if args.mode:
- params["Mode"] = args.mode
- return raw_sdk_call(cli, "QueryMemorySdk", params)
-
-
-def cmd_list_sessions(cli, args):
- params = {
- "MemoryCollectionId": args.memory_id,
- "AgentUserId": args.user_id,
- "Page": args.page,
- "PageSize": args.page_size,
- }
- if args.query:
- params["Query"] = args.query
- if args.created_after:
- params["CreatedAfter"] = args.created_after
- if args.created_before:
- params["CreatedBefore"] = args.created_before
- return raw_sdk_call(cli, "ListSessions", params)
-
-
-def cmd_session_memories(cli, args):
- params = {
- "MemoryCollectionId": args.memory_id,
- "SessionId": args.session_id,
- }
- return raw_sdk_call(cli, "QuerySessionMemories", params)
-
-
-def cmd_metrics(cli, args):
- end_time = args.end_time or int(time.time())
- start_time = args.start_time or end_time - args.last_seconds
- params = {
- "MemoryCollectionId": args.memory_id,
- "StartTime": start_time,
- "EndTime": end_time,
- }
- return raw_sdk_call(cli, "QueryMemoryCollectionMetrics", params)
-
-
-def build_parser() -> argparse.ArgumentParser:
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument(
- "--env-file",
- help="Optional path to a .env file. Values are loaded before reading process env.",
- )
- subparsers = parser.add_subparsers(dest="command", required=True)
-
- subparsers.add_parser("service-status", help="Get memory base service status")
-
- list_parser = subparsers.add_parser("list", help="List memory collections")
- list_parser.add_argument("--name-keyword")
- list_parser.add_argument("--name")
- list_parser.add_argument("--memory-id")
- list_parser.add_argument("--status")
- list_parser.add_argument("--marker", type=int, default=1)
- list_parser.add_argument("--max-results", type=int, default=20)
-
- get_parser = subparsers.add_parser("get", help="Get a memory collection by id")
- get_parser.add_argument("--memory-id", required=True)
-
- create_parser = subparsers.add_parser("create", help="Create a memory collection")
- create_parser.add_argument("--name", required=True)
- create_parser.add_argument("--description", default="")
-
- write_parser = subparsers.add_parser("write", help="Write memory into a memory collection")
- write_parser.add_argument("--memory-id", "--namespace", dest="memory_id", required=True)
- write_parser.add_argument("--user-id", default="debug-user")
- write_parser.add_argument("--text", required=True)
- write_parser.add_argument("--role", default="user")
- write_parser.add_argument("--agent-id", default="")
- write_parser.add_argument("--session-id", default="")
- write_parser.add_argument("--scene-id", default="_sys_general")
-
- query_parser = subparsers.add_parser("query", help="Query memory from a memory collection")
- query_parser.add_argument("--memory-id", "--namespace", dest="memory_id", required=True)
- query_parser.add_argument("--user-id", default="debug-user")
- query_parser.add_argument("--query", required=True)
- query_parser.add_argument("--limit", type=int, default=5)
- query_parser.add_argument("--scene-id", default="_sys_general")
- query_parser.add_argument("--mode", default="")
-
- sessions_parser = subparsers.add_parser(
- "list-sessions", help="List raw memory sessions for a user"
- )
- sessions_parser.add_argument("--memory-id", "--namespace", dest="memory_id", required=True)
- sessions_parser.add_argument("--user-id", default="debug-user")
- sessions_parser.add_argument("--query", default="")
- sessions_parser.add_argument("--page", type=int, default=1)
- sessions_parser.add_argument("--page-size", type=int, default=20)
- sessions_parser.add_argument("--created-after", type=int, default=0)
- sessions_parser.add_argument("--created-before", type=int, default=0)
-
- session_memories_parser = subparsers.add_parser(
- "session-memories", help="Query extracted memories for one raw session"
- )
- session_memories_parser.add_argument("--memory-id", "--namespace", dest="memory_id", required=True)
- session_memories_parser.add_argument("--session-id", required=True)
-
- metrics_parser = subparsers.add_parser(
- "metrics", help="Query memory collection action metrics"
- )
- metrics_parser.add_argument("--memory-id", "--namespace", dest="memory_id", required=True)
- metrics_parser.add_argument("--start-time", type=int, default=0)
- metrics_parser.add_argument("--end-time", type=int, default=0)
- metrics_parser.add_argument("--last-seconds", type=int, default=3600)
-
- return parser
-
-
-def main() -> int:
- parser = build_parser()
- args = parser.parse_args()
- load_simple_dotenv(args.env_file)
- cli, config = build_client()
-
- print_json({"config": config, "command": args.command})
-
- if args.command == "service-status":
- result = cmd_service_status(cli)
- elif args.command == "list":
- result = cmd_list(cli, args)
- elif args.command == "get":
- result = cmd_get(cli, args)
- elif args.command == "create":
- result = cmd_create(cli, args)
- elif args.command == "write":
- result = cmd_write(cli, args)
- elif args.command == "query":
- result = cmd_query(cli, args)
- elif args.command == "list-sessions":
- result = cmd_list_sessions(cli, args)
- elif args.command == "session-memories":
- result = cmd_session_memories(cli, args)
- elif args.command == "metrics":
- result = cmd_metrics(cli, args)
- else:
- parser.error(f"Unsupported command: {args.command}")
- return 2
-
- print_json(result)
- return 0 if result.get("ok") else 1
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/scripts/generate_public_assets.py b/scripts/generate_public_assets.py
index 8113c501..36042d3f 100644
--- a/scripts/generate_public_assets.py
+++ b/scripts/generate_public_assets.py
@@ -24,7 +24,7 @@
ROOT = Path(__file__).resolve().parents[1]
-ASSETS_DIR = ROOT / "public-docs" / "assets"
+ASSETS_DIR = ROOT / "docs-site" / "public" / "assets"
ARCH_SVG = ASSETS_DIR / "ksadk-runtime-architecture.svg"
ARCH_PNG = ASSETS_DIR / "ksadk-runtime-architecture.png"
HERO_PNG = ASSETS_DIR / "ksadk-runtime-platform-hero.png"
diff --git a/scripts/open_source_audit.py b/scripts/open_source_audit.py
index 21f6d077..12ad1213 100644
--- a/scripts/open_source_audit.py
+++ b/scripts/open_source_audit.py
@@ -105,7 +105,13 @@ def to_dict(self) -> dict[str, object]:
DenyRule(
name="non-curated-docs",
prefixes=("docs/",),
- allowed_paths=("docs/maintainer-approval-record.md", "docs/ksadk\u73af\u5883\u53d8\u91cf\u53c2\u8003.md", "docs/\u8fdc\u7a0bAgent\u8fd0\u884c\u65f6\u63a5\u53e3\u8bf4\u660e.md", "docs/reference/ksadk\u6280\u672f\u8bbe\u8ba1.md"),
+ allowed_paths=(
+ "docs/maintainer-approval-record.md",
+ "docs/ksadk\u73af\u5883\u53d8\u91cf\u53c2\u8003.md",
+ "docs/\u8fdc\u7a0bAgent\u8fd0\u884c\u65f6\u63a5\u53e3\u8bf4\u660e.md",
+ "docs/reference/ksadk\u6280\u672f\u8bbe\u8ba1.md",
+ "docs/reference/ksadk\u73af\u5883\u53d8\u91cf\u53c2\u8003.md",
+ ),
prefix_only=True,
description="internal planning and technical design docs stay out of the public repository; public docs live in docs-site/ (Fumadocs)",
),
diff --git a/scripts/prepare_ksadk_python_export.py b/scripts/prepare_ksadk_python_export.py
index b85581cb..5d46f6ff 100644
--- a/scripts/prepare_ksadk_python_export.py
+++ b/scripts/prepare_ksadk_python_export.py
@@ -28,10 +28,12 @@
DEFAULT_OUTPUT_DIR = Path("/tmp/ksadk-python-export-candidate")
CURATED_DOCS: set[str] = {"docs/maintainer-approval-record.md"}
+CURATED_REFERENCE_DOCS: set[str] = {"docs/reference/ksadk环境变量参考.md"}
ROOT_EXPORT_FILES = {
".dockerignore",
".gitattributes",
+ ".github/BRANCH_PROTECTION.md",
".github/ISSUE_TEMPLATE/bug_report.md",
".github/ISSUE_TEMPLATE/feature_request.md",
".github/dependabot.yml",
@@ -39,6 +41,7 @@
".github/workflows/ci.yml",
".github/workflows/codeql.yml",
".github/workflows/pages.yml",
+ ".github/workflows/publish-pypi.yml",
".github/workflows/release-check.yml",
".github/workflows/secret-patterns.yml",
".gitignore",
@@ -53,31 +56,45 @@
"README.en.md",
"README.zh-CN.md",
"SECURITY.md",
- "mkdocs.yml",
"pyproject.toml",
"uv.lock",
}
EXPORT_PREFIXES = (
+ "docs-site/",
"ksadk/",
"ksadk_runtime_common/",
- "public-docs/",
)
+REQUIRED_PUBLIC_FILES = {
+ "AGENTS.md",
+ "CLAUDE.md",
+ "LICENSE",
+ "README.md",
+ "README.en.md",
+ "README.zh-CN.md",
+ "docs-site/package.json",
+ "docs-site/pnpm-lock.yaml",
+ "pyproject.toml",
+}
+
SCRIPT_EXPORT_FILES = {
"scripts/audit_release_artifacts.py",
"scripts/check_approval_record.py",
"scripts/check_publication_state.py",
+ "scripts/check_release_version.py",
"scripts/generate_public_assets.py",
"scripts/open_source_audit.py",
"scripts/prepare_ksadk_python_export.py",
"scripts/prepare_ksadk_web_export.py",
+ "scripts/public_secret_audit.py",
}
PUBLIC_TEST_FILES = {
"tests/conftest.py",
"tests/test_check_approval_record.py",
"tests/test_check_publication_state.py",
+ "tests/test_config_env_registry.py",
"tests/test_markdown_repair.py",
"tests/test_open_source_audit.py",
"tests/test_public_release_positioning.py",
@@ -108,16 +125,6 @@
EXCLUDED_PATHS = {
".pypirc",
".pypirc.example",
- "Dockerfile.docs",
- "deploy/helm/ksadk-docs/Chart.yaml",
- "deploy/helm/ksadk-docs/templates/_helpers.tpl",
- "deploy/helm/ksadk-docs/templates/deployment.yaml",
- "deploy/helm/ksadk-docs/templates/ingress.yaml",
- "deploy/helm/ksadk-docs/templates/service.yaml",
- "deploy/helm/ksadk-docs/values-online.yaml",
- "deploy/helm/ksadk-docs/values-pre.yaml",
- "deploy/helm/ksadk-docs/values.yaml",
- "scripts/zread_subpath_proxy.py",
}
EXCLUDED_SUFFIXES = (
@@ -194,9 +201,9 @@ def is_excluded(path: str) -> bool:
return True
if normalized in EXCLUDED_PATHS:
return True
- if normalized.startswith("deploy/helm/ksadk-docs/"):
+ if normalized.startswith("docs/reference/") and normalized not in CURATED_REFERENCE_DOCS:
return True
- if normalized.startswith("docs/") and normalized not in CURATED_DOCS:
+ if normalized.startswith("docs/") and normalized not in CURATED_DOCS and normalized not in CURATED_REFERENCE_DOCS:
return True
if normalized.endswith(EXCLUDED_SUFFIXES):
return True
@@ -211,6 +218,7 @@ def is_included_by_policy(path: str) -> bool:
return (
normalized in ROOT_EXPORT_FILES
or normalized in CURATED_DOCS
+ or normalized in CURATED_REFERENCE_DOCS
or normalized in SCRIPT_EXPORT_FILES
or normalized in PUBLIC_TEST_FILES
or normalized.startswith(EXPORT_PREFIXES)
@@ -233,17 +241,7 @@ def build_export_plan(repo_root: Path) -> ExportPlan:
export_paths = sorted(path for path in discovered if not is_excluded(path))
excluded_paths = sorted(path for path in discovered if is_excluded(path))
- required_paths = {
- "AGENTS.md",
- "CLAUDE.md",
- "LICENSE",
- "README.md",
- "README.en.md",
- "README.zh-CN.md",
- "pyproject.toml",
- "mkdocs.yml",
- }
- for required_path in sorted(required_paths):
+ for required_path in sorted(REQUIRED_PUBLIC_FILES):
if required_path not in export_paths:
violations.append(f"missing required public file: {required_path}")
@@ -283,6 +281,7 @@ def copy_export(plan: ExportPlan, output_dir: Path) -> None:
"rootFiles": sorted(ROOT_EXPORT_FILES),
"prefixes": list(EXPORT_PREFIXES),
"curatedDocs": sorted(CURATED_DOCS),
+ "curatedReferenceDocs": sorted(CURATED_REFERENCE_DOCS),
"scripts": sorted(SCRIPT_EXPORT_FILES),
"tests": sorted(PUBLIC_TEST_FILES),
},
diff --git a/scripts/prepare_zread_source_snapshot.py b/scripts/prepare_zread_source_snapshot.py
deleted file mode 100644
index e7111202..00000000
--- a/scripts/prepare_zread_source_snapshot.py
+++ /dev/null
@@ -1,64 +0,0 @@
-#!/usr/bin/env python3
-"""Copy source files referenced by zread pages into a small Docker snapshot."""
-
-from __future__ import annotations
-
-import re
-import shutil
-from pathlib import Path
-from urllib.parse import unquote
-
-
-ROOT = Path.cwd()
-WIKI_CURRENT = ROOT / ".zread" / "wiki" / "current"
-SOURCE_DIR = ROOT / ".zread" / "source"
-LINK_RE = re.compile(r"\[[^\]]+\]\(([^)]+)\)")
-
-
-def current_wiki_root() -> Path:
- version = WIKI_CURRENT.read_text(encoding="utf-8").strip().removeprefix("versions/")
- return ROOT / ".zread" / "wiki" / "versions" / version
-
-
-def is_local_source_href(href: str) -> bool:
- return not href.startswith(("http://", "https://", "#", "mailto:", "javascript:"))
-
-
-def referenced_files(wiki_root: Path) -> list[Path]:
- files: set[Path] = set()
- for markdown in wiki_root.glob("*.md"):
- text = markdown.read_text(encoding="utf-8")
- for match in LINK_RE.finditer(text):
- href = unquote(match.group(1).strip()).split("#", 1)[0]
- if not href or not is_local_source_href(href):
- continue
- candidate = Path(href)
- if candidate.is_absolute() or ".." in candidate.parts:
- continue
- source = ROOT / candidate
- if source.is_file():
- files.add(candidate)
- return sorted(files, key=lambda path: path.as_posix())
-
-
-def main() -> int:
- wiki_root = current_wiki_root()
- files = referenced_files(wiki_root)
- if SOURCE_DIR.exists():
- shutil.rmtree(SOURCE_DIR)
- SOURCE_DIR.mkdir(parents=True, exist_ok=True)
-
- total_bytes = 0
- for relative in files:
- source = ROOT / relative
- target = SOURCE_DIR / relative
- target.parent.mkdir(parents=True, exist_ok=True)
- shutil.copy2(source, target)
- total_bytes += source.stat().st_size
-
- print(f"✅ zread source snapshot: files={len(files)}, bytes={total_bytes}, dir={SOURCE_DIR}")
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/scripts/test_ks3_upload.py b/scripts/test_ks3_upload.py
deleted file mode 100644
index cec592d4..00000000
--- a/scripts/test_ks3_upload.py
+++ /dev/null
@@ -1,81 +0,0 @@
-#!/usr/bin/env python3
-"""
-KS3 上传调试脚本
-测试 bucket 创建和文件上传
-"""
-
-import os
-from pathlib import Path
-from dotenv import load_dotenv
-
-# 加载 .env (支持多种位置)
-for env_path in [Path(".env"), Path("/tmp/my-agent/.env"), Path.home() / ".env"]:
- if env_path.exists():
- load_dotenv(env_path)
- print(f"✓ 加载 .env: {env_path}")
- break
-
-ak = os.environ.get("KSYUN_ACCESS_KEY")
-sk = os.environ.get("KSYUN_SECRET_KEY")
-
-print("AK: 已设置" if ak else "AK: 未设置")
-print("SK: 已设置" if sk else "SK: 未设置")
-
-BUCKET_NAME = "agentengine"
-REGION = "cn-beijing"
-HOST = f"ks3-{REGION}.ksyuncs.com"
-
-print(f"\nHost: {HOST}")
-print(f"Bucket: {BUCKET_NAME}")
-
-try:
- from ks3.connection import Connection
-
- conn = Connection(ak, sk, host=HOST)
- print(f"\n✓ 连接成功")
-
- # 列出所有 bucket
- print("\n现有 Buckets:")
- buckets = conn.get_all_buckets()
- for b in buckets:
- print(f" - {b.name}")
-
- # 检查目标 bucket 是否存在
- bucket_exists = any(b.name == BUCKET_NAME for b in buckets)
-
- if not bucket_exists:
- print(f"\n⚠️ Bucket '{BUCKET_NAME}' 不存在,尝试创建...")
- try:
- new_bucket = conn.create_bucket(BUCKET_NAME)
- print(f"✓ Bucket 创建成功: {new_bucket.name}")
- except Exception as e:
- print(f"✗ 创建失败: {e}")
- # 可能需要指定 location
- print("\n尝试使用 location 参数创建...")
- try:
- new_bucket = conn.create_bucket(BUCKET_NAME, location=REGION.upper())
- print(f"✓ Bucket 创建成功: {new_bucket.name}")
- except Exception as e2:
- print(f"✗ 仍然失败: {e2}")
- else:
- print(f"\n✓ Bucket '{BUCKET_NAME}' 已存在")
-
- # 测试上传
- print("\n测试上传...")
- bucket = conn.get_bucket(BUCKET_NAME)
- test_key = bucket.new_key("test/hello.txt")
- result = test_key.set_contents_from_string("Hello from KsADK!")
- print(f"上传结果: {result}")
-
- if result and result.status == 200:
- print("✓ 测试上传成功!")
- # 读取验证
- content = test_key.get_contents_as_string()
- print(f"读取内容: {content}")
- else:
- print(f"✗ 上传返回非 200: {result}")
-
-except ImportError as e:
- print(f"✗ ks3sdk 未安装: {e}")
-except Exception as e:
- print(f"✗ 错误: {type(e).__name__}: {e}")
diff --git a/scripts/validate_checkpoint_resume_e2e.py b/scripts/validate_checkpoint_resume_e2e.py
deleted file mode 100644
index f3fac4f3..00000000
--- a/scripts/validate_checkpoint_resume_e2e.py
+++ /dev/null
@@ -1,747 +0,0 @@
-#!/usr/bin/env python3
-"""Validate KSADK checkpoint resume against a real PostgreSQL backend.
-
-This script is intentionally self-contained so it can run inside a preprod Pod
-with the current source tree copied in. It validates the W1 path:
-
-1. LangGraph persists a checkpoint in PostgreSQL.
-2. KSADK writes run_checkpoint events into the shared session backend.
-3. ListSessionCheckpoints exposes the checkpoint.
-4. ResumeRun resumes through LangGraphRunner without rerunning prior nodes.
-"""
-
-from __future__ import annotations
-
-import argparse
-import asyncio
-import importlib
-import json
-import os
-import uuid
-from types import SimpleNamespace
-from typing import Any
-
-import httpx
-
-from ksadk.runners.langgraph_runner import LangGraphRunner
-from ksadk.runners.base_runner import BaseRunner
-
-
-AGENT_ID = "lt-w1-e2e-agent"
-USER_ID = "lt-w1-e2e-user"
-CANCEL_AGENT_ID = "lt-w25-cancel-agent"
-CANCEL_USER_ID = "lt-w25-cancel-user"
-CANCEL_RESUME_AGENT_ID = "lt-w25-cancel-resume-agent"
-CANCEL_RESUME_USER_ID = "lt-w25-cancel-resume-user"
-E2E_NODE_COUNTS: dict[str, int] = {}
-
-
-class E2ELangGraphRunner(LangGraphRunner):
- def load_agent(self) -> None:
- return None
-
-
-class CancellableStreamingRunner(BaseRunner):
- def __init__(self) -> None:
- super().__init__(
- detection_result=SimpleNamespace(
- name=CANCEL_AGENT_ID,
- type=SimpleNamespace(value="mock"),
- ),
- project_dir=".",
- )
- self.cancel_requests: list[str] = []
-
- def load_agent(self) -> None:
- return None
-
- async def invoke(self, input_data: dict[str, Any]) -> dict[str, Any]:
- return {"output": "should not be used"}
-
- async def stream(self, input_data: dict[str, Any]):
- yield {"type": "text", "delta": "started"}
- await asyncio.Event().wait()
-
- def request_cancel(self, invocation_id: str) -> str:
- self.cancel_requests.append(str(invocation_id))
- return "accepted"
-
-
-class CancelThenResumeLangGraphRunner(E2ELangGraphRunner):
- def __init__(self, *args: Any, hold_after_checkpoint: bool = True, **kwargs: Any) -> None:
- super().__init__(*args, **kwargs)
- self.hold_after_checkpoint = hold_after_checkpoint
- self.cancel_requests: list[str] = []
-
- async def stream(self, input_data: dict[str, Any]):
- payload = dict(input_data)
- is_checkpoint_resume = bool(payload.get("checkpoint_resume"))
- if is_checkpoint_resume:
- async for chunk in super().stream(payload):
- yield chunk
- return
-
- result = await self.invoke(payload)
- metadata = result.get("metadata") if isinstance(result, dict) else None
- if isinstance(metadata, dict) and metadata.get("agentengine"):
- yield {"type": "checkpoint", "metadata": metadata}
- yield {"type": "text", "delta": "checkpoint persisted"}
- if self.hold_after_checkpoint:
- await asyncio.Event().wait()
-
- def request_cancel(self, invocation_id: str) -> str:
- self.cancel_requests.append(str(invocation_id))
- return "accepted"
-
-
-async def _build_graph(*, dsn: str) -> Any:
- from typing import TypedDict
-
- from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
- from langgraph.graph import END, StateGraph
-
- class VerifyState(TypedDict, total=False):
- input: str
- log: list[str]
- answer: str
-
- def _append(state: VerifyState, node: str) -> VerifyState:
- E2E_NODE_COUNTS[node] = E2E_NODE_COUNTS.get(node, 0) + 1
- log = list(state.get("log") or [])
- log.append(node)
- return {"log": log, "answer": ",".join(log)}
-
- def node_a(state: VerifyState) -> VerifyState:
- return _append(state, "a")
-
- def node_b(state: VerifyState) -> VerifyState:
- return _append(state, "b")
-
- def node_c(state: VerifyState) -> VerifyState:
- return _append(state, "c")
-
- saver_cm = AsyncPostgresSaver.from_conn_string(dsn)
- saver = await saver_cm.__aenter__()
- await saver.setup()
- graph = StateGraph(VerifyState)
- graph.add_node("a", node_a)
- graph.add_node("b", node_b)
- graph.add_node("c", node_c)
- graph.set_entry_point("a")
- graph.add_edge("a", "b")
- graph.add_edge("b", "c")
- graph.add_edge("c", END)
- app = graph.compile(checkpointer=saver, interrupt_before=["c"])
- app._ksadk_e2e_saver_cm = saver_cm
- return app
-
-
-async def _build_runner(*, dsn: str) -> LangGraphRunner:
- runner = E2ELangGraphRunner(
- detection_result=SimpleNamespace(
- name=AGENT_ID,
- type=SimpleNamespace(value="langgraph"),
- entry_point="agent.py",
- agent_variable="app",
- ),
- project_dir=".",
- )
- runner._agent = await _build_graph(dsn=dsn)
- runner._module = SimpleNamespace()
- return runner
-
-
-async def _build_cancel_then_resume_runner(*, dsn: str) -> CancelThenResumeLangGraphRunner:
- runner = CancelThenResumeLangGraphRunner(
- detection_result=SimpleNamespace(
- name=CANCEL_RESUME_AGENT_ID,
- type=SimpleNamespace(value="langgraph"),
- entry_point="agent.py",
- agent_variable="app",
- ),
- project_dir=".",
- )
- runner._agent = await _build_graph(dsn=dsn)
- runner._module = SimpleNamespace()
- return runner
-
-
-async def _close_runner(runner: LangGraphRunner) -> None:
- agent = getattr(runner, "_agent", None)
- saver_cm = getattr(agent, "_ksadk_e2e_saver_cm", None)
- if saver_cm is not None:
- await saver_cm.__aexit__(None, None, None)
-
-
-def _action_data(payload: dict[str, Any]) -> dict[str, Any]:
- data = payload.get("Data")
- if not isinstance(data, dict):
- raise AssertionError(f"Action payload missing Data: {payload}")
- return data
-
-
-async def _list_events(client: httpx.AsyncClient, session_id: str) -> list[dict[str, Any]]:
- events_response = await client.post(
- "/agentengine/api/v1/ListSessionEvents",
- json={"SessionId": session_id},
- )
- events_response.raise_for_status()
- return _action_data(events_response.json())["Events"]
-
-
-async def _checkpoint_state_values(runner: LangGraphRunner, checkpoint: dict[str, Any]) -> dict[str, Any]:
- framework_ref = checkpoint.get("FrameworkRef") if isinstance(checkpoint.get("FrameworkRef"), dict) else {}
- langgraph_ref = framework_ref.get("langgraph") if isinstance(framework_ref.get("langgraph"), dict) else {}
- configurable = {
- key: value
- for key, value in {
- "thread_id": langgraph_ref.get("thread_id"),
- "checkpoint_id": langgraph_ref.get("checkpoint_id"),
- "checkpoint_ns": langgraph_ref.get("checkpoint_ns"),
- }.items()
- if value
- }
- if not configurable:
- return {}
- state = await runner._agent.aget_state({"configurable": configurable})
- values = getattr(state, "values", None)
- return dict(values or {}) if isinstance(values, dict) else {}
-
-
-def _summarize_events(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
- summary: list[dict[str, Any]] = []
- for event in events:
- metadata = event.get("Metadata") if isinstance(event.get("Metadata"), dict) else {}
- content = event.get("Content") if isinstance(event.get("Content"), dict) else {}
- summary.append(
- {
- "SeqId": event.get("SeqId"),
- "EventType": event.get("EventType"),
- "Author": event.get("Author"),
- "MetadataKeys": sorted(metadata.keys()),
- "AgentEngine": metadata.get("agentengine"),
- "Content": content,
- }
- )
- return summary
-
-
-async def run_validation(*, dsn: str, keep_session: bool) -> dict[str, Any]:
- namespace = f"lt_w1_e2e_{uuid.uuid4().hex[:10]}"
- session_id = f"sess_{uuid.uuid4().hex}"
- thread_prefix = f"{namespace}:{AGENT_ID}:{session_id}"
- os.environ["KSADK_SESSION_BACKEND"] = "postgres"
- os.environ["KSADK_SESSION_DSN"] = dsn
- os.environ["KSADK_SESSION_NAMESPACE"] = namespace
- os.environ["KSADK_SESSION_TENANT_ID"] = "lt_w1_e2e_tenant"
- os.environ["KSADK_SESSION_WORKSPACE_ID"] = "lt_w1_e2e_workspace"
- os.environ["KSADK_E2E_LANGGRAPH_DSN"] = dsn
- E2E_NODE_COUNTS.clear()
-
- runner = await _build_runner(dsn=dsn)
- try:
- from ksadk.sessions import reset_session_service
-
- server_app_module = importlib.import_module("ksadk.server.app")
- await reset_session_service()
- server_app_module.set_runner(runner)
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(
- transport=transport,
- base_url="http://ksadk.local",
- timeout=60,
- ) as client:
- run_response = await client.post(
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": AGENT_ID,
- "UserId": USER_ID,
- "SessionId": session_id,
- "ApiFormat": "responses",
- "Stream": False,
- "ResponsesInput": [
- {
- "role": "user",
- "content": [
- {
- "type": "input_text",
- "text": "run until checkpoint",
- }
- ],
- }
- ],
- },
- )
- run_response.raise_for_status()
- run_payload = run_response.json()
- checkpoints_response = await client.post(
- "/agentengine/api/v1/ListSessionCheckpoints",
- json={"AgentId": AGENT_ID, "SessionId": session_id},
- )
- checkpoints_response.raise_for_status()
- checkpoints = _action_data(checkpoints_response.json())["Checkpoints"]
- if not checkpoints:
- events = await _list_events(client, session_id)
- raise AssertionError(
- "ListSessionCheckpoints returned no checkpoints\n"
- f"RunAgent payload: {json.dumps(run_payload, ensure_ascii=False)}\n"
- f"Events: {json.dumps(_summarize_events(events), ensure_ascii=False)}"
- )
- checkpoint = checkpoints[0]
- run_id = checkpoint["RunId"]
- checkpoint_id = checkpoint["CheckpointId"]
- checkpoint_state = await _checkpoint_state_values(runner, checkpoint)
- checkpoint_log = list(checkpoint_state.get("log") or [])
- if checkpoint_log != ["a", "b"]:
- raise AssertionError(
- "Checkpoint state before resume should contain exactly a,b; "
- f"got {checkpoint_log!r}"
- )
- node_counts_before_resume = dict(E2E_NODE_COUNTS)
-
- resume_response = await client.post(
- "/agentengine/api/v1/ResumeRun",
- json={
- "AgentId": AGENT_ID,
- "SessionId": session_id,
- "RunId": run_id,
- "CheckpointId": checkpoint_id,
- "Stream": False,
- },
- )
- resume_response.raise_for_status()
- resume_payload = resume_response.json()
- resume_data = _action_data(resume_payload)
- output_text = str(resume_data.get("output_text") or "")
- if output_text != "a,b,c":
- raise AssertionError(
- f"ResumeRun output should be 'a,b,c', got {output_text!r}"
- )
- node_counts_after_resume = dict(E2E_NODE_COUNTS)
- if node_counts_after_resume != {"a": 1, "b": 1, "c": 1}:
- raise AssertionError(
- "ResumeRun should not rerun completed nodes; "
- f"before={node_counts_before_resume}, after={node_counts_after_resume}"
- )
-
- events = await _list_events(client, session_id)
- run_checkpoint_count = sum(
- 1 for event in events if event.get("EventType") == "run_checkpoint"
- )
- run_resume_count = sum(
- 1 for event in events if event.get("EventType") == "run_resume"
- )
- if run_checkpoint_count < 2:
- raise AssertionError(
- f"Expected at least two run_checkpoint events, got {run_checkpoint_count}"
- )
- if run_resume_count < 1:
- raise AssertionError("Expected a run_resume event")
-
- if not keep_session:
- delete_response = await client.post(
- "/agentengine/api/v1/DeleteSession",
- json={"SessionId": session_id},
- )
- delete_response.raise_for_status()
-
- return {
- "namespace": namespace,
- "session_id": session_id,
- "run_id": run_id,
- "checkpoint_id": checkpoint_id,
- "output_text": output_text,
- "checkpoint_count": len(checkpoints),
- "run_checkpoint_event_count": run_checkpoint_count,
- "run_resume_event_count": run_resume_count,
- "checkpoint_log_before_resume": checkpoint_log,
- "node_counts_before_resume": node_counts_before_resume,
- "node_counts_after_resume": node_counts_after_resume,
- "resume_did_not_rerun_prior_nodes": True,
- "kept_session": keep_session,
- "run_action": run_payload.get("Code"),
- "resume_action": resume_payload.get("Code"),
- }
- finally:
- await _close_runner(runner)
-
-
-async def run_cancel_validation(*, dsn: str, keep_session: bool) -> dict[str, Any]:
- namespace = f"lt_w25_cancel_{uuid.uuid4().hex[:10]}"
- session_id = f"sess_{uuid.uuid4().hex}"
- invocation_id = f"run_{uuid.uuid4().hex}"
- os.environ["KSADK_SESSION_BACKEND"] = "postgres"
- os.environ["KSADK_SESSION_DSN"] = dsn
- os.environ["KSADK_SESSION_NAMESPACE"] = namespace
- os.environ["KSADK_SESSION_TENANT_ID"] = "lt_w25_cancel_tenant"
- os.environ["KSADK_SESSION_WORKSPACE_ID"] = "lt_w25_cancel_workspace"
-
- from ksadk.sessions import reset_session_service
- import ksadk.conversations as conversation
-
- server_app_module = importlib.import_module("ksadk.server.app")
- await reset_session_service()
- runner = CancellableStreamingRunner()
- server_app_module.set_runner(runner)
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(
- transport=transport,
- base_url="http://ksadk.local",
- timeout=60,
- ) as client:
- server_app_module._detached_streaming_response(
- conversation.stream_responses_conversation_turn(
- runner=runner,
- agent_id=CANCEL_AGENT_ID,
- user_id=CANCEL_USER_ID,
- messages=[{"role": "user", "content": "start long streaming run"}],
- session_id=session_id,
- model=None,
- prepare_runner=lambda _runner, _model: None,
- invocation_id=invocation_id,
- session_service_provider=server_app_module.resolve_session_service,
- ),
- invocation_id=invocation_id,
- )
-
- for _ in range(50):
- events = await _list_events(client, session_id)
- statuses = [
- event.get("Content", {}).get("status")
- for event in events
- if event.get("EventType") == "run_status"
- ]
- if statuses == ["in_progress"]:
- break
- await asyncio.sleep(0.1)
- else:
- raise AssertionError("Cancel validation did not observe in_progress status")
-
- cancel_response = await client.post(
- "/agentengine/api/v1/CancelRun",
- json={"AgentId": CANCEL_AGENT_ID, "InvocationId": invocation_id},
- )
- cancel_response.raise_for_status()
- cancel_data = _action_data(cancel_response.json())
- if cancel_data.get("Found") is not True or cancel_data.get("Cancelled") is not True:
- raise AssertionError(f"CancelRun did not hit active run: {cancel_data}")
-
- for _ in range(50):
- events = await _list_events(client, session_id)
- statuses = [
- event.get("Content", {}).get("status")
- for event in events
- if event.get("EventType") == "run_status"
- ]
- if statuses and statuses[-1] == "cancelled":
- break
- await asyncio.sleep(0.1)
- else:
- raise AssertionError("Cancel validation did not observe cancelled status")
-
- event_count_at_cancel = len(events)
- await asyncio.sleep(3)
- final_events = await _list_events(client, session_id)
- unexpected_terminal = [
- event
- for event in final_events[event_count_at_cancel:]
- if event.get("EventType") in {"assistant_message", "run_checkpoint"}
- or (
- event.get("EventType") == "run_status"
- and event.get("Content", {}).get("status") == "completed"
- )
- ]
- if unexpected_terminal:
- raise AssertionError(
- "Cancel validation observed unexpected events after cancelled: "
- f"{json.dumps(_summarize_events(unexpected_terminal), ensure_ascii=False)}"
- )
-
- if not keep_session:
- delete_response = await client.post(
- "/agentengine/api/v1/DeleteSession",
- json={"SessionId": session_id},
- )
- delete_response.raise_for_status()
-
- return {
- "namespace": namespace,
- "session_id": session_id,
- "invocation_id": invocation_id,
- "cancel_action": cancel_response.json().get("Code"),
- "cancel_found": cancel_data.get("Found"),
- "cancel_status": cancel_data.get("Status"),
- "cancelled_event_count": sum(
- 1
- for event in final_events
- if event.get("EventType") == "run_status"
- and event.get("Content", {}).get("status") == "cancelled"
- ),
- "post_cancel_extra_event_count": len(final_events) - event_count_at_cancel,
- "runner_cancel_requests": list(runner.cancel_requests),
- "kept_session": keep_session,
- }
-
-
-async def _wait_for_status(
- client: httpx.AsyncClient,
- *,
- session_id: str,
- status: str,
- attempts: int = 50,
-) -> list[dict[str, Any]]:
- for _ in range(attempts):
- events = await _list_events(client, session_id)
- statuses = [
- event.get("Content", {}).get("status")
- for event in events
- if event.get("EventType") == "run_status"
- ]
- if statuses and statuses[-1] == status:
- return events
- await asyncio.sleep(0.1)
- raise AssertionError(f"Did not observe run_status={status!r}")
-
-
-async def _wait_for_checkpoint(
- client: httpx.AsyncClient,
- *,
- agent_id: str,
- session_id: str,
- attempts: int = 50,
-) -> dict[str, Any]:
- for _ in range(attempts):
- response = await client.post(
- "/agentengine/api/v1/ListSessionCheckpoints",
- json={"AgentId": agent_id, "SessionId": session_id},
- )
- response.raise_for_status()
- checkpoints = _action_data(response.json())["Checkpoints"]
- if checkpoints:
- return checkpoints[0]
- await asyncio.sleep(0.1)
- events = await _list_events(client, session_id)
- raise AssertionError(
- "Did not observe checkpoint before cancel\n"
- f"Events: {json.dumps(_summarize_events(events), ensure_ascii=False)}"
- )
-
-
-async def run_cancel_then_resume_validation(*, dsn: str, keep_session: bool) -> dict[str, Any]:
- namespace = f"lt_w25_cancel_resume_{uuid.uuid4().hex[:10]}"
- session_id = f"sess_{uuid.uuid4().hex}"
- invocation_id = f"run_{uuid.uuid4().hex}"
- os.environ["KSADK_SESSION_BACKEND"] = "postgres"
- os.environ["KSADK_SESSION_DSN"] = dsn
- os.environ["KSADK_SESSION_NAMESPACE"] = namespace
- os.environ["KSADK_SESSION_TENANT_ID"] = "lt_w25_cancel_resume_tenant"
- os.environ["KSADK_SESSION_WORKSPACE_ID"] = "lt_w25_cancel_resume_workspace"
- os.environ["KSADK_E2E_LANGGRAPH_DSN"] = dsn
- E2E_NODE_COUNTS.clear()
-
- runner = await _build_cancel_then_resume_runner(dsn=dsn)
- try:
- from ksadk.sessions import reset_session_service
- import ksadk.conversations as conversation
-
- server_app_module = importlib.import_module("ksadk.server.app")
- await reset_session_service()
- server_app_module.set_runner(runner)
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(
- transport=transport,
- base_url="http://ksadk.local",
- timeout=60,
- ) as client:
- server_app_module._detached_streaming_response(
- conversation.stream_responses_conversation_turn(
- runner=runner,
- agent_id=CANCEL_RESUME_AGENT_ID,
- user_id=CANCEL_RESUME_USER_ID,
- messages=[{"role": "user", "content": "start, checkpoint, then wait"}],
- session_id=session_id,
- model=None,
- prepare_runner=lambda _runner, _model: None,
- invocation_id=invocation_id,
- session_service_provider=server_app_module.resolve_session_service,
- ),
- invocation_id=invocation_id,
- )
-
- await _wait_for_status(client, session_id=session_id, status="in_progress")
- checkpoint = await _wait_for_checkpoint(
- client,
- agent_id=CANCEL_RESUME_AGENT_ID,
- session_id=session_id,
- )
- run_id = checkpoint["RunId"]
- checkpoint_id = checkpoint["CheckpointId"]
- if run_id != invocation_id:
- raise AssertionError(
- f"Checkpoint run_id should match cancelled invocation_id; {run_id!r} != {invocation_id!r}"
- )
- checkpoint_state = await _checkpoint_state_values(runner, checkpoint)
- checkpoint_log = list(checkpoint_state.get("log") or [])
- if checkpoint_log != ["a", "b"]:
- raise AssertionError(
- "Checkpoint state before cancel should contain exactly a,b; "
- f"got {checkpoint_log!r}"
- )
-
- cancel_response = await client.post(
- "/agentengine/api/v1/CancelRun",
- json={"AgentId": CANCEL_RESUME_AGENT_ID, "InvocationId": invocation_id},
- )
- cancel_response.raise_for_status()
- cancel_data = _action_data(cancel_response.json())
- if cancel_data.get("Found") is not True or cancel_data.get("Cancelled") is not True:
- raise AssertionError(f"CancelRun did not hit checkpointed active run: {cancel_data}")
-
- cancelled_events = await _wait_for_status(
- client,
- session_id=session_id,
- status="cancelled",
- )
- event_count_at_cancel = len(cancelled_events)
- await asyncio.sleep(1)
- post_cancel_events = await _list_events(client, session_id)
- unexpected_post_cancel = [
- event
- for event in post_cancel_events[event_count_at_cancel:]
- if event.get("EventType") in {"assistant_message", "run_checkpoint"}
- or (
- event.get("EventType") == "run_status"
- and event.get("Content", {}).get("status") == "completed"
- )
- ]
- if unexpected_post_cancel:
- raise AssertionError(
- "Cancel then resume validation observed unexpected events after cancelled: "
- f"{json.dumps(_summarize_events(unexpected_post_cancel), ensure_ascii=False)}"
- )
-
- node_counts_before_resume = dict(E2E_NODE_COUNTS)
- resume_response = await client.post(
- "/agentengine/api/v1/ResumeRun",
- json={
- "AgentId": CANCEL_RESUME_AGENT_ID,
- "SessionId": session_id,
- "RunId": run_id,
- "CheckpointId": checkpoint_id,
- "Stream": False,
- },
- )
- resume_response.raise_for_status()
- resume_payload = resume_response.json()
- resume_data = _action_data(resume_payload)
- output_text = str(resume_data.get("output_text") or "")
- if output_text != "a,b,c":
- raise AssertionError(
- f"ResumeRun after cancel should output 'a,b,c', got {output_text!r}"
- )
- node_counts_after_resume = dict(E2E_NODE_COUNTS)
- if node_counts_after_resume != {"a": 1, "b": 1, "c": 1}:
- raise AssertionError(
- "ResumeRun after cancel should not rerun completed nodes; "
- f"before={node_counts_before_resume}, after={node_counts_after_resume}"
- )
-
- final_events = await _list_events(client, session_id)
- run_checkpoint_count = sum(
- 1 for event in final_events if event.get("EventType") == "run_checkpoint"
- )
- run_resume_count = sum(
- 1 for event in final_events if event.get("EventType") == "run_resume"
- )
- cancelled_event_count = sum(
- 1
- for event in final_events
- if event.get("EventType") == "run_status"
- and event.get("Content", {}).get("status") == "cancelled"
- )
- if run_checkpoint_count < 2:
- raise AssertionError(
- f"Expected at least two run_checkpoint events after resume, got {run_checkpoint_count}"
- )
- if run_resume_count < 1:
- raise AssertionError("Expected a run_resume event after cancel")
-
- if not keep_session:
- delete_response = await client.post(
- "/agentengine/api/v1/DeleteSession",
- json={"SessionId": session_id},
- )
- delete_response.raise_for_status()
-
- return {
- "namespace": namespace,
- "session_id": session_id,
- "run_id": run_id,
- "invocation_id": invocation_id,
- "checkpoint_id": checkpoint_id,
- "cancel_action": cancel_response.json().get("Code"),
- "cancel_found": cancel_data.get("Found"),
- "cancel_status": cancel_data.get("Status"),
- "cancelled_event_count": cancelled_event_count,
- "post_cancel_extra_event_count": len(post_cancel_events) - event_count_at_cancel,
- "runner_cancel_requests": list(runner.cancel_requests),
- "output_text_after_resume": output_text,
- "run_checkpoint_event_count": run_checkpoint_count,
- "run_resume_event_count": run_resume_count,
- "checkpoint_log_before_cancel": checkpoint_log,
- "node_counts_before_resume": node_counts_before_resume,
- "node_counts_after_resume": node_counts_after_resume,
- "resume_after_cancel_did_not_rerun_prior_nodes": True,
- "kept_session": keep_session,
- "resume_action": resume_payload.get("Code"),
- }
- finally:
- await _close_runner(runner)
-
-
-def main() -> int:
- parser = argparse.ArgumentParser()
- parser.add_argument(
- "--dsn",
- default=os.environ.get("KSADK_SESSION_DSN", ""),
- help="PostgreSQL DSN. Defaults to KSADK_SESSION_DSN.",
- )
- parser.add_argument(
- "--keep-session",
- action="store_true",
- help="Keep the generated KSADK session rows for debugging.",
- )
- parser.add_argument(
- "--include-cancel",
- action="store_true",
- help="Also validate W2.5 detached streaming CancelRun behavior.",
- )
- args = parser.parse_args()
- dsn = args.dsn.strip()
- if not dsn:
- raise SystemExit("--dsn or KSADK_SESSION_DSN is required")
- async def _run_all() -> dict[str, Any]:
- result: dict[str, Any] = {
- "checkpoint_resume": await run_validation(
- dsn=dsn,
- keep_session=args.keep_session,
- )
- }
- if args.include_cancel:
- result["runtime_cancel"] = await run_cancel_validation(
- dsn=dsn,
- keep_session=args.keep_session,
- )
- result["cancel_then_resume"] = await run_cancel_then_resume_validation(
- dsn=dsn,
- keep_session=args.keep_session,
- )
- return result
-
- result = asyncio.run(_run_all())
- print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/scripts/validate_hosted_long_task_e2e.py b/scripts/validate_hosted_long_task_e2e.py
deleted file mode 100644
index c35d21b9..00000000
--- a/scripts/validate_hosted_long_task_e2e.py
+++ /dev/null
@@ -1,699 +0,0 @@
-#!/usr/bin/env python3
-"""Validate long-task resume through the public Hosted path.
-
-This script targets the real Hosted route:
-
- PublicEndpoint -> agentengine-gateway -> agentengine-server -> runtime
-
-It does not talk to the runtime pod directly and it does not need the PG DSN.
-Use it after a long-task-capable LangGraph/ADK agent is already deployed.
-"""
-
-from __future__ import annotations
-
-import argparse
-import json
-import sys
-import threading
-import time
-import uuid
-from dataclasses import dataclass
-from typing import Any
-from urllib.parse import urljoin
-
-import httpx
-
-
-DEFAULT_PROMPT = "run until checkpoint"
-TERMINAL_STATUSES = {"completed", "failed", "cancelled", "resume_failed"}
-
-
-class HostedE2EError(AssertionError):
- pass
-
-
-@dataclass
-class HostedClient:
- base_url: str
- agent_id: str
- user_id: str
- timeout: float
- api_key: str = ""
- cookie: str = ""
- account_id: str = ""
- principal_id: str = ""
-
- def __post_init__(self) -> None:
- self.base_url = self.base_url.rstrip("/") + "/"
- headers = {
- "Accept": "application/json",
- "X-Ksc-Request-Id": f"hosted-e2e-{uuid.uuid4().hex}",
- }
- if self.api_key:
- headers["Authorization"] = f"Bearer {self.api_key}"
- if self.cookie:
- headers["Cookie"] = self.cookie
- if self.account_id:
- headers["X-Auth-Account-Id"] = self.account_id
- if self.principal_id:
- headers["X-Ksc-User-uuid"] = self.principal_id
- self.client = httpx.Client(
- base_url=self.base_url,
- headers=headers,
- timeout=httpx.Timeout(self.timeout, connect=min(self.timeout, 20.0)),
- follow_redirects=True,
- verify=False,
- )
-
- def close(self) -> None:
- self.client.close()
-
- def action(self, name: str, payload: dict[str, Any]) -> dict[str, Any]:
- response = self.client.post(f"agentengine/api/v1/{name}", json=payload)
- if response.status_code >= 400:
- raise HostedE2EError(
- f"{name} HTTP {response.status_code}: {response.text[:1000]}"
- )
- try:
- body = response.json()
- except json.JSONDecodeError as exc:
- raise HostedE2EError(f"{name} returned non-JSON body: {response.text[:1000]}") from exc
- if isinstance(body, dict) and body.get("Code") not in (None, 0):
- raise HostedE2EError(f"{name} returned Code={body.get('Code')}: {body}")
- return body
-
- def stream_action(self, name: str, payload: dict[str, Any], *, max_seconds: float) -> str:
- chunks: list[str] = []
- deadline = time.monotonic() + max_seconds
- with self.client.stream(
- "POST",
- f"agentengine/api/v1/{name}",
- json=payload,
- headers={"Accept": "text/event-stream"},
- ) as response:
- if response.status_code >= 400:
- text = response.read().decode("utf-8", "replace")
- raise HostedE2EError(f"{name} stream HTTP {response.status_code}: {text[:1000]}")
- content_type = response.headers.get("content-type", "")
- if "text/event-stream" not in content_type.lower():
- raise HostedE2EError(f"{name} did not return SSE content-type: {content_type}")
- for line in response.iter_lines():
- if time.monotonic() > deadline:
- raise HostedE2EError(f"{name} stream timed out after {max_seconds}s")
- if line:
- chunks.append(line)
- return "\n".join(chunks)
-
-
-def _data(payload: dict[str, Any], action: str) -> dict[str, Any]:
- data = payload.get("Data")
- if not isinstance(data, dict):
- raise HostedE2EError(f"{action} missing Data object: {payload}")
- return data
-
-
-def _extract_capabilities(bootstrap: dict[str, Any]) -> dict[str, Any]:
- capabilities = _data(bootstrap, "GetAgentUiBootstrap").get("Capabilities")
- if not isinstance(capabilities, dict):
- raise HostedE2EError(f"GetAgentUiBootstrap missing Capabilities: {bootstrap}")
- return capabilities
-
-
-def _assert_checkpoint_capability(capabilities: dict[str, Any]) -> None:
- run_lifecycle = capabilities.get("RunLifecycle")
- if not isinstance(run_lifecycle, dict):
- raise HostedE2EError(f"Capabilities.RunLifecycle is missing: {capabilities}")
- missing = [
- key
- for key in ("Checkpoints", "CheckpointResume")
- if run_lifecycle.get(key) is not True
- ]
- if missing:
- raise HostedE2EError(
- "Hosted bootstrap does not advertise checkpoint lifecycle: "
- f"missing_true={missing}, RunLifecycle={run_lifecycle}"
- )
-
-
-def _make_responses_input(prompt: str) -> list[dict[str, Any]]:
- return [
- {
- "role": "user",
- "content": [
- {
- "type": "input_text",
- "text": prompt,
- }
- ],
- }
- ]
-
-
-def _run_agent(client: HostedClient, *, session_id: str, prompt: str) -> dict[str, Any]:
- return client.action(
- "RunAgent",
- {
- "AgentId": client.agent_id,
- "UserId": client.user_id,
- "SessionId": session_id,
- "ApiFormat": "responses",
- "Stream": False,
- "ResponsesInput": _make_responses_input(prompt),
- },
- )
-
-
-def _stream_run_agent_background(
- client: HostedClient,
- *,
- session_id: str,
- prompt: str,
- invocation_id: str,
- max_seconds: float,
-) -> tuple[threading.Thread, dict[str, Any]]:
- result: dict[str, Any] = {"sse": "", "error": None}
-
- def _run() -> None:
- try:
- result["sse"] = client.stream_action(
- "RunAgent",
- {
- "AgentId": client.agent_id,
- "UserId": client.user_id,
- "SessionId": session_id,
- "InvocationId": invocation_id,
- "ApiFormat": "responses",
- "Stream": True,
- "ResponsesInput": _make_responses_input(prompt),
- },
- max_seconds=max_seconds,
- )
- except Exception as exc: # pragma: no cover - surfaced by caller in integration mode.
- result["error"] = exc
-
- thread = threading.Thread(target=_run, name=f"hosted-e2e-stream-{invocation_id}", daemon=True)
- thread.start()
- return thread, result
-
-
-def _list_checkpoints(client: HostedClient, *, session_id: str, run_id: str = "") -> list[dict[str, Any]]:
- payload: dict[str, Any] = {
- "AgentId": client.agent_id,
- "SessionId": session_id,
- }
- if run_id:
- payload["RunId"] = run_id
- response = client.action("ListSessionCheckpoints", payload)
- checkpoints = _data(response, "ListSessionCheckpoints").get("Checkpoints")
- if not isinstance(checkpoints, list):
- raise HostedE2EError(f"ListSessionCheckpoints missing Checkpoints list: {response}")
- return checkpoints
-
-
-def _is_retryable_checkpoint_not_found(exc: HostedE2EError) -> bool:
- text = str(exc)
- return "ListSessionCheckpoints" in text and (
- "Code=404" in text
- or "HTTP 404" in text
- or "not found" in text.lower()
- or "资源不存在" in text
- )
-
-
-def _list_events(client: HostedClient, *, session_id: str) -> list[dict[str, Any]]:
- response = client.action(
- "ListSessionEvents",
- {"AgentId": client.agent_id, "SessionId": session_id},
- )
- events = _data(response, "ListSessionEvents").get("Events")
- if not isinstance(events, list):
- raise HostedE2EError(f"ListSessionEvents missing Events list: {response}")
- return events
-
-
-def _event_type_counts(events: list[dict[str, Any]]) -> dict[str, int]:
- counts: dict[str, int] = {}
- for event in events:
- event_type = str(event.get("EventType") or "")
- counts[event_type] = counts.get(event_type, 0) + 1
- return counts
-
-
-def _wait_for_checkpoint(
- client: HostedClient,
- *,
- session_id: str,
- run_id: str = "",
- attempts: int,
- interval: float,
-) -> dict[str, Any]:
- last_checkpoints: list[dict[str, Any]] = []
- for _ in range(attempts):
- try:
- last_checkpoints = _list_checkpoints(client, session_id=session_id, run_id=run_id)
- except HostedE2EError as exc:
- if not _is_retryable_checkpoint_not_found(exc):
- raise
- last_checkpoints = []
- if last_checkpoints:
- return last_checkpoints[0]
- time.sleep(interval)
- events = _list_events(client, session_id=session_id)
- raise HostedE2EError(
- "No checkpoint appeared through Hosted facade; "
- f"last_checkpoints={last_checkpoints}, event_counts={_event_type_counts(events)}"
- )
-
-
-def _maybe_preview(client: HostedClient, *, session_id: str, run_id: str, checkpoint_id: str) -> dict[str, Any]:
- try:
- return client.action(
- "GetCheckpointResumePreview",
- {
- "AgentId": client.agent_id,
- "SessionId": session_id,
- "RunId": run_id,
- "CheckpointId": checkpoint_id,
- },
- )
- except HostedE2EError as exc:
- return {"skipped": True, "error": str(exc)}
-
-
-def _maybe_list_tool_receipts(
- client: HostedClient,
- *,
- session_id: str,
- run_id: str,
- checkpoint_id: str,
-) -> dict[str, Any]:
- try:
- return client.action(
- "ListToolReceipts",
- {
- "AgentId": client.agent_id,
- "SessionId": session_id,
- "RunId": run_id,
- "CheckpointId": checkpoint_id,
- },
- )
- except HostedE2EError as exc:
- return {"skipped": True, "error": str(exc)}
-
-
-def _resume_stream(
- client: HostedClient,
- *,
- session_id: str,
- run_id: str,
- checkpoint_id: str,
- invocation_id: str,
- max_seconds: float,
-) -> str:
- return client.stream_action(
- "ResumeRun",
- {
- "AgentId": client.agent_id,
- "SessionId": session_id,
- "RunId": run_id,
- "CheckpointId": checkpoint_id,
- "InvocationId": invocation_id,
- "Stream": True,
- },
- max_seconds=max_seconds,
- )
-
-
-def _cancel_run(client: HostedClient, *, invocation_id: str) -> dict[str, Any]:
- return client.action(
- "CancelRun",
- {
- "AgentId": client.agent_id,
- "InvocationId": invocation_id,
- },
- )
-
-
-def _event_statuses(events: list[dict[str, Any]], *, invocation_id: str = "") -> list[str]:
- statuses: list[str] = []
- for event in events:
- if invocation_id and str(event.get("InvocationId") or "") != invocation_id:
- continue
- if event.get("EventType") != "run_status":
- continue
- content = event.get("Content")
- if isinstance(content, dict) and content.get("status"):
- statuses.append(str(content["status"]))
- return statuses
-
-
-def validate_checkpoint_resume(
- client: HostedClient,
- *,
- session_id: str,
- prompt: str,
- wait_attempts: int,
- wait_interval: float,
- stream_timeout: float,
-) -> dict[str, Any]:
- bootstrap = client.action(
- "GetAgentUiBootstrap",
- {"AgentId": client.agent_id, "SessionId": session_id},
- )
- capabilities = _extract_capabilities(bootstrap)
- _assert_checkpoint_capability(capabilities)
-
- run_payload = _run_agent(client, session_id=session_id, prompt=prompt)
- checkpoint = _wait_for_checkpoint(
- client,
- session_id=session_id,
- attempts=wait_attempts,
- interval=wait_interval,
- )
- run_id = str(checkpoint.get("RunId") or "").strip()
- checkpoint_id = str(checkpoint.get("CheckpointId") or "").strip()
- if not run_id or not checkpoint_id:
- raise HostedE2EError(f"Checkpoint missing RunId/CheckpointId: {checkpoint}")
-
- preview = _maybe_preview(
- client,
- session_id=session_id,
- run_id=run_id,
- checkpoint_id=checkpoint_id,
- )
- tool_receipts = _maybe_list_tool_receipts(
- client,
- session_id=session_id,
- run_id=run_id,
- checkpoint_id=checkpoint_id,
- )
-
- resume_invocation_id = f"run_{uuid.uuid4().hex}"
- resume_sse = _resume_stream(
- client,
- session_id=session_id,
- run_id=run_id,
- checkpoint_id=checkpoint_id,
- invocation_id=resume_invocation_id,
- max_seconds=stream_timeout,
- )
- events = _list_events(client, session_id=session_id)
- event_counts = _event_type_counts(events)
- statuses = _event_statuses(events, invocation_id=resume_invocation_id)
-
- if event_counts.get("run_resume", 0) < 1:
- raise HostedE2EError(f"ResumeRun did not create run_resume event: {event_counts}")
- if event_counts.get("run_checkpoint", 0) < 1:
- raise HostedE2EError(f"Session has no run_checkpoint events after resume: {event_counts}")
- if statuses and statuses[-1] not in TERMINAL_STATUSES:
- raise HostedE2EError(f"ResumeRun terminal status not reached: {statuses}")
-
- return {
- "status": "pass",
- "session_id": session_id,
- "run_id": run_id,
- "checkpoint_id": checkpoint_id,
- "resume_invocation_id": resume_invocation_id,
- "bootstrap_run_lifecycle": capabilities.get("RunLifecycle"),
- "run_agent_code": run_payload.get("Code"),
- "checkpoint_count": len(_list_checkpoints(client, session_id=session_id)),
- "event_counts": event_counts,
- "resume_statuses": statuses,
- "resume_sse_line_count": len([line for line in resume_sse.splitlines() if line.strip()]),
- "preview": _summarize_optional_action(preview, "Preview"),
- "tool_receipts": _summarize_optional_action(tool_receipts, "ToolReceipts"),
- }
-
-
-def _summarize_optional_action(payload: dict[str, Any], data_key: str) -> dict[str, Any]:
- if payload.get("skipped"):
- return {"status": "skipped", "error": payload.get("error")}
- data = payload.get("Data") if isinstance(payload.get("Data"), dict) else {}
- value = data.get(data_key)
- if isinstance(value, list):
- return {"status": "pass", "count": len(value)}
- if isinstance(value, dict):
- return {"status": "pass", "keys": sorted(value.keys())}
- return {"status": "pass", "present": value is not None}
-
-
-def validate_cancel_active(
- client: HostedClient,
- *,
- session_id: str,
- invocation_id: str,
- wait_attempts: int,
- wait_interval: float,
-) -> dict[str, Any]:
- cancel_payload = _cancel_run(client, invocation_id=invocation_id)
- cancel_data = _data(cancel_payload, "CancelRun")
- for _ in range(wait_attempts):
- events = _list_events(client, session_id=session_id)
- statuses = _event_statuses(events, invocation_id=invocation_id)
- if statuses and statuses[-1] in TERMINAL_STATUSES:
- return {
- "status": "pass",
- "session_id": session_id,
- "invocation_id": invocation_id,
- "cancel_data": cancel_data,
- "statuses": statuses,
- "event_counts": _event_type_counts(events),
- }
- time.sleep(wait_interval)
- raise HostedE2EError(
- f"CancelRun did not reach terminal status for {invocation_id}: {cancel_data}"
- )
-
-
-def validate_cancel_then_resume(
- client: HostedClient,
- *,
- session_id: str,
- prompt: str,
- wait_attempts: int,
- wait_interval: float,
- stream_timeout: float,
-) -> dict[str, Any]:
- bootstrap = client.action(
- "GetAgentUiBootstrap",
- {"AgentId": client.agent_id, "SessionId": session_id},
- )
- capabilities = _extract_capabilities(bootstrap)
- _assert_checkpoint_capability(capabilities)
-
- invocation_id = f"run_{uuid.uuid4().hex}"
- stream_thread, stream_result = _stream_run_agent_background(
- client,
- session_id=session_id,
- prompt=prompt,
- invocation_id=invocation_id,
- max_seconds=stream_timeout,
- )
-
- try:
- checkpoint = _wait_for_checkpoint(
- client,
- session_id=session_id,
- run_id=invocation_id,
- attempts=wait_attempts,
- interval=wait_interval,
- )
- run_id = str(checkpoint.get("RunId") or "").strip()
- checkpoint_id = str(checkpoint.get("CheckpointId") or "").strip()
- if run_id != invocation_id:
- raise HostedE2EError(
- f"Checkpoint RunId should match active invocation_id: {run_id!r} != {invocation_id!r}"
- )
- if not checkpoint_id:
- raise HostedE2EError(f"Checkpoint missing CheckpointId: {checkpoint}")
-
- cancel_payload = _cancel_run(client, invocation_id=invocation_id)
- cancel_data = _data(cancel_payload, "CancelRun")
- if cancel_data.get("Cancelled") is not True:
- raise HostedE2EError(f"CancelRun did not accept active stream: {cancel_data}")
-
- cancelled_statuses: list[str] = []
- event_count_at_cancel = 0
- for _ in range(wait_attempts):
- events = _list_events(client, session_id=session_id)
- cancelled_statuses = _event_statuses(events, invocation_id=invocation_id)
- if cancelled_statuses and cancelled_statuses[-1] == "cancelled":
- event_count_at_cancel = len(events)
- break
- time.sleep(wait_interval)
- else:
- raise HostedE2EError(
- f"CancelRun did not create cancelled status for {invocation_id}: {cancel_data}"
- )
-
- stream_thread.join(timeout=min(stream_timeout, 10.0))
- if stream_thread.is_alive():
- raise HostedE2EError("RunAgent stream did not close after CancelRun")
- if stream_result.get("error") is not None:
- raise HostedE2EError(f"RunAgent stream failed during cancel validation: {stream_result['error']}")
-
- post_cancel_events = _list_events(client, session_id=session_id)
- unexpected_post_cancel = [
- event
- for event in post_cancel_events[event_count_at_cancel:]
- if event.get("EventType") in {"assistant_message", "run_checkpoint"}
- or (
- event.get("EventType") == "run_status"
- and isinstance(event.get("Content"), dict)
- and event["Content"].get("status") == "completed"
- )
- ]
- if unexpected_post_cancel:
- raise HostedE2EError(
- "Unexpected assistant/checkpoint/completed events appeared after cancel: "
- f"{_event_type_counts(unexpected_post_cancel)}"
- )
-
- resume_invocation_id = f"run_{uuid.uuid4().hex}"
- resume_sse = _resume_stream(
- client,
- session_id=session_id,
- run_id=run_id,
- checkpoint_id=checkpoint_id,
- invocation_id=resume_invocation_id,
- max_seconds=stream_timeout,
- )
-
- final_events = _list_events(client, session_id=session_id)
- final_counts = _event_type_counts(final_events)
- resume_statuses = _event_statuses(final_events, invocation_id=resume_invocation_id)
- if final_counts.get("run_resume", 0) < 1:
- raise HostedE2EError(f"ResumeRun after cancel did not create run_resume event: {final_counts}")
- if final_counts.get("run_checkpoint", 0) < 2:
- raise HostedE2EError(
- f"ResumeRun after cancel should leave at least two checkpoint events: {final_counts}"
- )
- if resume_statuses and resume_statuses[-1] not in TERMINAL_STATUSES:
- raise HostedE2EError(f"ResumeRun after cancel did not reach terminal status: {resume_statuses}")
-
- return {
- "status": "pass",
- "session_id": session_id,
- "run_id": run_id,
- "checkpoint_id": checkpoint_id,
- "cancel_invocation_id": invocation_id,
- "resume_invocation_id": resume_invocation_id,
- "bootstrap_run_lifecycle": capabilities.get("RunLifecycle"),
- "cancel_data": cancel_data,
- "cancel_statuses": cancelled_statuses,
- "event_counts": final_counts,
- "resume_statuses": resume_statuses,
- "stream_closed_after_cancel": not stream_thread.is_alive(),
- "run_sse_line_count": len(
- [line for line in str(stream_result.get("sse") or "").splitlines() if line.strip()]
- ),
- "resume_sse_line_count": len([line for line in resume_sse.splitlines() if line.strip()]),
- }
- finally:
- if stream_thread.is_alive():
- stream_thread.join(timeout=1.0)
-
-
-def build_parser() -> argparse.ArgumentParser:
- parser = argparse.ArgumentParser()
- parser.add_argument(
- "--endpoint",
- required=True,
- help="PublicEndpoint, e.g. https://ar-xxx.agent-pre.kspmas.ksyun.com",
- )
- parser.add_argument("--agent-id", required=True, help="Agent runtime ID, e.g. ar-...")
- parser.add_argument("--api-key", default="", help="Optional runtime API key for public endpoint auth.")
- parser.add_argument(
- "--cookie",
- default="",
- help="Optional Cookie header value, e.g. ae_ui_session=... from a private/share link.",
- )
- parser.add_argument("--account-id", default="", help="Optional account id header.")
- parser.add_argument("--principal-id", default="", help="Optional principal/user id header.")
- parser.add_argument("--user-id", default="hosted-long-task-e2e-user")
- parser.add_argument("--session-id", default="", help="Defaults to a generated sess_... id.")
- parser.add_argument("--prompt", default=DEFAULT_PROMPT)
- parser.add_argument("--timeout", type=float, default=90.0)
- parser.add_argument("--stream-timeout", type=float, default=120.0)
- parser.add_argument("--wait-attempts", type=int, default=60)
- parser.add_argument("--wait-interval", type=float, default=1.0)
- parser.add_argument(
- "--mode",
- choices=["checkpoint-resume", "cancel-active", "cancel-then-resume"],
- default="checkpoint-resume",
- )
- parser.add_argument(
- "--invocation-id",
- default="",
- help="Required for --mode cancel-active; must be an active run invocation id.",
- )
- return parser
-
-
-def main() -> int:
- args = build_parser().parse_args()
- session_id = args.session_id or f"sess_{uuid.uuid4().hex}"
- client = HostedClient(
- base_url=args.endpoint,
- agent_id=args.agent_id,
- user_id=args.user_id,
- timeout=args.timeout,
- api_key=args.api_key,
- cookie=args.cookie,
- account_id=args.account_id,
- principal_id=args.principal_id,
- )
- try:
- if args.mode == "checkpoint-resume":
- result = validate_checkpoint_resume(
- client,
- session_id=session_id,
- prompt=args.prompt,
- wait_attempts=args.wait_attempts,
- wait_interval=args.wait_interval,
- stream_timeout=args.stream_timeout,
- )
- else:
- if args.mode == "cancel-then-resume":
- result = validate_cancel_then_resume(
- client,
- session_id=session_id,
- prompt=args.prompt,
- wait_attempts=args.wait_attempts,
- wait_interval=args.wait_interval,
- stream_timeout=args.stream_timeout,
- )
- elif not args.invocation_id:
- raise HostedE2EError("--invocation-id is required for --mode cancel-active")
- else:
- result = validate_cancel_active(
- client,
- session_id=session_id,
- invocation_id=args.invocation_id,
- wait_attempts=args.wait_attempts,
- wait_interval=args.wait_interval,
- )
- print(json.dumps({"hosted_long_task_e2e": result}, ensure_ascii=False, indent=2))
- return 0
- except Exception as exc:
- print(
- json.dumps(
- {
- "hosted_long_task_e2e": {
- "status": "fail",
- "error_type": type(exc).__name__,
- "error": str(exc),
- }
- },
- ensure_ascii=False,
- indent=2,
- ),
- file=sys.stderr,
- )
- return 1
- finally:
- client.close()
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/scripts/validate_long_task_pilot.py b/scripts/validate_long_task_pilot.py
deleted file mode 100644
index 7e443175..00000000
--- a/scripts/validate_long_task_pilot.py
+++ /dev/null
@@ -1,493 +0,0 @@
-#!/usr/bin/env python3
-"""Run the long-task pilot acceptance validation and emit a JSON report.
-
-This wraps the lower-level checkpoint resume e2e script with the W3 acceptance
-shape expected by the delivery plan. It intentionally avoids printing the DSN.
-"""
-
-from __future__ import annotations
-
-import argparse
-import asyncio
-import json
-import os
-import sys
-from datetime import UTC, datetime
-from pathlib import Path
-from typing import Any
-
-ROOT = Path(__file__).resolve().parents[1]
-if str(ROOT) not in sys.path:
- sys.path.insert(0, str(ROOT))
-
-from scripts.validate_checkpoint_resume_e2e import (
- run_cancel_validation,
- run_cancel_then_resume_validation,
- run_validation,
-)
-
-
-def _pass_fail(condition: bool) -> str:
- return "pass" if condition else "fail"
-
-
-def _rate(condition: bool) -> float:
- return 1.0 if condition else 0.0
-
-
-def _checkpoint_resume_passed(result: dict[str, Any]) -> bool:
- return (
- result.get("output_text") == "a,b,c"
- and int(result.get("checkpoint_count") or 0) >= 1
- and int(result.get("run_checkpoint_event_count") or 0) >= 2
- and int(result.get("run_resume_event_count") or 0) >= 1
- and result.get("resume_did_not_rerun_prior_nodes") is True
- )
-
-
-def _runtime_cancel_passed(result: dict[str, Any] | None) -> bool:
- if not result:
- return False
- return (
- result.get("cancel_found") is True
- and str(result.get("cancel_status") or "") in {"cancelling", "cancelled"}
- and int(result.get("cancelled_event_count") or 0) >= 1
- and int(result.get("post_cancel_extra_event_count") or 0) == 0
- )
-
-
-def _cancel_then_resume_passed(result: dict[str, Any] | None) -> bool:
- if not result:
- return False
- return (
- result.get("cancel_found") is True
- and str(result.get("cancel_status") or "") in {"cancelling", "cancelled"}
- and int(result.get("cancelled_event_count") or 0) >= 1
- and int(result.get("post_cancel_extra_event_count") or 0) == 0
- and result.get("output_text_after_resume") == "a,b,c"
- and result.get("resume_after_cancel_did_not_rerun_prior_nodes") is True
- )
-
-
-async def _build_single_pilot_report(
- *,
- dsn: str,
- keep_session: bool,
- include_cancel: bool,
-) -> dict[str, Any]:
- generated_at = datetime.now(UTC).isoformat()
- try:
- checkpoint_result = await run_validation(dsn=dsn, keep_session=keep_session)
- except Exception as exc:
- return {
- "report_type": "long_task_pilot_validation",
- "generated_at": generated_at,
- "overall_status": "fail",
- "cases": {
- "checkpoint_resume": {
- "status": "fail",
- "error_type": type(exc).__name__,
- "error": str(exc),
- },
- "runtime_cancel": {
- "status": "skipped",
- "reason": "checkpoint_resume failed before cancel validation",
- },
- "cancel_then_resume": {
- "status": "skipped",
- "reason": "checkpoint_resume failed before cancel-then-resume validation",
- },
- },
- "metrics": {
- "checkpoint_resume_success_rate": 0.0,
- "runtime_cancel_success_rate": None,
- "cancel_then_resume_success_rate": None,
- "checkpoint_event_count": None,
- "resume_event_count": None,
- "post_cancel_extra_event_count": None,
- },
- "acceptance": {
- "same_run_id_resume": "fail",
- "checkpoint_list_visible": "fail",
- "resume_does_not_restart": "fail",
- "runtime_cancel_terminal": "skipped",
- "no_events_after_cancel": "skipped",
- "cancel_then_resume_after_cancelled": "skipped",
- "resume_after_cancel_does_not_restart": "skipped",
- },
- "notes": [
- "DSN is intentionally omitted from this report.",
- "Provider/tool deep cancellation support must be reported as accepted or unsupported per runner/tool.",
- ],
- }
-
- cancel_result = None
- cancel_then_resume_result = None
- if include_cancel:
- try:
- cancel_result = await run_cancel_validation(dsn=dsn, keep_session=keep_session)
- except Exception as exc:
- cancel_result = {
- "error_type": type(exc).__name__,
- "error": str(exc),
- }
- try:
- cancel_then_resume_result = await run_cancel_then_resume_validation(
- dsn=dsn,
- keep_session=keep_session,
- )
- except Exception as exc:
- cancel_then_resume_result = {
- "error_type": type(exc).__name__,
- "error": str(exc),
- }
-
- checkpoint_ok = _checkpoint_resume_passed(checkpoint_result)
- cancel_ok = _runtime_cancel_passed(cancel_result) if include_cancel else None
- cancel_then_resume_ok = (
- _cancel_then_resume_passed(cancel_then_resume_result) if include_cancel else None
- )
- overall_ok = checkpoint_ok and (cancel_ok is not False) and (cancel_then_resume_ok is not False)
-
- cases: dict[str, Any] = {
- "checkpoint_resume": {
- "status": _pass_fail(checkpoint_ok),
- "session_id": checkpoint_result.get("session_id"),
- "run_id": checkpoint_result.get("run_id"),
- "checkpoint_id": checkpoint_result.get("checkpoint_id"),
- "output_text": checkpoint_result.get("output_text"),
- "checkpoint_count": checkpoint_result.get("checkpoint_count"),
- "run_checkpoint_event_count": checkpoint_result.get("run_checkpoint_event_count"),
- "run_resume_event_count": checkpoint_result.get("run_resume_event_count"),
- "checkpoint_log_before_resume": checkpoint_result.get("checkpoint_log_before_resume"),
- "node_counts_after_resume": checkpoint_result.get("node_counts_after_resume"),
- "resume_did_not_rerun_prior_nodes": checkpoint_result.get("resume_did_not_rerun_prior_nodes"),
- }
- }
- if include_cancel:
- cases["runtime_cancel"] = {
- "status": _pass_fail(bool(cancel_ok)),
- "error_type": cancel_result.get("error_type") if cancel_result else None,
- "error": cancel_result.get("error") if cancel_result else None,
- "session_id": cancel_result.get("session_id") if cancel_result else None,
- "invocation_id": cancel_result.get("invocation_id") if cancel_result else None,
- "cancel_found": cancel_result.get("cancel_found") if cancel_result else None,
- "cancel_status": cancel_result.get("cancel_status") if cancel_result else None,
- "cancelled_event_count": cancel_result.get("cancelled_event_count") if cancel_result else None,
- "post_cancel_extra_event_count": (
- cancel_result.get("post_cancel_extra_event_count") if cancel_result else None
- ),
- }
- cases["cancel_then_resume"] = {
- "status": _pass_fail(bool(cancel_then_resume_ok)),
- "error_type": (
- cancel_then_resume_result.get("error_type") if cancel_then_resume_result else None
- ),
- "error": cancel_then_resume_result.get("error") if cancel_then_resume_result else None,
- "session_id": (
- cancel_then_resume_result.get("session_id") if cancel_then_resume_result else None
- ),
- "run_id": cancel_then_resume_result.get("run_id") if cancel_then_resume_result else None,
- "invocation_id": (
- cancel_then_resume_result.get("invocation_id") if cancel_then_resume_result else None
- ),
- "checkpoint_id": (
- cancel_then_resume_result.get("checkpoint_id") if cancel_then_resume_result else None
- ),
- "cancel_found": (
- cancel_then_resume_result.get("cancel_found") if cancel_then_resume_result else None
- ),
- "cancel_status": (
- cancel_then_resume_result.get("cancel_status") if cancel_then_resume_result else None
- ),
- "cancelled_event_count": (
- cancel_then_resume_result.get("cancelled_event_count")
- if cancel_then_resume_result
- else None
- ),
- "post_cancel_extra_event_count": (
- cancel_then_resume_result.get("post_cancel_extra_event_count")
- if cancel_then_resume_result
- else None
- ),
- "output_text_after_resume": (
- cancel_then_resume_result.get("output_text_after_resume")
- if cancel_then_resume_result
- else None
- ),
- "checkpoint_log_before_cancel": (
- cancel_then_resume_result.get("checkpoint_log_before_cancel")
- if cancel_then_resume_result
- else None
- ),
- "node_counts_after_resume": (
- cancel_then_resume_result.get("node_counts_after_resume")
- if cancel_then_resume_result
- else None
- ),
- "resume_after_cancel_did_not_rerun_prior_nodes": (
- cancel_then_resume_result.get("resume_after_cancel_did_not_rerun_prior_nodes")
- if cancel_then_resume_result
- else None
- ),
- }
- else:
- cases["runtime_cancel"] = {
- "status": "skipped",
- "reason": "cancel validation disabled by --skip-cancel",
- }
- cases["cancel_then_resume"] = {
- "status": "skipped",
- "reason": "cancel validation disabled by --skip-cancel",
- }
-
- no_events_after_cancel = (
- cancel_result is not None and int(cancel_result.get("post_cancel_extra_event_count") or 0) == 0
- )
- cancel_then_resume_after_cancel = bool(cancel_then_resume_ok)
- return {
- "report_type": "long_task_pilot_validation",
- "generated_at": generated_at,
- "overall_status": _pass_fail(overall_ok),
- "cases": cases,
- "metrics": {
- "checkpoint_resume_success_rate": _rate(checkpoint_ok),
- "runtime_cancel_success_rate": _rate(bool(cancel_ok)) if include_cancel else None,
- "cancel_then_resume_success_rate": (
- _rate(bool(cancel_then_resume_ok)) if include_cancel else None
- ),
- "checkpoint_event_count": checkpoint_result.get("run_checkpoint_event_count"),
- "resume_event_count": checkpoint_result.get("run_resume_event_count"),
- "node_counts_after_resume": checkpoint_result.get("node_counts_after_resume"),
- "post_cancel_extra_event_count": (
- cancel_result.get("post_cancel_extra_event_count") if cancel_result else None
- ),
- },
- "acceptance": {
- "same_run_id_resume": _pass_fail(bool(checkpoint_result.get("run_id")) and checkpoint_ok),
- "checkpoint_list_visible": _pass_fail(int(checkpoint_result.get("checkpoint_count") or 0) >= 1),
- "resume_does_not_restart": _pass_fail(
- checkpoint_result.get("resume_did_not_rerun_prior_nodes") is True
- ),
- "runtime_cancel_terminal": (
- _pass_fail(bool(cancel_ok)) if include_cancel else "skipped"
- ),
- "no_events_after_cancel": (
- _pass_fail(no_events_after_cancel) if include_cancel else "skipped"
- ),
- "cancel_then_resume_after_cancelled": (
- _pass_fail(cancel_then_resume_after_cancel) if include_cancel else "skipped"
- ),
- "resume_after_cancel_does_not_restart": (
- _pass_fail(
- cancel_then_resume_result is not None
- and cancel_then_resume_result.get("resume_after_cancel_did_not_rerun_prior_nodes")
- is True
- )
- if include_cancel
- else "skipped"
- ),
- },
- "notes": [
- "DSN is intentionally omitted from this report.",
- "Provider/tool deep cancellation support must be reported as accepted or unsupported per runner/tool.",
- ],
- }
-
-
-def _case_passed(report: dict[str, Any], case_name: str) -> bool:
- cases = report.get("cases") if isinstance(report.get("cases"), dict) else {}
- case = cases.get(case_name) if isinstance(cases, dict) else None
- return isinstance(case, dict) and case.get("status") == "pass"
-
-
-def _first_nonpassing_case(reports: list[dict[str, Any]], case_name: str) -> dict[str, Any]:
- for report in reports:
- cases = report.get("cases") if isinstance(report.get("cases"), dict) else {}
- case = cases.get(case_name) if isinstance(cases, dict) else None
- if isinstance(case, dict) and case.get("status") != "pass":
- return dict(case)
- cases = reports[-1].get("cases") if reports else {}
- case = cases.get(case_name) if isinstance(cases, dict) else {}
- return dict(case) if isinstance(case, dict) else {}
-
-
-def _max_metric(reports: list[dict[str, Any]], metric_name: str) -> int | None:
- values: list[int] = []
- for report in reports:
- metrics = report.get("metrics") if isinstance(report.get("metrics"), dict) else {}
- value = metrics.get(metric_name) if isinstance(metrics, dict) else None
- if value is not None:
- values.append(int(value or 0))
- return max(values) if values else None
-
-
-async def build_pilot_report(
- *,
- dsn: str,
- keep_session: bool,
- include_cancel: bool,
- iterations: int = 1,
-) -> dict[str, Any]:
- total_iterations = max(1, int(iterations or 1))
- if total_iterations == 1:
- return await _build_single_pilot_report(
- dsn=dsn,
- keep_session=keep_session,
- include_cancel=include_cancel,
- )
-
- generated_at = datetime.now(UTC).isoformat()
- iteration_reports: list[dict[str, Any]] = []
- for index in range(1, total_iterations + 1):
- report = await _build_single_pilot_report(
- dsn=dsn,
- keep_session=keep_session,
- include_cancel=include_cancel,
- )
- report["iteration"] = index
- iteration_reports.append(report)
-
- checkpoint_passed = sum(
- 1 for report in iteration_reports if _case_passed(report, "checkpoint_resume")
- )
- cancel_passed = (
- sum(1 for report in iteration_reports if _case_passed(report, "runtime_cancel"))
- if include_cancel
- else None
- )
- cancel_then_resume_passed = (
- sum(1 for report in iteration_reports if _case_passed(report, "cancel_then_resume"))
- if include_cancel
- else None
- )
- checkpoint_ok = checkpoint_passed == total_iterations
- cancel_ok = (cancel_passed == total_iterations) if include_cancel else True
- cancel_then_resume_ok = (
- cancel_then_resume_passed == total_iterations
- if include_cancel
- else True
- )
-
- checkpoint_case = _first_nonpassing_case(iteration_reports, "checkpoint_resume")
- checkpoint_case["status"] = _pass_fail(checkpoint_ok)
- cases: dict[str, Any] = {"checkpoint_resume": checkpoint_case}
- if include_cancel:
- cancel_case = _first_nonpassing_case(iteration_reports, "runtime_cancel")
- cancel_case["status"] = _pass_fail(cancel_ok)
- cases["runtime_cancel"] = cancel_case
- else:
- cases["runtime_cancel"] = {
- "status": "skipped",
- "reason": "cancel validation disabled by --skip-cancel",
- }
- cases["cancel_then_resume"] = {
- "status": "skipped",
- "reason": "cancel validation disabled by --skip-cancel",
- }
- if include_cancel:
- cancel_then_resume_case = _first_nonpassing_case(
- iteration_reports,
- "cancel_then_resume",
- )
- cancel_then_resume_case["status"] = _pass_fail(cancel_then_resume_ok)
- cases["cancel_then_resume"] = cancel_then_resume_case
-
- return {
- "report_type": "long_task_pilot_validation",
- "generated_at": generated_at,
- "overall_status": _pass_fail(checkpoint_ok and cancel_ok and cancel_then_resume_ok),
- "cases": cases,
- "iterations": iteration_reports,
- "metrics": {
- "total_iterations": total_iterations,
- "checkpoint_resume_passed": checkpoint_passed,
- "checkpoint_resume_failed": total_iterations - checkpoint_passed,
- "runtime_cancel_passed": cancel_passed if include_cancel else None,
- "runtime_cancel_failed": (
- total_iterations - int(cancel_passed or 0) if include_cancel else None
- ),
- "cancel_then_resume_passed": (
- cancel_then_resume_passed if include_cancel else None
- ),
- "cancel_then_resume_failed": (
- total_iterations - int(cancel_then_resume_passed or 0)
- if include_cancel
- else None
- ),
- "checkpoint_resume_success_rate": checkpoint_passed / total_iterations,
- "runtime_cancel_success_rate": (
- int(cancel_passed or 0) / total_iterations if include_cancel else None
- ),
- "cancel_then_resume_success_rate": (
- int(cancel_then_resume_passed or 0) / total_iterations
- if include_cancel
- else None
- ),
- "max_post_cancel_extra_event_count": _max_metric(
- iteration_reports,
- "post_cancel_extra_event_count",
- ),
- },
- "acceptance": {
- "same_run_id_resume": _pass_fail(checkpoint_ok),
- "checkpoint_list_visible": _pass_fail(checkpoint_ok),
- "resume_does_not_restart": _pass_fail(checkpoint_ok),
- "runtime_cancel_terminal": _pass_fail(cancel_ok) if include_cancel else "skipped",
- "no_events_after_cancel": _pass_fail(cancel_ok) if include_cancel else "skipped",
- "cancel_then_resume_after_cancelled": (
- _pass_fail(cancel_then_resume_ok) if include_cancel else "skipped"
- ),
- "resume_after_cancel_does_not_restart": (
- _pass_fail(cancel_then_resume_ok) if include_cancel else "skipped"
- ),
- },
- "notes": [
- "DSN is intentionally omitted from this report.",
- "Provider/tool deep cancellation support must be reported as accepted or unsupported per runner/tool.",
- ],
- }
-
-
-def main() -> int:
- parser = argparse.ArgumentParser()
- parser.add_argument(
- "--dsn",
- default=os.environ.get("KSADK_SESSION_DSN", ""),
- help="PostgreSQL DSN. Defaults to KSADK_SESSION_DSN. The value is not printed.",
- )
- parser.add_argument(
- "--keep-session",
- action="store_true",
- help="Keep generated session rows for debugging.",
- )
- parser.add_argument(
- "--skip-cancel",
- action="store_true",
- help="Skip W2.5 runtime cancel validation.",
- )
- parser.add_argument(
- "--iterations",
- type=int,
- default=1,
- help="Number of independent validation iterations to run. Use 100 for W3 95%% acceptance.",
- )
- args = parser.parse_args()
- dsn = args.dsn.strip()
- if not dsn:
- raise SystemExit("--dsn or KSADK_SESSION_DSN is required")
-
- report = asyncio.run(
- build_pilot_report(
- dsn=dsn,
- keep_session=args.keep_session,
- include_cancel=not args.skip_cancel,
- iterations=args.iterations,
- )
- )
- print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
- return 0 if report["overall_status"] == "pass" else 1
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/scripts/zread_subpath_proxy.py b/scripts/zread_subpath_proxy.py
deleted file mode 100644
index 311dd253..00000000
--- a/scripts/zread_subpath_proxy.py
+++ /dev/null
@@ -1,230 +0,0 @@
-#!/usr/bin/env python3
-"""Serve native zread under a non-root path.
-
-zread's embedded UI currently assumes root-relative assets and APIs. This proxy
-keeps zread itself unchanged, while making the app safe to publish below
-`/ksadk-docs` on a shared ingress host.
-"""
-
-from __future__ import annotations
-
-import os
-import signal
-import subprocess
-import sys
-import threading
-import time
-import gzip
-import urllib.error
-import urllib.parse
-import urllib.request
-from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
-
-
-LISTEN_HOST = os.environ.get("HOST", "0.0.0.0")
-LISTEN_PORT = int(os.environ.get("PORT", "8080"))
-UPSTREAM_HOST = "127.0.0.1"
-UPSTREAM_PORT = int(os.environ.get("ZREAD_UPSTREAM_PORT", "9681"))
-BASE_PATH = "/" + os.environ.get("DOCS_BASE_PATH", "/ksadk-docs").strip("/")
-if BASE_PATH == "/":
- BASE_PATH = ""
-try:
- CACHE_BUSTER = os.environ.get("DOCS_CACHE_BUSTER") or open(".zread/wiki/current", encoding="utf-8").read().strip()
-except OSError:
- CACHE_BUSTER = str(int(time.time()))
-CACHE_BUSTER = CACHE_BUSTER.replace("/", "-")
-
-
-def start_zread() -> subprocess.Popen[bytes]:
- return subprocess.Popen(
- [
- "zread",
- "browse",
- "--host",
- UPSTREAM_HOST,
- "--port",
- str(UPSTREAM_PORT),
- "--stdio",
- ],
- stdin=subprocess.PIPE,
- stdout=subprocess.DEVNULL,
- stderr=subprocess.STDOUT,
- )
-
-
-def wait_for_upstream(process: subprocess.Popen[bytes]) -> None:
- deadline = time.time() + 60
- url = f"http://{UPSTREAM_HOST}:{UPSTREAM_PORT}/"
- last_error: Exception | None = None
- while time.time() < deadline:
- if process.poll() is not None:
- raise RuntimeError(f"zread exited early with code {process.returncode}")
- try:
- with urllib.request.urlopen(url, timeout=2) as response:
- if response.status < 500:
- return
- except Exception as exc: # noqa: BLE001 - surfaced after retry window.
- last_error = exc
- time.sleep(0.5)
- raise RuntimeError(f"zread did not become ready: {last_error}")
-
-
-def rewrite_text(body: bytes, content_type: str, content_encoding: str) -> bytes:
- if content_encoding.lower() == "gzip":
- body = gzip.decompress(body)
-
- if not any(token in content_type for token in ("text/html", "javascript", "text/css")):
- return body
-
- text = body.decode("utf-8", errors="replace")
- if "text/html" in content_type:
- text = text.replace('src="/', f'src="{BASE_PATH}/')
- text = text.replace('href="/', f'href="{BASE_PATH}/')
- text = text.replace('.js"', f'.js?v={CACHE_BUSTER}"')
- text = text.replace('.css"', f'.css?v={CACHE_BUSTER}"')
- if 'rel="icon"' not in text:
- text = text.replace("", '')
-
- if "javascript" in content_type:
- text = text.replace("`/api/", f"`{BASE_PATH}/api/")
- text = text.replace('"/api/', f'"{BASE_PATH}/api/')
- text = text.replace("'/api/", f"'{BASE_PATH}/api/")
- text = text.replace("fetch(`/api/", f"fetch(`{BASE_PATH}/api/")
- text = text.replace('fetch("/api/', f'fetch("{BASE_PATH}/api/')
- text = text.replace("href:`/api/", f"href:`{BASE_PATH}/api/")
- text = text.replace(
- "resolveInternalWikiHref:e=>`/${e}`",
- f"resolveInternalWikiHref:e=>`{BASE_PATH}/${{e}}`",
- )
- text = text.replace(
- "buildTopicHref:e=>`/${e}`",
- f"buildTopicHref:e=>`{BASE_PATH}/${{e}}`",
- )
- text = text.replace(
- "window.history.pushState(null,``,`/${e.slug}`)",
- f"window.history.pushState(null,``,`{BASE_PATH}/${{e.slug}}`)",
- )
- text = text.replace(
- "function pJt(){return window.location.pathname.replace(/^\\//,``)}",
- "function pJt(){return window.location.pathname"
- f".replace(/^\\/{BASE_PATH.strip('/')}\\/?/,``)"
- ".replace(/^\\//,``)}",
- )
-
- return text.encode("utf-8")
-
-
-class ProxyHandler(BaseHTTPRequestHandler):
- protocol_version = "HTTP/1.1"
-
- def log_message(self, fmt: str, *args: object) -> None:
- sys.stderr.write("%s - - [%s] %s\n" % (self.address_string(), self.log_date_time_string(), fmt % args))
-
- def do_GET(self) -> None: # noqa: N802 - stdlib hook name.
- self._proxy()
-
- def do_HEAD(self) -> None: # noqa: N802 - stdlib hook name.
- self._proxy(head_only=True)
-
- def do_PUT(self) -> None: # noqa: N802 - zread editor save endpoint.
- self._proxy(with_body=True)
-
- def _proxy(self, *, head_only: bool = False, with_body: bool = False) -> None:
- parsed = urllib.parse.urlsplit(self.path)
- if parsed.path == "/healthz":
- self._send_plain(200, "ok\n")
- return
-
- if BASE_PATH and parsed.path == BASE_PATH:
- self.send_response(308)
- self.send_header("Location", f"{BASE_PATH}/")
- self.send_header("Content-Length", "0")
- self.end_headers()
- return
-
- upstream_path = parsed.path
- if BASE_PATH:
- if not (parsed.path == BASE_PATH or parsed.path.startswith(f"{BASE_PATH}/")):
- self._send_plain(404, "not found\n")
- return
- upstream_path = parsed.path[len(BASE_PATH) :] or "/"
-
- upstream_url = urllib.parse.urlunsplit(
- ("http", f"{UPSTREAM_HOST}:{UPSTREAM_PORT}", upstream_path, parsed.query, "")
- )
- body = None
- if with_body:
- length = int(self.headers.get("Content-Length", "0"))
- body = self.rfile.read(length) if length else b""
-
- headers = {
- "Accept": self.headers.get("Accept", "*/*"),
- "Accept-Encoding": "identity",
- }
- if "Content-Type" in self.headers:
- headers["Content-Type"] = self.headers["Content-Type"]
-
- request = urllib.request.Request(upstream_url, data=body, headers=headers, method=self.command)
- try:
- with urllib.request.urlopen(request, timeout=60) as response:
- response_body = b"" if head_only else response.read()
- content_type = response.headers.get("Content-Type", "")
- content_encoding = response.headers.get("Content-Encoding", "")
- is_rewritten = any(token in content_type for token in ("text/html", "javascript", "text/css"))
- response_body = rewrite_text(response_body, content_type, content_encoding)
- self.send_response(response.status)
- for key, value in response.headers.items():
- lower = key.lower()
- if lower in {"content-length", "content-encoding", "transfer-encoding", "connection"}:
- continue
- if is_rewritten and lower == "cache-control":
- continue
- self.send_header(key, value)
- if is_rewritten:
- self.send_header("Cache-Control", "no-cache")
- self.send_header("Content-Length", str(len(response_body)))
- self.end_headers()
- if not head_only:
- self.wfile.write(response_body)
- except urllib.error.HTTPError as exc:
- error_body = b"" if head_only else exc.read()
- self.send_response(exc.code)
- self.send_header("Content-Length", str(len(error_body)))
- self.end_headers()
- if not head_only:
- self.wfile.write(error_body)
-
- def _send_plain(self, status: int, body: str) -> None:
- payload = body.encode("utf-8")
- self.send_response(status)
- self.send_header("Content-Type", "text/plain; charset=utf-8")
- self.send_header("Content-Length", str(len(payload)))
- self.end_headers()
- self.wfile.write(payload)
-
-
-def main() -> int:
- process = start_zread()
-
- def stop_process(*_: object) -> None:
- if process.poll() is None:
- process.terminate()
-
- signal.signal(signal.SIGTERM, stop_process)
- signal.signal(signal.SIGINT, stop_process)
-
- wait_for_upstream(process)
- server = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), ProxyHandler)
-
- watcher = threading.Thread(target=lambda: (process.wait(), server.shutdown()), daemon=True)
- watcher.start()
-
- try:
- server.serve_forever()
- finally:
- stop_process()
- return process.returncode or 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/tests/long_task/__init__.py b/tests/long_task/__init__.py
deleted file mode 100644
index acd33e3f..00000000
--- a/tests/long_task/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Long-task recovery acceptance tests."""
diff --git a/tests/long_task/test_checkpoint_resume.py b/tests/long_task/test_checkpoint_resume.py
deleted file mode 100644
index 8dd51310..00000000
--- a/tests/long_task/test_checkpoint_resume.py
+++ /dev/null
@@ -1,158 +0,0 @@
-from __future__ import annotations
-
-from ksadk.conversations.context import build_history_from_events
-from ksadk.conversations.runtime import (
- append_run_checkpoint_event,
- append_run_resume_event,
- extract_responses_resume_input,
- invoke_conversation_once,
-)
-from ksadk.sessions.base import SessionEvent
-from ksadk.sessions.in_memory import InMemorySessionService
-
-import pytest
-
-
-class _CheckpointResumeRunner:
- def __init__(self):
- self.detection_result = type("Detection", (), {"name": "demo-agent"})()
- self.calls: list[dict] = []
-
- def prepare_for_request(self, model):
- del model
-
- async def invoke(self, input_data: dict) -> dict:
- self.calls.append(input_data)
- return {
- "output": "resumed",
- "metadata": {
- "agentengine": {
- "run_id": input_data["run_id"],
- "framework": "langgraph",
- "framework_ref": {
- "langgraph": {
- "thread_id": "tenant:agent:sess-long",
- "checkpoint_id": "ckpt-after",
- }
- },
- }
- },
- }
-
-
-def test_checkpoint_runtime_events_are_not_projected_to_model_history():
- events = [
- SessionEvent(
- id="evt-checkpoint",
- author="demo-agent",
- event_type="run_checkpoint",
- content={"status": "saved"},
- metadata={"run_id": "run-1", "checkpoint_id": "ckpt-1"},
- seq_id=1,
- ),
- SessionEvent(
- id="evt-resume",
- author="demo-agent",
- event_type="run_resume",
- content={"status": "requested"},
- metadata={"run_id": "run-1", "resume_attempt_id": "resume-1"},
- seq_id=2,
- ),
- SessionEvent(
- id="evt-user",
- author="user",
- event_type="user_message",
- content={"role": "user", "parts": [{"text": "继续"}]},
- seq_id=3,
- ),
- ]
-
- assert build_history_from_events(events) == [{"role": "user", "content": "继续"}]
-
-
-def test_responses_input_accepts_checkpoint_resume_action():
- resume_input = extract_responses_resume_input(
- [
- {
- "type": "agentengine.resume_checkpoint",
- "run_id": "run-1",
- "checkpoint_id": "ckpt-before",
- "resume_attempt_id": "resume-1",
- "framework": "langgraph",
- "framework_ref": {
- "langgraph": {
- "thread_id": "tenant:agent:sess-long",
- "checkpoint_id": "ckpt-before",
- }
- },
- }
- ]
- )
-
- assert resume_input["type"] == "agentengine.resume_checkpoint"
- assert resume_input["run_id"] == "run-1"
- assert resume_input["checkpoint_id"] == "ckpt-before"
- assert resume_input["resume_attempt_id"] == "resume-1"
-
-
-@pytest.mark.asyncio
-async def test_checkpoint_resume_keeps_same_run_id_and_records_attempt(monkeypatch):
- service = InMemorySessionService()
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-long")
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- checkpoint = await append_run_checkpoint_event(
- session_id="sess-long",
- author="demo-agent",
- run_id="run-1",
- checkpoint_id="ckpt-before",
- framework="langgraph",
- framework_ref={
- "langgraph": {
- "thread_id": "tenant:agent:sess-long",
- "checkpoint_id": "ckpt-before",
- }
- },
- phase="tool_result",
- invocation_id="inv-checkpoint",
- )
- resume = await append_run_resume_event(
- session_id="sess-long",
- author="demo-agent",
- run_id="run-1",
- checkpoint_id="ckpt-before",
- resume_attempt_id="resume-1",
- framework="langgraph",
- framework_ref=checkpoint.metadata["framework_ref"],
- invocation_id="inv-resume",
- )
-
- assert checkpoint.metadata["run_id"] == resume.metadata["run_id"] == "run-1"
- assert resume.metadata["resume_attempt_id"] == "resume-1"
-
- runner = _CheckpointResumeRunner()
- _, result = await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-long",
- messages=[],
- model="demo-model",
- prepare_runner=lambda active_runner, model: active_runner.prepare_for_request(model),
- resume_input={
- "type": "agentengine.resume_checkpoint",
- "run_id": "run-1",
- "checkpoint_id": "ckpt-before",
- "resume_attempt_id": "resume-1",
- "framework": "langgraph",
- "framework_ref": checkpoint.metadata["framework_ref"],
- },
- )
-
- assert runner.calls[0]["checkpoint_resume"] is True
- assert runner.calls[0]["run_id"] == "run-1"
- assert result["metadata"]["agentengine"]["run_id"] == "run-1"
- assert (
- result["metadata"]["agentengine"]["framework_ref"]["langgraph"]["checkpoint_id"]
- == "ckpt-after"
- )
diff --git a/tests/long_task/test_runtime_cancel.py b/tests/long_task/test_runtime_cancel.py
deleted file mode 100644
index 55b57a42..00000000
--- a/tests/long_task/test_runtime_cancel.py
+++ /dev/null
@@ -1,135 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-import importlib
-from types import SimpleNamespace
-
-import httpx
-import pytest
-
-from ksadk.conversations import runtime as conversation_runtime
-from ksadk.runners.base_runner import BaseRunner
-from ksadk.sessions.in_memory import InMemorySessionService
-
-
-class _UnsupportedCancelRunner(BaseRunner):
- def __init__(self):
- super().__init__(
- detection_result=SimpleNamespace(name="demo-agent", type=SimpleNamespace(value="mock")),
- project_dir=".",
- )
-
- def load_agent(self) -> None:
- return None
-
- async def invoke(self, input_data: dict):
- return {"output": "unused"}
-
- async def stream(self, input_data: dict):
- yield {"type": "text", "delta": "unused"}
-
-
-class _CancellableStreamingRunner(_UnsupportedCancelRunner):
- def __init__(self):
- super().__init__()
- self.cancel_requests: list[str] = []
-
- async def stream(self, input_data: dict):
- yield {"type": "text", "delta": "started"}
- await asyncio.Event().wait()
-
- def request_cancel(self, invocation_id: str) -> str:
- self.cancel_requests.append(invocation_id)
- return "accepted"
-
-
-@pytest.mark.asyncio
-async def test_cancel_run_reports_unsupported_for_runner_without_cancel_hook(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _UnsupportedCancelRunner()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/CancelRun",
- json={"AgentId": "demo-agent", "InvocationId": "inv-unsupported"},
- )
-
- assert response.status_code == 200
- data = response.json()["Data"]
- assert data["Cancelled"] is False
- assert data["Found"] is False
- assert data["Status"] == "unsupported"
- assert data["RunnerCancelStatus"] == "unsupported"
-
-
-@pytest.mark.asyncio
-async def test_cancel_run_stops_detached_stream_and_writes_cancelled_terminal(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _CancellableStreamingRunner()
- invocation_id = "inv-cancel-long-task"
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
- server_app_module._detached_streaming_response(
- conversation_runtime.stream_responses_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- messages=[{"role": "user", "content": "start"}],
- session_id="sess-cancel-long-task",
- model=None,
- prepare_runner=lambda _runner, _model: None,
- invocation_id=invocation_id,
- session_service_provider=lambda: service,
- ),
- invocation_id=invocation_id,
- )
-
- for _ in range(20):
- events = await service.get_events("sess-cancel-long-task")
- statuses = [
- event.content.get("status")
- for event in events
- if event.event_type == "run_status"
- ]
- if statuses == ["in_progress"]:
- break
- await asyncio.sleep(0.02)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/CancelRun",
- json={"AgentId": "demo-agent", "InvocationId": invocation_id},
- )
-
- assert response.status_code == 200
- data = response.json()["Data"]
- assert data["Cancelled"] is True
- assert data["Found"] is True
- assert data["Status"] == "cancelling"
- assert data["RunnerCancelStatus"] == "accepted"
- assert runner.cancel_requests == [invocation_id]
-
- for _ in range(30):
- events = await service.get_events("sess-cancel-long-task")
- if events and events[-1].event_type == "run_status" and events[-1].content.get("status") == "cancelled":
- break
- await asyncio.sleep(0.02)
-
- events = await service.get_events("sess-cancel-long-task")
- statuses = [
- event.content.get("status")
- for event in events
- if event.event_type == "run_status"
- ]
- event_types = [event.event_type for event in events]
- assert statuses == ["in_progress", "cancelled"]
- assert "assistant_message" not in event_types
- assert "run_checkpoint" not in event_types
diff --git a/tests/long_task/test_tool_idempotency.py b/tests/long_task/test_tool_idempotency.py
deleted file mode 100644
index f57ba9ac..00000000
--- a/tests/long_task/test_tool_idempotency.py
+++ /dev/null
@@ -1,118 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-import pytest
-
-from ksadk.conversations.runtime import invoke_conversation_once
-from ksadk.sessions.base import SessionEvent
-from ksadk.sessions.in_memory import InMemorySessionService
-from ksadk.tools.gateway import build_tool_receipt_idempotency_key
-
-
-class _ApprovalRunner:
- def __init__(self):
- self.detection_result = type("Detection", (), {"name": "demo-agent"})()
- self.calls: list[dict] = []
-
- def prepare_for_request(self, model):
- del model
-
- async def invoke(self, input_data: dict) -> dict:
- self.calls.append(input_data)
- return {"output": "ok"}
-
-
-def test_tool_receipt_key_is_stable_for_argument_order():
- left = build_tool_receipt_idempotency_key(
- session_id="sess-1",
- run_id="run-1",
- checkpoint_id="ckpt-1",
- tool_call_id="call-1",
- tool_name="write_workspace_file",
- tool_args={"path": "notes.txt", "content": "hello"},
- )
- right = build_tool_receipt_idempotency_key(
- session_id="sess-1",
- run_id="run-1",
- checkpoint_id="ckpt-1",
- tool_call_id="call-1",
- tool_name="write_workspace_file",
- tool_args={"content": "hello", "path": "notes.txt"},
- )
-
- assert left == right
- assert left.startswith("tool_receipt:")
-
-
-@pytest.mark.asyncio
-async def test_approved_side_effect_tool_replays_receipt_without_second_write(
- monkeypatch,
- tmp_path: Path,
-):
- service = InMemorySessionService()
- workspace_ui = tmp_path / "ui"
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(workspace_ui))
- monkeypatch.setenv("KSADK_TOOL_APPROVAL_MODE", "strict")
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-tool")
- await service.append_event(
- "sess-tool",
- SessionEvent(
- id="evt-approval",
- author="demo-agent",
- event_type="approval_request",
- content={"role": "model", "parts": [{"text": "approval required"}]},
- metadata={
- "interrupt_info": {
- "approval_request_id": "appr_write",
- "tool_name": "write_workspace_file",
- "arguments": {"path": "notes.txt", "content": "hello"},
- "run_id": "call_write",
- "server_label": "ksadk",
- }
- },
- invocation_id="inv-approval",
- ),
- )
- runner = _ApprovalRunner()
- resume_input = {
- "type": "mcp_approval_response",
- "approval_request_id": "appr_write",
- "approve": True,
- }
-
- await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-tool",
- messages=[],
- model="demo-model",
- resume_input=resume_input,
- prepare_runner=lambda active_runner, model: active_runner.prepare_for_request(model),
- )
- target = workspace_ui / "workspace" / "notes.txt"
- assert target.read_text(encoding="utf-8") == "hello"
- target.write_text("changed-by-user", encoding="utf-8")
-
- await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-tool",
- messages=[],
- model="demo-model",
- resume_input=resume_input,
- prepare_runner=lambda active_runner, model: active_runner.prepare_for_request(model),
- )
-
- assert target.read_text(encoding="utf-8") == "changed-by-user"
- events = await service.get_events("sess-tool")
- tool_results = [event for event in events if event.event_type == "tool_result"]
- assert len(tool_results) == 2
- assert tool_results[-1].metadata["tool_receipt"]["replayed"] is True
- assert (
- tool_results[-1].metadata["tool_receipt"]["idempotency_key"]
- == tool_results[0].metadata["tool_receipt"]["idempotency_key"]
- )
diff --git a/tests/skills/__init__.py b/tests/skills/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/skills/test_adk_runner_skill_runtime.py b/tests/skills/test_adk_runner_skill_runtime.py
deleted file mode 100644
index 38dddf1e..00000000
--- a/tests/skills/test_adk_runner_skill_runtime.py
+++ /dev/null
@@ -1,371 +0,0 @@
-from __future__ import annotations
-
-import textwrap
-from types import SimpleNamespace
-from uuid import uuid4
-
-from ksadk.detection import DetectionResult, FrameworkType
-
-
-def _write_adk_project(tmp_path, source: str) -> DetectionResult:
- package_name = f"skill_agent_{uuid4().hex[:8]}"
- package_dir = tmp_path / package_name
- package_dir.mkdir()
- (package_dir / "__init__.py").write_text("", encoding="utf-8")
- (package_dir / "agent.py").write_text(textwrap.dedent(source), encoding="utf-8")
- return DetectionResult(
- type=FrameworkType.ADK,
- name="demo-agent",
- entry_point=f"{package_name}/agent.py",
- package_path=str(package_dir),
- agent_variable="root_agent",
- confidence=1.0,
- )
-
-
-def _tool_names(tools):
- return [getattr(tool, "name", None) or getattr(tool, "__name__", "") for tool in tools]
-
-
-class FakeRunner:
- instances = []
-
- def __init__(self, **kwargs):
- self.kwargs = kwargs
- FakeRunner.instances.append(self)
-
-
-def _patch_runner(monkeypatch):
- import google.adk.runners as adk_runners
-
- from ksadk.runners.adk_runner import ADKRunner
-
- FakeRunner.instances.clear()
- monkeypatch.setattr(ADKRunner, "_apply_json_patch", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_short_term_memory", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_long_term_memory", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_knowledge_base", lambda self: None)
- monkeypatch.setattr(adk_runners, "Runner", FakeRunner)
- return ADKRunner
-
-
-def test_adk_runner_injects_execute_skills_for_sandbox_mode(monkeypatch, tmp_path):
- ADKRunner = _patch_runner(monkeypatch)
- detection = _write_adk_project(
- tmp_path,
- """
- class DemoAgent:
- def __init__(self):
- self.name = "demo-agent"
- self.tools = []
- self.instruction = "Be helpful."
-
- root_agent = DemoAgent()
- """,
- )
- monkeypatch.setenv("KSADK_SKILLS_MODE", "sandbox")
- monkeypatch.setenv("KSADK_SKILL_RUNTIME_BACKEND", "disabled")
- monkeypatch.setenv("KSADK_SKILL_SPACE_IDS", "ss-1")
-
- runner = ADKRunner(detection, str(tmp_path))
- runner.load_agent()
-
- assert _tool_names(runner._agent.tools) == ["execute_skills"]
- assert len(FakeRunner.instances) == 1
-
-
-def test_adk_runner_injects_remote_skill_manifest_into_instruction(monkeypatch, tmp_path):
- ADKRunner = _patch_runner(monkeypatch)
- detection = _write_adk_project(
- tmp_path,
- """
- class DemoAgent:
- def __init__(self):
- self.name = "demo-agent"
- self.tools = []
- self.instruction = "Be helpful."
-
- root_agent = DemoAgent()
- """,
- )
-
- def fake_load_remote_skill_manifests(skill_space_ids=None):
- assert skill_space_ids == ["ss-user", "ss-public"]
- return [
- {
- "name": "demo-skill",
- "description": "Create spreadsheet reports",
- "version": "v1",
- }
- ]
-
- monkeypatch.setenv("KSADK_SKILLS_MODE", "sandbox")
- monkeypatch.setenv("KSADK_SKILL_RUNTIME_BACKEND", "disabled")
- monkeypatch.setenv("KSADK_SKILL_SPACE_IDS", "ss-user")
- monkeypatch.setenv("KSADK_PUBLIC_SKILL_SPACE_IDS", "ss-public")
- monkeypatch.setattr(
- "ksadk.skills.tool_defs.load_remote_skill_manifests",
- fake_load_remote_skill_manifests,
- raising=False,
- )
-
- runner = ADKRunner(detection, str(tmp_path))
- runner.load_agent()
-
- assert "demo-skill" in runner._agent.instruction
- assert "Create spreadsheet reports" in runner._agent.instruction
- assert "skill_names" in runner._agent.instruction
-
-
-def test_remote_skill_manifest_filters_public_skills_with_allowlist(monkeypatch):
- from ksadk.skills.tool_defs import load_remote_skill_manifests
-
- class FakeClient:
- def __init__(self, **kwargs):
- pass
-
- def list_skills_by_space_id(self, space_id):
- from ksadk.skills.models import SkillListResponse
-
- assert space_id == "ss-user"
- payload = {
- "Data": {
- "Skills": [
- {
- "SkillId": "sk-demo",
- "VersionId": "sv-demo-v1",
- "Version": "v1",
- "Name": "demo-skill",
- "Status": "Active",
- }
- ]
- }
- }
- return SkillListResponse.from_payload(payload, space_id=space_id)
-
- def list_available_premade_skills(self):
- from ksadk.skills.models import SkillListResponse
-
- payload = {
- "Data": {
- "Skills": [
- {
- "SkillId": "premade-pdf",
- "VersionId": "",
- "Version": "",
- "Name": "pdf",
- "Status": "AVAILABLE",
- },
- {
- "SkillId": "premade-weather",
- "VersionId": "",
- "Version": "",
- "Name": "weather",
- "Status": "AVAILABLE",
- },
- ]
- }
- }
- return SkillListResponse.from_payload(payload, space_id="public")
-
- monkeypatch.setenv("KSADK_SKILL_SERVICE_URL", "https://skill.example/api/v1")
- monkeypatch.setenv("KSADK_SKILL_SPACE_IDS", "ss-user")
- monkeypatch.setenv("KSADK_PUBLIC_SKILL_SPACE_IDS", "ss-public")
- monkeypatch.setenv("KSADK_PUBLIC_SKILL_ALLOWLIST", "weather")
- monkeypatch.setattr(
- "ksadk.skills.tool_defs.SkillServiceClient",
- FakeClient,
- )
-
- manifests = load_remote_skill_manifests()
-
- assert [item["name"] for item in manifests] == ["demo-skill", "weather"]
-
-
-def test_execute_skills_passes_public_skill_spaces_through_env(monkeypatch):
- from ksadk.skills.tool_defs import build_execute_skills_tool
-
- calls = []
-
- class FakeBackend:
- def run_workflow(self, workflow_prompt, **kwargs):
- calls.append((workflow_prompt, kwargs))
- return SimpleNamespace(to_dict=lambda: {"status": "ok"})
-
- monkeypatch.setenv("KSADK_SKILL_SPACE_IDS", "ss-user")
- monkeypatch.setenv("KSADK_PUBLIC_SKILL_SPACE_IDS", "ss-public-a, ss-public-b")
-
- tool = build_execute_skills_tool(backend=FakeBackend(), session_id="sess-1")
- result = tool("use demo-skill")
-
- assert result == {"status": "ok"}
- assert calls[0][1]["skill_space_ids"] == ["ss-user"]
- assert calls[0][1]["env"]["KSADK_PUBLIC_SKILL_SPACE_IDS"] == "ss-public-a, ss-public-b"
-
-
-def test_adk_runner_auto_mode_prefers_configured_runtime_backend_over_cache_dir(monkeypatch, tmp_path):
- ADKRunner = _patch_runner(monkeypatch)
- cache_dir = tmp_path / "cache"
- cache_dir.mkdir()
- detection = _write_adk_project(
- tmp_path,
- """
- class DemoAgent:
- def __init__(self):
- self.name = "demo-agent"
- self.tools = []
- self.instruction = "Be helpful."
-
- root_agent = DemoAgent()
- """,
- )
- monkeypatch.delenv("KSADK_SKILLS_MODE", raising=False)
- monkeypatch.setenv("KSADK_SKILL_RUNTIME_BACKEND", "disabled")
- monkeypatch.setenv("KSADK_SKILL_CACHE_DIR", str(cache_dir))
-
- runner = ADKRunner(detection, str(tmp_path))
- runner.load_agent()
- assert _tool_names(runner._agent.tools) == []
-
- monkeypatch.setenv("KSADK_SKILL_RUNTIME_BACKEND", "e2b")
- monkeypatch.setenv("KSADK_SKILL_RUNTIME_TEMPLATE_ID", "tpl-1")
- monkeypatch.setattr(
- "ksadk.skills.runtime.backends.e2b.E2BSkillRuntimeBackend.from_env",
- lambda: type("Backend", (), {"run_workflow": lambda self, *args, **kwargs: None})(),
- )
-
- runner = ADKRunner(detection, str(tmp_path))
- runner.load_agent()
- assert _tool_names(runner._agent.tools) == ["execute_skills"]
-
-
-def test_adk_runner_auto_mode_uses_generic_sandbox_template(monkeypatch, tmp_path):
- ADKRunner = _patch_runner(monkeypatch)
- detection = _write_adk_project(
- tmp_path,
- """
- class DemoAgent:
- def __init__(self):
- self.name = "demo-agent"
- self.tools = []
- self.instruction = "Be helpful."
-
- root_agent = DemoAgent()
- """,
- )
- monkeypatch.delenv("KSADK_SKILLS_MODE", raising=False)
- monkeypatch.delenv("KSADK_SKILL_RUNTIME_BACKEND", raising=False)
- monkeypatch.delenv("KSADK_SKILL_RUNTIME_TEMPLATE_ID", raising=False)
- monkeypatch.setenv("KSADK_SANDBOX_TEMPLATE_ID", "tpl-aio")
- monkeypatch.setattr(
- "ksadk.skills.runtime.backends.e2b.E2BSkillRuntimeBackend.from_env",
- lambda: type("Backend", (), {"run_workflow": lambda self, *args, **kwargs: None})(),
- )
-
- runner = ADKRunner(detection, str(tmp_path))
- runner.load_agent()
-
- assert _tool_names(runner._agent.tools) == ["execute_skills"]
-
-
-def test_adk_runner_auto_mode_respects_explicit_disabled_runtime_backend(monkeypatch, tmp_path):
- ADKRunner = _patch_runner(monkeypatch)
- detection = _write_adk_project(
- tmp_path,
- """
- class DemoAgent:
- def __init__(self):
- self.name = "demo-agent"
- self.tools = []
- self.instruction = "Be helpful."
-
- root_agent = DemoAgent()
- """,
- )
- monkeypatch.delenv("KSADK_SKILLS_MODE", raising=False)
- monkeypatch.setenv("KSADK_SKILL_RUNTIME_BACKEND", "disabled")
- monkeypatch.setenv("KSADK_SANDBOX_TEMPLATE_ID", "tpl-aio")
-
- runner = ADKRunner(detection, str(tmp_path))
- runner.load_agent()
-
- assert _tool_names(runner._agent.tools) == []
-
-
-def test_adk_runner_no_longer_injects_legacy_sandbox_tools_by_default(monkeypatch, tmp_path):
- ADKRunner = _patch_runner(monkeypatch)
- detection = _write_adk_project(
- tmp_path,
- """
- class DemoAgent:
- def __init__(self):
- self.name = "demo-agent"
- self.tools = []
- self.instruction = "Be helpful."
-
- root_agent = DemoAgent()
- """,
- )
- monkeypatch.delenv("KSADK_SKILLS_MODE", raising=False)
- runner = ADKRunner(detection, str(tmp_path))
- runner.load_agent()
-
- assert "execute_python" not in _tool_names(runner._agent.tools)
- assert "execute_bash" not in _tool_names(runner._agent.tools)
- assert "execute_javascript" not in _tool_names(runner._agent.tools)
-
-
-def test_adk_runner_deduplicates_existing_execute_skills(monkeypatch, tmp_path):
- ADKRunner = _patch_runner(monkeypatch)
- detection = _write_adk_project(
- tmp_path,
- """
- def execute_skills(workflow_prompt: str) -> dict:
- return {"stdout": workflow_prompt}
-
- class DemoAgent:
- def __init__(self):
- self.name = "demo-agent"
- self.tools = [execute_skills]
- self.instruction = "Be helpful."
-
- root_agent = DemoAgent()
- """,
- )
- monkeypatch.setenv("KSADK_SKILLS_MODE", "sandbox")
- monkeypatch.setenv("KSADK_SKILL_RUNTIME_BACKEND", "disabled")
- monkeypatch.setenv("KSADK_SKILL_SPACE_IDS", "ss-1")
-
- runner = ADKRunner(detection, str(tmp_path))
- runner.load_agent()
-
- assert _tool_names(runner._agent.tools).count("execute_skills") == 1
-
-
-def test_adk_runner_injects_local_skills_tool_for_local_mode(monkeypatch, tmp_path):
- ADKRunner = _patch_runner(monkeypatch)
- skill_root = tmp_path / "skills" / "demo-skill"
- skill_root.mkdir(parents=True)
- (skill_root / "SKILL.md").write_text(
- "---\nname: demo-skill\ndescription: Demo\n---\n# Demo\n",
- encoding="utf-8",
- )
- detection = _write_adk_project(
- tmp_path,
- """
- class DemoAgent:
- def __init__(self):
- self.name = "demo-agent"
- self.tools = []
- self.instruction = "Be helpful."
-
- root_agent = DemoAgent()
- """,
- )
- monkeypatch.setenv("KSADK_SKILLS_MODE", "local")
- monkeypatch.setenv("KSADK_LOCAL_SKILLS_DIR", str(tmp_path / "skills"))
-
- runner = ADKRunner(detection, str(tmp_path))
- runner.load_agent()
-
- assert _tool_names(runner._agent.tools) == ["skills_tool"]
diff --git a/tests/skills/test_loader_and_tools.py b/tests/skills/test_loader_and_tools.py
deleted file mode 100644
index 6a603659..00000000
--- a/tests/skills/test_loader_and_tools.py
+++ /dev/null
@@ -1,190 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-import pytest
-
-from ksadk.skills.loader import load_local_skill
-from ksadk.skills.tool_defs import build_execute_skills_tool, build_skills_tool
-from ksadk.skills.runtime import SkillRuntimeResult
-
-
-def test_load_local_skill_reads_frontmatter(tmp_path: Path):
- root = tmp_path / "web-artifacts-builder"
- root.mkdir()
- (root / "SKILL.md").write_text(
- "---\nname: web-artifacts-builder\ndescription: Build artifacts\n---\n# Body\n",
- encoding="utf-8",
- )
-
- skill = load_local_skill(root)
-
- assert skill.name == "web-artifacts-builder"
- assert skill.description == "Build artifacts"
- assert skill.root_dir == root
-
-
-def test_execute_skills_tool_delegates_to_runtime_without_leaking_secret(monkeypatch):
- monkeypatch.setenv("E2B_API_KEY", "secret-token")
- monkeypatch.setenv("KSADK_SKILL_SERVICE_URL", "https://skill.example/api/v1")
- monkeypatch.setenv("KSADK_SKILL_SERVICE_SECRET_KEY", "skill-secret")
-
- class Backend:
- def __init__(self):
- self.calls = []
-
- def run_workflow(self, workflow_prompt: str, **kwargs):
- self.calls.append((workflow_prompt, kwargs))
- return SkillRuntimeResult(
- runtime_id="sbx-1",
- exit_code=0,
- stdout="artifact ready\n",
- stderr="",
- duration_ms=15,
- )
-
- backend = Backend()
- tool = build_execute_skills_tool(backend=backend, skill_space_ids=["ss-1"], session_id="sess-1")
-
- output = tool("build a page")
-
- assert output["stdout"] == "artifact ready\n"
- assert output["runtime_id"] == "sbx-1"
- assert "secret-token" not in repr(output)
- assert backend.calls[0][0] == "build a page"
- assert backend.calls[0][1]["skill_space_ids"] == ["ss-1"]
- assert backend.calls[0][1]["env"]["KSADK_SKILL_SERVICE_URL"] == "https://skill.example/api/v1"
- assert backend.calls[0][1]["env"]["KSADK_SKILL_SERVICE_SECRET_KEY"] == "skill-secret"
- assert "E2B_API_KEY" not in backend.calls[0][1]["env"]
-
-
-def test_execute_skills_tool_passes_explicit_skill_names_to_runtime():
- class Backend:
- def __init__(self):
- self.calls = []
-
- def run_workflow(self, workflow_prompt: str, **kwargs):
- self.calls.append((workflow_prompt, kwargs))
- return SkillRuntimeResult(exit_code=0)
-
- backend = Backend()
- tool = build_execute_skills_tool(backend=backend, skill_space_ids=["ss-1"], session_id="sess-1")
-
- tool("build a page", skill_names=["demo-skill"])
-
- assert backend.calls[0][1]["skill_names"] == ["demo-skill"]
-
-
-def test_execute_skills_tool_maps_ksyun_fallbacks_to_skill_service_env(monkeypatch):
- monkeypatch.delenv("KSADK_SKILL_SERVICE_ACCOUNT_ID", raising=False)
- monkeypatch.delenv("KSADK_SKILL_SERVICE_REGION", raising=False)
- monkeypatch.setenv("KSYUN_ACCOUNT_ID", "2000003485")
- monkeypatch.setenv("KSYUN_REGION", "cn-beijing-6")
- monkeypatch.setenv("KSYUN_ACCESS_KEY", "generic-ak-should-not-cross")
- monkeypatch.setenv("KSYUN_SECRET_KEY", "generic-sk-should-not-cross")
-
- class Backend:
- def __init__(self):
- self.calls = []
-
- def run_workflow(self, workflow_prompt: str, **kwargs):
- self.calls.append((workflow_prompt, kwargs))
- return SkillRuntimeResult(exit_code=0)
-
- backend = Backend()
- tool = build_execute_skills_tool(backend=backend, skill_space_ids=["ss-1"], session_id="sess-1")
-
- tool("build a page")
-
- env = backend.calls[0][1]["env"]
- assert env["KSADK_SKILL_SERVICE_ACCOUNT_ID"] == "2000003485"
- assert env["KSADK_SKILL_SERVICE_REGION"] == "cn-beijing-6"
- assert "KSYUN_ACCESS_KEY" not in env
- assert "KSYUN_SECRET_KEY" not in env
- assert env["KSADK_SKILL_SERVICE_ACCESS_KEY"] == "generic-ak-should-not-cross"
- assert env["KSADK_SKILL_SERVICE_SECRET_KEY"] == "generic-sk-should-not-cross"
-
-
-def test_execute_skills_tool_auto_resolves_skill_service_url_for_runtime(monkeypatch):
- monkeypatch.delenv("KSADK_SKILL_SERVICE_URL", raising=False)
- monkeypatch.delenv("KSADK_SKILL_SERVICE_ENDPOINT", raising=False)
- monkeypatch.delenv("KSADK_SKILL_SERVICE_SCHEME", raising=False)
- monkeypatch.setenv("KSADK_AICP_ENDPOINT_MODE", "internal")
- monkeypatch.setenv("KSYUN_ACCOUNT_ID", "2000003485")
- monkeypatch.setenv("KSYUN_REGION", "cn-beijing-6")
-
- class Backend:
- def __init__(self):
- self.calls = []
-
- def run_workflow(self, workflow_prompt: str, **kwargs):
- self.calls.append((workflow_prompt, kwargs))
- return SkillRuntimeResult(exit_code=0)
-
- backend = Backend()
- tool = build_execute_skills_tool(backend=backend, skill_space_ids=["ss-1"], session_id="sess-1")
-
- tool("build a page")
-
- env = backend.calls[0][1]["env"]
- assert env["KSADK_SKILL_SERVICE_URL"] == "http://aicp.internal.api.ksyun.com"
- assert env["KSADK_SKILL_SERVICE_ACCOUNT_ID"] == "2000003485"
- assert env["KSADK_SKILL_SERVICE_REGION"] == "cn-beijing-6"
-
-
-def test_execute_skills_tool_leaves_auto_endpoint_detection_to_runtime(monkeypatch):
- monkeypatch.delenv("KSADK_SKILL_SERVICE_URL", raising=False)
- monkeypatch.delenv("KSADK_SKILL_SERVICE_ENDPOINT", raising=False)
- monkeypatch.delenv("KSADK_SKILL_SERVICE_SCHEME", raising=False)
- monkeypatch.setenv("KSADK_AICP_ENDPOINT_MODE", "auto")
-
- class Backend:
- def __init__(self):
- self.calls = []
-
- def run_workflow(self, workflow_prompt: str, **kwargs):
- self.calls.append((workflow_prompt, kwargs))
- return SkillRuntimeResult(exit_code=0)
-
- backend = Backend()
- tool = build_execute_skills_tool(backend=backend, skill_space_ids=["ss-1"], session_id="sess-1")
-
- tool("build a page")
-
- assert "KSADK_SKILL_SERVICE_URL" not in backend.calls[0][1]["env"]
-
-
-def test_execute_skills_tool_passes_public_skill_allowlist_to_runtime(monkeypatch):
- monkeypatch.setenv("KSADK_PUBLIC_SKILL_ALLOWLIST", "pdf,weather")
-
- class Backend:
- def __init__(self):
- self.calls = []
-
- def run_workflow(self, workflow_prompt: str, **kwargs):
- self.calls.append((workflow_prompt, kwargs))
- return SkillRuntimeResult(exit_code=0)
-
- backend = Backend()
- tool = build_execute_skills_tool(backend=backend, skill_space_ids=["ss-1"], session_id="sess-1")
-
- tool("build a page")
-
- assert backend.calls[0][1]["env"]["KSADK_PUBLIC_SKILL_ALLOWLIST"] == "pdf,weather"
-
-
-def test_skills_tool_reports_loaded_local_skills(tmp_path: Path):
- root = tmp_path / "demo-skill"
- root.mkdir()
- (root / "SKILL.md").write_text(
- "---\nname: demo-skill\ndescription: Demo skill\n---\n# Demo Body\nUse this carefully.\n",
- encoding="utf-8",
- )
-
- tool = build_skills_tool([load_local_skill(root)])
-
- result = tool("list")
-
- assert result["skills"][0]["name"] == "demo-skill"
- assert result["skills"][0]["description"] == "Demo skill"
- assert "Use this carefully" in result["skills"][0]["body"]
diff --git a/tests/skills/test_package_store.py b/tests/skills/test_package_store.py
deleted file mode 100644
index 6d27ad2d..00000000
--- a/tests/skills/test_package_store.py
+++ /dev/null
@@ -1,73 +0,0 @@
-from __future__ import annotations
-
-import hashlib
-import io
-import zipfile
-from pathlib import Path
-
-import pytest
-
-from ksadk.skills.models import ContentHash, SkillRef
-from ksadk.skills.package_store import PackageStore, SkillPackageError
-
-
-def _make_zip(entries: dict[str, str | bytes]) -> bytes:
- buffer = io.BytesIO()
- with zipfile.ZipFile(buffer, "w") as archive:
- for name, content in entries.items():
- archive.writestr(name, content)
- return buffer.getvalue()
-
-
-def _ref(digest: str) -> SkillRef:
- return SkillRef(
- skill_id="sk-web",
- version_id="sv-web-v1",
- version="v1",
- name="web-artifacts-builder",
- content_hash=ContentHash.parse(f"sha256:{digest}"),
- )
-
-
-def test_package_store_verifies_hash_and_extracts_skill_root(tmp_path: Path):
- payload = _make_zip({"web-artifacts-builder/SKILL.md": "---\nname: web-artifacts-builder\n---\n# Skill\n"})
- digest = hashlib.sha256(payload).hexdigest()
- store = PackageStore(cache_dir=tmp_path)
-
- package = store.store_archive(_ref(digest), payload)
-
- assert package.root_dir.name == "web-artifacts-builder"
- assert (package.root_dir / "SKILL.md").exists()
- assert package.archive_path.exists()
- assert package.cache_hit is False
-
- second = store.store_archive(_ref(digest), payload)
- assert second.cache_hit is True
- assert second.root_dir == package.root_dir
-
-
-def test_package_store_rejects_hash_mismatch(tmp_path: Path):
- payload = _make_zip({"skill/SKILL.md": "# Skill\n"})
- store = PackageStore(cache_dir=tmp_path)
-
- with pytest.raises(SkillPackageError, match="ContentHash mismatch"):
- store.store_archive(_ref("0" * 64), payload)
-
-
-def test_package_store_rejects_zip_slip(tmp_path: Path):
- payload = _make_zip({"../escape.txt": "nope", "skill/SKILL.md": "# Skill\n"})
- digest = hashlib.sha256(payload).hexdigest()
- store = PackageStore(cache_dir=tmp_path)
-
- with pytest.raises(SkillPackageError, match="unsafe zip member"):
- store.store_archive(_ref(digest), payload)
-
-
-def test_package_store_returns_none_for_corrupted_cache(tmp_path: Path):
- payload = _make_zip({"skill/SKILL.md": "# Skill\n"})
- digest = hashlib.sha256(payload).hexdigest()
- store = PackageStore(cache_dir=tmp_path)
- package = store.store_archive(_ref(digest), payload)
- package.archive_path.write_bytes(b"corrupted")
-
- assert store.get_cached(_ref(digest)) is None
diff --git a/tests/skills/test_runtime.py b/tests/skills/test_runtime.py
deleted file mode 100644
index 7921a32d..00000000
--- a/tests/skills/test_runtime.py
+++ /dev/null
@@ -1,332 +0,0 @@
-from __future__ import annotations
-
-import json
-import subprocess
-import sys
-from pathlib import Path
-
-import pytest
-
-from ksadk.skills.runtime import (
- SkillRuntimeError,
- SkillRuntimeResult,
- SkillWorkflowRequest,
- create_skill_runtime_backend,
-)
-from ksadk.skills.runtime.backends.e2b import E2BSkillRuntimeBackend
-from ksadk.skills.runtime.backends.local import LocalProcessSkillRuntimeBackend
-
-
-def test_runtime_factory_creates_disabled_backend_by_default(monkeypatch):
- monkeypatch.delenv("KSADK_SKILL_RUNTIME_BACKEND", raising=False)
-
- backend = create_skill_runtime_backend()
-
- with pytest.raises(SkillRuntimeError, match="disabled"):
- backend.run_workflow("hello", skill_space_ids=["ss-1"], session_id="s1")
-
-
-def test_skill_workflow_request_is_public_runtime_protocol():
- request = SkillWorkflowRequest(workflow_prompt="build", skill_names=["demo-skill"])
-
- assert request.workflow_prompt == "build"
- assert request.skill_names == ["demo-skill"]
-
-
-def test_runtime_factory_creates_local_process_backend(monkeypatch, tmp_path: Path):
- agent = tmp_path / "agent.py"
- agent.write_text("print('agent')", encoding="utf-8")
- monkeypatch.setenv("KSADK_SKILL_RUNTIME_BACKEND", "local_process")
- monkeypatch.setenv("KSADK_SKILL_RUNTIME_AGENT_PATH", str(agent))
-
- backend = create_skill_runtime_backend()
-
- assert isinstance(backend, LocalProcessSkillRuntimeBackend)
-
-
-def test_runtime_factory_auto_uses_e2b_when_generic_sandbox_template_is_configured(monkeypatch):
- sentinel = object()
- monkeypatch.delenv("KSADK_SKILL_RUNTIME_BACKEND", raising=False)
- monkeypatch.setenv("KSADK_SANDBOX_TEMPLATE_ID", "tpl-aio")
- monkeypatch.setattr(
- "ksadk.skills.runtime.factory.E2BSkillRuntimeBackend.from_env",
- lambda: sentinel,
- )
-
- backend = create_skill_runtime_backend()
-
- assert backend is sentinel
-
-
-def test_e2b_skill_runtime_backend_from_env_prefers_generic_sandbox_vars(monkeypatch):
- monkeypatch.setenv("KSADK_SANDBOX_TEMPLATE_ID", "tpl-aio")
- monkeypatch.setenv("KSADK_SKILL_RUNTIME_TEMPLATE_ID", "tpl-legacy")
- monkeypatch.setenv("KSADK_SANDBOX_TIMEOUT", "321")
- monkeypatch.setenv("KSADK_SKILL_RUNTIME_TIMEOUT", "123")
- monkeypatch.setenv("KSADK_SANDBOX_ALLOW_INTERNET_ACCESS", "false")
- monkeypatch.setattr("e2b.Sandbox", object)
-
- backend = E2BSkillRuntimeBackend.from_env()
-
- assert backend.template_id == "tpl-aio"
- assert backend.timeout == 321
- assert backend.allow_internet_access is False
-
-
-def test_e2b_skill_runtime_backend_error_mentions_generic_template_var():
- with pytest.raises(SkillRuntimeError, match="KSADK_SANDBOX_TEMPLATE_ID"):
- E2BSkillRuntimeBackend(template_id="")
-
-
-def test_e2b_backend_uses_native_env_and_always_kills(monkeypatch):
- calls: list[tuple[str, object]] = []
-
- class FakeResult:
- stdout = 'ok\nworkflow_result={"output_files":["/tmp/bundle.html"],"status":"ok"}\n'
- stderr = ""
- exit_code = 0
-
- class FakeCommands:
- def run(self, command: str, **kwargs):
- calls.append(("run", command))
- calls.append(("run_kwargs", kwargs))
- return FakeResult()
-
- class FakeFiles:
- def write(self, path, data):
- calls.append(("file_write", (path, data)))
-
- class FakeSandbox:
- sandbox_id = "sbx-123"
-
- def __init__(self):
- self.files = FakeFiles()
- self.commands = FakeCommands()
-
- @classmethod
- def create(cls, **kwargs):
- calls.append(("create", kwargs))
- return cls()
-
- def kill(self):
- calls.append(("kill", self.sandbox_id))
-
- backend = E2BSkillRuntimeBackend(sandbox_cls=FakeSandbox, template_id="tpl-1", timeout=123)
-
- result = backend.run_workflow(
- "build artifact",
- skill_space_ids=["ss-1"],
- skill_names=["demo-skill"],
- session_id="sess-1",
- )
-
- assert result == SkillRuntimeResult(
- runtime_id="sbx-123",
- exit_code=0,
- stdout='ok\nworkflow_result={"output_files":["/tmp/bundle.html"],"status":"ok"}\n',
- stderr="",
- duration_ms=result.duration_ms,
- output_files=["/tmp/bundle.html"],
- )
- assert calls[0] == (
- "create",
- {
- "template": "tpl-1",
- "timeout": 123,
- "metadata": {
- "runtime": "ksadk",
- "sandbox_type": "aio",
- "component": "skill-runtime",
- "session_id": "sess-1",
- },
- "envs": {
- "KSADK_SKILL_SPACE_IDS": "ss-1",
- "SKILL_SPACE_ID": "ss-1",
- "KSADK_SELECTED_SKILL_NAMES": "demo-skill",
- },
- "allow_internet_access": True,
- },
- )
- assert (
- "run_kwargs",
- {
- "timeout": 900,
- "envs": {
- "KSADK_SKILL_SPACE_IDS": "ss-1",
- "SKILL_SPACE_ID": "ss-1",
- "KSADK_SELECTED_SKILL_NAMES": "demo-skill",
- },
- },
- ) in calls
- request_write = next(
- value
- for name, value in calls
- if name == "file_write" and value[0] == "/tmp/ksadk-workflow-request.json"
- )
- assert request_write[0] == "/tmp/ksadk-workflow-request.json"
- assert json.loads(request_write[1].decode("utf-8")) == {
- "workflow_prompt": "build artifact",
- "skill_names": ["demo-skill"],
- }
- assert calls[-1] == ("kill", "sbx-123")
-
-
-def test_e2b_backend_preserves_public_skill_space_env(monkeypatch):
- monkeypatch.setenv("KSADK_PUBLIC_SKILL_SPACE_IDS", "ss-public")
- calls: list[tuple[str, object]] = []
-
- class FakeResult:
- stdout = "ok\n"
- stderr = ""
- exit_code = 0
-
- class FakeCommands:
- def run(self, command: str, **kwargs):
- return FakeResult()
-
- class FakeFiles:
- def write(self, path, data):
- pass
-
- class FakeSandbox:
- sandbox_id = "sbx-123"
-
- def __init__(self):
- self.files = FakeFiles()
- self.commands = FakeCommands()
-
- @classmethod
- def create(cls, **kwargs):
- calls.append(("create", kwargs))
- return cls()
-
- def kill(self):
- pass
-
- backend = E2BSkillRuntimeBackend(sandbox_cls=FakeSandbox, template_id="tpl-1")
-
- backend.run_workflow("build artifact", skill_space_ids=["ss-user"], session_id="sess-1")
-
- envs = calls[0][1]["envs"]
- assert envs["KSADK_SKILL_SPACE_IDS"] == "ss-user"
- assert envs["SKILL_SPACE_ID"] == "ss-user"
- assert envs["KSADK_PUBLIC_SKILL_SPACE_IDS"] == "ss-public"
-
-
-def test_e2b_backend_redacts_secret_from_errors(monkeypatch):
- monkeypatch.setenv("E2B_API_KEY", "super-secret-token")
- monkeypatch.setenv("KSADK_SKILL_SERVICE_TOKEN", "skill-service-token")
- monkeypatch.setenv("KSADK_SKILL_SERVICE_SECRET_KEY", "skill-service-secret")
-
- class FakeSandbox:
- @classmethod
- def create(cls, **kwargs):
- raise RuntimeError(
- "failed with super-secret-token and skill-service-token and skill-service-secret"
- )
-
- backend = E2BSkillRuntimeBackend(sandbox_cls=FakeSandbox, template_id="tpl-1")
-
- result = backend.run_workflow("x", skill_space_ids=["ss-1"], session_id="sess-1")
-
- assert result.exit_code is None
- assert result.error_type == "RuntimeError"
- assert "super-secret-token" not in result.error_message
- assert "skill-service-token" not in result.error_message
- assert "skill-service-secret" not in result.error_message
- assert "[REDACTED]" in result.error_message
-
-
-def test_e2b_backend_writes_request_file_instead_of_shell_quoting_long_prompt():
- calls: list[tuple[str, object]] = []
-
- class FakeResult:
- stdout = "ok\n"
- stderr = ""
- exit_code = 0
-
- class FakeFiles:
- def write(self, path, data):
- calls.append(("file_write", (path, data)))
-
- class FakeCommands:
- def run(self, command: str, **kwargs):
- calls.append(("run", command))
- return FakeResult()
-
- class FakeSandbox:
- sandbox_id = "sbx-123"
-
- def __init__(self):
- self.files = FakeFiles()
- self.commands = FakeCommands()
-
- @classmethod
- def create(cls, **kwargs):
- return cls()
-
- def kill(self):
- calls.append(("kill", self.sandbox_id))
-
- backend = E2BSkillRuntimeBackend(sandbox_cls=FakeSandbox, template_id="tpl-1")
-
- backend.run_workflow(
- "hello 'quoted'",
- skill_space_ids=["ss-1"],
- skill_names=["demo-skill"],
- session_id="sess-1",
- )
-
- request_write = next(
- value
- for name, value in calls
- if name == "file_write" and value[0] == "/tmp/ksadk-workflow-request.json"
- )
- request_path, request_bytes = request_write
- assert request_path == "/tmp/ksadk-workflow-request.json"
- assert json.loads(request_bytes.decode("utf-8")) == {
- "workflow_prompt": "hello 'quoted'",
- "skill_names": ["demo-skill"],
- }
- run_command = next(
- value for name, value in calls if name == "run" and "/home/ksadk/agent.py" in value
- )
- assert (
- run_command
- == "python -u /home/ksadk/agent.py --request-file /tmp/ksadk-workflow-request.json"
- )
-
-
-def test_local_process_backend_writes_request_file_envelope(monkeypatch, tmp_path: Path):
- calls: list[dict[str, object]] = []
- agent = tmp_path / "agent.py"
- agent.write_text("print('agent')", encoding="utf-8")
-
- def fake_run(args, **kwargs):
- request_path = Path(args[-1])
- calls.append(
- {
- "args": args,
- "request": json.loads(request_path.read_text(encoding="utf-8")),
- "env": kwargs["env"],
- }
- )
- return subprocess.CompletedProcess(args=args, returncode=0, stdout="ok\n", stderr="")
-
- monkeypatch.setattr("ksadk.skills.runtime.backends.local.subprocess.run", fake_run)
- backend = LocalProcessSkillRuntimeBackend(agent_path=agent)
-
- backend.run_workflow(
- "build artifact",
- skill_space_ids=["ss-1"],
- skill_names=["demo-skill"],
- session_id="sess-1",
- )
-
- assert calls[0]["args"][:3] == [sys.executable, "-u", str(agent)]
- assert calls[0]["args"][3] == "--request-file"
- assert calls[0]["request"] == {
- "workflow_prompt": "build artifact",
- "skill_names": ["demo-skill"],
- }
- assert calls[0]["env"]["KSADK_SELECTED_SKILL_NAMES"] == "demo-skill"
diff --git a/tests/skills/test_runtime_agent.py b/tests/skills/test_runtime_agent.py
deleted file mode 100644
index e0ed34fb..00000000
--- a/tests/skills/test_runtime_agent.py
+++ /dev/null
@@ -1,744 +0,0 @@
-from __future__ import annotations
-
-import hashlib
-import io
-import json
-import subprocess
-import zipfile
-from pathlib import Path
-
-import httpx
-
-from ksadk.skills.loader import load_local_skill
-from ksadk.skills.models import SkillRef
-from ksadk.skills.runtime.registry import select_remote_skill_refs
-from ksadk.skills.runtime import agent as runtime_agent
-from ksadk.skills.runtime.agent import run_agent
-
-
-def _zip_bytes(skill_name: str = "demo-skill") -> bytes:
- buf = io.BytesIO()
- with zipfile.ZipFile(buf, "w") as archive:
- archive.writestr(f"{skill_name}/SKILL.md", f"---\nname: {skill_name}\ndescription: Demo\n---\n# Demo\n")
- return buf.getvalue()
-
-
-def test_runtime_agent_loads_active_skills_from_service(monkeypatch, tmp_path: Path, capsys):
- archive = _zip_bytes()
- digest = hashlib.sha256(archive).hexdigest()
-
- def handler(request: httpx.Request) -> httpx.Response:
- if request.url.path.endswith("/ListSkillsBySpaceId"):
- return httpx.Response(
- 200,
- json={
- "Data": {
- "SkillSpaceId": "ss-1",
- "Skills": [
- {
- "SkillId": "sk-demo",
- "VersionId": "sv-demo-v1",
- "Version": "v1",
- "Name": "demo-skill",
- "Status": "Active",
- "ContentHash": f"sha256:{digest}",
- }
- ],
- }
- },
- )
- if request.url.path.endswith("/GetSkillDownloadUrl"):
- return httpx.Response(200, json={"Data": {"DownloadUrl": "https://download.example/demo.zip"}})
- if str(request.url) == "https://download.example/demo.zip":
- return httpx.Response(200, content=archive)
- return httpx.Response(404)
-
- monkeypatch.setenv("KSADK_SKILL_SPACE_IDS", "ss-1")
- monkeypatch.setenv("KSADK_SKILL_SERVICE_URL", "https://skill.example/api/v1")
- monkeypatch.setenv("KSADK_SKILL_CACHE_DIR", str(tmp_path / "cache"))
-
- code = run_agent(
- ["使用 demo-skill build something"],
- service_transport=httpx.MockTransport(handler),
- )
-
- out = capsys.readouterr().out
- assert code == 0
- assert "workflow=使用 demo-skill build something" in out
- assert "loaded_skills=demo-skill" in out
- assert (tmp_path / "cache" / "sk-demo__sv-demo-v1" / "extracted" / "demo-skill" / "SKILL.md").exists()
-
-
-def test_runtime_selects_remote_skill_by_alias_tag_and_description():
- skills = [
- SkillRef(
- skill_id="sk-report",
- version_id="v1",
- version="1",
- name="report-writer",
- description="Write research reports",
- aliases=("研究报告",),
- tags=("research",),
- ),
- SkillRef(
- skill_id="sk-web",
- version_id="v1",
- version="1",
- name="web-builder",
- description="Build web pages",
- tags=("frontend",),
- ),
- ]
-
- assert [skill.name for skill in select_remote_skill_refs(skills, "帮我生成一份研究报告")] == ["report-writer"]
- assert [skill.name for skill in select_remote_skill_refs(skills, "frontend artifact")] == ["web-builder"]
- assert [skill.name for skill in select_remote_skill_refs(skills, "write a research report")] == ["report-writer"]
-
-
-def test_runtime_agent_auto_resolves_aicp_skill_service_when_url_unset(
- monkeypatch,
- tmp_path: Path,
- capsys,
-):
- archive = _zip_bytes()
- digest = hashlib.sha256(archive).hexdigest()
- seen_urls: list[str] = []
-
- def handler(request: httpx.Request) -> httpx.Response:
- seen_urls.append(str(request.url))
- if request.url.params.get("Action") == "ListSkillsBySpaceId":
- return httpx.Response(
- 200,
- json={
- "Data": {
- "SkillSpaceId": "ss-1",
- "Skills": [
- {
- "SkillId": "sk-demo",
- "VersionId": "sv-demo-v1",
- "Version": "v1",
- "Name": "demo-skill",
- "Status": "Active",
- "ContentHash": f"sha256:{digest}",
- }
- ],
- }
- },
- )
- if request.url.params.get("Action") == "GetSkillDownloadUrl":
- return httpx.Response(200, json={"Data": {"DownloadUrl": "https://download.example/demo.zip"}})
- if str(request.url) == "https://download.example/demo.zip":
- return httpx.Response(200, content=archive)
- return httpx.Response(404)
-
- monkeypatch.setenv("KSADK_SKILL_SPACE_IDS", "ss-1")
- monkeypatch.delenv("KSADK_SKILL_SERVICE_URL", raising=False)
- monkeypatch.delenv("KSADK_SKILL_SERVICE_ENDPOINT", raising=False)
- monkeypatch.delenv("KSADK_SKILL_SERVICE_SCHEME", raising=False)
- monkeypatch.setenv("KSADK_AICP_ENDPOINT_MODE", "internal")
- monkeypatch.setenv("KSADK_SKILL_CACHE_DIR", str(tmp_path / "cache"))
-
- code = run_agent(
- ["使用 demo-skill build something"],
- service_transport=httpx.MockTransport(handler),
- )
-
- out = capsys.readouterr().out
- assert code == 0
- assert "loaded_skills=demo-skill" in out
- assert seen_urls[0].startswith(
- "http://aicp.internal.api.ksyun.com/?Action=ListSkillsBySpaceId&Version=2024-06-12"
- )
-
-
-def test_runtime_agent_downloads_only_prompted_remote_skill(monkeypatch, tmp_path: Path, capsys):
- demo_archive = _zip_bytes("demo-skill")
- unused_archive = _zip_bytes("unused-skill")
- demo_digest = hashlib.sha256(demo_archive).hexdigest()
- unused_digest = hashlib.sha256(unused_archive).hexdigest()
- download_urls: list[str] = []
-
- def handler(request: httpx.Request) -> httpx.Response:
- if request.url.path.endswith("/ListSkillsBySpaceId"):
- return httpx.Response(
- 200,
- json={
- "Data": {
- "SkillSpaceId": "ss-1",
- "Skills": [
- {
- "SkillId": "sk-demo",
- "VersionId": "sv-demo-v1",
- "Version": "v1",
- "Name": "demo-skill",
- "Status": "Active",
- "ContentHash": f"sha256:{demo_digest}",
- },
- {
- "SkillId": "sk-unused",
- "VersionId": "sv-unused-v1",
- "Version": "v1",
- "Name": "unused-skill",
- "Status": "Active",
- "ContentHash": f"sha256:{unused_digest}",
- },
- ],
- }
- },
- )
- if request.url.path.endswith("/GetSkillDownloadUrl"):
- skill_id = request.url.params.get("SkillId")
- return httpx.Response(200, json={"Data": {"DownloadUrl": f"https://download.example/{skill_id}.zip"}})
- if str(request.url) == "https://download.example/sk-demo.zip":
- download_urls.append(str(request.url))
- return httpx.Response(200, content=demo_archive)
- if str(request.url) == "https://download.example/sk-unused.zip":
- download_urls.append(str(request.url))
- return httpx.Response(200, content=unused_archive)
- return httpx.Response(404)
-
- monkeypatch.setenv("KSADK_SKILL_SPACE_IDS", "ss-1")
- monkeypatch.setenv("KSADK_SKILL_SERVICE_URL", "https://skill.example/api/v1")
- monkeypatch.setenv("KSADK_SKILL_CACHE_DIR", str(tmp_path / "cache"))
-
- code = run_agent(
- ["请使用 demo-skill 处理这个任务"],
- service_transport=httpx.MockTransport(handler),
- )
-
- out = capsys.readouterr().out
- assert code == 0
- assert "loaded_skills=demo-skill" in out
- assert download_urls == ["https://download.example/sk-demo.zip"]
- assert (tmp_path / "cache" / "sk-demo__sv-demo-v1" / "extracted" / "demo-skill" / "SKILL.md").exists()
- assert not (tmp_path / "cache" / "sk-unused__sv-unused-v1").exists()
-
-
-def test_runtime_agent_downloads_explicit_remote_skill_even_when_prompt_omits_name(
- monkeypatch,
- tmp_path: Path,
- capsys,
-):
- demo_archive = _zip_bytes("demo-skill")
- unused_archive = _zip_bytes("unused-skill")
- demo_digest = hashlib.sha256(demo_archive).hexdigest()
- unused_digest = hashlib.sha256(unused_archive).hexdigest()
- download_urls: list[str] = []
-
- def handler(request: httpx.Request) -> httpx.Response:
- if request.url.path.endswith("/ListSkillsBySpaceId"):
- return httpx.Response(
- 200,
- json={
- "Data": {
- "SkillSpaceId": "ss-1",
- "Skills": [
- {
- "SkillId": "sk-demo",
- "VersionId": "sv-demo-v1",
- "Version": "v1",
- "Name": "demo-skill",
- "Status": "Active",
- "ContentHash": f"sha256:{demo_digest}",
- },
- {
- "SkillId": "sk-unused",
- "VersionId": "sv-unused-v1",
- "Version": "v1",
- "Name": "unused-skill",
- "Status": "Active",
- "ContentHash": f"sha256:{unused_digest}",
- },
- ],
- }
- },
- )
- if request.url.path.endswith("/GetSkillDownloadUrl"):
- skill_id = request.url.params.get("SkillId")
- return httpx.Response(200, json={"Data": {"DownloadUrl": f"https://download.example/{skill_id}.zip"}})
- if str(request.url) == "https://download.example/sk-demo.zip":
- download_urls.append(str(request.url))
- return httpx.Response(200, content=demo_archive)
- if str(request.url) == "https://download.example/sk-unused.zip":
- download_urls.append(str(request.url))
- return httpx.Response(200, content=unused_archive)
- return httpx.Response(404)
-
- monkeypatch.setenv("KSADK_SKILL_SPACE_IDS", "ss-1")
- monkeypatch.setenv("KSADK_SKILL_SERVICE_URL", "https://skill.example/api/v1")
- monkeypatch.setenv("KSADK_SKILL_CACHE_DIR", str(tmp_path / "cache"))
- monkeypatch.setenv("KSADK_SELECTED_SKILL_NAMES", "demo-skill")
-
- code = run_agent(
- ["请处理这个任务"],
- service_transport=httpx.MockTransport(handler),
- )
-
- out = capsys.readouterr().out
- assert code == 0
- assert "loaded_skills=demo-skill" in out
- assert download_urls == ["https://download.example/sk-demo.zip"]
- assert (tmp_path / "cache" / "sk-demo__sv-demo-v1" / "extracted" / "demo-skill" / "SKILL.md").exists()
- assert not (tmp_path / "cache" / "sk-unused__sv-unused-v1").exists()
-
-
-def test_runtime_agent_loads_all_public_skills_without_allowlist(monkeypatch, tmp_path: Path, capsys):
- pdf_archive = _zip_bytes("pdf")
- weather_archive = _zip_bytes("weather")
- pdf_digest = hashlib.sha256(pdf_archive).hexdigest()
- weather_digest = hashlib.sha256(weather_archive).hexdigest()
- download_urls: list[str] = []
-
- def handler(request: httpx.Request) -> httpx.Response:
- if request.url.path.endswith("/ListAvailablePremadeSkills"):
- return httpx.Response(
- 200,
- json={
- "Data": {
- "SkillSpaceId": "ss-public",
- "Skills": [
- {
- "SkillId": "premade-pdf",
- "VersionId": "",
- "Version": "",
- "Name": "pdf",
- "Status": "AVAILABLE",
- "ContentHash": pdf_digest,
- },
- {
- "SkillId": "premade-weather",
- "VersionId": "",
- "Version": "",
- "Name": "weather",
- "Status": "AVAILABLE",
- "ContentHash": weather_digest,
- },
- ],
- }
- },
- )
- if request.url.path.endswith("/GetPremadeSkillDownloadUrl"):
- skill_id = request.url.params.get("SkillId")
- return httpx.Response(200, json={"Data": {"DownloadUrl": f"https://download.example/{skill_id}.zip"}})
- if str(request.url) == "https://download.example/premade-pdf.zip":
- download_urls.append(str(request.url))
- return httpx.Response(200, content=pdf_archive)
- if str(request.url) == "https://download.example/premade-weather.zip":
- download_urls.append(str(request.url))
- return httpx.Response(200, content=weather_archive)
- return httpx.Response(404)
-
- monkeypatch.setenv("KSADK_PUBLIC_SKILL_SPACE_IDS", "ss-public")
- monkeypatch.setenv("KSADK_SKILL_SERVICE_URL", "https://skill.example/api/v1")
- monkeypatch.setenv("KSADK_SKILL_CACHE_DIR", str(tmp_path / "cache"))
-
- code = run_agent(["请处理这个任务"], service_transport=httpx.MockTransport(handler))
-
- out = capsys.readouterr().out
- assert code == 0
- assert "loaded_skills=pdf,weather" in out
- assert download_urls == [
- "https://download.example/premade-pdf.zip",
- "https://download.example/premade-weather.zip",
- ]
- assert (tmp_path / "cache" / f"premade-pdf__{pdf_digest}" / "extracted" / "pdf" / "SKILL.md").exists()
- assert (
- tmp_path
- / "cache"
- / f"premade-weather__{weather_digest}"
- / "extracted"
- / "weather"
- / "SKILL.md"
- ).exists()
-
-
-def test_runtime_agent_filters_public_skills_with_allowlist(monkeypatch, tmp_path: Path, capsys):
- pdf_archive = _zip_bytes("pdf")
- weather_archive = _zip_bytes("weather")
- pdf_digest = hashlib.sha256(pdf_archive).hexdigest()
- weather_digest = hashlib.sha256(weather_archive).hexdigest()
- download_urls: list[str] = []
-
- def handler(request: httpx.Request) -> httpx.Response:
- if request.url.path.endswith("/ListAvailablePremadeSkills"):
- return httpx.Response(
- 200,
- json={
- "Data": {
- "SkillSpaceId": "ss-public",
- "Skills": [
- {
- "SkillId": "premade-pdf",
- "VersionId": "",
- "Version": "",
- "Name": "pdf",
- "Status": "AVAILABLE",
- "ContentHash": pdf_digest,
- },
- {
- "SkillId": "premade-weather",
- "VersionId": "",
- "Version": "",
- "Name": "weather",
- "Status": "AVAILABLE",
- "ContentHash": weather_digest,
- },
- ],
- }
- },
- )
- if request.url.path.endswith("/GetPremadeSkillDownloadUrl"):
- skill_id = request.url.params.get("SkillId")
- return httpx.Response(200, json={"Data": {"DownloadUrl": f"https://download.example/{skill_id}.zip"}})
- if str(request.url) == "https://download.example/premade-pdf.zip":
- download_urls.append(str(request.url))
- return httpx.Response(200, content=pdf_archive)
- if str(request.url) == "https://download.example/premade-weather.zip":
- download_urls.append(str(request.url))
- return httpx.Response(200, content=weather_archive)
- return httpx.Response(404)
-
- monkeypatch.setenv("KSADK_PUBLIC_SKILL_SPACE_IDS", "ss-public")
- monkeypatch.setenv("KSADK_PUBLIC_SKILL_ALLOWLIST", "weather")
- monkeypatch.setenv("KSADK_SKILL_SERVICE_URL", "https://skill.example/api/v1")
- monkeypatch.setenv("KSADK_SKILL_CACHE_DIR", str(tmp_path / "cache"))
-
- code = run_agent(["请处理这个任务"], service_transport=httpx.MockTransport(handler))
-
- out = capsys.readouterr().out
- assert code == 0
- assert "loaded_skills=weather" in out
- assert download_urls == ["https://download.example/premade-weather.zip"]
- assert not (tmp_path / "cache" / f"premade-pdf__{pdf_digest}").exists()
- assert (
- tmp_path
- / "cache"
- / f"premade-weather__{weather_digest}"
- / "extracted"
- / "weather"
- / "SKILL.md"
- ).exists()
-
-
-def test_runtime_agent_prefers_user_skill_over_same_name_public_skill(
- monkeypatch,
- tmp_path: Path,
- capsys,
-):
- user_archive = _zip_bytes("demo-skill")
- public_archive = _zip_bytes("demo-skill") + b"public"
- user_digest = hashlib.sha256(user_archive).hexdigest()
- public_digest = hashlib.sha256(public_archive).hexdigest()
- download_urls: list[str] = []
-
- def handler(request: httpx.Request) -> httpx.Response:
- if request.url.path.endswith("/ListSkillsBySpaceId"):
- space_id = request.url.params.get("SpaceId")
- if space_id == "ss-user":
- return httpx.Response(
- 200,
- json={
- "Data": {
- "SkillSpaceId": "ss-user",
- "Skills": [
- {
- "SkillId": "sk-user-demo",
- "VersionId": "sv-user-v1",
- "Version": "v1",
- "Name": "demo-skill",
- "Status": "Active",
- "ContentHash": f"sha256:{user_digest}",
- }
- ],
- }
- },
- )
- return httpx.Response(404)
- if request.url.path.endswith("/ListAvailablePremadeSkills"):
- return httpx.Response(
- 200,
- json={
- "Data": {
- "SkillSpaceId": "ss-public",
- "Skills": [
- {
- "SkillId": "premade-demo",
- "VersionId": "",
- "Version": "",
- "Name": "demo-skill",
- "Status": "AVAILABLE",
- "ContentHash": f"sha256:{public_digest}",
- }
- ],
- }
- },
- )
- if request.url.path.endswith("/GetSkillDownloadUrl"):
- return httpx.Response(200, json={"Data": {"DownloadUrl": "https://download.example/user.zip"}})
- if request.url.path.endswith("/GetPremadeSkillDownloadUrl"):
- return httpx.Response(200, json={"Data": {"DownloadUrl": "https://download.example/public.zip"}})
- if str(request.url) == "https://download.example/user.zip":
- download_urls.append(str(request.url))
- return httpx.Response(200, content=user_archive)
- if str(request.url) == "https://download.example/public.zip":
- download_urls.append(str(request.url))
- return httpx.Response(200, content=public_archive)
- return httpx.Response(404)
-
- monkeypatch.setenv("KSADK_SKILL_SPACE_IDS", "ss-user")
- monkeypatch.setenv("KSADK_PUBLIC_SKILL_SPACE_IDS", "ss-public")
- monkeypatch.setenv("KSADK_SKILL_SERVICE_URL", "https://skill.example/api/v1")
- monkeypatch.setenv("KSADK_SKILL_CACHE_DIR", str(tmp_path / "cache"))
-
- code = run_agent(["请使用 demo-skill 处理任务"], service_transport=httpx.MockTransport(handler))
-
- out = capsys.readouterr().out
- assert code == 0
- assert "loaded_skills=demo-skill" in out
- assert download_urls == ["https://download.example/user.zip"]
- assert (tmp_path / "cache" / "sk-user-demo__sv-user-v1" / "extracted" / "demo-skill" / "SKILL.md").exists()
- assert not (tmp_path / "cache" / f"premade-demo__{public_digest}").exists()
-
-
-def test_runtime_agent_can_load_legacy_remote_skill_when_hash_mismatch_is_allowed(
- monkeypatch,
- tmp_path: Path,
- capsys,
-):
- archive = _zip_bytes("legacy-skill")
- download_urls: list[str] = []
-
- def handler(request: httpx.Request) -> httpx.Response:
- if request.url.path.endswith("/ListSkillsBySpaceId"):
- return httpx.Response(
- 200,
- json={
- "Data": {
- "SkillSpaceId": "ss-1",
- "Skills": [
- {
- "SkillId": "sk-legacy",
- "VersionId": "sv-legacy-v1",
- "Version": "v1",
- "Name": "legacy-skill",
- "Status": "Active",
- "ContentHash": f"sha256:{'0' * 64}",
- }
- ],
- }
- },
- )
- if request.url.path.endswith("/GetSkillDownloadUrl"):
- return httpx.Response(200, json={"Data": {"DownloadUrl": "https://download.example/legacy.zip"}})
- if str(request.url) == "https://download.example/legacy.zip":
- download_urls.append(str(request.url))
- return httpx.Response(200, content=archive)
- return httpx.Response(404)
-
- monkeypatch.setenv("KSADK_SKILL_SPACE_IDS", "ss-1")
- monkeypatch.setenv("KSADK_SKILL_SERVICE_URL", "https://skill.example/api/v1")
- monkeypatch.setenv("KSADK_SKILL_CACHE_DIR", str(tmp_path / "cache"))
- monkeypatch.setenv("KSADK_SELECTED_SKILL_NAMES", "legacy-skill")
- monkeypatch.setenv("KSADK_SKILL_ALLOW_HASH_MISMATCH", "true")
-
- code = run_agent(
- ["请处理这个任务"],
- service_transport=httpx.MockTransport(handler),
- )
-
- out = capsys.readouterr().out
- assert code == 0
- assert "loaded_skills=legacy-skill" in out
- assert "skill_warnings=" in out
- assert "ContentHash mismatch for legacy-skill" in out
- assert download_urls == ["https://download.example/legacy.zip"]
- assert (
- tmp_path
- / "cache"
- / "unverified-sk-legacy__sv-legacy-v1"
- / "extracted"
- / "legacy-skill"
- / "SKILL.md"
- ).exists()
-
-
-def test_runtime_agent_without_service_still_reports_workflow(monkeypatch, capsys):
- monkeypatch.delenv("KSADK_SKILL_SERVICE_URL", raising=False)
- monkeypatch.delenv("KSADK_SKILL_SPACE_IDS", raising=False)
- monkeypatch.delenv("SKILL_SPACE_ID", raising=False)
- monkeypatch.delenv("KSADK_PUBLIC_SKILL_SPACE_IDS", raising=False)
-
- code = run_agent(["noop"])
-
- out = capsys.readouterr().out
- assert code == 0
- assert "workflow=noop" in out
- assert "loaded_skills=" in out
-
-
-def test_runtime_agent_reads_prompt_file(tmp_path: Path, monkeypatch, capsys):
- prompt_file = tmp_path / "prompt.txt"
- prompt_file.write_text("from file", encoding="utf-8")
- monkeypatch.delenv("KSADK_SKILL_SERVICE_URL", raising=False)
-
- code = run_agent(["--prompt-file", str(prompt_file)])
-
- out = capsys.readouterr().out
- assert code == 0
- assert "workflow=from file" in out
-
-
-def test_runtime_agent_accepts_request_file_json(tmp_path: Path, monkeypatch, capsys):
- request_file = tmp_path / "request.json"
- request_file.write_text(
- json.dumps(
- {
- "workflow_prompt": "from request",
- "skill_names": ["generic-workflow"],
- }
- ),
- encoding="utf-8",
- )
- monkeypatch.delenv("KSADK_SKILL_SERVICE_URL", raising=False)
-
- code = run_agent(["--request-file", str(request_file)])
-
- out = capsys.readouterr().out
- assert code == 0
- assert "workflow=from request" in out
- assert 'selected_skills=["generic-workflow"]' in out
-
-
-def test_runtime_agent_rejects_prompt_file_and_request_file_together(tmp_path: Path, monkeypatch, capsys):
- prompt_file = tmp_path / "prompt.txt"
- prompt_file.write_text("from file", encoding="utf-8")
- request_file = tmp_path / "request.json"
- request_file.write_text(json.dumps({"workflow_prompt": "from request"}), encoding="utf-8")
- monkeypatch.delenv("KSADK_SKILL_SERVICE_URL", raising=False)
-
- code = run_agent(["--prompt-file", str(prompt_file), "--request-file", str(request_file)])
-
- out = capsys.readouterr().out
- assert code == 1
- assert "workflow_result=" in out
- payload = json.loads(out.split("workflow_result=", 1)[1])
- assert payload["status"] == "failed"
- assert "cannot be used together" in payload["error"]
-
-
-def test_runtime_agent_executes_generic_run_workflow_script(monkeypatch, tmp_path: Path):
- skill_root = tmp_path / "skills" / "generic-workflow"
- scripts_dir = skill_root / "scripts"
- scripts_dir.mkdir(parents=True)
- (skill_root / "SKILL.md").write_text(
- "---\nname: generic-workflow\ndescription: Generic workflow\n---\n# Demo\n",
- encoding="utf-8",
- )
- (scripts_dir / "run-workflow.sh").write_text(
- "#!/bin/bash\n"
- "mkdir -p \"$KSADK_SKILL_WORKDIR/out\"\n"
- "printf '%s' \"$KSADK_WORKFLOW_PROMPT\" > \"$KSADK_SKILL_WORKDIR/out/prompt.txt\"\n"
- "echo \"artifact=$KSADK_SKILL_WORKDIR/out/prompt.txt\"\n",
- encoding="utf-8",
- )
- workdir = tmp_path / "work"
- monkeypatch.setenv("KSADK_SKILL_WORKDIR", str(workdir))
-
- result = runtime_agent._execute_workflow(
- "run generic workflow",
- [load_local_skill(skill_root)],
- selected_skill_names=["generic-workflow"],
- )
-
- artifact = str(workdir / "out" / "prompt.txt")
- assert result.status == "ok"
- assert result.executed_skill == "generic-workflow"
- assert result.selected_skills == ["generic-workflow"]
- assert result.loaded_skills == ["generic-workflow"]
- assert result.output_files == [artifact]
- assert result.artifacts == [artifact]
- assert result.commands[0]["exit_code"] == 0
- assert (workdir / "out" / "prompt.txt").read_text(encoding="utf-8") == "run generic workflow"
-
-
-def test_runtime_agent_collects_generic_workflow_output_dir(monkeypatch, tmp_path: Path):
- skill_root = tmp_path / "skills" / "output-dir-workflow"
- scripts_dir = skill_root / "scripts"
- scripts_dir.mkdir(parents=True)
- (skill_root / "SKILL.md").write_text(
- "---\nname: output-dir-workflow\ndescription: Output dir workflow\n---\n# Demo\n",
- encoding="utf-8",
- )
- (scripts_dir / "run-workflow.sh").write_text(
- "#!/bin/bash\n"
- "mkdir -p \"$KSADK_SKILL_OUTPUT_DIR\"\n"
- "printf 'generated' > \"$KSADK_SKILL_OUTPUT_DIR/result.txt\"\n",
- encoding="utf-8",
- )
- workdir = tmp_path / "work"
- monkeypatch.setenv("KSADK_SKILL_WORKDIR", str(workdir))
-
- result = runtime_agent._execute_workflow(
- "run output-dir workflow",
- [load_local_skill(skill_root)],
- selected_skill_names=["output-dir-workflow"],
- )
-
- artifact = str(workdir / "artifacts" / "result.txt")
- assert result.status == "ok"
- assert result.output_files == [artifact]
- assert result.artifacts == [artifact]
-
-
-def test_runtime_agent_warns_when_loaded_skill_has_no_workflow_entrypoint(tmp_path: Path):
- skill_root = tmp_path / "skills" / "instruction-only"
- skill_root.mkdir(parents=True)
- (skill_root / "SKILL.md").write_text(
- "---\nname: instruction-only\ndescription: Instruction only\n---\n# Demo\n",
- encoding="utf-8",
- )
-
- result = runtime_agent._execute_workflow(
- "run instruction-only",
- [load_local_skill(skill_root)],
- selected_skill_names=["instruction-only"],
- )
-
- assert result.status == "skipped"
- assert result.selected_skills == ["instruction-only"]
- assert result.loaded_skills == ["instruction-only"]
- assert result.warnings == ["No loaded skill exposes an executable workflow entrypoint."]
-
-
-def test_runtime_agent_executes_web_artifacts_builder_without_real_npm(monkeypatch, tmp_path: Path):
- skill_root = tmp_path / "skills" / "web-artifacts-builder"
- scripts_dir = skill_root / "scripts"
- scripts_dir.mkdir(parents=True)
- (skill_root / "SKILL.md").write_text(
- "---\nname: web-artifacts-builder\ndescription: Build artifacts\n---\n# Demo\n",
- encoding="utf-8",
- )
- (scripts_dir / "init-artifact.sh").write_text("#!/bin/bash\n", encoding="utf-8")
- (scripts_dir / "bundle-artifact.sh").write_text("#!/bin/bash\n", encoding="utf-8")
- workdir = tmp_path / "work"
- monkeypatch.setenv("KSADK_SKILL_WORKDIR", str(workdir))
- monkeypatch.setenv("KSADK_SKILL_ARTIFACT_PROJECT", "demo-artifact")
-
- def fake_run(args, **kwargs):
- if str(args[-1]).endswith("demo-artifact"):
- (workdir / "demo-artifact").mkdir(parents=True)
- elif str(args[1]).endswith("bundle-artifact.sh"):
- (workdir / "demo-artifact" / "bundle.html").write_text("", encoding="utf-8")
- return subprocess.CompletedProcess(args=args, returncode=0, stdout="ok\n", stderr="")
-
- monkeypatch.setattr(runtime_agent.subprocess, "run", fake_run)
-
- result = runtime_agent._execute_workflow(
- "使用 web-artifacts-builder 初始化并打包一个最小 artifact",
- [load_local_skill(skill_root)],
- )
-
- assert result.status == "ok"
- assert result.executed_skill == "web-artifacts-builder"
- assert result.output_files == [str(workdir / "demo-artifact" / "bundle.html")]
- assert [command["exit_code"] for command in result.commands] == [0, 0]
diff --git a/tests/skills/test_service_client_http.py b/tests/skills/test_service_client_http.py
deleted file mode 100644
index e4c0f21b..00000000
--- a/tests/skills/test_service_client_http.py
+++ /dev/null
@@ -1,396 +0,0 @@
-from __future__ import annotations
-
-import httpx
-import pytest
-
-from ksadk.skills.service_client import SkillServiceClient
-
-
-def test_service_client_lists_skills_and_downloads_archive_with_mock_transport():
- requests = []
-
- def handler(request: httpx.Request) -> httpx.Response:
- requests.append((request.method, str(request.url), request.headers.get("Authorization")))
- if request.url.path.endswith("/ListSkillsBySpaceId"):
- return httpx.Response(
- 200,
- json={
- "Code": 200,
- "RequestId": "req-list",
- "Data": {
- "Skills": [
- {"SkillId": "sk-1", "VersionId": "sv-1", "Version": "v1", "Name": "demo", "Status": "Active"}
- ],
- },
- },
- )
- if request.url.path.endswith("/GetSkillDownloadUrl"):
- return httpx.Response(200, json={"Code": 200, "Data": {"DownloadUrl": "https://download.example/skill.zip"}})
- if str(request.url) == "https://download.example/skill.zip":
- return httpx.Response(200, content=b"zip-bytes")
- return httpx.Response(404)
-
- client = SkillServiceClient(
- base_url="https://skill.example/api/v1",
- token="secret-token",
- transport=httpx.MockTransport(handler),
- )
-
- listing = client.list_skills_by_space_id("ss-1")
- archive = client.download_skill_archive(listing.skills[0])
-
- assert listing.request_id == "req-list"
- assert listing.skills[0].skill_id == "sk-1"
- assert archive == b"zip-bytes"
- assert requests[0] == (
- "GET",
- "https://skill.example/api/v1/ListSkillsBySpaceId?SpaceId=ss-1",
- "Bearer secret-token",
- )
- assert requests[1] == (
- "GET",
- "https://skill.example/api/v1/GetSkillDownloadUrl?SkillId=sk-1&VersionId=sv-1",
- "Bearer secret-token",
- )
- assert requests[-1] == ("GET", "https://download.example/skill.zip", None)
-
-
-def test_service_client_downloads_no_version_skill_from_premade_endpoint():
- requests = []
-
- def handler(request: httpx.Request) -> httpx.Response:
- requests.append((request.method, str(request.url)))
- if request.url.path.endswith("/ListSkillsBySpaceId"):
- return httpx.Response(
- 200,
- json={
- "Code": 200,
- "RequestId": "req-list",
- "Data": {
- "Skills": [
- {
- "SkillId": "premade-pdf",
- "VersionId": "",
- "Version": "",
- "Name": "pdf",
- "Status": "AVAILABLE",
- }
- ],
- },
- },
- )
- if request.url.path.endswith("/GetPremadeSkillDownloadUrl"):
- return httpx.Response(
- 200,
- json={"Code": 200, "Data": {"DownloadUrl": "https://download.example/pdf.zip"}},
- )
- if str(request.url) == "https://download.example/pdf.zip":
- return httpx.Response(200, content=b"premade-zip-bytes")
- return httpx.Response(404)
-
- client = SkillServiceClient(
- base_url="https://skill.example/api/v1",
- transport=httpx.MockTransport(handler),
- )
-
- listing = client.list_skills_by_space_id("ss-public")
- archive = client.download_skill_archive(listing.skills[0])
-
- assert archive == b"premade-zip-bytes"
- assert requests[1] == (
- "GET",
- "https://skill.example/api/v1/GetPremadeSkillDownloadUrl?SkillId=premade-pdf&VersionId=",
- )
-
-
-def test_service_client_lists_available_premade_skills_from_dedicated_endpoint():
- requests = []
-
- def handler(request: httpx.Request) -> httpx.Response:
- requests.append((request.method, str(request.url)))
- if request.url.path.endswith("/ListAvailablePremadeSkills"):
- return httpx.Response(
- 200,
- json={
- "Code": 200,
- "RequestId": "req-premade-list",
- "Data": {
- "Skills": [
- {
- "SkillId": "premade-pdf",
- "VersionId": "",
- "Version": "",
- "Name": "pdf",
- "Status": "AVAILABLE",
- "ContentHash": "abc123",
- },
- {
- "SkillId": "premade-xlsx",
- "VersionId": "",
- "Version": "",
- "Name": "xlsx",
- "Status": "AVAILABLE",
- "ContentHash": "def456",
- },
- ],
- },
- },
- )
- return httpx.Response(404)
-
- client = SkillServiceClient(
- base_url="https://skill.example/api/v1",
- transport=httpx.MockTransport(handler),
- )
-
- listing = client.list_available_premade_skills()
-
- assert listing.request_id == "req-premade-list"
- assert listing.space_id == "public"
- assert [skill.name for skill in listing.active_skills()] == ["pdf", "xlsx"]
- assert [skill.version_id for skill in listing.active_skills()] == ["", ""]
- assert requests == [
- ("GET", "https://skill.example/api/v1/ListAvailablePremadeSkills"),
- ]
-
-
-def test_service_client_sends_account_header_for_direct_rest_service():
- seen_headers = {}
-
- def handler(request: httpx.Request) -> httpx.Response:
- seen_headers.update(request.headers)
- return httpx.Response(200, json={"Code": 200, "Data": {"Skills": []}})
-
- client = SkillServiceClient(
- base_url="https://skill.example/api/v1",
- account_id="2000003485",
- transport=httpx.MockTransport(handler),
- )
-
- client.list_skills_by_space_id("ss-1")
-
- assert seen_headers["x-ksc-account-id"] == "2000003485"
-
-
-def test_service_client_supports_custom_action_paths():
- client = SkillServiceClient(base_url="https://skill.example/root/")
-
- assert client.action_url("ListSkillsBySpaceId") == "https://skill.example/root/ListSkillsBySpaceId"
-
-
-def test_service_client_normalizes_docs_and_openapi_urls():
- docs_client = SkillServiceClient(base_url="https://skill.example/agentengine/skill/docs#/SkillSpace")
- openapi_client = SkillServiceClient(base_url="https://skill.example/agentengine/skill/api/v1/openapi.json")
-
- assert (
- docs_client.action_url("ListSkillsBySpaceId")
- == "https://skill.example/agentengine/skill/api/v1/ListSkillsBySpaceId"
- )
- assert (
- openapi_client.action_url("ListSkillsBySpaceId")
- == "https://skill.example/agentengine/skill/api/v1/ListSkillsBySpaceId"
- )
-
-
-def test_service_client_lists_skill_spaces_with_real_query_contract():
- def handler(request: httpx.Request) -> httpx.Response:
- assert request.method == "GET"
- assert str(request.url) == "https://skill.example/api/v1/ListSkillSpaces?PageNumber=1&PageSize=20"
- return httpx.Response(
- 200,
- json={
- "Code": 200,
- "Message": "ok",
- "RequestId": "req-space",
- "Data": {"Items": [{"Id": "ss-1", "Name": "demo"}], "TotalCount": 1},
- },
- )
-
- client = SkillServiceClient(
- base_url="https://skill.example/api/v1",
- transport=httpx.MockTransport(handler),
- )
-
- response = client.list_skill_spaces(page_number=1, page_size=20)
-
- assert response["Data"]["Items"][0]["Id"] == "ss-1"
-
-
-def test_service_client_uses_registered_kop_action_for_aicp_skill_space_listing():
- requests = []
-
- def handler(request: httpx.Request) -> httpx.Response:
- requests.append((request.method, str(request.url), dict(request.headers)))
- if request.url.params.get("Action") == "ListSkillsBySpaceId":
- return httpx.Response(
- 200,
- json={
- "Code": 200,
- "RequestId": "req-kop-list",
- "Data": {
- "Skills": [
- {
- "SkillId": "sk-1",
- "VersionId": "sv-1",
- "Version": "v1",
- "Name": "demo",
- "Status": "AVAILABLE",
- }
- ],
- },
- },
- )
- return httpx.Response(404)
-
- client = SkillServiceClient(
- base_url="http://aicp.inner.api.ksyun.com",
- account_id="2000003485",
- transport=httpx.MockTransport(handler),
- )
-
- listing = client.list_skills_by_space_id("ss-1")
-
- assert listing.space_id == "ss-1"
- assert listing.active_skills()[0].skill_id == "sk-1"
- method, url, headers = requests[0]
- assert method == "GET"
- assert url == (
- "http://aicp.inner.api.ksyun.com/"
- "?Action=ListSkillsBySpaceId&Version=2024-06-12"
- "&SpaceId=ss-1&PageNumber=1&PageSize=100"
- )
- assert headers["x-action"] == "ListSkillsBySpaceId"
- assert headers["x-version"] == "2024-06-12"
- assert headers["x-ksc-account-id"] == "2000003485"
-
-
-def test_service_client_uses_kop_mode_for_internal_aicp_endpoint():
- requests = []
-
- def handler(request: httpx.Request) -> httpx.Response:
- requests.append((request.method, str(request.url), dict(request.headers)))
- return httpx.Response(
- 200,
- json={
- "Code": 200,
- "RequestId": "req-internal-kop",
- "Data": {"Skills": []},
- },
- )
-
- client = SkillServiceClient(
- base_url="http://aicp.internal.api.ksyun.com",
- account_id="2000003485",
- transport=httpx.MockTransport(handler),
- )
-
- listing = client.list_skills_by_space_id("ss-1")
-
- assert listing.space_id == "ss-1"
- method, url, headers = requests[0]
- assert method == "GET"
- assert url == (
- "http://aicp.internal.api.ksyun.com/"
- "?Action=ListSkillsBySpaceId&Version=2024-06-12"
- "&SpaceId=ss-1&PageNumber=1&PageSize=100"
- )
- assert headers["x-action"] == "ListSkillsBySpaceId"
- assert headers["x-version"] == "2024-06-12"
- assert headers["x-ksc-account-id"] == "2000003485"
-
-
-def test_service_client_routes_pre_online_kop_requests_with_custom_source(monkeypatch):
- monkeypatch.setenv("KSADK_SKILL_SERVICE_REGION", "pre-online")
- monkeypatch.setenv("AGENTENGINE_PRE_CONTROL_REGION", "cn-beijing-6")
- monkeypatch.setenv("AGENTENGINE_PRE_CUSTOM_SOURCE", "pre")
- requests = []
-
- def handler(request: httpx.Request) -> httpx.Response:
- requests.append((request.method, str(request.url), dict(request.headers)))
- return httpx.Response(
- 200,
- json={
- "Code": 200,
- "RequestId": "req-pre-kop",
- "Data": {"Skills": []},
- },
- )
-
- client = SkillServiceClient(
- base_url="http://aicp.inner.api.ksyun.com",
- account_id="73398439",
- transport=httpx.MockTransport(handler),
- )
-
- listing = client.list_skills_by_space_id("ss-pre")
-
- assert listing.space_id == "ss-pre"
- method, url, headers = requests[0]
- assert method == "GET"
- assert url == (
- "http://aicp.inner.api.ksyun.com/"
- "?Action=ListSkillsBySpaceId&Version=2024-06-12"
- "&SpaceId=ss-pre&PageNumber=1&PageSize=100"
- )
- assert headers["x-ksc-region"] == "cn-beijing-6"
- assert headers["x-ksc-custom-source"] == "pre"
- assert headers["x-ksc-account-id"] == "73398439"
-
-
-def test_service_client_uses_registered_kop_action_for_available_premade_skills():
- requests = []
-
- def handler(request: httpx.Request) -> httpx.Response:
- requests.append((request.method, str(request.url), dict(request.headers)))
- if request.url.params.get("Action") == "ListAvailablePremadeSkills":
- return httpx.Response(
- 200,
- json={
- "Code": 200,
- "RequestId": "req-kop-premade",
- "Data": {
- "Skills": [
- {
- "SkillId": "premade-pdf",
- "VersionId": "",
- "Version": "",
- "Name": "pdf",
- "Status": "AVAILABLE",
- }
- ],
- },
- },
- )
- return httpx.Response(404)
-
- client = SkillServiceClient(
- base_url="http://aicp.inner.api.ksyun.com",
- account_id="2000003485",
- transport=httpx.MockTransport(handler),
- )
-
- listing = client.list_available_premade_skills()
-
- assert listing.space_id == "public"
- assert listing.active_skills()[0].skill_id == "premade-pdf"
- method, url, headers = requests[0]
- assert method == "GET"
- assert url == (
- "http://aicp.inner.api.ksyun.com/"
- "?Action=ListAvailablePremadeSkills&Version=2024-06-12"
- )
- assert headers["x-action"] == "ListAvailablePremadeSkills"
- assert headers["x-version"] == "2024-06-12"
- assert headers["x-ksc-account-id"] == "2000003485"
-
-
-def test_service_client_kop_requires_credentials_without_mock_transport(monkeypatch):
- monkeypatch.delenv("KSADK_SKILL_SERVICE_ACCESS_KEY", raising=False)
- monkeypatch.delenv("KSADK_SKILL_SERVICE_SECRET_KEY", raising=False)
- monkeypatch.delenv("KSYUN_ACCESS_KEY", raising=False)
- monkeypatch.delenv("KSYUN_SECRET_KEY", raising=False)
-
- client = SkillServiceClient(base_url="http://aicp.inner.api.ksyun.com")
-
- with pytest.raises(ValueError, match="requires signing credentials"):
- client.list_skill_spaces()
diff --git a/tests/skills/test_skill_service_client.py b/tests/skills/test_skill_service_client.py
deleted file mode 100644
index 14738e82..00000000
--- a/tests/skills/test_skill_service_client.py
+++ /dev/null
@@ -1,90 +0,0 @@
-from __future__ import annotations
-
-from ksadk.skills.models import SkillListResponse
-
-
-def test_parse_list_skills_by_space_id_preserves_progressive_disclosure_fields():
- response = SkillListResponse.from_payload(
- {
- "Code": 0,
- "Message": "OK",
- "RequestId": "req-1",
- "Data": {
- "SkillSpaceId": "ss-abc",
- "SkillSpaceName": "office",
- "Skills": [
- {
- "SkillId": "sk-web",
- "VersionId": "sv-web-v1",
- "Version": "v1",
- "Name": "web-artifacts-builder",
- "Description": "Build web artifacts",
- "Status": "Active",
- "ContentHash": "sha256:b95f0735357fcf879bd53ed85cb242679ec74438e3bc8e85b1f27193169b6ecf",
- "ArchiveUri": "ks3://agentengine-skills/skills/sk-web/v1/web-artifacts-builder.zip",
- }
- ],
- },
- }
- )
-
- assert response.request_id == "req-1"
- assert response.space_id == "ss-abc"
- assert response.skills[0].skill_id == "sk-web"
- assert response.skills[0].version_id == "sv-web-v1"
- assert response.skills[0].version == "v1"
- assert response.skills[0].content_hash.algorithm == "sha256"
- assert response.skills[0].archive_uri == "ks3://agentengine-skills/skills/sk-web/v1/web-artifacts-builder.zip"
-
-
-def test_parse_list_skills_preserves_discovery_metadata():
- response = SkillListResponse.from_payload(
- {
- "Data": {
- "SkillSpaceId": "ss-abc",
- "Skills": [
- {
- "SkillId": "sk-report",
- "VersionId": "sv-report-v1",
- "Name": "report-writer",
- "Description": "Write research reports",
- "Aliases": ["研究报告", "deep report"],
- "Tags": ["writing", "research"],
- "Examples": ["生成一份行业研究报告"],
- "InputSchema": {"type": "object"},
- "RuntimeRequirements": {"sandbox": True},
- }
- ],
- }
- }
- )
-
- skill = response.skills[0]
- assert skill.aliases == ("研究报告", "deep report")
- assert skill.tags == ("writing", "research")
- assert skill.examples == ("生成一份行业研究报告",)
- assert skill.input_schema == {"type": "object"}
- assert skill.runtime_requirements == {"sandbox": True}
-
-
-def test_parse_list_skills_filters_inactive_by_default():
- response = SkillListResponse.from_payload(
- {
- "Data": {
- "SkillSpaceId": "ss-abc",
- "Skills": [
- {"SkillId": "sk-active", "VersionId": "v1", "Version": "1", "Name": "active", "Status": "Active"},
- {
- "SkillId": "sk-available",
- "VersionId": "v1",
- "Version": "1",
- "Name": "available",
- "Status": "AVAILABLE",
- },
- {"SkillId": "sk-disabled", "VersionId": "v2", "Version": "2", "Name": "disabled", "Status": "Disabled"},
- ],
- }
- }
- )
-
- assert [skill.skill_id for skill in response.active_skills()] == ["sk-active", "sk-available"]
diff --git a/tests/skills/test_web_artifacts_fixture.py b/tests/skills/test_web_artifacts_fixture.py
deleted file mode 100644
index 401dd00f..00000000
--- a/tests/skills/test_web_artifacts_fixture.py
+++ /dev/null
@@ -1,31 +0,0 @@
-from __future__ import annotations
-
-import hashlib
-import os
-import zipfile
-from pathlib import Path
-
-import pytest
-
-
-EXPECTED_SHA256 = "b95f0735357fcf879bd53ed85cb242679ec74438e3bc8e85b1f27193169b6ecf"
-
-
-def test_web_artifacts_builder_zip_matches_skill_service_fixture_contract():
- fixture_env = os.environ.get("KSADK_WEB_ARTIFACTS_FIXTURE", "").strip()
- if not fixture_env:
- pytest.skip("KSADK_WEB_ARTIFACTS_FIXTURE not set; local/preprod fixture zip is not present in CI or clean environments")
- fixture = Path(fixture_env)
- if not fixture.exists():
- pytest.skip(f"fixture zip not found at {fixture}")
- data = fixture.read_bytes()
- assert hashlib.sha256(data).hexdigest() == EXPECTED_SHA256
-
- with zipfile.ZipFile(fixture) as archive:
- names = set(archive.namelist())
- skill_md = archive.read("web-artifacts-builder/SKILL.md").decode("utf-8")
-
- assert "web-artifacts-builder/SKILL.md" in names
- assert "web-artifacts-builder/scripts/init-artifact.sh" in names
- assert "web-artifacts-builder/scripts/bundle-artifact.sh" in names
- assert "name: web-artifacts-builder" in skill_md
diff --git a/tests/snapshots/error_hint_snapshots.txt b/tests/snapshots/error_hint_snapshots.txt
deleted file mode 100644
index 68bb2e49..00000000
--- a/tests/snapshots/error_hint_snapshots.txt
+++ /dev/null
@@ -1,44 +0,0 @@
-=== dashboard_not_found ===
-未找到 Agent。
-- 请确认 Agent 名称/ID 是否正确,可先执行 `agentengine agent list` 查看已部署 Agent。
-- 可显式指定 Agent:`agentengine dashboard open --agent `。
-=== dashboard_list_not_found ===
-未找到 Agent。
-- 请确认 Agent 名称/ID 是否正确,可先执行 `agentengine agent list` 查看已部署 Agent。
-- `agentengine dashboard list` 不是有效命令。
-- 如果要查看分享链接,请使用 `agentengine dashboard share list --agent `。
-=== dashboard_share_not_found ===
-未找到 Dashboard 分享链接或目标 Agent。
-- 请先执行 `agentengine dashboard share list --agent ` 查看分享链接。
-- 如需先确认 Agent,请执行 `agentengine agent list`。
-=== mcp_not_found ===
-未找到 MCP。
-- 请确认 MCP 名称/ID 是否正确,可先执行 `agentengine mcp list` 查看已部署 MCP。
-=== openclaw_not_found ===
-未找到 OpenClaw。
-- 请确认 OpenClaw 名称/ID 是否正确,可先执行 `agentengine openclaw list` 查看已部署实例。
-=== version_not_found ===
-未找到目标 Agent 或版本。
-- 请先执行 `agentengine agent list` 确认目标 Agent。
-- 然后执行 `agentengine version list --agent ` 查看版本。
-=== auth_failed ===
-鉴权失败。
-- 请检查 KSYUN_ACCESS_KEY / KSYUN_SECRET_KEY 是否正确。
-- 如使用子账号,请确认已授予对应接口权限。
-=== missing_aksk ===
-未检测到金山云 AK/SK。
-- 请检查当前 shell 或项目 `.env` 中是否设置了 `KSYUN_ACCESS_KEY` / `KSYUN_SECRET_KEY`(兼容 `KS3_ACCESS_KEY` / `KS3_SECRET_KEY`)。
-- 先到 AgentEngine Runtime 控制台确认账号是否具备运行时权限: https://ksp.console.ksyun.com/#/agentEngineRuntime
-- 如当前子账号没有权限,请到 IAM 授权页授权: https://uc.console.ksyun.com/pro/iam/#/permission/authorize
-- 如果还没有金山云 AK/SK,请让主账号先到 IAM 控制台创建子账号并生成访问密钥: https://uc.console.ksyun.com/pro/iam/
-=== invalid_aksk ===
-金山云 AK/SK 不正确或已失效。
-- 请检查当前 shell 或项目 `.env` 中的 `KSYUN_ACCESS_KEY` / `KSYUN_SECRET_KEY` 是否填写正确,且没有多余空格。
-- 确认该 AK/SK 未被禁用、删除或重置,并且属于当前要操作的金山云账号。
-- 如需确认账号是否具备 AgentEngine Runtime 权限,可先查看: https://ksp.console.ksyun.com/#/agentEngineRuntime
-- 如果凭证属于子账号但仍然被拒绝,请到 IAM 授权页检查授权: https://uc.console.ksyun.com/pro/iam/#/permission/authorize
-=== missing_runtime_permission ===
-当前金山云账号没有 AgentEngine Runtime 所需权限。
-- 请先到 AgentEngine Runtime 控制台确认当前账号是否具备运行时权限: https://ksp.console.ksyun.com/#/agentEngineRuntime
-- 如当前子账号没有权限,请到 IAM 授权页授权: https://uc.console.ksyun.com/pro/iam/#/permission/authorize
-- 如果还没有可用的金山云 AK/SK,请让主账号先到 IAM 控制台创建子账号并生成访问密钥: https://uc.console.ksyun.com/pro/iam/
diff --git a/tests/snapshots/help_snapshots.txt b/tests/snapshots/help_snapshots.txt
deleted file mode 100644
index 20092891..00000000
--- a/tests/snapshots/help_snapshots.txt
+++ /dev/null
@@ -1,350 +0,0 @@
-=== root_help ===
-Usage: cli [OPTIONS] COMMAND [ARGS]...
-
-AgentEngine CLI
-支持 Hermes / OpenClaw / DeepAgents / LangGraph / LangChain / Google ADK
-的本地运行与云端部署。
-
-工作流命令:
- agentengine a2a 暴露 A2A 服务与 Agent Card
- agentengine agent Agent 资源管理
- agentengine build 构建部署制品
- agentengine completion Shell 补全管理
- agentengine dashboard 打开云端 Agent Dashboard
- agentengine deploy 部署到云端
- agentengine files 管理 workspace 文件
- agentengine hermes Hermes Agent 资源管理
- agentengine init 创建新项目
- agentengine launch 一键构建+部署
- agentengine mcp MCP 资源管理
- agentengine openclaw OpenClaw 资源管理
- agentengine run 运行 Agent
- agentengine version Agent 版本管理
- agentengine web 本地调试 Agent Invoke UI
-
-配置:
- agentengine config 项目配置与模型设置
-
-全局选项:
- --output 输出格式(pretty/json)
- --no-color 禁用颜色输出
- --dry-run 全局 Dry Run(仅打印请求,不执行)
- --version 显示版本号
- -h, --help 显示帮助信息
-
-使用 `agentengine --help` 查看子命令帮助。
-
-=== a2a_help ===
-Usage: cli a2a [OPTIONS] COMMAND [ARGS]...
-
- A2A 协议服务与 Agent Card
-
-Options:
- -h, --help Show this message and exit.
-
-Commands:
- card 输出 Agent Card JSON。
- serve 启动 A2A 协议服务。
-
-=== a2a_serve_help ===
-Usage: cli a2a serve [OPTIONS] [AGENT_DIR]
-
- 启动 A2A 协议服务。
-
-Options:
- --host TEXT 服务监听地址 [default: 0.0.0.0]
- --port INTEGER 服务端口 [default: 8081]
- --url TEXT Agent Card 对外宣告地址
- --name TEXT 覆盖 Agent 名称
- --description TEXT 覆盖 Agent 描述
- --skill TEXT 可重复传入,追加 Agent Card 技能
- --no-trace 禁用 Tracing
- -h, --help Show this message and exit.
-
-=== a2a_card_help ===
-Usage: cli a2a card [OPTIONS] [AGENT_DIR]
-
- 输出 Agent Card JSON。
-
-Options:
- --url TEXT Agent Card 对外宣告地址 [default: http://127.0.0.1:8081]
- --name TEXT 覆盖 Agent 名称
- --description TEXT 覆盖 Agent 描述
- --skill TEXT 可重复传入,追加 Agent Card 技能
- -h, --help Show this message and exit.
-
-=== agent_help ===
-Usage: cli agent [OPTIONS] COMMAND [ARGS]...
-
- Agent 资源管理。
-
-Options:
- -h, --help Show this message and exit.
-
-Commands:
- delete 删除一个或多个 Agent。
- invoke 与 Agent 交互。
- list 列出已部署的 Agent。
- status 查看单个 Agent 状态。
-
-=== dashboard_help ===
-Usage: cli dashboard [OPTIONS] COMMAND [ARGS]...
-
- Dashboard 资源管理。
-
- 标准动作:
- open 打开 Agent Dashboard
- share 管理 Dashboard 分享链接
-
- 示例:
- agentengine dashboard open
- agentengine dashboard open ar-xxxx
- agentengine dashboard share list ar-xxxx
-
-Options:
- -h, --help Show this message and exit.
-
-Commands:
- open 打开 Agent Dashboard。
- share Dashboard 分享链接管理。
-
-=== dashboard_open_help ===
-Usage: cli dashboard open [OPTIONS] [AGENT_REF]
-
- 打开 Agent Dashboard。
-
-Options:
- -a, --agent, --agent-id TEXT Agent 名称或 ID
- -r, --region TEXT 区域
- --path TEXT 目标 UI 路径(默认根据配置自动推导)
- --share 创建可分享链接(默认创建私有临时链接)
- --expires-seconds TEXT 链接有效期(秒);支持 never(=0)
- --force-new 强制新建链接(跳过复用)
- --no-open 仅打印 URL,不自动打开浏览器
- --direct 直接打开 endpoint/path(跳过短链接创建)
- --output [pretty|json] 输出格式
- -h, --help Show this message and exit.
-
-=== hermes_help ===
-Usage: cli hermes [OPTIONS] COMMAND [ARGS]...
-
- Hermes Agent 资源管理。
-
-Options:
- -h, --help Show this message and exit.
-
-Commands:
- connect 进入远端 Hermes gateway setup 向导,执行扫码连接。
- delete 删除 Hermes Agent。
- deploy 部署 Hermes runtime 到云端。
- exec 透传受限 Hermes 只读运维子命令。
- list 列出 Hermes Agent。
- open 打开 Hermes 管理 UI,或使用 --chat 打开统一聊天页。
- pairing 透传 Hermes pairing 审批子命令。
- status 查看 Hermes Agent 状态。
-
-=== mcp_help ===
-Usage: cli mcp [OPTIONS] COMMAND [ARGS]...
-
- MCP 资源管理。
-
- 标准动作:
- list 列出已部署的 MCP
- status 查看单个 MCP 状态
- delete 删除一个或多个 MCP
- deploy 部署 MCP 到云端
- build 构建 MCP 制品
-
- 示例:
- agentengine mcp deploy .
- agentengine mcp list
- KSYUN_REGION=cn-beijing-6 agentengine mcp status
-
-Options:
- -h, --help Show this message and exit.
-
-Commands:
- build 构建 MCP Server 制品。
- delete 删除 MCP。
- deploy 部署 MCP Server 到云端
- list 列出已部署的 MCP
- status 查看 MCP 状态
-
-=== mcp_build_help ===
-Usage: cli mcp build [OPTIONS] [MCP_DIR]
-
- 构建 MCP Server 制品。
-
-Options:
- --artifact-type [Code|Container]
- 构建模式: Code-代码包 (默认) 或 Container-镜像模式
- --push 构建后上传/推送制品
- --tag TEXT 镜像标签 (Container 模式)
- --registry TEXT 镜像仓库地址 (Container 模式)
- -r, --region TEXT 构建使用的区域 (Code 模式用于 KS3,Container 模式用于默认镜像仓库推断)
- --ks3-bucket TEXT KS3 存储桶名称 (Code 模式,默认:
- agentengine-{account_id}-{region})
- --no-cache 强制重新构建,不使用缓存 (Code/Container 模式均适用)
- --output [pretty|json] 输出格式
- -h, --help Show this message and exit.
-
-=== openclaw_help ===
-Usage: cli openclaw [OPTIONS] COMMAND [ARGS]...
-
- OpenClaw 资源管理。
-
- 标准动作:
- deploy 部署 OpenClaw 到云端
- list 列出已部署的 OpenClaw
- status 查看单个 OpenClaw 状态
- gateway Gateway 入口、日志与诊断
- tui 连接远端 OpenClaw 原生 TUI
- repair 通过控制面执行 OpenClaw 修复动作
- channel Channel 统一入口
- delete 删除一个或多个 OpenClaw
-
- 示例:
- agentengine openclaw deploy
- agentengine openclaw list
- agentengine openclaw status
- agentengine openclaw tui
- agentengine openclaw gateway open
- agentengine openclaw repair
- agentengine openclaw channel status --probe
- agentengine openclaw channel connect --channel weixin
- agentengine openclaw delete
-
-Options:
- -h, --help Show this message and exit.
-
-Commands:
- channel OpenClaw Channel 统一入口。
- delete 删除 OpenClaw 实例。
- deploy 部署 OpenClaw 到云端
- gateway OpenClaw gateway 入口、日志与诊断。
- list 列出已部署的 OpenClaw 实例
- repair 通过控制面执行 OpenClaw 修复动作。
- status 查看 OpenClaw 状态
- tui 连接远端 OpenClaw 原生 TUI(不需要本机安装 OpenClaw CLI)。
-
-=== version_help ===
-Usage: cli version [OPTIONS] COMMAND [ARGS]...
-
- Agent 版本资源管理。
-
- 标准动作:
- list 列出版本历史
- release 发布新版本
- rollback 回滚到指定版本
-
- 示例:
- agentengine version list
- agentengine version list --agent ar-xxxx
- agentengine version release --agent ar-xxxx --tag vX.Y.Z
- agentengine version rollback --agent ar-xxxx --to vX.Y.Z -y
-
- 说明:
- 在项目目录下可不传 --agent,会自动从本地状态/配置解析目标 Agent
- 也支持显式指定: --agent / --agent-id / 位置参数
- 跨环境执行时请显式设置 KSYUN_REGION
-
-Options:
- -h, --help Show this message and exit.
-
-Commands:
- list 列出版本历史
- release 发布新版本
- rollback 回滚到指定版本
-
-=== config_help ===
-Usage: cli config [OPTIONS] COMMAND [ARGS]...
-
- 配置命令组。
-
- 直接运行 `agentengine config` 会进入向导。 标准子命令为 `wizard` / `show` / `set` / `model`。
-
-Options:
- -h, --help Show this message and exit.
-
-Commands:
- model 切换默认模型。
- set 非交互式设置配置项。
- show 查看项目配置、全局配置与当前生效环境变量。
- wizard 通过交互式向导配置项目。
-
-=== config_wizard_help ===
-Usage: cli config wizard [OPTIONS]
-
- 通过交互式向导配置项目。
-
-Options:
- --file TEXT 配置文件路径(默认自动复用 agentengine.yaml/ksadk.yaml)
- -s, --set TEXT 设置配置项 key=value
- --global 强制更新全局配置
- -h, --help Show this message and exit.
-
-=== config_show_help ===
-Usage: cli config show [OPTIONS]
-
- 查看项目配置、全局配置与当前生效环境变量。
-
-Options:
- --output [pretty|json] 输出格式
- -h, --help Show this message and exit.
-
-=== config_set_help ===
-Usage: cli config set [OPTIONS] [SET_ITEMS]...
-
- 非交互式设置配置项。
-
- 示例:
- agentengine config set region=cn-beijing-6
- agentengine config set OPENAI_MODEL_NAME=glm-5.2 OPENAI_BASE_URL=https://example.com/v1
- agentengine config set KSYUN_REGION=cn-beijing-6 --global
-
-Options:
- --global 同时更新全局配置
- --output [pretty|json] 输出格式
- -h, --help Show this message and exit.
-
-=== config_model_help ===
-Usage: cli config model [OPTIONS]
-
- 切换默认模型。
-
-Options:
- --multi 交互式多选模型,并按当前框架写入模型 allowlist
- --env TEXT 按模型列表生成环境变量,逗号分隔;首个模型作为默认模型,不写入 .env
- --framework [auto|openclaw|hermes|generic]
- allowlist 变量选择策略;auto 会读取当前目录框架 [default:
- auto]
- -h, --help Show this message and exit.
-
-=== completion_help ===
-Usage: cli completion [OPTIONS] COMMAND [ARGS]...
-
- Shell 补全管理。
-
-Options:
- -h, --help Show this message and exit.
-
-Commands:
- bash 输出 Bash 补全脚本
- install 自动安装补全脚本到 Shell 配置文件
- zsh 输出 Zsh 补全脚本
-
-=== model_alias_help ===
-Usage: cli model [OPTIONS]
-
-这是兼容入口,建议迁移到新的 canonical 命令。
-
-推荐命令: agentengine config model
-查看帮助: agentengine config model --help
-
-=== status_alias_help ===
-Usage: cli status [OPTIONS] [AGENT_REF]
-
-这是兼容入口,建议迁移到新的 canonical 命令。
-
-推荐命令: agentengine agent status
-查看帮助: agentengine agent status --help
diff --git a/tests/snapshots/resource_output_snapshots.txt b/tests/snapshots/resource_output_snapshots.txt
deleted file mode 100644
index e94eeb52..00000000
--- a/tests/snapshots/resource_output_snapshots.txt
+++ /dev/null
@@ -1,73 +0,0 @@
-=== mcp_list ===
-MCP 列表
-┏━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
-┃ ID ┃ 名称 ┃ 状态 ┃ MCP URL ┃
-┡━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
-│ mcp-1 │ demo-mcp │ RUNNING │ https://demo.example.com/mcp │
-└───────┴──────────┴─────────┴──────────────────────────────┘
-MCP总数: 1 页码: 1 每页: 20
-使用 `agentengine mcp status ` 查看详情。
-=== mcp_status ===
-MCP 状态 demo-mcp
-────────────────────────────────────────────────────────────────────────────────
- ID: mcp-1
- 状态: RUNNING
- 区域: cn-beijing-6
- Endpoint: https://demo.example.com
- MCP URL: https://demo.example.com/mcp
- 认证: 已开启
- 工具: search
- 创建时间: 2026-03-20T12:00:00Z
- 更新时间: 2026-03-20T12:05:00Z
-下一步建议
- • `agentengine mcp list`
-=== openclaw_list ===
-OpenClaw 列表
-┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┓
-┃ ID ┃ 名称 ┃ 状态 ┃ Endpoint ┃ 区域 ┃
-┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━┩
-│ ar-openclaw-1 │ demo-openclaw │ RUNNING │ https://openclaw.e… │ cn-beijing-6 │
-└───────────────┴───────────────┴─────────┴─────────────────────┴──────────────┘
-OpenClaw总数: 1 页码: 1 每页: 20
-账号: 2000003485 region: cn-beijing-6 总计: 1
-=== openclaw_status ===
-OpenClaw 状态 demo-openclaw
-────────────────────────────────────────────────────────────────────────────────
- ID: ar-openclaw-1
- 状态: RUNNING
- 框架: openclaw
- 区域: cn-beijing-6
- Endpoint: https://openclaw.example.com
- Langfuse: -
- 镜像: hub.kce.ksyun.com/openclaw:latest
- 创建时间: 2026-03-20 20:00:00 CST (2026-03-20 12:00:00 UTC)
- 更新时间: 2026-03-20 20:05:00 CST (2026-03-20 12:05:00 UTC)
-下一步建议
- • `agentengine invoke ar-openclaw-1`
- • `agentengine openclaw tui ar-openclaw-1`
- • `agentengine dashboard open ar-openclaw-1 --path /chat`
- • `agentengine openclaw list`
-=== version_list ===
-版本列表
-┏━━━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┓
-┃ Tag ┃ 状态 ┃ 流量 ┃ 创建时间 ┃ 描述 ┃
-┡━━━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━┩
-│ v1.0.0 │ 当前 │ 100% │ 2026-03-20 20:00:00 │ 部署自动发布 │
-└────────┴──────┴──────┴─────────────────────┴──────────────┘
-版本总数: 1 页码: 1 每页: 20
-使用 `agentengine version release` 创建新版本。
-=== dashboard_share_list ===
-Dashboard 链接列表
-┏━━━━━━━┳━━━━━━━┳━━━━━━━━┳━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
-┃ ID ┃ 类型 ┃ 状态 ┃ 路径 ┃ 过期时间 ┃ 创建时间 ┃
-┡━━━━━━━╇━━━━━━━╇━━━━━━━━╇━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
-│ lnk-1 │ share │ active │ / │ 永久 │ 2026-03-20 20:00:00 CST │
-│ │ │ │ │ │ (2026-03-20 12:00:00 UTC) │
-└───────┴───────┴────────┴──────┴──────────┴───────────────────────────────────┘
-Dashboard 链接总数: 1 页码: 1 每页: 20
-使用 `agentengine dashboard share revoke ` 撤销链接。
-=== dashboard_share_revoke ===
-Dashboard 链接撤销结果 lnk-1
-────────────────────────────────────────────────────────────────────────────────
- ID: lnk-1
- 状态: 已撤销
diff --git a/tests/snapshots/workflow_help_snapshots.txt b/tests/snapshots/workflow_help_snapshots.txt
deleted file mode 100644
index 61dae382..00000000
--- a/tests/snapshots/workflow_help_snapshots.txt
+++ /dev/null
@@ -1,146 +0,0 @@
-=== build_help ===
-Usage: build [OPTIONS] [AGENT_DIR]
-
- 将 Agent 应用构建为可部署的格式
-
- AGENT_DIR: Agent 项目目录 (默认: 当前目录)
-
- 模式:
- code: 打包 zip + 依赖,上传 KS3 (默认)
- container: 构建 Docker 镜像
-
- 示例:
- # 1) 默认构建 (code 模式)
- agentengine build .
- # 2) 显式指定构建参数
- agentengine build . --mode container --push --registry hub-cn-beijing-6.kce.ksyun.com
- # 3) 显式指定区域
- KSYUN_REGION=cn-beijing-6 agentengine build . --mode code --push --no-cache
-
-Options:
- -m, --mode [container|code] 构建模式: code (默认, zip+KS3) 或 container (Docker)
- -t, --tag TEXT 镜像标签 (container 模式)
- --registry TEXT 镜像仓库地址 (container 模式)
- --push 构建后推送 (镜像到仓库 / zip到KS3)
- --no-cache 强制重新构建,不使用缓存 (code: 忽略已有 zip;container: docker
- --no-cache)
- --repackage Code 模式复用依赖缓存,但强制重新打包当前代码/runtime
- -r, --region TEXT KS3 区域 (code 模式)
- --ks3-bucket TEXT KS3 bucket 名称 (code 模式, 默认: agentengine-{region})
- --output [pretty|json] 输出格式
- -h, --help Show this message and exit.
-=== deploy_help ===
-Usage: deploy [OPTIONS] [AGENT_DIR]
-
- 部署 Agent 到云端
-
- AGENT_DIR: Agent 项目目录 (默认: 当前目录)
-
- 示例:
- # 1) 默认部署 (serverless)
- agentengine deploy .
- # 2) 显式指定部署参数
- agentengine deploy . --target kcf --account-id X-Ksc-Account-Id
- # 3) 显式指定区域
- KSYUN_REGION=cn-beijing-6 agentengine deploy . --target serverless --dry-run
-
-Options:
- -t, --target [serverless|kcf|kce]
- 部署目标 (default: serverless)
- -n, --name TEXT 部署名称
- -r, --region TEXT 区域 (default: cn-beijing-6)
- --account-id TEXT 金山云账号 ID(可选;未设置时从 AK/SK 反查)
- --artifact-type [Code|Container]
- Serverless 部署模式 (default: Code)
- --namespace TEXT K8s 命名空间
- -p, --port INTEGER 服务端口 (default: 8000)
- --registry TEXT 镜像仓库地址 (k8s/serverless Container 模式)
- --ks3-path TEXT KS3 代码包路径 (Serverless Code 模式)
- --ks3-bucket TEXT KS3 bucket 名称 (Serverless Code 模式, 默认:
- agentengine-{region})
- --image TEXT Docker 镜像地址 (Container 模式)
- --ui-profile [auto|adk|langchain|openclaw|hermes|custom]
- Dashboard UI 类型
- --ui-path TEXT Dashboard UI 路径 (例如 /)
- --ui-url TEXT 完整 Dashboard URL(自研前端)
- --storage-size-gi INTEGER PVC 容量(Gi) [default: 20]
- --storage-mount-path TEXT PVC 挂载目录(hermes/openclaw 默认按框架推导;adk/langgraph
- 等其他框架默认不挂盘,需显式指定)
- --no-storage 禁用默认 PVC 挂载
- --enable-public-access / --disable-public-access
- 是否开启公网访问;未指定时使用配置文件或平台默认值
- --enable-vpc-access 开启 VPC 私网访问
- --vpc-id TEXT VPC ID(开启 VPC 访问时必填)
- --subnet-id TEXT 子网 ID(开启 VPC 访问时必填)
- --security-group-id TEXT 安全组 ID(开启 VPC 访问时必填)
- --availability-zone TEXT 可用区(可选)
- --env TEXT 额外透传运行时环境变量,格式 KEY=VALUE,可重复传入
- --env-file FILE 额外运行时环境变量文件,支持 .env 或 JSON 对象
- --observability / --no-observability
- 是否启用可观测性 (默认开启)
- --push 构建后推送镜像
- --no-cache 强制重新构建,不使用缓存
- --repackage Code 模式复用依赖缓存,但强制重新打包当前代码/runtime
- --no-version 部署成功后不自动创建版本快照
- --auto-rollback 部署失败时自动回滚到上一版本
- --dry-run 只生成配置,打印 curl 请求,不执行部署
- --list-providers 列出可用的部署目标
- --output [pretty|json] 输出格式
- -h, --help Show this message and exit.
-=== launch_help ===
-Usage: launch [OPTIONS] [AGENT_DIR]
-
- 一键完成构建和部署 (Build + Deploy)
-
- 此命令会自动执行:
- 1. 代码打包 / 镜像构建
- 2. 上传代码到 KS3 / 推送镜像到 KCR
- 3. 调用 API 创建或更新 Agent
-
- 示例:
- # 1) 默认一键部署 (serverless)
- agentengine launch .
- # 2) 显式指定部署参数
- agentengine launch . --target kce --artifact-type Container
- # 3) 显式指定区域
- KSYUN_REGION=cn-beijing-6 agentengine launch . --target serverless --no-cache
-
-Options:
- -t, --target [serverless|kcf|kce]
- 部署目标 (default: serverless)
- -n, --name TEXT 部署名称
- -r, --region TEXT 区域 (serverless)
- --account-id TEXT 金山云账号 ID(可选;未设置时从 AK/SK 反查)
- --observability / --no-observability
- 是否启用可观测性
- --no-cache 强制重新构建,不使用缓存
- -p, --port INTEGER 服务端口 (default: 8000)
- --namespace TEXT K8s 命名空间
- --registry TEXT 镜像仓库地址
- --ks3-bucket TEXT KS3 bucket 名称
- --ks3-path TEXT KS3 代码包路径
- --image TEXT Docker 镜像地址
- --ui-profile [auto|adk|langchain|openclaw|hermes|custom]
- Dashboard UI 类型
- --ui-path TEXT Dashboard UI 路径 (例如 /)
- --ui-url TEXT 完整 Dashboard URL(自研前端)
- --storage-size-gi INTEGER PVC 容量(Gi) [default: 20]
- --storage-mount-path TEXT PVC 挂载目录(hermes/openclaw 默认按框架推导;adk/langgraph
- 等其他框架默认不挂盘,需显式指定)
- --no-storage 禁用默认 PVC 挂载
- --enable-public-access / --disable-public-access
- 是否开启公网访问;未指定时使用配置文件或平台默认值
- --enable-vpc-access 开启 VPC 私网访问
- --vpc-id TEXT VPC ID(开启 VPC 访问时必填)
- --subnet-id TEXT 子网 ID(开启 VPC 访问时必填)
- --security-group-id TEXT 安全组 ID(开启 VPC 访问时必填)
- --availability-zone TEXT 可用区(可选)
- --env TEXT 额外透传运行时环境变量,格式 KEY=VALUE,可重复传入
- --env-file FILE 额外运行时环境变量文件,支持 .env 或 JSON 对象
- --dry-run 仅打印请求,不执行实际操作
- --artifact-type [Code|Container]
- 部署模式 (serverless default: Code)
- --no-version 部署成功后不自动创建版本快照
- --auto-rollback 部署失败时自动回滚到上一版本
- --output [pretty|json] 输出格式
- -h, --help Show this message and exit.
diff --git a/tests/test_a2a_cli.py b/tests/test_a2a_cli.py
deleted file mode 100644
index cc8306d1..00000000
--- a/tests/test_a2a_cli.py
+++ /dev/null
@@ -1,125 +0,0 @@
-from __future__ import annotations
-
-import json
-from pathlib import Path
-
-from click.testing import CliRunner
-from starlette.testclient import TestClient
-
-from ksadk.cli import _register_commands, cli
-
-
-def _write_project_config(tmp_path: Path) -> Path:
- (tmp_path / "agentengine.yaml").write_text(
- "\n".join(
- [
- "framework: adk",
- "name: demo-agent",
- "package: demo_agent",
- "entry_point: demo_agent/agent.py",
- "agent_variable: root_agent",
- "",
- ]
- ),
- encoding="utf-8",
- )
- package_dir = tmp_path / "demo_agent"
- package_dir.mkdir()
- (package_dir / "__init__.py").write_text("", encoding="utf-8")
- (package_dir / "agent.py").write_text("root_agent = object()\n", encoding="utf-8")
- return tmp_path
-
-
-def test_root_help_lists_a2a_workflow_command():
- _register_commands()
-
- result = CliRunner().invoke(cli, ["--help"])
-
- assert result.exit_code == 0, result.output
- assert "a2a" in result.output
-
-
-def test_a2a_card_command_outputs_agent_card_json(monkeypatch, tmp_path):
- project_dir = _write_project_config(tmp_path)
- monkeypatch.setattr("ksadk.configs.setup_environment", lambda _path: None)
- _register_commands()
-
- result = CliRunner().invoke(
- cli,
- [
- "a2a",
- "card",
- str(project_dir),
- "--description",
- "CLI generated card",
- "--skill",
- "echo",
- ],
- )
-
- assert result.exit_code == 0, result.output
- payload = json.loads(result.output)
- assert payload["name"] == "demo-agent"
- assert payload["url"] == "http://127.0.0.1:8081"
- assert payload["description"] == "CLI generated card"
- assert [skill["id"] for skill in payload["skills"]] == ["echo"]
-
-
-def test_a2a_serve_builds_server_and_exposes_agent_card(monkeypatch, tmp_path):
- project_dir = _write_project_config(tmp_path)
- captured: dict[str, object] = {}
-
- class FakeRunner:
- def __init__(self) -> None:
- self.loaded = False
-
- def load_agent(self) -> None:
- self.loaded = True
-
- async def invoke(self, input_data):
- return {"output": input_data["input"]}
-
- async def stream(self, input_data):
- yield {"type": "final", "output": input_data["input"]}
-
- fake_runner = FakeRunner()
-
- monkeypatch.setattr("ksadk.configs.setup_environment", lambda _path: None)
- monkeypatch.setattr(
- "ksadk.cli.cmd_a2a.create_runner",
- lambda result, project_dir: fake_runner,
- )
-
- def fake_uvicorn_run(app, host, port, **kwargs):
- captured.update({"app": app, "host": host, "port": port, "kwargs": kwargs})
-
- monkeypatch.setattr("uvicorn.run", fake_uvicorn_run)
- _register_commands()
-
- result = CliRunner().invoke(
- cli,
- [
- "a2a",
- "serve",
- str(project_dir),
- "--port",
- "9091",
- "--skill",
- "echo",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert fake_runner.loaded is True
- assert captured["host"] == "0.0.0.0"
- assert captured["port"] == 9091
-
- client = TestClient(captured["app"])
- current_card = client.get("/.well-known/agent-card.json")
- legacy_card = client.get("/.well-known/agent.json")
-
- assert current_card.status_code == 200
- assert legacy_card.status_code == 200
- assert current_card.json()["name"] == "demo-agent"
- assert current_card.json()["url"] == "http://127.0.0.1:9091"
- assert [skill["id"] for skill in current_card.json()["skills"]] == ["echo"]
diff --git a/tests/test_a2a_integration.py b/tests/test_a2a_integration.py
deleted file mode 100644
index 1aa5a54d..00000000
--- a/tests/test_a2a_integration.py
+++ /dev/null
@@ -1,242 +0,0 @@
-from __future__ import annotations
-
-import httpx
-import pytest
-from sse_starlette.sse import AppStatus
-
-from ksadk.a2a import AgentCardBuilder, KsA2AServer, RemoteA2AAgent, RemoteA2AClient, to_a2a
-
-
-@pytest.fixture(autouse=True)
-def _reset_sse_app_status():
- AppStatus.should_exit = False
- AppStatus.should_exit_event = None
- yield
- AppStatus.should_exit = False
- AppStatus.should_exit_event = None
-
-
-class _InvokeOnlyRunner:
- def __init__(self) -> None:
- self.calls: list[dict] = []
-
- async def invoke(self, input_data):
- self.calls.append(input_data)
- return {"output": f"invoke:{input_data['input']}"}
-
-
-class _StreamingRunner:
- def __init__(self) -> None:
- self.calls: list[dict] = []
-
- async def invoke(self, input_data):
- self.calls.append({"mode": "invoke", **input_data})
- return {"output": f"invoke:{input_data['input']}"}
-
- async def stream(self, input_data):
- self.calls.append(input_data)
- yield {"delta": "hello", "type": "text"}
- yield {"delta": " world", "type": "text"}
-
-
-class _StreamingRunnerWithFinalChunk(_StreamingRunner):
- async def stream(self, input_data):
- self.calls.append(input_data)
- yield {"delta": "hello", "type": "text"}
- yield {"delta": " world", "type": "text"}
- yield {"output": "hello world", "type": "final"}
-
-
-class _StreamingRunnerWithOverrideFinalChunk(_StreamingRunner):
- async def stream(self, input_data):
- self.calls.append(input_data)
- yield {"delta": "hello", "type": "text"}
- yield {"delta": " world", "type": "text"}
- yield {"output": "goodbye", "type": "final"}
-
-
-class _FailingRunner:
- async def invoke(self, input_data):
- raise RuntimeError(f"boom:{input_data['input']}")
-
-
-def test_agent_card_builder_defaults():
- card = AgentCardBuilder(
- name="demo",
- url="http://localhost:8000",
- skills=["search"],
- ).build()
-
- assert card.name == "demo"
- assert card.url == "http://localhost:8000"
- assert card.capabilities.streaming is True
- assert card.capabilities.push_notifications is False
- assert card.default_input_modes == ["text/plain"]
- assert card.default_output_modes == ["text/plain"]
- assert card.skills[0].id == "search"
- assert card.skills[0].tags == ["search"]
-
-
-@pytest.mark.asyncio
-async def test_a2a_server_exposes_cards_and_supports_invoke_roundtrip():
- runner = _InvokeOnlyRunner()
- server = to_a2a(
- runner=runner,
- app_name="echo_agent",
- url="http://testserver",
- description="Echo test agent",
- skills=["echo"],
- )
- app = server.build()
- transport = httpx.ASGITransport(app=app)
-
- async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as http_client:
- current_card = await http_client.get("/.well-known/agent-card.json")
- legacy_card = await http_client.get("/.well-known/agent.json")
-
- assert current_card.status_code == 200
- assert legacy_card.status_code == 200
- assert current_card.json()["name"] == "echo_agent"
- assert legacy_card.json()["name"] == "echo_agent"
-
- client = RemoteA2AClient(endpoint="http://testserver", http_client=http_client)
- card = await client.get_card()
- result = await client.invoke("ping", context_id="session-1")
-
- assert card.name == "echo_agent"
- assert result["output"] == "invoke:ping"
- assert result["context_id"] == "session-1"
- assert runner.calls == [
- {
- "input": "ping",
- "task_id": result["task_id"],
- "context_id": "session-1",
- "session_id": "session-1",
- "state": {},
- "branch": "",
- "metadata": {},
- }
- ]
-
-
-@pytest.mark.asyncio
-async def test_remote_a2a_agent_streams_chunks_and_adapts_runner_contract():
- runner = _StreamingRunner()
- server = KsA2AServer(
- runner=runner,
- app_name="stream_agent",
- url="http://testserver",
- skills=["stream"],
- )
- app = server.build()
- transport = httpx.ASGITransport(app=app)
-
- async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as http_client:
- agent = RemoteA2AAgent(
- endpoint="http://testserver",
- name="remote_stream_agent",
- http_client=http_client,
- )
-
- invoke_result = await agent.invoke(
- {
- "input": "hi",
- "session_id": "thread-1",
- "state": {"topic": "streaming"},
- "branch": "fanout-a",
- }
- )
- chunks = [
- chunk
- async for chunk in agent.stream(
- {
- "input": "hi",
- "session_id": "thread-1",
- "state": {"topic": "streaming"},
- "branch": "fanout-a",
- }
- )
- ]
-
- assert invoke_result["output"] == "hello world"
- assert invoke_result["context_id"] == "thread-1"
- assert [chunk["delta"] for chunk in chunks] == ["hello", " world"]
- assert all(chunk["context_id"] == "thread-1" for chunk in chunks)
- assert runner.calls == [
- {
- "input": "hi",
- "task_id": invoke_result["task_id"],
- "context_id": "thread-1",
- "session_id": "thread-1",
- "state": {"topic": "streaming"},
- "branch": "fanout-a",
- "metadata": {
- "state": {"topic": "streaming"},
- "branch": "fanout-a",
- },
- },
- {
- "input": "hi",
- "task_id": chunks[0]["task_id"],
- "context_id": "thread-1",
- "session_id": "thread-1",
- "state": {"topic": "streaming"},
- "branch": "fanout-a",
- "metadata": {
- "state": {"topic": "streaming"},
- "branch": "fanout-a",
- },
- },
- ]
-
-
-@pytest.mark.asyncio
-async def test_remote_a2a_client_ignores_duplicate_final_stream_output():
- server = KsA2AServer(
- runner=_StreamingRunnerWithFinalChunk(),
- app_name="stream_agent",
- url="http://testserver",
- skills=["stream"],
- )
- app = server.build()
- transport = httpx.ASGITransport(app=app)
-
- async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as http_client:
- client = RemoteA2AClient(endpoint="http://testserver", http_client=http_client)
- result = await client.invoke("hi", context_id="thread-1")
-
- assert result["output"] == "hello world"
-
-
-@pytest.mark.asyncio
-async def test_remote_a2a_client_uses_non_prefix_final_stream_output_as_authoritative():
- server = KsA2AServer(
- runner=_StreamingRunnerWithOverrideFinalChunk(),
- app_name="stream_agent",
- url="http://testserver",
- skills=["stream"],
- )
- app = server.build()
- transport = httpx.ASGITransport(app=app)
-
- async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as http_client:
- client = RemoteA2AClient(endpoint="http://testserver", http_client=http_client)
- result = await client.invoke("hi", context_id="thread-1")
-
- assert result["output"] == "goodbye"
-
-
-@pytest.mark.asyncio
-async def test_remote_a2a_client_raises_for_failed_tasks():
- server = KsA2AServer(
- runner=_FailingRunner(),
- app_name="broken_agent",
- url="http://testserver",
- )
- app = server.build()
- transport = httpx.ASGITransport(app=app)
-
- async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as http_client:
- client = RemoteA2AClient(endpoint="http://testserver", http_client=http_client)
- with pytest.raises(RuntimeError, match="boom:oops"):
- await client.invoke("oops")
diff --git a/tests/test_agent.py b/tests/test_agent.py
deleted file mode 100644
index 1097cebe..00000000
--- a/tests/test_agent.py
+++ /dev/null
@@ -1,68 +0,0 @@
-"""Tests for the current agent loading contract."""
-
-from __future__ import annotations
-
-import sys
-from pathlib import Path
-
-import pytest
-
-from ksadk.runners.utils.loader import load_agent_module
-
-
-def _write_package(project_dir: Path, package_name: str, module_name: str, content: str) -> str:
- package_dir = project_dir / package_name
- package_dir.mkdir(parents=True, exist_ok=True)
- (package_dir / "__init__.py").write_text("", encoding="utf-8")
- (package_dir / f"{module_name}.py").write_text(content, encoding="utf-8")
- return f"{package_name}/{module_name}.py"
-
-
-def _cleanup_module(module_name: str) -> None:
- sys.modules.pop(module_name, None)
-
-
-def test_load_agent_module_returns_root_agent_and_module(tmp_path: Path):
- entry_point = _write_package(
- tmp_path,
- "agent_loader_basic_pkg",
- "agent_impl",
- 'root_agent = {"name": "demo-agent", "framework": "langgraph"}\n',
- )
- module_name = "agent_loader_basic_pkg.agent_impl"
- _cleanup_module(module_name)
-
- agent, module = load_agent_module(str(tmp_path), entry_point, "root_agent")
-
- assert agent == {"name": "demo-agent", "framework": "langgraph"}
- assert module.__name__ == module_name
-
-
-def test_load_agent_module_supports_nested_entry_point(tmp_path: Path):
- project_pkg = tmp_path / "agent_loader_nested_pkg"
- nested_pkg = project_pkg / "agents"
- nested_pkg.mkdir(parents=True, exist_ok=True)
- (project_pkg / "__init__.py").write_text("", encoding="utf-8")
- (nested_pkg / "__init__.py").write_text("", encoding="utf-8")
- (nested_pkg / "entry.py").write_text('root_agent = "nested-root-agent"\n', encoding="utf-8")
- module_name = "agent_loader_nested_pkg.agents.entry"
- _cleanup_module(module_name)
-
- agent, module = load_agent_module(str(tmp_path), "agent_loader_nested_pkg/agents/entry.py", "root_agent")
-
- assert agent == "nested-root-agent"
- assert module.__name__ == module_name
-
-
-def test_load_agent_module_raises_when_agent_variable_is_missing(tmp_path: Path):
- entry_point = _write_package(
- tmp_path,
- "agent_loader_missing_attr_pkg",
- "agent_impl",
- 'some_other_name = "not-root-agent"\n',
- )
- module_name = "agent_loader_missing_attr_pkg.agent_impl"
- _cleanup_module(module_name)
-
- with pytest.raises(AttributeError, match="未找到 root_agent"):
- load_agent_module(str(tmp_path), entry_point, "root_agent")
diff --git a/tests/test_agent_access.py b/tests/test_agent_access.py
deleted file mode 100644
index 10f6c54c..00000000
--- a/tests/test_agent_access.py
+++ /dev/null
@@ -1,104 +0,0 @@
-from __future__ import annotations
-
-from contextlib import contextmanager
-
-from ksadk.api.client import AgentEngineAPIError
-from ksadk.deployment.agent_access import (
- get_latest_agent_access,
- normalize_deployment_status,
-)
-
-
-class _FakeClient:
- def __init__(self) -> None:
- self.calls = 0
- self.suppression_used = False
-
- @contextmanager
- def suppress_http_error_logging(self, predicate=None):
- self.suppression_used = predicate is not None
- yield
-
- async def get_agent(self, *, agent_id=None, name=None, include_api_key=False):
- self.calls += 1
- if self.calls < 3:
- raise AgentEngineAPIError(
- 404,
- "未找到对应的 Agent",
- details={
- "http_status": 404,
- "remote_error_message": "未找到对应的 Agent",
- },
- )
- return {
- "basic": {
- "agent_id": agent_id or "ar-demo",
- "name": name or "demo-agent",
- "status": "RUNNING",
- "framework": "hermes",
- "region": "pre-online",
- },
- "quick_access": {
- "public_endpoint": "https://agent.example.com",
- "api_key": "ak-demo" if include_api_key else None,
- },
- }
-
-
-async def _fake_detail_fetcher(agent_ref: str, include_api_key: bool):
- return {
- "agent_id": agent_ref,
- "name": "demo-openclaw",
- "status": "RUNNING",
- "framework": "openclaw",
- "region": "pre-online",
- "endpoint": "https://openclaw.example.com",
- "api_key": "ak-openclaw" if include_api_key else None,
- }
-
-
-def test_get_latest_agent_access_retries_transient_get_agent_not_found_and_suppresses_logs():
- import asyncio
-
- client = _FakeClient()
-
- result = asyncio.run(
- get_latest_agent_access(
- client,
- agent_id="ar-demo",
- attempts=3,
- interval_seconds=0,
- include_api_key=True,
- )
- )
-
- assert client.suppression_used is True
- assert client.calls == 3
- assert result["agent_id"] == "ar-demo"
- assert result["endpoint"] == "https://agent.example.com"
- assert result["api_key"] == "ak-demo"
- assert result["status"] == "RUNNING"
-
-
-def test_get_latest_agent_access_supports_custom_detail_fetcher():
- import asyncio
-
- result = asyncio.run(
- get_latest_agent_access(
- object(),
- agent_id="ar-openclaw-demo",
- attempts=1,
- interval_seconds=0,
- detail_fetcher=_fake_detail_fetcher,
- )
- )
-
- assert result["agent_id"] == "ar-openclaw-demo"
- assert result["framework"] == "openclaw"
- assert result["region"] == "pre-online"
- assert result["endpoint"] == "https://openclaw.example.com"
-
-
-def test_normalize_deployment_status_maps_numeric_to_submitted():
- assert normalize_deployment_status(200) == "SUBMITTED"
- assert normalize_deployment_status("running") == "RUNNING"
diff --git a/tests/test_agentengine_toolsets.py b/tests/test_agentengine_toolsets.py
deleted file mode 100644
index 6184bc4e..00000000
--- a/tests/test_agentengine_toolsets.py
+++ /dev/null
@@ -1,431 +0,0 @@
-from ksadk.toolsets import agentengine_tool_dispatcher
-from ksadk.toolsets import get_agentengine_tools
-from ksadk.toolsets import (
- clear_external_tools,
- read_workspace_file,
- edit_workspace_file,
- list_workspace_files,
- register_external_tools,
- search_workspace_files,
- tool_dispatcher,
- tool_search,
- multi_edit_workspace_file,
-)
-from ksadk.toolsets.workspace_state import clear_read_state
-from ksadk.runtime_context import PlatformInvocationContext, platform_invocation_scope, tool_execution_scope
-
-
-class _FakeMemoryService:
- def __init__(self):
- self.save_calls = []
- self._backend = None
-
- def save_text(self, *, user_id: str, content: str, metadata: dict) -> bool:
- self.save_calls.append((user_id, content, metadata))
- return True
-
-
-class _FailingMemoryService(_FakeMemoryService):
- def __init__(self):
- super().__init__()
- self._backend = type("Backend", (), {"last_error": "write not persisted"})()
-
- def save_text(self, *, user_id: str, content: str, metadata: dict) -> bool:
- self.save_calls.append((user_id, content, metadata))
- return False
-
-
-def _context() -> PlatformInvocationContext:
- return PlatformInvocationContext(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-1",
- history=[],
- input_content=[],
- input_messages=[],
- input_parts=[],
- attachments=[],
- attachment_results=[],
- current_attachments=[],
- current_attachment_results=[],
- has_current_files=False,
- runner_type="langgraph",
- )
-
-
-def test_dispatcher_list_returns_error_for_unknown_include_without_raising():
- result = agentengine_tool_dispatcher(action="list", include="file")
-
- assert result["ok"] is False
- assert result["error_type"] == "unknown_tool"
- assert result["tool_name"] == "file"
-
-
-def test_tool_dispatcher_is_canonical_and_agentengine_dispatcher_is_alias():
- canonical = tool_dispatcher(action="describe", tool_name="component_status")
- compat = agentengine_tool_dispatcher(action="describe", tool_name="component_status")
-
- assert canonical["ok"] is True
- assert canonical == compat
- names = {tool.name for tool in get_agentengine_tools(include=["tool_dispatcher"])}
- assert names == {"tool_dispatcher"}
-
-
-def test_tool_search_discovers_workspace_edit_tools():
- result = tool_search("edit a file after reading it", profile="coding", max_results=5)
-
- assert result["ok"] is True
- names = [item["name"] for item in result["results"]]
- assert "read_workspace_file" in names
- assert "edit_workspace_file" in names
- assert result["deferred_tool_names"][:2]
- assert all("score" in item and item["score"] > 0 for item in result["results"])
-
-
-def test_tool_search_discovers_registered_framework_mcp_tools():
- class WeatherForecastTool:
- name = "weather_forecast"
- description = "Get weather forecast for a city from the weather MCP server."
- args = {"city": {"type": "string", "description": "City name"}}
-
- clear_external_tools()
- try:
- registered = register_external_tools(
- [WeatherForecastTool()],
- group="mcp:weather",
- boundary="framework_managed_mcp_tool",
- )
-
- result = tool_search("weather forecast", profile="coding", max_results=5)
- finally:
- clear_external_tools()
-
- assert registered == ["weather_forecast"]
- forecast = next(item for item in result["results"] if item["name"] == "weather_forecast")
- assert forecast["group"] == "mcp:weather"
- assert forecast["boundary"] == "framework_managed_mcp_tool"
- assert forecast["execution"] == "external"
- assert forecast["args"]["city"]["type"] == "string"
- assert "weather_forecast" in result["deferred_tool_names"]
-
-
-def test_coding_profile_exposes_focused_direct_tools_and_deferred_mode_exposes_search_dispatcher():
- coding_names = {tool.name for tool in get_agentengine_tools(profile="coding", mode="direct")}
- deferred_names = {tool.name for tool in get_agentengine_tools(profile="coding", mode="deferred")}
-
- assert {"read_workspace_file", "edit_workspace_file", "multi_edit_workspace_file", "tool_search"} <= coding_names
- assert deferred_names == {"tool_search", "tool_dispatcher"}
-
-
-def test_dispatcher_langchain_tool_accepts_json_string_arguments():
- dispatcher = next(
- tool
- for tool in get_agentengine_tools(include=["agentengine_tool_dispatcher"])
- if tool.name == "agentengine_tool_dispatcher"
- )
-
- result = dispatcher.invoke(
- {
- "action": "list",
- "include": "workspace",
- "arguments": '{"unused": true}',
- }
- )
-
- assert result["ok"] is True
- assert result["tool_count"] > 0
-
-
-def test_dispatcher_call_accepts_json_string_arguments():
- dispatcher = next(
- tool
- for tool in get_agentengine_tools(include=["agentengine_tool_dispatcher"])
- if tool.name == "agentengine_tool_dispatcher"
- )
-
- result = dispatcher.invoke(
- {
- "action": "call",
- "tool_name": "component_status",
- "arguments": "{}",
- }
- )
-
- assert result["ok"] is True
- assert result["tool_name"] == "component_status"
- assert result["result"]["ok"] is True
-
-
-def test_dispatcher_describe_exposes_langchain_tool_args():
- result = agentengine_tool_dispatcher(action="describe", tool_name="save_memory")
-
- assert result["ok"] is True
- assert result["tool"]["args"]["content"]["type"] == "string"
-
-
-def test_dispatcher_save_memory_accepts_key_value_arguments(monkeypatch):
- service = _FakeMemoryService()
- monkeypatch.setattr("ksadk.memory.tool._get_or_create_service", lambda: service)
-
- with platform_invocation_scope(_context()):
- result = agentengine_tool_dispatcher(
- action="call",
- tool_name="save_memory",
- arguments={"key": "user_name", "value": "张三"},
- )
-
- assert result == {
- "ok": True,
- "tool_name": "save_memory",
- "result": {"ok": True, "status": "persisted", "message": "记忆已保存。"},
- }
- assert service.save_calls == [
- (
- "user-1",
- "user_name: 张三",
- {
- "agent_id": "demo-agent",
- "session_id": "sess-1",
- "runner_type": "langgraph",
- },
- )
- ]
-
-
-def test_dispatcher_propagates_save_memory_failure(monkeypatch):
- service = _FailingMemoryService()
- monkeypatch.setattr("ksadk.memory.tool._get_or_create_service", lambda: service)
-
- with platform_invocation_scope(_context()):
- result = agentengine_tool_dispatcher(
- action="call",
- tool_name="save_memory",
- arguments={"content": "用户喜欢云主机"},
- )
-
- assert result["ok"] is False
- assert result["tool_name"] == "save_memory"
- assert result["result"]["ok"] is False
- assert "记忆保存失败" in result["result"]["message"]
-
-
-def test_workspace_read_returns_line_metadata_and_records_state(monkeypatch, tmp_path):
- clear_read_state()
- monkeypatch.setattr("ksadk.toolsets.workspace.resolve_local_session_dir", lambda: tmp_path)
- workspace = tmp_path / "workspace"
- workspace.mkdir()
- (workspace / "demo.py").write_text("one\ntwo\nthree\n", encoding="utf-8")
-
- result = read_workspace_file("demo.py", start_line=2, end_line=3)
-
- assert result["ok"] is True
- assert result["content"] == "2 | two\n3 | three"
- assert result["start_line"] == 2
- assert result["end_line"] == 3
- assert result["total_lines"] == 3
- assert result["line_count"] == 2
- assert result["read_range"] == {"start": 2, "end": 3}
- assert result["suggested_action"] == ""
- assert result["partial"] is True
- assert result["mtime_ns"] > 0
-
-
-def test_workspace_edit_requires_prior_read(monkeypatch, tmp_path):
- clear_read_state()
- monkeypatch.setattr("ksadk.toolsets.workspace.resolve_local_session_dir", lambda: tmp_path)
- workspace = tmp_path / "workspace"
- workspace.mkdir()
- (workspace / "demo.py").write_text("print('hello')\n", encoding="utf-8")
-
- result = edit_workspace_file("demo.py", "hello", "world")
-
- assert result["ok"] is False
- assert result["error_type"] == "file_not_read"
- assert "read_workspace_file" in result["suggested_action"]
-
-
-def test_workspace_read_state_is_isolated_by_tool_session(monkeypatch, tmp_path):
- clear_read_state()
- monkeypatch.setattr("ksadk.toolsets.workspace.resolve_local_session_dir", lambda: tmp_path)
- workspace = tmp_path / "workspace"
- workspace.mkdir()
- (workspace / "demo.py").write_text("print('hello')\n", encoding="utf-8")
-
- with tool_execution_scope(session_id="sess-a"):
- assert read_workspace_file("demo.py")["ok"] is True
-
- with tool_execution_scope(session_id="sess-b"):
- unread = edit_workspace_file("demo.py", "hello", "world")
-
- with tool_execution_scope(session_id="sess-a"):
- edited = edit_workspace_file("demo.py", "hello", "world")
-
- assert unread["ok"] is False
- assert unread["error_type"] == "file_not_read"
- assert edited["ok"] is True
- assert (workspace / "demo.py").read_text(encoding="utf-8") == "print('world')\n"
-
-
-def test_workspace_read_state_default_context_supports_direct_read_then_edit(monkeypatch, tmp_path):
- clear_read_state()
- monkeypatch.setattr("ksadk.toolsets.workspace.resolve_local_session_dir", lambda: tmp_path)
- workspace = tmp_path / "workspace"
- workspace.mkdir()
- (workspace / "demo.py").write_text("print('hello')\n", encoding="utf-8")
-
- assert read_workspace_file("demo.py")["ok"] is True
- result = edit_workspace_file("demo.py", "hello", "world")
-
- assert result["ok"] is True
- assert (workspace / "demo.py").read_text(encoding="utf-8") == "print('world')\n"
-
-
-def test_workspace_edit_checks_mtime_and_updates_read_state(monkeypatch, tmp_path):
- clear_read_state()
- monkeypatch.setattr("ksadk.toolsets.workspace.resolve_local_session_dir", lambda: tmp_path)
- workspace = tmp_path / "workspace"
- workspace.mkdir()
- target = workspace / "demo.py"
- target.write_text("print('hello')\nprint('again')\n", encoding="utf-8")
- assert read_workspace_file("demo.py")["ok"] is True
- target.write_text("print('external')\n", encoding="utf-8")
-
- stale = edit_workspace_file("demo.py", "external", "changed")
-
- assert stale["ok"] is False
- assert stale["error_type"] == "file_modified_since_read"
- assert read_workspace_file("demo.py")["ok"] is True
- first = edit_workspace_file("demo.py", "external", "changed")
- second = edit_workspace_file("demo.py", "changed", "changed again")
- assert first["ok"] is True
- assert second["ok"] is True
- assert "changed again" in target.read_text(encoding="utf-8")
-
-
-def test_workspace_edit_supports_quote_normalization_and_replace_all(monkeypatch, tmp_path):
- clear_read_state()
- monkeypatch.setattr("ksadk.toolsets.workspace.resolve_local_session_dir", lambda: tmp_path)
- workspace = tmp_path / "workspace"
- workspace.mkdir()
- target = workspace / "demo.txt"
- target.write_text("“name”\n“name”\n", encoding="utf-8")
- assert read_workspace_file("demo.txt")["ok"] is True
-
- ambiguous = edit_workspace_file("demo.txt", '"name"', "value")
- replaced = edit_workspace_file("demo.txt", '"name"', "value", replace_all=True)
-
- assert ambiguous["ok"] is False
- assert ambiguous["error_type"] == "ambiguous_edit"
- assert replaced["ok"] is True
- assert replaced["used_quote_normalization"] is True
- assert replaced["replacements"] == 2
- assert target.read_text(encoding="utf-8") == "value\nvalue\n"
-
-
-def test_workspace_edit_returns_match_diagnostics_for_not_found_and_ambiguous(monkeypatch, tmp_path):
- clear_read_state()
- monkeypatch.setattr("ksadk.toolsets.workspace.resolve_local_session_dir", lambda: tmp_path)
- workspace = tmp_path / "workspace"
- workspace.mkdir()
- target = workspace / "demo.py"
- target.write_text("alpha = 1\nalpha = 2\nalphabet = 3\n", encoding="utf-8")
- assert read_workspace_file("demo.py")["ok"] is True
-
- missing = edit_workspace_file("demo.py", "alpah = 2", "alpha = 20")
- ambiguous = edit_workspace_file("demo.py", "alpha = ", "beta = ")
-
- assert missing["ok"] is False
- assert missing["error_type"] == "snippet_not_found"
- assert missing["nearby_candidates"]
- assert missing["nearby_candidates"][0]["line"] == 2
- assert ambiguous["ok"] is False
- assert ambiguous["error_type"] == "ambiguous_edit"
- assert [match["line"] for match in ambiguous["matches"]] == [1, 2]
-
-
-def test_workspace_multi_edit_is_atomic_and_returns_budgeted_diff(monkeypatch, tmp_path):
- clear_read_state()
- monkeypatch.setenv("KSADK_TOOL_RESULT_DIR", str(tmp_path / "tool-results"))
- monkeypatch.setattr("ksadk.toolsets.workspace.resolve_local_session_dir", lambda: tmp_path)
- workspace = tmp_path / "workspace"
- workspace.mkdir()
- target = workspace / "demo.py"
- target.write_text("one = 1\ntwo = 2\nthree = 3\n", encoding="utf-8")
- assert read_workspace_file("demo.py")["ok"] is True
-
- failed = multi_edit_workspace_file(
- "demo.py",
- edits=[
- {"old_text": "one = 1", "new_text": "one = 10"},
- {"old_text": "missing = 0", "new_text": "missing = 1"},
- ],
- )
- assert failed["ok"] is False
- assert target.read_text(encoding="utf-8") == "one = 1\ntwo = 2\nthree = 3\n"
-
- result = multi_edit_workspace_file(
- "demo.py",
- edits=[
- {"old_text": "one = 1", "new_text": "one = 10"},
- {"old_text": "three = 3", "new_text": "three = 30"},
- ],
- )
-
- assert result["ok"] is True
- assert result["edit_count"] == 2
- assert result["replacements"] == 2
- assert "one = 10" in target.read_text(encoding="utf-8")
- assert "diff" in result
-
-
-def test_workspace_search_supports_regex_glob_context_and_max_results(monkeypatch, tmp_path):
- monkeypatch.setattr("ksadk.toolsets.workspace.shutil.which", lambda _name: None)
- monkeypatch.setattr("ksadk.toolsets.workspace.resolve_local_session_dir", lambda: tmp_path)
- workspace = tmp_path / "workspace"
- workspace.mkdir()
- (workspace / "a.py").write_text("alpha\nneedle_123\nomega\n", encoding="utf-8")
- (workspace / "b.txt").write_text("needle_456\n", encoding="utf-8")
-
- result = search_workspace_files(r"needle_\d+", glob="*.py", is_regex=True, context_lines=1, max_results=1)
-
- assert result["ok"] is True
- assert len(result["results"]) == 1
- assert result["results"][0]["path"] == "a.py"
- assert result["results"][0]["line"] == 2
- assert "alpha" in result["results"][0]["context_before"]
- assert "omega" in result["results"][0]["context_after"]
- assert result["truncated"] is False
- assert result["searched_path"] == "."
- assert result["match_count"] == 1
- assert result["context_lines"] == 1
- assert result["search_backend"] == "python"
-
-
-def test_workspace_search_marks_truncated_when_matches_exceed_limit(monkeypatch, tmp_path):
- monkeypatch.setattr("ksadk.toolsets.workspace.shutil.which", lambda _name: None)
- monkeypatch.setattr("ksadk.toolsets.workspace.resolve_local_session_dir", lambda: tmp_path)
- workspace = tmp_path / "workspace"
- workspace.mkdir()
- (workspace / "a.py").write_text("needle\n", encoding="utf-8")
- (workspace / "b.py").write_text("needle\n", encoding="utf-8")
-
- result = search_workspace_files("needle", glob="*.py", max_results=1)
-
- assert result["ok"] is True
- assert len(result["results"]) == 1
- assert result["truncated"] is True
-
-
-def test_workspace_list_supports_glob_sort_and_include_dirs(monkeypatch, tmp_path):
- clear_read_state()
- monkeypatch.setattr("ksadk.toolsets.workspace.resolve_local_session_dir", lambda: tmp_path)
- workspace = tmp_path / "workspace"
- (workspace / "pkg").mkdir(parents=True)
- (workspace / "b.py").write_text("b", encoding="utf-8")
- (workspace / "a.py").write_text("aaaa", encoding="utf-8")
- (workspace / "pkg" / "c.txt").write_text("c", encoding="utf-8")
-
- result = list_workspace_files(".", glob="*.py", recursive=True, include_dirs=False, sort_by="size")
-
- assert result["ok"] is True
- assert [entry["path"] for entry in result["entries"]] == ["b.py", "a.py"]
diff --git a/tests/test_aicp_env.py b/tests/test_aicp_env.py
deleted file mode 100644
index 49d965f5..00000000
--- a/tests/test_aicp_env.py
+++ /dev/null
@@ -1,83 +0,0 @@
-from __future__ import annotations
-
-import socket
-
-from ksadk.common.aicp_env import resolve_aicp_connection
-
-
-def test_resolve_aicp_connection_uses_explicit_endpoint(monkeypatch):
- monkeypatch.setenv("KSADK_KB_ENDPOINT", "aicp.example.com")
- monkeypatch.setenv("KSADK_KB_REGION", "pre-online")
-
- connection = resolve_aicp_connection("KSADK_KB")
-
- assert connection == {
- "endpoint": "aicp.example.com",
- "scheme": "https",
- "region": "pre-online",
- }
-
-
-def test_resolve_aicp_connection_prefers_reachable_inner_endpoint(monkeypatch):
- def fake_create_connection(address, timeout=1.0):
- host, port = address
- if host == "aicp.internal.api.ksyun.com" and port == 80:
- return _FakeSocket()
- raise OSError("unreachable")
-
- monkeypatch.delenv("KSADK_KB_ENDPOINT", raising=False)
- monkeypatch.delenv("KSADK_KB_SCHEME", raising=False)
- monkeypatch.setattr(socket, "create_connection", fake_create_connection)
-
- connection = resolve_aicp_connection("KSADK_KB")
-
- assert connection["endpoint"] == "aicp.internal.api.ksyun.com"
- assert connection["scheme"] == "http"
-
-
-def test_resolve_aicp_connection_falls_back_to_inner_when_internal_unreachable(monkeypatch):
- def fake_create_connection(address, timeout=1.0):
- host, port = address
- if host == "aicp.inner.api.ksyun.com" and port == 80:
- return _FakeSocket()
- raise OSError("unreachable")
-
- monkeypatch.delenv("KSADK_KB_ENDPOINT", raising=False)
- monkeypatch.delenv("KSADK_KB_SCHEME", raising=False)
- monkeypatch.setattr(socket, "create_connection", fake_create_connection)
-
- connection = resolve_aicp_connection("KSADK_KB")
-
- assert connection["endpoint"] == "aicp.inner.api.ksyun.com"
- assert connection["scheme"] == "http"
-
-
-def test_resolve_aicp_connection_falls_back_to_public_when_private_endpoints_unreachable(monkeypatch):
- monkeypatch.delenv("KSADK_KB_ENDPOINT", raising=False)
- monkeypatch.delenv("KSADK_KB_SCHEME", raising=False)
- monkeypatch.setattr(socket, "create_connection", lambda *args, **kwargs: (_ for _ in ()).throw(OSError()))
-
- connection = resolve_aicp_connection("KSADK_KB")
-
- assert connection["endpoint"] == "aicp.api.ksyun.com"
- assert connection["scheme"] == "https"
-
-
-def test_resolve_aicp_connection_honors_global_endpoint_mode(monkeypatch):
- monkeypatch.delenv("KSADK_KB_ENDPOINT", raising=False)
- monkeypatch.delenv("KSADK_KB_SCHEME", raising=False)
- monkeypatch.setenv("KSADK_AICP_ENDPOINT_MODE", "inner")
- monkeypatch.setattr(socket, "create_connection", lambda *args, **kwargs: (_ for _ in ()).throw(OSError()))
-
- connection = resolve_aicp_connection("KSADK_KB")
-
- assert connection["endpoint"] == "aicp.inner.api.ksyun.com"
- assert connection["scheme"] == "http"
-
-
-class _FakeSocket:
- def __enter__(self):
- return self
-
- def __exit__(self, exc_type, exc, tb):
- return False
diff --git a/tests/test_attachment_pipeline.py b/tests/test_attachment_pipeline.py
deleted file mode 100644
index d83c2579..00000000
--- a/tests/test_attachment_pipeline.py
+++ /dev/null
@@ -1,106 +0,0 @@
-from __future__ import annotations
-
-import base64
-import io
-import zipfile
-
-import pytest
-
-from ksadk.conversations.normalize import normalize_parts_content
-from ksadk.server.api_models import InlineData, Part
-
-
-def _inline_part(*, name: str, mime_type: str, raw: bytes) -> Part:
- return Part(
- inlineData=InlineData(
- data=base64.b64encode(raw).decode("ascii"),
- mimeType=mime_type,
- displayName=name,
- )
- )
-
-
-def test_normalize_parts_content_returns_attachment_results_for_inline_text_file():
- payload = normalize_parts_content(
- [_inline_part(name="resume.txt", mime_type="text/plain", raw="张三\n8年经验".encode("utf-8"))]
- )
-
- assert payload["attachments"][0]["display_name"] == "resume.txt"
- result = payload["attachment_results"][0]
- assert result["display_name"] == "resume.txt"
- assert result["kind"] == "text"
- assert result["status"] == "ok"
- assert result["extraction_method"] == "text_decode"
- assert result["text_excerpt"] == "张三\n8年经验"
- assert result["text"] == "张三\n8年经验"
- assert payload["content"].startswith("[上传文件: resume.txt]")
- assert "8年经验" in payload["content"]
-
-
-def test_normalize_parts_content_falls_back_to_pdf_ocr_when_native_extract_is_empty(monkeypatch):
- monkeypatch.setattr(
- "ksadk.conversations.attachments.extract_pdf_text",
- lambda raw: "",
- )
- monkeypatch.setattr(
- "ksadk.conversations.attachments.perform_ocr",
- lambda raw, mime_type, display_name: {
- "text": "李四 10年产品经验",
- "engine": "mock-ocr",
- },
- )
-
- payload = normalize_parts_content(
- [_inline_part(name="resume.pdf", mime_type="application/pdf", raw=b"%PDF-1.4 fake")]
- )
-
- result = payload["attachment_results"][0]
- assert result["kind"] == "document"
- assert result["status"] == "ok"
- assert result["extraction_method"] == "pdf_ocr"
- assert result["text"] == "李四 10年产品经验"
- assert any("OCR" in warning for warning in result["warnings"])
- assert result["document"]["ocr_engine"] == "mock-ocr"
-
-
-def test_normalize_parts_content_uses_ocr_for_image_attachments(monkeypatch):
- monkeypatch.setattr(
- "ksadk.conversations.attachments.perform_ocr",
- lambda raw, mime_type, display_name: {
- "text": "王五\n算法工程师",
- "engine": "mock-ocr",
- },
- )
-
- payload = normalize_parts_content(
- [_inline_part(name="avatar.png", mime_type="image/png", raw=b"\x89PNG\r\n")]
- )
-
- result = payload["attachment_results"][0]
- assert result["kind"] == "image"
- assert result["status"] == "ok"
- assert result["extraction_method"] == "image_ocr"
- assert result["text"] == "王五\n算法工程师"
- assert result["image"]["ocr_engine"] == "mock-ocr"
-
-
-def test_normalize_parts_content_safely_enumerates_zip_and_blocks_nested_archives():
- archive_stream = io.BytesIO()
- with zipfile.ZipFile(archive_stream, "w") as archive:
- archive.writestr("resume.txt", "候选人A\n负责增长业务")
- archive.writestr("nested.zip", b"PK\x03\x04not-allowed")
- archive.writestr("../escape.txt", "blocked")
-
- payload = normalize_parts_content(
- [_inline_part(name="bundle.zip", mime_type="application/zip", raw=archive_stream.getvalue())]
- )
-
- result = payload["attachment_results"][0]
- assert result["kind"] == "archive"
- assert result["status"] == "partial"
- assert result["extraction_method"] == "zip_enumeration"
- assert any("nested.zip" in warning for warning in result["warnings"])
- assert any("escape.txt" in warning for warning in result["warnings"])
- assert result["archive"]["entries"][0]["path"] == "resume.txt"
- assert result["archive"]["extracted_entries"][0]["display_name"] == "resume.txt"
- assert "候选人A" in result["text"]
diff --git a/tests/test_attachment_storage.py b/tests/test_attachment_storage.py
deleted file mode 100644
index 9102c52e..00000000
--- a/tests/test_attachment_storage.py
+++ /dev/null
@@ -1,169 +0,0 @@
-from __future__ import annotations
-
-import importlib
-
-import httpx
-import pytest
-
-from ksadk.conversations.attachment_storage import AttachmentStorageService
-from ksadk.conversations.attachments import resolve_attachment_storage_path
-from ksadk.conversations.normalize import normalize_parts_content
-from ksadk.server.api_models import FileData, Part
-
-
-@pytest.mark.asyncio
-async def test_runtime_upload_file_uses_ks3_metadata_and_attachment_content_reads_ks3(
- monkeypatch,
- tmp_path,
-):
- server_app_module = importlib.import_module("ksadk.server.app")
- ui_dir = tmp_path / ".agentengine" / "ui"
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir))
- monkeypatch.setenv("KSYUN_ACCOUNT_ID", "acct-1")
- monkeypatch.setenv("KS3_REGION", "cn-beijing-6")
- stored: dict[tuple[str, str], bytes] = {}
-
- async def fake_put(self, *, bucket, object_key, data, mime_type):
- assert bucket == "agentengine-acct-1-cn-beijing-6"
- assert object_key.startswith("agents/_runtime/attachments/")
- assert object_key.endswith(".png")
- assert mime_type == "image/png"
- stored[(bucket, object_key)] = data
-
- async def fake_read(self, *, bucket, object_key):
- return stored[(bucket, object_key)]
-
- monkeypatch.setattr(AttachmentStorageService, "_put_ks3_object", fake_put)
- monkeypatch.setattr(AttachmentStorageService, "_read_ks3_object", fake_read)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- upload_response = await client.post(
- "/agentengine/api/v1/UploadFile",
- files={"file": ("arch.png", b"\x89PNG\r\n\x1a\nruntime-ks3", "image/png")},
- )
-
- assert upload_response.status_code == 200
- file_uri = upload_response.json()["Data"]["FileData"]["fileUri"]
- file_id = file_uri.removeprefix("ksadk-upload://")
- local_file = ui_dir / "files" / f"{file_id}.png"
- local_file.unlink()
-
- content_response = await client.get(
- "/agentengine/api/v1/AttachmentContent",
- params={"FileUri": file_uri},
- )
-
- assert content_response.status_code == 200
- assert content_response.headers["content-type"].startswith("image/png")
- assert content_response.content == b"\x89PNG\r\n\x1a\nruntime-ks3"
-
-
-def test_resolve_attachment_storage_path_restores_missing_local_cache_from_ks3(
- monkeypatch,
- tmp_path,
-):
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / ".agentengine" / "ui"))
- monkeypatch.setenv("KSYUN_ACCOUNT_ID", "acct-1")
- monkeypatch.setenv("KS3_REGION", "cn-beijing-6")
- service = AttachmentStorageService()
-
- async def fake_put(self, **_kwargs):
- return None
-
- async def fake_read(self, *, bucket, object_key):
- assert bucket == "agentengine-acct-1-cn-beijing-6"
- assert object_key.startswith("agents/_runtime/attachments/")
- return b"restored"
-
- monkeypatch.setattr(AttachmentStorageService, "_put_ks3_object", fake_put)
- monkeypatch.setattr(AttachmentStorageService, "_read_ks3_object", fake_read)
-
- file_uri, local_path = service.store_sync(
- data=b"initial",
- file_id="abc123.png",
- display_name="abc.png",
- mime_type="image/png",
- )
- local_path.unlink()
-
- restored_path = resolve_attachment_storage_path(file_uri)
-
- assert restored_path == local_path
- assert restored_path.read_bytes() == b"restored"
-
-
-def test_resolve_attachment_storage_path_downloads_hosted_ae_upload_via_kop(
- monkeypatch,
- tmp_path,
-):
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / ".agentengine" / "ui"))
- calls = []
-
- class FakeResponse:
- status_code = 200
- headers = {
- "content-type": "text/markdown; charset=utf-8",
- "content-disposition": 'inline; filename="brief.md"',
- }
- content = b"# Brief\n\nHosted attachment body"
-
- def fake_action_raw_request(self, method, action, *, params=None, **_kwargs):
- calls.append({"method": method, "action": action, "params": params})
- return FakeResponse()
-
- monkeypatch.setattr(
- "ksadk.api.client.AgentEngineClient._action_raw_request",
- fake_action_raw_request,
- )
-
- restored_path = resolve_attachment_storage_path("ae-upload://hosted123.md")
-
- assert calls == [
- {
- "method": "GET",
- "action": "AttachmentContent",
- "params": {"FileUri": "ae-upload://hosted123.md"},
- }
- ]
- assert restored_path is not None
- assert restored_path.name == "hosted123.md"
- assert restored_path.read_bytes() == b"# Brief\n\nHosted attachment body"
-
-
-def test_normalize_parts_content_reads_hosted_markdown_attachment_via_kop(
- monkeypatch,
- tmp_path,
-):
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / ".agentengine" / "ui"))
-
- class FakeResponse:
- status_code = 200
- headers = {
- "content-type": "text/markdown",
- "content-disposition": 'inline; filename="brief.md"',
- }
- content = b"# Brief\n\nHosted attachment body"
-
- monkeypatch.setattr(
- "ksadk.api.client.AgentEngineClient._action_raw_request",
- lambda self, method, action, *, params=None, **_kwargs: FakeResponse(),
- )
-
- payload = normalize_parts_content(
- [
- Part(
- fileData=FileData(
- fileUri="ae-upload://hosted123.md",
- mimeType="text/markdown",
- displayName="brief.md",
- )
- )
- ]
- )
-
- result = payload["attachment_results"][0]
- assert result["status"] == "ok"
- assert result["kind"] == "text"
- assert result["text"] == "# Brief\n\nHosted attachment body"
- assert "Hosted attachment body" in payload["content"]
diff --git a/tests/test_background_run.py b/tests/test_background_run.py
deleted file mode 100644
index f70d86f1..00000000
--- a/tests/test_background_run.py
+++ /dev/null
@@ -1,468 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-import importlib
-from types import SimpleNamespace
-
-import httpx
-import pytest
-from fastapi.testclient import TestClient
-from httpx import ASGITransport
-
-from ksadk.runners.base_runner import BaseRunner
-
-
-class _SlowBackgroundRunner(BaseRunner):
- """runner 的 stream 产慢流,用于验证 background 立即返回。"""
-
- def __init__(self):
- super().__init__(
- detection_result=SimpleNamespace(
- name="background-test-agent",
- description="bg",
- type=SimpleNamespace(value="langgraph"),
- ),
- project_dir=".",
- )
- self.stream_started = asyncio.Event()
- self.stream_finished = asyncio.Event()
-
- def load_agent(self) -> None:
- return None
-
- async def invoke(self, input_data: dict) -> dict:
- return {"output": "ok"}
-
- async def stream(self, input_data: dict):
- self.stream_started.set()
- await asyncio.sleep(0.3)
- yield {"type": "text", "delta": "hello"}
- yield {"type": "final", "output": "hello world"}
- self.stream_finished.set()
-
-
-class _FailingBackgroundRunner(_SlowBackgroundRunner):
- async def stream(self, input_data: dict):
- self.stream_started.set()
- raise RuntimeError("background boom")
- yield # pragma: no cover
-
-
-@pytest.fixture
-def bg_client(monkeypatch, tmp_path):
- monkeypatch.setenv("KSADK_SESSION_BACKEND", "memory")
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / "ui"))
- from ksadk.server import app, set_runner
-
- runner = _SlowBackgroundRunner()
- set_runner(runner)
- yield app, runner
-
-
-@pytest.fixture
-def failing_bg_client(monkeypatch, tmp_path):
- monkeypatch.setenv("KSADK_SESSION_BACKEND", "memory")
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / "ui"))
- from ksadk.server import app, set_runner
-
- runner = _FailingBackgroundRunner()
- set_runner(runner)
- yield app, runner
-
-
-async def _run_statuses(session_id: str, invocation_id: str) -> list[str]:
- from ksadk.sessions import resolve_session_service
-
- events = await resolve_session_service().get_events(session_id)
- return [
- (event.content or {}).get("status")
- for event in events
- if event.event_type == "run_status" and event.invocation_id == invocation_id
- ]
-
-
-async def _wait_for_terminal_statuses(session_id: str, invocation_id: str) -> list[str]:
- for _ in range(40):
- statuses = await _run_statuses(session_id, invocation_id)
- if _terminal_statuses(statuses):
- return statuses
- await asyncio.sleep(0.05)
- return await _run_statuses(session_id, invocation_id)
-
-
-def _terminal_statuses(statuses: list[str]) -> list[str]:
- return [status for status in statuses if status in {"completed", "cancelled", "failed"}]
-
-
-def test_background_field_is_parsed(bg_client):
- """RunAgentActionRequest 能解析 Background 字段(默认 False)。"""
- app, _ = bg_client
- client = TestClient(app)
- # Background=true 应被接受(不报 422 校验错误),后续 Task 才验证行为
- # 这里只验证字段存在且可解析——发一个最小请求触发校验
- resp = client.post(
- "/agentengine/api/v1/RunAgent",
- json={"AgentId": "a", "Messages": [{"role": "user", "content": "hi"}], "Background": True},
- )
- # 非 422 即说明 Background 字段被模型接受
- assert resp.status_code != 422
- # 正向断言:字段确已落在模型上(pydantic 默认忽略 extra,需直接校验属性可读)
- from ksadk.server.app import RunAgentActionRequest
-
- req = RunAgentActionRequest(AgentId="a", Messages=[], Background=True)
- assert hasattr(req, "Background"), "RunAgentActionRequest 缺 Background 字段"
- assert req.Background is True
- # 默认 False(向后兼容)
- assert RunAgentActionRequest(AgentId="a").Background is False
-
-
-@pytest.mark.asyncio
-async def test_run_agent_background_returns_immediately_with_job_handle(bg_client):
- """Background=true 立即返回 job 句柄,不等后台 stream 跑完。"""
- app, runner = bg_client
- # 用 ASGITransport + AsyncClient 而非同步 TestClient:同步 TestClient 在
- # 请求返回后即拆除事件循环,asyncio.create_task 的 detached 后台任务无法存活
- # (会在 sleep 中被取消)。async client 让事件循环持续驱动 detached _consume。
- transport = ASGITransport(app=app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- resp = await client.post(
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": "a",
- "SessionId": "sess-bg-1",
- "Messages": [{"role": "user", "content": "研究 AI Agent 趋势"}],
- "Background": True,
- "Stream": False,
- },
- )
- assert resp.status_code == 200, resp.text
- data = resp.json()["Data"]
- # 立即返回 running 状态(后台 stream 还在跑,stream_finished 未 set)
- assert data["Status"] == "running"
- assert data["Background"] is True
- assert "InvocationId" in data and data["InvocationId"]
- # SessionId 顶层字段(与 SubscribeUrl 一致,避免前端从 URL 反解)
- assert "SessionId" in data and data["SessionId"]
- # 关键:响应返回时后台慢流(0.3s)还没跑完,证明是立即返回而非阻塞
- assert not runner.stream_finished.is_set(), "background 应立即返回,不该等 stream 完成"
- # InvocationId 落入 _DETACHED_STREAMS_BY_INVOCATION,CancelRun 能查到
- from ksadk.server.app import _DETACHED_STREAMS_BY_INVOCATION
-
- assert data["InvocationId"] in _DETACHED_STREAMS_BY_INVOCATION
-
-
-@pytest.mark.asyncio
-async def test_run_agent_background_primes_session_title_before_detached_stream_consumes(bg_client, monkeypatch):
- """Background=true 返回 job 句柄前先写入首轮 prompt/title,刷新列表不显示空标题。"""
- server_app_module = importlib.import_module("ksadk.server.app")
- from ksadk.sessions import resolve_session_service
-
- class _IdleDetachedStream:
- def __init__(self, source, *, invocation_id=None, session_id=None, run_mode="unknown", run_trigger="unknown"):
- self.source = source
- self.invocation_id = invocation_id
- self.session_id = session_id
- self._run_mode = run_mode
- self._run_trigger = run_trigger
- self._task = asyncio.Future()
-
- monkeypatch.setattr(server_app_module, "_DetachedSSEStream", _IdleDetachedStream)
-
- app, _runner = bg_client
- transport = ASGITransport(app=app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- resp = await client.post(
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": "a",
- "SessionId": "sess-bg-title",
- "ResponsesInput": [
- {
- "role": "user",
- "content": [{"type": "input_text", "text": "调研 2026 企业 AI Agent 平台趋势"}],
- }
- ],
- "ApiFormat": "responses",
- "Background": True,
- "Stream": False,
- },
- )
- assert resp.status_code == 200, resp.text
- invocation_id = resp.json()["Data"]["InvocationId"]
- listed = await client.post(
- "/agentengine/api/v1/ListSessions",
- json={"AgentId": "a"},
- )
- assert listed.status_code == 200, listed.text
-
- session = await resolve_session_service().get_session("sess-bg-title")
- assert session is not None
- assert session.first_prompt == "调研 2026 企业 AI Agent 平台趋势"
- assert session.last_prompt == "调研 2026 企业 AI Agent 平台趋势"
- assert session.title
- assert session.title != "sess-bg-title"
- assert session.title_source == "fallback_first_prompt"
- listed_session = listed.json()["Data"]["Sessions"][0]
- assert listed_session["FirstPrompt"] == "调研 2026 企业 AI Agent 平台趋势"
- assert listed_session["ActiveInvocationId"] == invocation_id
- assert listed_session["ActiveRunStatus"] == "in_progress"
- # Background:true 的 run 应标记 run_mode=background, run_trigger=new_run
- assert listed_session["ActiveRunMode"] == "background"
- assert listed_session["ActiveRunTrigger"] == "new_run"
-
-
-@pytest.mark.asyncio
-async def test_run_agent_background_subscribe_gets_terminal_status(bg_client):
- """background 起任务后,SubscribeRunEvents 拉到 run_status 终态 + [DONE]。"""
- app, runner = bg_client
- transport = ASGITransport(app=app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- resp = await client.post(
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": "a",
- "SessionId": "sess-bg-2",
- "Messages": [{"role": "user", "content": "研究 X"}],
- "Background": True,
- "Stream": False,
- },
- )
- invocation_id = resp.json()["Data"]["InvocationId"]
- # 等后台慢流(0.3s)跑完写终态,再 subscribe(SubscribeRunEvents 也支持
- # 后到也能拉到历史 run_status 事件,但先等终态写入更稳)
- await runner.stream_finished.wait()
- # 用流式 GET 拉 SubscribeRunEvents,读到 [DONE] 终止符
- chunks: list[str] = []
- async with client.stream(
- "GET",
- f"/agentengine/api/v1/SubscribeRunEvents?SessionId=sess-bg-2&InvocationId={invocation_id}",
- ) as response:
- async for line in response.aiter_lines():
- chunks.append(line)
- if "[DONE]" in line:
- break
- body = "\n".join(chunks)
- assert "completed" in body or "run_status" in body, f"期望 run_status 终态,实际: {body[:500]}"
- assert "[DONE]" in body, "期望 [DONE] 终止符"
- assert _terminal_statuses(await _run_statuses("sess-bg-2", invocation_id)) == ["completed"]
-
-
-@pytest.mark.asyncio
-async def test_run_agent_background_writes_single_in_progress_status(bg_client):
- """Background 起始态只由 conversation runtime 写一次,避免刷新/订阅看到重复 running。"""
- app, runner = bg_client
- transport = ASGITransport(app=app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- resp = await client.post(
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": "a",
- "SessionId": "sess-bg-single-start",
- "Messages": [{"role": "user", "content": "研究 X"}],
- "Background": True,
- "Stream": False,
- },
- )
- assert resp.status_code == 200, resp.text
- invocation_id = resp.json()["Data"]["InvocationId"]
- await runner.stream_finished.wait()
-
- statuses = await _run_statuses("sess-bg-single-start", invocation_id)
- assert statuses.count("in_progress") == 1, (
- f"期望同一 InvocationId 只有一个 in_progress,实际 statuses: {statuses}"
- )
- assert _terminal_statuses(statuses) == ["completed"]
-
-
-async def test_detached_stream_does_not_write_duplicate_completed_status(monkeypatch, tmp_path):
- """_DetachedSSEStream 正常结束时不补写 completed,终态由 conversation stream 主写入。"""
- monkeypatch.setenv("KSADK_SESSION_BACKEND", "memory")
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / "ui"))
- from ksadk.server.app import _DetachedSSEStream
- from ksadk.conversations import append_run_status_event
- from ksadk.sessions import resolve_session_service
-
- async def source():
- await append_run_status_event(
- session_id=session_id,
- author="runner",
- status="in_progress",
- invocation_id=invocation_id,
- )
- yield "data: chunk1\n\n"
- await append_run_status_event(
- session_id=session_id,
- author="runner",
- status="completed",
- invocation_id=invocation_id,
- )
- yield "data: chunk2\n\n"
-
- invocation_id = "inv_test_completed"
- session_id = "sess_test_completed"
- service = resolve_session_service()
- await service.create_session(agent_id="a", user_id="u", session_id=session_id)
- detached = _DetachedSSEStream(source(), invocation_id=invocation_id, session_id=session_id)
- # 等后台 _consume 跑完
- await detached._task
- # 查 session 里的 run_status 事件
- statuses = await _run_statuses(session_id, invocation_id)
- assert _terminal_statuses(statuses) == ["completed"], (
- f"期望只有 conversation stream 写入一个 completed,实际 statuses: {statuses}"
- )
-
-
-async def test_detached_stream_writes_failed_fallback_only_when_source_raises(monkeypatch, tmp_path):
- """_DetachedSSEStream 只在源流异常且没有已有终态时兜底写 failed。"""
- monkeypatch.setenv("KSADK_SESSION_BACKEND", "memory")
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / "ui"))
- from ksadk.server.app import _DetachedSSEStream
- from ksadk.sessions import resolve_session_service
-
- async def source():
- yield "data: chunk1\n\n"
- raise RuntimeError("raw stream failed")
-
- invocation_id = "inv_test_raw_failed"
- session_id = "sess_test_raw_failed"
- service = resolve_session_service()
- await service.create_session(agent_id="a", user_id="u", session_id=session_id)
- detached = _DetachedSSEStream(source(), invocation_id=invocation_id, session_id=session_id)
-
- with pytest.raises(RuntimeError, match="raw stream failed"):
- await detached._task
-
- statuses = await _run_statuses(session_id, invocation_id)
- assert _terminal_statuses(statuses) == ["failed"], (
- f"期望 detached 异常兜底只写一个 failed,实际 statuses: {statuses}"
- )
-
-
-@pytest.mark.asyncio
-async def test_run_agent_background_lifecycle_does_not_create_checkpoints(bg_client):
- """background lifecycle run_status 不应被 ListSessionCheckpoints 当成恢复点。"""
- app, runner = bg_client
- transport = ASGITransport(app=app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- resp = await client.post(
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": "a",
- "SessionId": "sess-bg-no-checkpoints",
- "Messages": [{"role": "user", "content": "研究 X"}],
- "Background": True,
- "Stream": False,
- },
- )
- assert resp.status_code == 200, resp.text
- await runner.stream_finished.wait()
- checkpoints_resp = await client.post(
- "/agentengine/api/v1/ListSessionCheckpoints",
- json={"AgentId": "a", "SessionId": "sess-bg-no-checkpoints"},
- )
-
- assert checkpoints_resp.status_code == 200, checkpoints_resp.text
- checkpoints_data = checkpoints_resp.json()["Data"]
- assert checkpoints_data["Checkpoints"] == []
- assert checkpoints_data["Total"] == 0
- # 无 checkpoint 时聚合字段应为 0/False
- assert checkpoints_data["ResumableTotal"] == 0
- assert checkpoints_data["HasResumableCheckpoint"] is False
-
-
-@pytest.mark.asyncio
-async def test_run_agent_background_cancel_writes_cancelled_status(bg_client):
- """CancelRun 对 background 任务生效,写 run_status=cancelled 终态。"""
- from ksadk.server.app import _DETACHED_STREAMS_BY_INVOCATION
- from ksadk.sessions import resolve_session_service
-
- app, runner = bg_client
- transport = ASGITransport(app=app)
- async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
- # 起 background 任务(慢流 0.3s,cancel 前后台还在 sleep)
- resp = await client.post(
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": "a",
- "SessionId": "sess-bg-cancel",
- "Messages": [{"role": "user", "content": "研究 X"}],
- "Background": True,
- "Stream": False,
- },
- )
- assert resp.status_code == 200, resp.text
- invocation_id = resp.json()["Data"]["InvocationId"]
- assert invocation_id in _DETACHED_STREAMS_BY_INVOCATION
- # CancelRun:detached 已注册进 dict,开箱即用
- cancel_resp = await client.post(
- "/agentengine/api/v1/CancelRun",
- json={"InvocationId": invocation_id},
- )
- cancel_data = cancel_resp.json()["Data"]
- assert cancel_data["Found"] is True
- # 等 detached task 处理取消 + finally 写终态
- detached = _DETACHED_STREAMS_BY_INVOCATION.get(invocation_id)
- if detached is not None:
- try:
- await detached._task
- except Exception:
- pass
- # 查 session 里的 run_status 事件
- service = resolve_session_service()
- events = await service.get_events("sess-bg-cancel")
- statuses = [
- (e.content or {}).get("status")
- for e in events
- if e.event_type == "run_status" and e.invocation_id == invocation_id
- ]
- assert _terminal_statuses(statuses) == ["cancelled"], (
- f"期望同一 InvocationId 只有一个 cancelled 终态,实际 statuses: {statuses}"
- )
-
-
-@pytest.mark.asyncio
-async def test_run_agent_background_failure_writes_single_terminal_status(failing_bg_client):
- """background stream 失败时,同一 InvocationId 只有一个 failed 终态。"""
- app, _runner = failing_bg_client
- transport = ASGITransport(app=app)
- async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
- resp = await client.post(
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": "a",
- "SessionId": "sess-bg-failed",
- "Messages": [{"role": "user", "content": "研究 X"}],
- "Background": True,
- "Stream": False,
- },
- )
- assert resp.status_code == 200, resp.text
- invocation_id = resp.json()["Data"]["InvocationId"]
-
- statuses = await _wait_for_terminal_statuses("sess-bg-failed", invocation_id)
- assert _terminal_statuses(statuses) == ["failed"], (
- f"期望同一 InvocationId 只有一个 failed 终态,实际 statuses: {statuses}"
- )
-
-
-@pytest.mark.asyncio
-async def test_run_agent_background_false_preserves_existing_behavior(bg_client):
- """Background 不传(默认 false)+ Stream=false 走现有同步 invoke 路径,行为不变。"""
- app, runner = bg_client
- transport = ASGITransport(app=app)
- async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
- resp = await client.post(
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": "a",
- "SessionId": "sess-bg-compat",
- "Messages": [{"role": "user", "content": "普通问题"}],
- # 不传 Background(默认 False),Stream=False
- },
- )
- assert resp.status_code == 200, resp.text
- data = resp.json()["Data"]
- # 同步 invoke 路径返回普通 payload(含 output),不含 background 句柄字段
- assert "output" in data, f"同步路径应返回 output,实际 Data: {data}"
- assert data.get("Background") is not True, "非 background 路径不该返回 Background:true"
- assert data.get("Status") != "running", "非 background 路径不该返回 Status:running"
diff --git a/tests/test_builder_requirements_merge.py b/tests/test_builder_requirements_merge.py
deleted file mode 100644
index 4b0802fe..00000000
--- a/tests/test_builder_requirements_merge.py
+++ /dev/null
@@ -1,443 +0,0 @@
-from types import SimpleNamespace
-
-from ksadk.builders import container_builder
-from ksadk.builders.code_builder import CodeBuilder
-from ksadk.builders.container_builder import ContainerBuilder
-from ksadk.builders.mcp_builder import MCPCodeBuilder
-from ksadk.deployment.manager import K8sDeployer
-from ksadk.detection import DetectionResult, FrameworkType
-
-
-def _detection_result(framework: str):
- return SimpleNamespace(type=SimpleNamespace(value=framework))
-
-
-def _full_detection_result(framework_type: FrameworkType):
- return DetectionResult(
- type=framework_type,
- name="demo_agent",
- entry_point="demo_agent/agent.py",
- package_path="/tmp/demo_agent",
- agent_variable="root_agent",
- )
-
-
-def test_ensure_docker_running_prints_windows_docker_desktop_hint(monkeypatch, capsys):
- monkeypatch.setattr(container_builder.shutil, "which", lambda _name: "/usr/bin/docker")
- monkeypatch.setattr(container_builder.platform, "system", lambda: "Windows")
- monkeypatch.setattr(
- container_builder.subprocess,
- "run",
- lambda *_args, **_kwargs: SimpleNamespace(returncode=1),
- )
-
- assert container_builder.ensure_docker_running() is False
- output = capsys.readouterr().out
- assert "Docker Desktop" in output
- assert "systemctl" not in output
-
-
-def test_code_builder_prefers_user_pins_over_base_requirements(tmp_path):
- (tmp_path / "requirements.txt").write_text(
- "fastapi==0.121.2\nuvicorn==0.38.0\npython-dotenv==1.2.1\n",
- encoding="utf-8",
- )
- builder = CodeBuilder(tmp_path)
-
- deps = builder._build_requirements_list(_detection_result("langgraph"))
-
- assert "fastapi==0.121.2" in deps
- assert "uvicorn==0.38.0" in deps
- assert "python-dotenv==1.2.1" in deps
- assert "fastapi>=0.100.0" not in deps
- assert "uvicorn>=0.23.0" not in deps
- assert "python-dotenv>=1.0.0" not in deps
-
-
-def test_container_builder_prefers_user_pins_over_base_requirements(tmp_path):
- (tmp_path / "requirements.txt").write_text(
- "fastapi==0.121.2\nuvicorn==0.38.0\npython-dotenv==1.2.1\n",
- encoding="utf-8",
- )
- builder = ContainerBuilder(tmp_path)
-
- deps = builder._generate_requirements(
- _detection_result("langgraph"),
- tmp_path,
- ).splitlines()
-
- assert "fastapi==0.121.2" in deps
- assert "uvicorn==0.38.0" in deps
- assert "python-dotenv==1.2.1" in deps
- assert "fastapi>=0.100.0" not in deps
- assert "uvicorn>=0.23.0" not in deps
- assert "python-dotenv>=1.0.0" not in deps
-
-
-def test_mcp_builder_prefers_user_pins_over_base_requirements(tmp_path):
- (tmp_path / "requirements.txt").write_text(
- "uvicorn==0.38.0\npython-dotenv==1.2.1\n",
- encoding="utf-8",
- )
- builder = MCPCodeBuilder(tmp_path)
- builder.build_dir.mkdir(parents=True, exist_ok=True)
-
- requirements_path = builder._prepare_mcp_requirements(SimpleNamespace())
- deps = requirements_path.read_text(encoding="utf-8").splitlines()
-
- assert "uvicorn==0.38.0" in deps
- assert "python-dotenv==1.2.1" in deps
- assert "uvicorn>=0.23.0" not in deps
- assert "python-dotenv>=1.0.0" not in deps
-
-
-def test_code_builder_omits_bundled_ksadk_package_from_runtime_requirements(tmp_path):
- (tmp_path / "requirements.txt").write_text(
- "fastapi==0.121.2\nksadk==0.4.0\n",
- encoding="utf-8",
- )
- builder = CodeBuilder(tmp_path)
-
- deps = builder._build_requirements_list(_detection_result("langgraph"))
-
- assert "fastapi==0.121.2" in deps
- assert "ksadk==0.4.0" not in deps
- assert all(not dep.startswith("ksadk") for dep in deps)
- assert "a2a-sdk>=0.3.22" in deps
- assert "requests-aws4auth>=1.2.0" in deps
-
-
-def test_container_builder_omits_bundled_ksadk_package_from_runtime_requirements(tmp_path):
- (tmp_path / "requirements.txt").write_text(
- "fastapi==0.121.2\nksadk==0.4.0\n",
- encoding="utf-8",
- )
- builder = ContainerBuilder(tmp_path)
-
- deps = builder._generate_requirements(
- _detection_result("langgraph"),
- tmp_path,
- ).splitlines()
-
- assert "fastapi==0.121.2" in deps
- assert "ksadk==0.4.0" not in deps
- assert all(not dep.startswith("ksadk") for dep in deps)
- assert "a2a-sdk>=0.3.22" in deps
- assert "requests-aws4auth>=1.2.0" in deps
-
-
-def test_code_builder_bundles_attachment_runtime_requirements_without_optional_backends(tmp_path):
- builder = CodeBuilder(tmp_path)
-
- deps = builder._build_requirements_list(_detection_result("langgraph"))
-
- assert "pypdf>=6.0.0" in deps
- assert "beautifulsoup4>=4.12.0" in deps
- assert "rapidocr-onnxruntime>=1.2.0" not in deps
- assert "mcp>=1.1.0" not in deps
- assert "langchain-mcp-adapters>=0.0.1" not in deps
- assert "asyncpg>=0.30.0,<1.0.0" not in deps
- assert "boto3==1.40.61" not in deps
- assert "SQLAlchemy==2.0.44" not in deps
- assert "psycopg[binary]==3.3.0" not in deps
- assert "psycopg-pool==3.3.0" not in deps
- assert "pandas==2.2.2" not in deps
- assert "openpyxl==3.1.5" not in deps
- assert "xlrd==2.0.2" not in deps
- assert "python-pptx==1.0.2" not in deps
- assert "docx2python==3.5.0" not in deps
-
-
-def test_code_builder_includes_mcp_runtime_when_project_uses_langchain_mcp_adapter(tmp_path):
- (tmp_path / "agent.py").write_text(
- "from langchain_mcp_adapters.client import MultiServerMCPClient\n",
- encoding="utf-8",
- )
- builder = CodeBuilder(tmp_path)
-
- deps = builder._build_requirements_list(_detection_result("langgraph"))
-
- assert "mcp>=1.1.0" in deps
- assert "langchain-mcp-adapters>=0.0.1" in deps
-
-
-def test_code_builder_includes_mcp_runtime_when_env_declares_mcp_servers(tmp_path):
- (tmp_path / ".env").write_text('KSADK_MCP_SERVERS=[{"name":"demo","url":"http://mcp"}]\n', encoding="utf-8")
- builder = CodeBuilder(tmp_path)
-
- deps = builder._build_requirements_list(_detection_result("langgraph"))
-
- assert "mcp>=1.1.0" in deps
- assert "langchain-mcp-adapters>=0.0.1" in deps
-
-
-def test_code_builder_does_not_include_mcp_runtime_for_empty_mcp_servers(tmp_path):
- (tmp_path / ".env").write_text("KSADK_MCP_SERVERS=[]\n", encoding="utf-8")
- builder = CodeBuilder(tmp_path)
-
- deps = builder._build_requirements_list(_detection_result("langgraph"))
-
- assert "mcp>=1.1.0" not in deps
- assert "langchain-mcp-adapters>=0.0.1" not in deps
-
-
-def test_code_builder_ignores_cached_build_files_when_detecting_optional_imports(tmp_path):
- cached_dir = tmp_path / ".agentengine" / "code_build" / "old"
- cached_dir.mkdir(parents=True)
- (cached_dir / "agent.py").write_text(
- "from langchain_mcp_adapters.client import MultiServerMCPClient\n",
- encoding="utf-8",
- )
- (tmp_path / "agent.py").write_text("root_agent = object()\n", encoding="utf-8")
- builder = CodeBuilder(tmp_path)
-
- deps = builder._build_requirements_list(_detection_result("langgraph"))
-
- assert "mcp>=1.1.0" not in deps
- assert "langchain-mcp-adapters>=0.0.1" not in deps
-
-
-def test_code_builder_includes_mcp_runtime_when_build_flag_enabled(tmp_path, monkeypatch):
- monkeypatch.setenv("KSADK_BUILD_ENABLE_MCP", "true")
- builder = CodeBuilder(tmp_path)
-
- deps = builder._build_requirements_list(_detection_result("langgraph"))
-
- assert "mcp>=1.1.0" in deps
- assert "langchain-mcp-adapters>=0.0.1" in deps
-
-
-def test_code_builder_includes_asyncpg_when_postgres_session_declared(tmp_path):
- (tmp_path / ".env").write_text("KSADK_SESSION_BACKEND=postgres\n", encoding="utf-8")
- builder = CodeBuilder(tmp_path)
-
- deps = builder._build_requirements_list(_detection_result("langgraph"))
-
- assert "asyncpg>=0.30.0,<1.0.0" in deps
-
-
-def test_code_builder_includes_asyncpg_when_postgres_dsn_declared(tmp_path):
- (tmp_path / ".env").write_text(
- "KSADK_SESSION_DSN=postgresql://user:pass@example.com/db\n",
- encoding="utf-8",
- )
- builder = CodeBuilder(tmp_path)
-
- deps = builder._build_requirements_list(_detection_result("langgraph"))
-
- assert "asyncpg>=0.30.0,<1.0.0" in deps
-
-
-def test_code_builder_includes_asyncpg_when_build_flag_enabled(tmp_path, monkeypatch):
- monkeypatch.setenv("KSADK_BUILD_ENABLE_POSTGRES_SESSION", "true")
- builder = CodeBuilder(tmp_path)
-
- deps = builder._build_requirements_list(_detection_result("langgraph"))
-
- assert "asyncpg>=0.30.0,<1.0.0" in deps
-
-
-def test_code_builder_includes_attachment_ocr_runtime_when_enabled(tmp_path, monkeypatch):
- monkeypatch.setenv("KSADK_BUILD_ENABLE_ATTACHMENT_OCR", "true")
- builder = CodeBuilder(tmp_path)
-
- deps = builder._build_requirements_list(_detection_result("langgraph"))
-
- assert "pypdf>=6.0.0" in deps
- assert "beautifulsoup4>=4.12.0" in deps
- assert "rapidocr-onnxruntime>=1.2.0" in deps
-
-
-def test_container_builder_uses_same_optional_runtime_detection(tmp_path):
- (tmp_path / ".env").write_text(
- 'KSADK_MCP_SERVERS=[{"name":"demo","url":"http://mcp"}]\nKSADK_SESSION_BACKEND=postgres\n',
- encoding="utf-8",
- )
- builder = ContainerBuilder(tmp_path)
-
- deps = builder._generate_requirements(
- _detection_result("langgraph"),
- tmp_path,
- ).splitlines()
-
- assert "mcp>=1.1.0" in deps
- assert "langchain-mcp-adapters>=0.0.1" in deps
- assert "asyncpg>=0.30.0,<1.0.0" in deps
-
-
-def test_code_builder_uses_validated_langgraph_ecosystem_dependency_window(tmp_path):
- builder = CodeBuilder(tmp_path)
-
- deps = builder._build_requirements_list(_detection_result("deepagents"))
-
- assert "fastapi>=0.100.0,<1.0.0" in deps
- assert "langchain>=1.3.0,<2.0.0" in deps
- assert "langchain-core>=1.4.0,<2.0.0" in deps
- assert "langchain-openai>=1.2.0,<2.0.0" in deps
- assert "langgraph>=1.2.0,<1.3.0" in deps
- assert "deepagents>=0.6.2,<1.0.0" in deps
- assert "langgraph>=0.1.0" not in deps
-
-
-def test_code_builder_uses_validated_adk_dependency_window(tmp_path):
- builder = CodeBuilder(tmp_path)
-
- deps = builder._build_requirements_list(_detection_result("adk"))
-
- assert "fastapi>=0.100.0,<1.0.0" in deps
- assert "google-adk>=1.34.0,<2.0.0" in deps
- assert "google-adk>=1.0.0" not in deps
-
-
-def test_container_builder_bundles_attachment_runtime_requirements_without_optional_backends(tmp_path):
- builder = ContainerBuilder(tmp_path)
-
- deps = builder._generate_requirements(
- _detection_result("langgraph"),
- tmp_path,
- ).splitlines()
-
- assert "pypdf>=6.0.0" in deps
- assert "beautifulsoup4>=4.12.0" in deps
- assert "rapidocr-onnxruntime>=1.2.0" not in deps
-
-
-def test_container_builder_includes_attachment_ocr_runtime_when_enabled(tmp_path, monkeypatch):
- monkeypatch.setenv("KSADK_BUILD_ENABLE_ATTACHMENT_OCR", "true")
- builder = ContainerBuilder(tmp_path)
-
- deps = builder._generate_requirements(
- _detection_result("langgraph"),
- tmp_path,
- ).splitlines()
-
- assert "pypdf>=6.0.0" in deps
- assert "beautifulsoup4>=4.12.0" in deps
- assert "rapidocr-onnxruntime>=1.2.0" in deps
-
-
-
-def test_container_builder_uses_same_framework_dependency_windows(tmp_path):
- builder = ContainerBuilder(tmp_path)
-
- deps = builder._generate_requirements(
- _detection_result("deepagents"),
- tmp_path,
- ).splitlines()
-
- assert "fastapi>=0.100.0,<1.0.0" in deps
- assert "langchain>=1.3.0,<2.0.0" in deps
- assert "langchain-core>=1.4.0,<2.0.0" in deps
- assert "langchain-openai>=1.2.0,<2.0.0" in deps
- assert "langgraph>=1.2.0,<1.3.0" in deps
- assert "deepagents>=0.6.2,<1.0.0" in deps
-
-
-def test_k8s_deployer_uses_same_framework_dependency_windows():
- deployer = K8sDeployer()
-
- deps = deployer._generate_requirements(_detection_result("deepagents")).splitlines()
-
- assert "fastapi>=0.100.0,<1.0.0" in deps
- assert "langchain>=1.3.0,<2.0.0" in deps
- assert "langchain-core>=1.4.0,<2.0.0" in deps
- assert "langchain-openai>=1.2.0,<2.0.0" in deps
- assert "langgraph>=1.2.0,<1.3.0" in deps
- assert "deepagents>=0.6.2,<1.0.0" in deps
- assert "boto3==1.40.61" not in deps
- assert "SQLAlchemy==2.0.44" not in deps
- assert "psycopg[binary]==3.3.0" not in deps
- assert "psycopg-pool==3.3.0" not in deps
- assert "pandas==2.2.2" not in deps
- assert "openpyxl==3.1.5" not in deps
- assert "xlrd==2.0.2" not in deps
- assert "python-pptx==1.0.2" not in deps
- assert "docx2python==3.5.0" not in deps
-
-
-def test_code_builder_includes_bundled_attachment_runtime_requirements(tmp_path):
- builder = CodeBuilder(tmp_path)
-
- deps = builder._build_requirements_list(_detection_result("langgraph"))
-
- assert "pypdf>=6.0.0" in deps
- assert "beautifulsoup4>=4.12.0" in deps
-
-
-def test_container_builder_includes_bundled_attachment_runtime_requirements(tmp_path):
- builder = ContainerBuilder(tmp_path)
-
- deps = builder._generate_requirements(
- _detection_result("langgraph"),
- tmp_path,
- ).splitlines()
-
- assert "pypdf>=6.0.0" in deps
- assert "beautifulsoup4>=4.12.0" in deps
-
-
-def test_code_builder_entrypoint_uses_otlp_direct_by_default_for_code_frameworks(tmp_path):
- builder = CodeBuilder(tmp_path)
-
- for framework_type in (
- FrameworkType.ADK,
- FrameworkType.LANGCHAIN,
- FrameworkType.LANGGRAPH,
- FrameworkType.DEEPAGENTS,
- ):
- entrypoint = builder._generate_entrypoint(_full_detection_result(framework_type))
-
- assert "LANGFUSE_USE_CALLBACK" in entrypoint
- assert "use_callback_only=is_langchain" not in entrypoint
- assert 'in ("LANGCHAIN", "LANGGRAPH", "DEEPAGENTS")' not in entrypoint
-
-
-def test_code_builder_entrypoint_patches_langchain_before_loading_user_agent(tmp_path):
- builder = CodeBuilder(tmp_path)
-
- entrypoint = builder._generate_entrypoint(_full_detection_result(FrameworkType.LANGGRAPH))
-
- patch_index = entrypoint.index("apply_langchain_patch()")
- load_index = entrypoint.index("runner.load_agent()")
- assert patch_index < load_index
-
-
-def test_code_builder_entrypoint_adds_src_layout_to_pythonpath(tmp_path):
- builder = CodeBuilder(tmp_path)
-
- entrypoint = builder._generate_entrypoint(_full_detection_result(FrameworkType.DEEPAGENTS))
-
- assert 'CODE_SRC = os.path.join(CODE_ROOT, "src")' in entrypoint
- assert "sys.path.insert(0, CODE_SRC)" in entrypoint
-
-
-def test_container_builder_entrypoint_uses_otlp_direct_by_default_for_code_frameworks(tmp_path):
- builder = ContainerBuilder(tmp_path)
-
- for framework_type in (
- FrameworkType.ADK,
- FrameworkType.LANGCHAIN,
- FrameworkType.LANGGRAPH,
- FrameworkType.DEEPAGENTS,
- ):
- entrypoint = builder._generate_entrypoint(
- _full_detection_result(framework_type),
- "demo_agent",
- )
-
- assert "LANGFUSE_USE_CALLBACK" in entrypoint
- assert "use_callback_only=is_langchain" not in entrypoint
- assert 'in ("LANGCHAIN", "LANGGRAPH", "DEEPAGENTS")' not in entrypoint
-
-
-def test_container_builder_entrypoint_patches_langchain_before_loading_user_agent(tmp_path):
- builder = ContainerBuilder(tmp_path)
-
- entrypoint = builder._generate_entrypoint(
- _full_detection_result(FrameworkType.LANGGRAPH),
- "demo_agent",
- )
-
- patch_index = entrypoint.index("apply_langchain_patch()")
- load_index = entrypoint.index("runner.load_agent()")
- assert patch_index < load_index
diff --git a/tests/test_builder_runtime_requirements.py b/tests/test_builder_runtime_requirements.py
deleted file mode 100644
index 6f2fd668..00000000
--- a/tests/test_builder_runtime_requirements.py
+++ /dev/null
@@ -1,21 +0,0 @@
-from ksadk.builders.code_builder import CodeBuilder
-
-
-def test_bundled_runtime_requirements_include_kingsoftcloud_sdk():
- assert "kingsoftcloud-sdk-python>=1.5.8.94" in CodeBuilder.BUNDLED_KSADK_RUNTIME_REQUIREMENTS
-
-
-def test_bundled_runtime_requirements_include_python_multipart():
- assert "python-multipart>=0.0.9,<1.0.0" in CodeBuilder.BUNDLED_KSADK_RUNTIME_REQUIREMENTS
-
-
-def test_bundled_runtime_requirements_keep_asyncpg_postgres_sessions_optional():
- assert "asyncpg>=0.30.0,<1.0.0" not in CodeBuilder.BUNDLED_KSADK_RUNTIME_REQUIREMENTS
- assert "asyncpg>=0.30.0,<1.0.0" in CodeBuilder.BUNDLED_KSADK_POSTGRES_SESSION_REQUIREMENTS
-
-
-def test_bundled_runtime_requirements_keep_mcp_adapters_optional():
- assert "mcp>=1.1.0" not in CodeBuilder.BUNDLED_KSADK_RUNTIME_REQUIREMENTS
- assert "langchain-mcp-adapters>=0.0.1" not in CodeBuilder.BUNDLED_KSADK_RUNTIME_REQUIREMENTS
- assert "mcp>=1.1.0" in CodeBuilder.BUNDLED_KSADK_MCP_RUNTIME_REQUIREMENTS
- assert "langchain-mcp-adapters>=0.0.1" in CodeBuilder.BUNDLED_KSADK_MCP_RUNTIME_REQUIREMENTS
diff --git a/tests/test_check_publication_state.py b/tests/test_check_publication_state.py
index 0361acdb..b89cd2c0 100644
--- a/tests/test_check_publication_state.py
+++ b/tests/test_check_publication_state.py
@@ -34,6 +34,7 @@ def _run_main(monkeypatch, module, *, phase: str, version_exists: dict[tuple[str
],
)
monkeypatch.setattr(module, "_expect_http_ok", lambda name, url: None)
+ monkeypatch.setattr(module, "_expect_github_repo_homepage", lambda url, expected_docs_url: None)
monkeypatch.setattr(
module,
"_github_release_tags",
@@ -98,6 +99,7 @@ def test_publication_state_fails_when_historical_github_release_is_missing(monke
],
)
monkeypatch.setattr(module, "_expect_http_ok", lambda name, url: None)
+ monkeypatch.setattr(module, "_expect_github_repo_homepage", lambda url, expected_docs_url: None)
monkeypatch.setattr(module, "_github_release_tags", lambda url: {"v0.6.1", "v0.6.3"})
with pytest.raises(RuntimeError, match="v0.6.2"):
@@ -205,6 +207,35 @@ def fake_run(argv, check, text, stdout, stderr):
) == {"v0.6.5", "v0.6.4"}
+def test_publication_state_fails_when_github_homepage_points_elsewhere(monkeypatch):
+ module = _load_module()
+
+ def fake_open(_url):
+ return 200, b'{"homepage":"https://kingsoftcloud.github.io/ksadk-python/getting-started/quickstart/"}'
+
+ monkeypatch.setattr(module, "_open", fake_open)
+
+ with pytest.raises(RuntimeError, match="github repo homepage"):
+ module._expect_github_repo_homepage(
+ "https://api.github.com/repos/kingsoftcloud/ksadk-python",
+ "https://kingsoftcloud.github.io/ksadk-python/",
+ )
+
+
+def test_publication_state_accepts_github_homepage_without_trailing_slash(monkeypatch):
+ module = _load_module()
+
+ def fake_open(_url):
+ return 200, b'{"homepage":"https://kingsoftcloud.github.io/ksadk-python"}'
+
+ monkeypatch.setattr(module, "_open", fake_open)
+
+ module._expect_github_repo_homepage(
+ "https://api.github.com/repos/kingsoftcloud/ksadk-python",
+ "https://kingsoftcloud.github.io/ksadk-python/",
+ )
+
+
def test_github_release_tags_falls_back_to_gh_cli_on_transient_server_error(monkeypatch):
module = _load_module()
diff --git a/tests/test_cli_dry_run.py b/tests/test_cli_dry_run.py
deleted file mode 100644
index a6395128..00000000
--- a/tests/test_cli_dry_run.py
+++ /dev/null
@@ -1,2634 +0,0 @@
-import asyncio
-import json
-from contextlib import contextmanager
-from pathlib import Path
-from types import SimpleNamespace
-from typing import Any, Dict
-
-import yaml
-from click.testing import CliRunner
-
-from ksadk.api.client import AgentEngineAPIError, AgentEngineClient, DryRunExit
-from ksadk.cli import _register_commands, cli, cmd_mcp
-from ksadk.cli.cmd_agent import agent
-from ksadk.cli.cmd_destroy import delete as destroy_delete
-from ksadk.cli.cmd_destroy import destroy as destroy_cmd
-from ksadk.cli.cmd_mcp import mcp
-from ksadk.cli import cmd_openclaw
-from ksadk.cli.cmd_openclaw import openclaw
-from ksadk.cli.cmd_version import version
-from ksadk.cli.dry_run import run_async_with_dry_run
-from ksadk.deployment.base import DeployTarget
-from ksadk.deployment.providers.serverless import ServerlessProvider
-
-
-class _FakeDryRunClient:
- last_init_kwargs: Dict[str, Any] = {}
-
- def __init__(self, *args, **kwargs):
- _FakeDryRunClient.last_init_kwargs = kwargs
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
- async def list_mcps(self, **kwargs):
- raise DryRunExit("dry-run")
-
- async def get_mcp(self, *_args, **_kwargs):
- raise DryRunExit("dry-run")
-
- async def delete_mcp(self, *_args, **_kwargs):
- raise DryRunExit("dry-run")
-
- async def list_agents(self, **kwargs):
- raise DryRunExit("dry-run")
-
- async def get_agent(self, **kwargs):
- raise DryRunExit("dry-run")
-
- async def delete_agent(self, *_args, **_kwargs):
- raise DryRunExit("dry-run")
-
- async def create_mcp(self, request):
- raise DryRunExit("dry-run", payload={"body": request})
-
- async def list_versions(self, *_args, **_kwargs):
- raise DryRunExit("dry-run")
-
- async def release_version(self, *_args, **_kwargs):
- raise DryRunExit("dry-run")
-
- async def rollback_version(self, *_args, **_kwargs):
- raise DryRunExit("dry-run")
-
- async def close(self):
- return None
-
-
-class _FakeMCPDetectionResult:
- is_valid = True
- entry_point = "mcp_main.py"
- mcp_variable = "mcp"
- tools = ["test_tool"]
-
-
-class _FakeMCPDetector:
- def __init__(self, *_args, **_kwargs):
- pass
-
- def detect(self):
- return _FakeMCPDetectionResult()
-
-
-class _FakeOpenClawListClient:
- def __init__(self, *args, **kwargs):
- pass
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
- async def list_agents(self, **_kwargs):
- return {
- "agents": [
- {
- "agent_id": "ar-demo-1",
- "name": "demo-openclaw",
- "status": "running",
- "endpoint": "https://openclaw.example.com",
- "region": "cn-beijing-6",
- }
- ],
- "total": 145,
- }
-
- async def close(self):
- return None
-
-
-class _FakeOpenClawDetailClient:
- last_log_kwargs: Dict[str, Any] = {}
-
- def __init__(self, *args, **kwargs):
- pass
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
- async def get_agent(self, **_kwargs):
- return {
- "basic": {
- "agent_id": "ar-demo-1",
- "name": "demo-openclaw",
- "status": "RUNNING",
- "framework": "openclaw",
- "region": "cn-beijing-6",
- },
- "quick_access": {
- "public_endpoint": "https://openclaw.example.com",
- },
- "advanced": {
- "observability_url": "https://trace.example.com/project/aropenclaw1/traces",
- },
- }
-
- async def get_agent_logs(self, **kwargs):
- _FakeOpenClawDetailClient.last_log_kwargs = kwargs
- return {
- "logs": ["line-1", "line-2"],
- "total": 2,
- "page": 1,
- "page_size": 200,
- "agent_id": "ar-demo-1",
- "instance": kwargs.get("instance"),
- "log_type": "Stdout",
- }
-
- async def close(self):
- return None
-
-
-class _FakeOpenClawCreatingDetailClient(_FakeOpenClawDetailClient):
- async def get_agent(self, **_kwargs):
- payload = await super().get_agent(**_kwargs)
- payload["basic"]["status"] = "CREATING"
- return payload
-
-
-class _FakeOpenClawFailedDetailClient(_FakeOpenClawDetailClient):
- last_repair_kwargs: Dict[str, Any] = {}
-
- async def get_agent(self, **_kwargs):
- payload = await super().get_agent(**_kwargs)
- payload["basic"]["status"] = "FAILED"
- return payload
-
- async def run_openclaw_repair(self, agent_id: str, *, repair_action: str = "doctor-fix"):
- self.__class__.last_repair_kwargs = {
- "agent_id": agent_id,
- "repair_action": repair_action,
- }
- return {
- "ok": True,
- "agent_id": agent_id,
- "repair_action": repair_action,
- "status": "succeeded",
- "exit_code": 0,
- "logs": "fixed",
- }
-
-
-class _FakeGatewayClient:
- applied_configs: list[Dict[str, Any]] = []
- last_wait_kwargs: Dict[str, Any] = {}
- disconnect_waits: list[int] = []
-
- def __init__(self, *args, **kwargs):
- self.methods = ["channels.status", "config.get", "web.login.start", "web.login.wait"]
-
- async def build_access_info(self):
- return SimpleNamespace(
- access_url="https://dashboard.example.com/s/lnk-demo",
- ws_url="wss://dashboard.example.com/",
- link_id="lnk-demo",
- expires_at="2026-03-23T12:00:00Z",
- )
-
- async def connect(self):
- return {"features": {"methods": self.methods}}
-
- async def close(self):
- return None
-
- async def wait_for_disconnect(self, *, timeout_ms=5_000):
- self.__class__.disconnect_waits.append(timeout_ms)
- return True
-
- async def channels_status(self, *, probe=False, timeout_ms=None):
- return {
- "channels": {
- "weixin": {"connected": True, "probe": probe, "timeout_ms": timeout_ms},
- "feishu": {"enabled": True},
- "wps-xiezuo": {"enabled": True},
- }
- }
-
- async def config_get(self):
- return {
- "hash": "cfg-1",
- "exists": True,
- "config": {
- "plugins": {"entries": {}},
- "channels": {},
- },
- }
-
- async def web_login_start(self, *, force=False, timeout_ms=None):
- return {
- "qrDataUrl": "https://qr.example.com/weixin-login",
- "sessionKey": "sess-1",
- "message": "scan now",
- }
-
- async def web_login_wait(self, *, account_id=None, session_key=None, timeout_ms=None):
- self.__class__.last_wait_kwargs = {
- "account_id": account_id,
- "session_key": session_key,
- "timeout_ms": timeout_ms,
- }
- return {"connected": True, "message": "connected"}
-
- async def config_apply(self, *, config, base_hash, note=None, session_key=None, restart_delay_ms=None):
- self.__class__.applied_configs.append(
- {
- "config": config,
- "base_hash": base_hash,
- "note": note,
- "session_key": session_key,
- "restart_delay_ms": restart_delay_ms,
- }
- )
- return {"ok": True}
-
-
-class _FakeConfigApplyReloadGatewayClient(_FakeGatewayClient):
- async def config_apply(self, *, config, base_hash, note=None, session_key=None, restart_delay_ms=None):
- await super().config_apply(
- config=config,
- base_hash=base_hash,
- note=note,
- session_key=session_key,
- restart_delay_ms=restart_delay_ms,
- )
- raise cmd_openclaw.OpenClawGatewayError(
- "Gateway websocket receive failed: received 1011 (internal error) Bad Gateway"
- )
-
-
-class _FakeDoctorGatewayClient(_FakeGatewayClient):
- async def config_get(self):
- return {
- "hash": "cfg-1",
- "exists": True,
- "config": {
- "plugins": {
- "entries": {
- "openclaw-weixin": {"enabled": True},
- "openclaw-lark": {"enabled": True},
- "wps-xiezuo": {"enabled": True},
- }
- },
- "skills": {"allowBundled": ["wps365-skill"]},
- "channels": {
- "feishu": {"enabled": True},
- "openclaw-weixin": {"accounts": {"default": {"enabled": True}}},
- "wps-xiezuo": {"enabled": True, "appId": "app-demo", "appSecret": "secret-demo"},
- },
- },
- }
-
-
-class _FakeDoctorFreshGatewayClient(_FakeGatewayClient):
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self.methods = ["channels.status", "config.get"]
-
- async def channels_status(self, *, probe=False, timeout_ms=None):
- return {
- "channels": {
- "openclaw-weixin": {"configured": False, "probe": probe, "timeout_ms": timeout_ms},
- }
- }
-
- async def config_get(self):
- return {
- "hash": "cfg-1",
- "exists": True,
- "config": {
- "plugins": {
- "entries": {
- "openclaw-weixin": {"enabled": True},
- "openclaw-lark": {"enabled": True},
- "wps-xiezuo": {"enabled": True},
- }
- },
- "skills": {"allowBundled": ["wps365-skill"]},
- "channels": {},
- },
- }
-
-
-class _FakeDoctorBrokenWeixinGatewayClient(_FakeDoctorFreshGatewayClient):
- async def channels_status(self, *, probe=False, timeout_ms=None):
- return {
- "channels": {
- "openclaw-weixin": {"configured": True, "probe": probe, "timeout_ms": timeout_ms},
- }
- }
-
- async def config_get(self):
- snapshot = await super().config_get()
- snapshot["config"]["channels"] = {
- "openclaw-weixin": {"accounts": {"default": {"enabled": True}}},
- }
- return snapshot
-
-
-class _FakeWeixinGatewayWithoutWebLoginClient(_FakeGatewayClient):
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self.methods = ["channels.status", "config.get"]
-
- async def web_login_start(self, *, force=False, timeout_ms=None):
- raise AssertionError("web login RPC should not be called when method discovery is missing")
-
- async def channels_status(self, *, probe=False, timeout_ms=None):
- return {
- "channels": {
- "openclaw-weixin": {"configured": True, "connected": False, "probe": probe, "timeout_ms": timeout_ms},
- }
- }
-
-
-class _FakeWeixinGatewayProviderUnavailableClient(_FakeGatewayClient):
- async def web_login_start(self, *, force=False, timeout_ms=None):
- raise cmd_openclaw.OpenClawGatewayRequestError(
- "web login provider is not available",
- code="INVALID_REQUEST",
- )
-
- async def channels_status(self, *, probe=False, timeout_ms=None):
- return {
- "channels": {
- "openclaw-weixin": {"configured": True, "connected": False, "probe": probe, "timeout_ms": timeout_ms},
- }
- }
-
-
-class _FakeRestartingWeixinGatewayClient(_FakeGatewayClient):
- async def web_login_start(self, *, force=False, timeout_ms=None):
- if not self.__class__.disconnect_waits:
- raise AssertionError("expected gateway restart wait before weixin login")
- return await super().web_login_start(force=force, timeout_ms=timeout_ms)
-
-
-class _FakeDeleteProvider:
- def __init__(self):
- self.calls = []
-
- async def destroy(self, agent_id, deploy_target):
- self.calls.append((agent_id, deploy_target))
- return True
-
-
-class _FakeBatchDeleteClient:
- deleted_agents = []
- deleted_mcps = []
-
- def __init__(self, *args, **kwargs):
- pass
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
- async def delete_agent(self, agent_id):
- self.deleted_agents.append(agent_id)
- return True
-
- async def delete_mcp(self, mcp_id):
- self.deleted_mcps.append(mcp_id)
- return True
-
- async def close(self):
- return None
-
-
-class _FakeOpenClawCreateClient:
- get_agent_calls = 0
- create_payload = None
- update_payload = None
-
- def __init__(self, *args, **kwargs):
- pass
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
- async def create_agent(self, _data):
- self.__class__.create_payload = _data
- return {
- "agent_id": "ar-created-1",
- "endpoint": "https://ar-created-1.agent.kspmas.ksyun.com",
- "api_key": "ak-created-1",
- }
-
- async def update_agent(self, _agent_id, _data):
- self.__class__.update_payload = _data
- return {
- "agent_id": _agent_id,
- "endpoint": "https://ar-existing-1.agent.kspmas.ksyun.com",
- "api_key": "ak-existing-1",
- }
-
- async def get_agent(self, **_kwargs):
- self.__class__.get_agent_calls += 1
- raise AssertionError("OpenClaw create response already contains complete quick access")
-
- async def close(self):
- return None
-
-
-class _FakeOpenClawImmediateAgentIdClient:
- get_agent_calls = 0
-
- def __init__(self, *args, **kwargs):
- pass
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
- async def create_agent(self, _data):
- return {
- "agent_id": "ar-created-2",
- "endpoint": None,
- "api_key": None,
- "order_id": "ord-created-2",
- }
-
- async def get_agent(self, **kwargs):
- self.__class__.get_agent_calls += 1
- assert kwargs["agent_id"] == "ar-created-2"
- assert kwargs["include_api_key"] is True
- return {
- "basic": {
- "agent_id": "ar-created-2",
- "name": "demo-openclaw",
- "status": "RUNNING",
- "framework": "openclaw",
- "region": "cn-beijing-6",
- },
- "quick_access": {
- "public_endpoint": "https://fresh-openclaw.example.com",
- "api_key": "ak-fresh-openclaw",
- },
- }
-
- async def close(self):
- return None
-
-
-class _FakeOpenClawDelayedAccessClient:
- get_agent_calls = 0
- suppression_used = False
-
- def __init__(self, *args, **kwargs):
- pass
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
- @contextmanager
- def suppress_http_error_logging(self, predicate=None):
- self.__class__.suppression_used = predicate is not None
- yield
-
- async def create_agent(self, _data):
- return {
- "agent_id": "ar-created-delayed",
- "endpoint": "https://created-openclaw.example.com",
- "api_key": None,
- "order_id": "ord-created-delayed",
- }
-
- async def get_agent(self, **kwargs):
- self.__class__.get_agent_calls += 1
- if self.__class__.get_agent_calls < 4:
- raise AgentEngineAPIError(
- 404,
- "未找到对应的 Agent",
- details={
- "http_status": 404,
- "remote_error_message": "未找到对应的 Agent",
- },
- )
- assert kwargs["agent_id"] == "ar-created-delayed"
- assert kwargs["include_api_key"] is True
- return {
- "basic": {
- "agent_id": "ar-created-delayed",
- "name": "demo-openclaw",
- "status": "RUNNING",
- "framework": "openclaw",
- "region": "cn-beijing-6",
- },
- "quick_access": {
- "public_endpoint": "https://ready-openclaw.example.com",
- "api_key": "ak-ready-openclaw",
- },
- "deployment": {
- "framework": "openclaw",
- "region": "cn-beijing-6",
- },
- }
-
- async def close(self):
- return None
-
-
-class _FakeDeleteClient:
- deleted_agents = []
- should_succeed = True
-
- def __init__(self, *args, **kwargs):
- pass
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
- async def delete_agent(self, agent_id):
- self.deleted_agents.append(agent_id)
- if self.should_succeed:
- return True
- raise RuntimeError("delete failed")
-
- async def close(self):
- return None
-
-
-class _FakePartialDeleteProvider:
- def __init__(self, results: Dict[str, bool]):
- self.results = dict(results)
- self.calls = []
-
- async def destroy(self, agent_id, deploy_target):
- self.calls.append((agent_id, deploy_target))
- return self.results.get(agent_id, False)
-
-
-def test_run_async_with_dry_run_handles_exit(capsys):
- async def _boom():
- raise DryRunExit("done")
-
- result = run_async_with_dry_run(_boom(), dry_run=True)
- assert result is None
- out = capsys.readouterr().out
- assert "Dry Run Completed" in out
-
-
-def test_client_respects_global_dry_run_env(monkeypatch):
- monkeypatch.setenv("AGENTENGINE_GLOBAL_DRY_RUN", "1")
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="", dry_run=False)
- assert client.dry_run is True
-
-
-def test_client_bootstrap_config_can_ignore_dry_run(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="", dry_run=True)
- captured = {}
-
- def fake_request(method, path, body=None, *, ignore_dry_run=False):
- captured.update(
- {
- "method": method,
- "path": path,
- "body": body,
- "ignore_dry_run": ignore_dry_run,
- }
- )
- return {
- "Code": 0,
- "Data": {
- "Configs": {
- "bootstrap.default_image": "registry.example.com/runtime:db",
- }
- },
- }
-
- monkeypatch.setattr(client, "_request", fake_request)
-
- result = asyncio.run(
- client.get_client_bootstrap_config(
- product="openclaw",
- framework="openclaw",
- region="pre-online",
- ignore_dry_run=True,
- )
- )
-
- assert captured["ignore_dry_run"] is True
- assert captured["body"]["Product"] == "openclaw"
- assert result["configs"]["bootstrap.default_image"] == "registry.example.com/runtime:db"
-
-
-def test_mcp_status_supports_dry_run(monkeypatch):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeDryRunClient)
-
- result = runner.invoke(
- mcp,
- ["status", "mcp-123", "--dry-run"],
- env={"AGENTENGINE_SERVER_URL": "http://example.com"},
- )
-
- assert result.exit_code == 0, result.output
- assert "Dry Run Completed" in result.output
- assert _FakeDryRunClient.last_init_kwargs.get("dry_run") is True
-
-
-def test_mcp_deploy_dry_run_json_plan(monkeypatch, tmp_path: Path):
- runner = CliRunner()
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr("ksadk.detection.mcp_detector.MCPDetector", _FakeMCPDetector)
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeDryRunClient)
-
- def _should_not_build(*_args, **_kwargs):
- raise AssertionError("Dry run should not build artifacts")
-
- monkeypatch.setattr(cmd_mcp, "_build_code_artifact", _should_not_build)
-
- result = runner.invoke(
- mcp,
- ["deploy", ".", "--dry-run", "--output", "json", "--ks3-bucket", "agentengine-test"],
- env={"AGENTENGINE_SERVER_URL": "http://example.com"},
- )
-
- assert result.exit_code == 0, result.output
- payload = json.loads(result.output)
- assert payload["resource"] == "workflow"
- assert payload["action"] == "deploy"
- assert payload["kind"] == "dry_run"
- assert payload["request"]["body"]["artifact_type"] == "Code"
- assert payload["plan"]["artifact"]["reference"].startswith("ks3://agentengine-test/")
-
-
-def test_mcp_deploy_dry_run_includes_explicit_network(monkeypatch, tmp_path: Path):
- runner = CliRunner()
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr("ksadk.detection.mcp_detector.MCPDetector", _FakeMCPDetector)
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeDryRunClient)
-
- def _should_not_build(*_args, **_kwargs):
- raise AssertionError("Dry run should not build artifacts")
-
- monkeypatch.setattr(cmd_mcp, "_build_code_artifact", _should_not_build)
-
- result = runner.invoke(
- mcp,
- [
- "deploy",
- ".",
- "--dry-run",
- "--output",
- "json",
- "--ks3-bucket",
- "agentengine-test",
- "--disable-public-access",
- "--enable-vpc-access",
- "--vpc-id",
- "vpc-cli",
- "--subnet-id",
- "subnet-cli",
- "--security-group-id",
- "sg-cli",
- "--availability-zone",
- "cn-beijing-6b",
- ],
- env={"AGENTENGINE_SERVER_URL": "http://example.com"},
- )
-
- assert result.exit_code == 0, result.output
- payload = json.loads(result.output)
- assert payload["request"]["body"]["network"] == {
- "enable_public_access": False,
- "enable_vpc_access": True,
- "vpc_id": "vpc-cli",
- "subnet_id": "subnet-cli",
- "security_group_id": "sg-cli",
- "availability_zone": "cn-beijing-6b",
- }
-
-
-def test_openclaw_list_supports_dry_run(monkeypatch):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeDryRunClient)
-
- result = runner.invoke(openclaw, ["list", "--dry-run"])
-
- assert result.exit_code == 0, result.output
- assert "Dry Run Completed" in result.output
- assert _FakeDryRunClient.last_init_kwargs.get("dry_run") is True
-
-
-def test_openclaw_list_shows_account_region_summary(monkeypatch):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawListClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
-
- result = runner.invoke(
- openclaw,
- ["list", "--region", "cn-beijing-6"],
- env={"KSYUN_ACCOUNT_ID": "2000003485"},
- )
-
- assert result.exit_code == 0, result.output
- assert "OpenClaw 列表" in result.output
- assert "账号: 2000003485" in result.output
- assert "region: cn-beijing-6" in result.output
- assert "总计: 145" in result.output
-
-
-def test_openclaw_help_exposes_channel_and_gateway_commands():
- runner = CliRunner()
-
- result = runner.invoke(openclaw, ["--help"])
-
- assert result.exit_code == 0, result.output
- assert "channel" in result.output
- assert "gateway" in result.output
- assert "tui" in result.output
-
-
-def test_openclaw_tui_help_states_no_local_openclaw_cli_required():
- runner = CliRunner()
-
- result = runner.invoke(openclaw, ["tui", "--help"])
-
- assert result.exit_code == 0, result.output
- assert "不需要本机安装 OpenClaw CLI" in result.output
-
-
-def test_openclaw_tui_dry_run_does_not_resolve_or_connect(monkeypatch):
- runner = CliRunner()
-
- async def _forbidden_resolve(*_args, **_kwargs):
- raise AssertionError("agent detail should not be resolved")
-
- async def _forbidden_terminal(**_kwargs):
- raise AssertionError("remote terminal should not be called")
-
- monkeypatch.setattr(cmd_openclaw, "_resolve_openclaw_detail_or_raise", _forbidden_resolve)
- monkeypatch.setattr(cmd_openclaw, "run_terminal_session", _forbidden_terminal, raising=False)
-
- result = runner.invoke(
- openclaw,
- [
- "tui",
- "ar-demo-1",
- "--gateway-token",
- "gw-token",
- "--message",
- "你好",
- "--thinking",
- "medium",
- "--history-limit",
- "50",
- "--timeout-ms",
- "30000",
- "--deliver",
- "--dry-run",
- "--output",
- "json",
- ],
- )
-
- assert result.exit_code == 0, result.output
- payload = json.loads(result.output)
- assert payload["kind"] == "dry_run"
- assert payload["resource"] == "openclaw"
- assert payload["action"] == "tui"
- assert payload["request"]["agent_ref"] == "ar-demo-1"
- assert payload["request"]["mode"] == "tui"
- assert payload["request"]["gateway_token_provided"] is True
- assert payload["request"]["options"] == {
- "message": "你好",
- "thinking": "medium",
- "history_limit": 50,
- "timeout_ms": 30000,
- "deliver": True,
- }
- assert "gw-token" not in result.output
-
-
-def test_openclaw_tui_uses_gateway_token_for_native_terminal(monkeypatch):
- runner = CliRunner()
- captured: Dict[str, Any] = {}
-
- async def _fake_resolve(agent_ref, *, region):
- assert agent_ref == "ar-demo-1"
- assert region == "pre-online"
- return "pre-online", {
- "agent_id": "ar-demo-1",
- "name": "demo-openclaw",
- "status": "RUNNING",
- "framework": "openclaw",
- "endpoint": "https://openclaw.example.com",
- "api_key": "ak-agentengine",
- "openclaw_auth_mode": "token",
- }
-
- async def _fake_terminal(**kwargs):
- captured.update(kwargs)
- return 0
-
- monkeypatch.setattr(cmd_openclaw, "_resolve_openclaw_detail_or_raise", _fake_resolve)
- monkeypatch.setattr(cmd_openclaw, "run_terminal_session", _fake_terminal, raising=False)
-
- result = runner.invoke(
- openclaw,
- [
- "tui",
- "ar-demo-1",
- "--region",
- "pre-online",
- "--gateway-token",
- "gw-token",
- "--session",
- "sess-1",
- "--message",
- "你好",
- "--thinking",
- "medium",
- "--history-limit",
- "50",
- "--timeout-ms",
- "30000",
- "--deliver",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert captured["endpoint"] == "https://openclaw.example.com"
- assert captured["api_key"] == "gw-token"
- assert captured["session_id"] == "sess-1"
- assert captured["mode"] == "tui"
- assert captured["argv"] == []
- assert captured["options"] == {
- "message": "你好",
- "thinking": "medium",
- "history_limit": 50,
- "timeout_ms": 30000,
- "deliver": True,
- }
-
-
-def test_openclaw_tui_uses_state_gateway_token_for_native_terminal(monkeypatch, tmp_path: Path):
- (tmp_path / ".agentengine.state").write_text(
- yaml.safe_dump(
- {
- "agent_id": "ar-demo-1",
- "type": "openclaw",
- "framework": "openclaw",
- "endpoint": "https://openclaw.example.com",
- "api_key": "ak-agentengine",
- "openclaw_auth_mode": "token",
- "openclaw_gateway_token": "gw-token-from-state",
- }
- ),
- encoding="utf-8",
- )
- runner = CliRunner()
- captured: Dict[str, Any] = {}
-
- async def _fake_terminal(**kwargs):
- captured.update(kwargs)
- return 0
-
- monkeypatch.chdir(tmp_path)
- monkeypatch.delenv("OPENCLAW_GATEWAY_TOKEN", raising=False)
- monkeypatch.delenv("OPENCLAW_GATEWAY_PASSWORD", raising=False)
- monkeypatch.setattr(cmd_openclaw, "run_terminal_session", _fake_terminal, raising=False)
-
- result = runner.invoke(openclaw, ["tui"])
-
- assert result.exit_code == 0, result.output
- assert captured["endpoint"] == "https://openclaw.example.com"
- assert captured["api_key"] == "gw-token-from-state"
- assert "gw-token-from-state" not in result.output
-
-
-def test_openclaw_tui_requires_gateway_token_for_token_auth(monkeypatch):
- runner = CliRunner()
-
- async def _fake_resolve(_agent_ref, *, region):
- return region or "pre-online", {
- "agent_id": "ar-demo-1",
- "status": "RUNNING",
- "framework": "openclaw",
- "endpoint": "https://openclaw.example.com",
- "api_key": "ak-agentengine",
- "openclaw_auth_mode": "token",
- }
-
- async def _forbidden_terminal(**_kwargs):
- raise AssertionError("remote terminal should not be called")
-
- monkeypatch.delenv("OPENCLAW_GATEWAY_TOKEN", raising=False)
- monkeypatch.delenv("OPENCLAW_GATEWAY_PASSWORD", raising=False)
- monkeypatch.setattr(cmd_openclaw, "_resolve_openclaw_detail_or_raise", _fake_resolve)
- monkeypatch.setattr(cmd_openclaw, "run_terminal_session", _forbidden_terminal, raising=False)
-
- result = runner.invoke(openclaw, ["tui", "ar-demo-1"])
-
- assert result.exit_code != 0
- assert "OPENCLAW_GATEWAY_TOKEN" in result.output
-
-
-def test_openclaw_channel_connect_help_separates_channel_specific_options():
- runner = CliRunner()
-
- result = runner.invoke(openclaw, ["channel", "connect", "--help"])
-
- assert result.exit_code == 0, result.output
- assert "微信:扫码登录" in result.output
- assert "飞书:启动官方 onboarding 流程" in result.output
- assert "WPS 协作:写入开放平台 appId/appSecret" in result.output
- assert "仅 WPS 协作:开放平台应用 ID" in result.output
- assert "仅微信:在本地浏览器额外打开二维码链接" in result.output
- assert "--dm-policy=open 表示允许所有用户私聊" in result.output
-
-
-def test_openclaw_gateway_ws_url_prints_dashboard_and_ws(monkeypatch):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
-
- result = runner.invoke(openclaw, ["gateway", "ws-url", "ar-demo-1"])
-
- assert result.exit_code == 0, result.output
- assert "dashboard.example.com/s/lnk-demo" in result.output
- assert "wss://" in result.output
- assert "cookie-session" in result.output
-
-
-def test_openclaw_gateway_logs_reads_agent_logs(monkeypatch):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
-
- result = runner.invoke(
- openclaw,
- ["gateway", "logs", "ar-demo-1", "--instance", "oc-0", "--log-type", "stdout"],
- )
-
- assert result.exit_code == 0, result.output
- assert "line-1" in result.output
- assert _FakeOpenClawDetailClient.last_log_kwargs["instance"] == "oc-0"
- assert _FakeOpenClawDetailClient.last_log_kwargs["log_type"] == "stdout"
-
-
-def test_openclaw_gateway_doctor_checks_short_link_and_ws(monkeypatch):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
-
- result = runner.invoke(openclaw, ["gateway", "doctor", "ar-demo-1"])
-
- assert result.exit_code == 0, result.output
- assert "dashboard_short_link" in result.output
- assert "cookie_ws_handshake" in result.output
- assert "gateway_rpc" in result.output
-
-
-def test_openclaw_gateway_ws_url_allows_creating_when_gateway_is_reachable(monkeypatch):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreatingDetailClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
-
- result = runner.invoke(openclaw, ["gateway", "ws-url", "ar-demo-1"])
-
- assert result.exit_code == 0, result.output
- assert "dashboard.example.com/s/lnk-demo" in result.output
- assert "wss://" in result.output
-
-
-def test_openclaw_gateway_doctor_continues_probe_when_status_is_creating(monkeypatch):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreatingDetailClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
-
- result = runner.invoke(openclaw, ["gateway", "doctor", "ar-demo-1"])
-
- assert result.exit_code == 0, result.output
- assert '"status": "CREATING"' in result.output
- assert '"dashboard_short_link"' in result.output
- assert '"ok": true' in result.output.lower()
-
-
-def test_openclaw_gateway_doctor_fix_uses_control_plane_repair_for_failed_runtime(monkeypatch):
- runner = CliRunner()
- _FakeOpenClawFailedDetailClient.last_repair_kwargs = {}
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawFailedDetailClient)
-
- result = runner.invoke(openclaw, ["gateway", "doctor", "ar-demo-1", "--fix"])
-
- assert result.exit_code == 0, result.output
- assert '"repair_action": "doctor-fix"' in result.output
- assert _FakeOpenClawFailedDetailClient.last_repair_kwargs == {
- "agent_id": "ar-demo-1",
- "repair_action": "doctor-fix",
- }
-
-
-def test_openclaw_repair_command_runs_doctor_fix_via_control_plane(monkeypatch):
- runner = CliRunner()
- _FakeOpenClawFailedDetailClient.last_repair_kwargs = {}
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawFailedDetailClient)
-
- result = runner.invoke(openclaw, ["repair", "ar-demo-1"])
-
- assert result.exit_code == 0, result.output
- assert '"repair_action": "doctor-fix"' in result.output
- assert _FakeOpenClawFailedDetailClient.last_repair_kwargs == {
- "agent_id": "ar-demo-1",
- "repair_action": "doctor-fix",
- }
-
-
-def test_openclaw_channel_status_uses_gateway_snapshot(monkeypatch):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
-
- result = runner.invoke(openclaw, ["channel", "status", "ar-demo-1", "--channel", "weixin", "--probe"])
-
- assert result.exit_code == 0, result.output
- assert '"connected": true' in result.output.lower()
- assert '"probe": true' in result.output.lower()
-
-
-def test_openclaw_channel_status_allows_creating_when_gateway_is_reachable(monkeypatch):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreatingDetailClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
-
- result = runner.invoke(openclaw, ["channel", "status", "ar-demo-1", "--channel", "weixin"])
-
- assert result.exit_code == 0, result.output
- assert '"connected": true' in result.output.lower()
-
-
-def test_openclaw_channel_enable_updates_weixin_account_config(monkeypatch):
- runner = CliRunner()
- _FakeGatewayClient.applied_configs = []
- async def _fake_sleep(*_args, **_kwargs):
- return None
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep)
-
- result = runner.invoke(
- openclaw,
- ["channel", "enable", "ar-demo-1", "--channel", "weixin", "--account-id", "wx-demo"],
- )
-
- assert result.exit_code == 0, result.output
- assert _FakeGatewayClient.applied_configs
- config = _FakeGatewayClient.applied_configs[-1]["config"]
- assert config["channels"]["openclaw-weixin"]["accounts"]["wx-demo"]["enabled"] is True
-
-
-def test_openclaw_channel_connect_weixin_prints_qr_url(monkeypatch):
- runner = CliRunner()
- _FakeGatewayClient.applied_configs = []
- _FakeGatewayClient.last_wait_kwargs = {}
- _FakeGatewayClient.disconnect_waits = []
- async def _fake_sleep(*_args, **_kwargs):
- return None
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep)
-
- result = runner.invoke(openclaw, ["channel", "connect", "ar-demo-1", "--channel", "weixin"])
-
- assert result.exit_code == 0, result.output
- assert "https://qr.example.com/weixin-login" in result.output
- assert _FakeGatewayClient.applied_configs
- config = _FakeGatewayClient.applied_configs[-1]["config"]
- assert config["plugins"]["entries"]["openclaw-weixin"]["enabled"] is True
- assert config["channels"]["openclaw-weixin"]["accounts"]["default"]["enabled"] is True
- assert _FakeGatewayClient.last_wait_kwargs["account_id"] == "sess-1"
-
-
-def test_openclaw_channel_connect_weixin_waits_for_gateway_restart(monkeypatch):
- runner = CliRunner()
- _FakeRestartingWeixinGatewayClient.applied_configs = []
- _FakeRestartingWeixinGatewayClient.last_wait_kwargs = {}
- _FakeRestartingWeixinGatewayClient.disconnect_waits = []
-
- async def _fake_sleep(*_args, **_kwargs):
- return None
-
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeRestartingWeixinGatewayClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep)
-
- result = runner.invoke(openclaw, ["channel", "connect", "ar-demo-1", "--channel", "weixin"])
-
- assert result.exit_code == 0, result.output
- assert _FakeRestartingWeixinGatewayClient.disconnect_waits == [5_000]
- assert _FakeRestartingWeixinGatewayClient.last_wait_kwargs["account_id"] == "sess-1"
-
-
-def test_openclaw_channel_connect_weixin_maps_session_key_to_account_id(monkeypatch):
- runner = CliRunner()
- _FakeGatewayClient.last_wait_kwargs = {}
- _FakeGatewayClient.disconnect_waits = []
-
- async def _fake_sleep(*_args, **_kwargs):
- return None
-
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep)
-
- result = runner.invoke(openclaw, ["channel", "connect", "ar-demo-1", "--channel", "weixin"])
-
- assert result.exit_code == 0, result.output
- assert _FakeGatewayClient.last_wait_kwargs == {
- "account_id": "sess-1",
- "session_key": None,
- "timeout_ms": 120_000,
- }
-
-
-def test_openclaw_channel_connect_weixin_falls_back_to_remote_cli_without_web_login_rpc(monkeypatch):
- runner = CliRunner()
- _FakeWeixinGatewayWithoutWebLoginClient.applied_configs = []
- _FakeWeixinGatewayWithoutWebLoginClient.disconnect_waits = []
- captured: Dict[str, Any] = {}
-
- async def _fake_sleep(*_args, **_kwargs):
- return None
-
- async def _fake_terminal(**kwargs):
- captured.update(kwargs)
- return 0
-
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeWeixinGatewayWithoutWebLoginClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep)
- monkeypatch.setattr(cmd_openclaw, "run_terminal_session", _fake_terminal, raising=False)
-
- result = runner.invoke(openclaw, ["channel", "connect", "ar-demo-1", "--channel", "weixin"])
-
- assert result.exit_code == 0, result.output
- assert captured["endpoint"] == "https://openclaw.example.com"
- assert captured["api_key"] is None
- assert captured["mode"] == "exec"
- assert captured["argv"] == ["openclaw", "channels", "login", "--channel", "openclaw-weixin"]
- assert _FakeWeixinGatewayWithoutWebLoginClient.applied_configs
- config = _FakeWeixinGatewayWithoutWebLoginClient.applied_configs[-1]["config"]
- assert config["plugins"]["entries"]["openclaw-weixin"]["enabled"] is True
- assert config["channels"]["openclaw-weixin"]["accounts"]["default"]["enabled"] is True
- assert '"mode": "remote_cli"' in result.output
-
-
-def test_openclaw_channel_connect_weixin_falls_back_to_remote_cli_when_provider_unavailable(monkeypatch):
- runner = CliRunner()
- captured: Dict[str, Any] = {}
-
- async def _fake_sleep(*_args, **_kwargs):
- return None
-
- async def _fake_terminal(**kwargs):
- captured.update(kwargs)
- return 0
-
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeWeixinGatewayProviderUnavailableClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep)
- monkeypatch.setattr(cmd_openclaw, "run_terminal_session", _fake_terminal, raising=False)
-
- result = runner.invoke(openclaw, ["channel", "connect", "ar-demo-1", "--channel", "weixin"])
-
- assert result.exit_code == 0, result.output
- assert captured["mode"] == "exec"
- assert captured["argv"] == ["openclaw", "channels", "login", "--channel", "openclaw-weixin"]
- assert "web login provider is not available" in result.output
-
-
-def test_openclaw_channel_connect_feishu_applies_remote_config(monkeypatch):
- runner = CliRunner()
- _FakeGatewayClient.applied_configs = []
-
- async def _fake_sleep(*_args, **_kwargs):
- return None
-
- async def _fake_onboarding(existing_app_id):
- assert existing_app_id is None
- return {
- "appId": "cli-app-id",
- "appSecret": "cli-app-secret",
- "domain": "lark",
- "userInfo": {"openId": "ou_demo"},
- }
-
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw._run_feishu_onboarding", _fake_onboarding)
-
- result = runner.invoke(openclaw, ["channel", "connect", "ar-demo-1", "--channel", "feishu"])
-
- assert result.exit_code == 0, result.output
- assert _FakeGatewayClient.applied_configs
- config = _FakeGatewayClient.applied_configs[-1]["config"]
- assert config["plugins"]["entries"]["openclaw-lark"]["enabled"] is True
- assert config["channels"]["feishu"]["enabled"] is True
- assert config["channels"]["feishu"]["appId"] == "cli-app-id"
- assert config["channels"]["feishu"]["appSecret"] == "cli-app-secret"
- assert config["channels"]["feishu"]["domain"] == "lark"
- assert config["channels"]["feishu"]["allowFrom"] == ["ou_demo"]
- assert config["channels"]["feishu"]["groupAllowFrom"] == ["ou_demo"]
-
-
-def test_should_auto_open_browser_on_local_macos(monkeypatch):
- from ksadk.cli.cmd_openclaw import _should_auto_open_browser
-
- monkeypatch.delenv("SSH_TTY", raising=False)
- monkeypatch.delenv("SSH_CONNECTION", raising=False)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.sys.platform", "darwin")
-
- assert _should_auto_open_browser() is True
-
-
-def test_should_not_auto_open_browser_over_ssh(monkeypatch):
- from ksadk.cli.cmd_openclaw import _should_auto_open_browser
-
- monkeypatch.setenv("SSH_TTY", "/dev/pts/1")
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.sys.platform", "darwin")
-
- assert _should_auto_open_browser() is False
-
-
-def test_openclaw_channel_connect_wps_xiezuo_applies_flat_remote_config(monkeypatch):
- runner = CliRunner()
- _FakeGatewayClient.applied_configs = []
-
- async def _fake_sleep(*_args, **_kwargs):
- return None
-
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep)
-
- result = runner.invoke(
- openclaw,
- [
- "channel",
- "connect",
- "ar-demo-1",
- "--channel",
- "wps-xiezuo",
- "--app-id",
- "app-demo",
- "--app-secret",
- "secret-demo",
- "--dm-policy",
- "open",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert _FakeGatewayClient.applied_configs
- config = _FakeGatewayClient.applied_configs[-1]["config"]
- assert config["plugins"]["entries"]["wps-xiezuo"]["enabled"] is True
- assert "wps-xiezuo" in config["plugins"]["allow"]
- channel = config["channels"]["wps-xiezuo"]
- assert channel["enabled"] is True
- assert channel["appId"] == "app-demo"
- assert channel["appSecret"] == "secret-demo"
- assert channel["baseUrl"] == "https://openapi.wps.cn"
- assert channel["dmPolicy"] == "open"
- assert channel["allowFrom"] == ["*"]
- assert channel["groupPolicy"] == "open"
- assert channel["sdk"] == {"enabled": True, "logLevel": "info"}
- assert channel["instantAck"]["text"] == "内容处理中,请稍候..."
- assert channel["mcp"]["enabled"] is True
- assert channel["mcp"]["mode"] == "app"
- assert "toolAllowlist" not in channel["mcp"]
- assert "accounts" not in channel
- assert "defaultAccountId" not in channel
- assert config["bindings"] == [
- {"type": "route", "agentId": "main", "match": {"channel": "wps-xiezuo"}}
- ]
-
-
-def test_openclaw_channel_connect_wps_xiezuo_tolerates_reload_disconnect(monkeypatch):
- runner = CliRunner()
- _FakeConfigApplyReloadGatewayClient.applied_configs = []
-
- async def _fake_sleep(*_args, **_kwargs):
- return None
-
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeConfigApplyReloadGatewayClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep)
-
- result = runner.invoke(
- openclaw,
- [
- "channel",
- "connect",
- "ar-demo-1",
- "--channel",
- "wps-xiezuo",
- "--app-id",
- "app-demo",
- "--app-secret",
- "secret-demo",
- "--dm-policy",
- "open",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert "gateway reload 期间连接短暂中断" in result.output
- assert _FakeConfigApplyReloadGatewayClient.applied_configs
- config = _FakeConfigApplyReloadGatewayClient.applied_configs[-1]["config"]
- assert config["channels"]["wps-xiezuo"]["appId"] == "app-demo"
-
-
-def test_openclaw_channel_connect_wps_xiezuo_rejects_non_default_account(monkeypatch):
- runner = CliRunner()
- _FakeGatewayClient.applied_configs = []
-
- async def _fake_sleep(*_args, **_kwargs):
- return None
-
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep)
-
- result = runner.invoke(
- openclaw,
- [
- "channel",
- "connect",
- "ar-demo-1",
- "--channel",
- "wps-xiezuo",
- "--app-id",
- "app-demo",
- "--app-secret",
- "secret-demo",
- "--account-id",
- "tenant-a",
- ],
- )
-
- assert result.exit_code != 0
- assert "仅支持 default" in result.output
-
-
-def test_openclaw_channel_connect_wps_xiezuo_requires_app_secret_when_dm_disabled(monkeypatch):
- runner = CliRunner()
-
- async def _fake_sleep(*_args, **_kwargs):
- return None
-
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep)
-
- result = runner.invoke(
- openclaw,
- [
- "channel",
- "connect",
- "ar-demo-1",
- "--channel",
- "wps-xiezuo",
- "--app-id",
- "app-demo",
- "--dm-policy",
- "disabled",
- ],
- )
-
- assert result.exit_code != 0
- assert "必须提供 --app-secret" in result.output
-
-
-def test_openclaw_channel_disable_wps_xiezuo_updates_flat_channel(monkeypatch):
- runner = CliRunner()
- _FakeGatewayClient.applied_configs = []
-
- async def _fake_sleep(*_args, **_kwargs):
- return None
-
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeGatewayClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.asyncio.sleep", _fake_sleep)
-
- result = runner.invoke(openclaw, ["channel", "disable", "ar-demo-1", "--channel", "wps-xiezuo"])
-
- assert result.exit_code == 0, result.output
- assert _FakeGatewayClient.applied_configs
- config = _FakeGatewayClient.applied_configs[-1]["config"]
- assert config["channels"]["wps-xiezuo"]["enabled"] is False
- assert "accounts" not in config["channels"]["wps-xiezuo"]
-
-
-def test_openclaw_channel_doctor_checks_snapshot_and_local_node(monkeypatch):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeDoctorGatewayClient)
- monkeypatch.setattr(
- "ksadk.cli.cmd_openclaw.shutil.which",
- lambda cmd: f"/usr/bin/{cmd}" if cmd in {"node", "npx"} else None,
- )
-
- result = runner.invoke(openclaw, ["channel", "doctor", "ar-demo-1", "--channel", "feishu"])
-
- assert result.exit_code == 0, result.output
- assert "feishu_plugin_visible" in result.output
- assert "feishu_status_snapshot" in result.output
- assert "feishu_local_node" in result.output
-
-
-def test_openclaw_channel_doctor_checks_wps_xiezuo_plugin_and_deps(monkeypatch):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeDoctorGatewayClient)
- monkeypatch.setattr(
- "ksadk.cli.cmd_openclaw._check_wps_xiezuo_local_deps",
- lambda: {
- "ok": True,
- "node": "/usr/bin/node",
- "npm": "/usr/bin/npm",
- },
- )
-
- result = runner.invoke(openclaw, ["channel", "doctor", "ar-demo-1", "--channel", "wps-xiezuo"])
-
- assert result.exit_code == 0, result.output
- assert "wps_xiezuo_plugin_visible" in result.output
- assert "wps_xiezuo_status_snapshot" in result.output
- assert "wps_xiezuo_local_deps" in result.output
-
-
-def test_openclaw_channel_doctor_treats_unconfigured_channels_as_connect_required(monkeypatch):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeDoctorFreshGatewayClient)
- monkeypatch.setattr(
- "ksadk.cli.cmd_openclaw.shutil.which",
- lambda cmd: f"/usr/bin/{cmd}" if cmd in {"node", "npx"} else None,
- )
- monkeypatch.setattr(
- "ksadk.cli.cmd_openclaw._check_wps_xiezuo_local_deps",
- lambda: {"ok": True, "node": "/usr/bin/node", "npm": "/usr/bin/npm"},
- )
-
- result = runner.invoke(openclaw, ["channel", "doctor", "ar-demo-1", "--output", "json"])
-
- assert result.exit_code == 0, result.output
- payload = json.loads(result.output)
- checks = {item["name"]: item for item in payload["checks"]}
- assert payload["ok"] is False
- assert checks["weixin_qr_rpc"]["ok"] is False
- assert checks["weixin_qr_rpc"]["state"] == "connect_required"
- assert checks["feishu_status_snapshot"]["state"] == "connect_required"
- assert checks["wps_xiezuo_status_snapshot"]["state"] == "connect_required"
-
-
-def test_openclaw_channel_doctor_keeps_configured_weixin_qr_rpc_as_hard_failure(monkeypatch):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw.OpenClawGatewayClient", _FakeDoctorBrokenWeixinGatewayClient)
- monkeypatch.setattr(
- "ksadk.cli.cmd_openclaw.shutil.which",
- lambda cmd: f"/usr/bin/{cmd}" if cmd in {"node", "npx"} else None,
- )
- monkeypatch.setattr(
- "ksadk.cli.cmd_openclaw._check_wps_xiezuo_local_deps",
- lambda: {"ok": True, "node": "/usr/bin/node", "npm": "/usr/bin/npm"},
- )
-
- result = runner.invoke(openclaw, ["channel", "doctor", "ar-demo-1", "--channel", "weixin", "--output", "json"])
-
- assert result.exit_code == 0, result.output
- payload = json.loads(result.output)
- checks = {item["name"]: item for item in payload["checks"]}
- assert payload["ok"] is False
- assert checks["weixin_qr_rpc"]["state"] == "missing"
-
-
-def test_openclaw_deploy_supports_security_profile_flags(monkeypatch):
- runner = CliRunner()
- captured: Dict[str, Any] = {}
-
- async def _fake_deploy_openclaw(**kwargs):
- captured.update(kwargs)
-
- monkeypatch.setattr("ksadk.cli.cmd_openclaw._deploy_openclaw", _fake_deploy_openclaw)
- monkeypatch.setattr(
- "ksadk.cli.cmd_openclaw.run_async_with_dry_run",
- lambda coro, dry_run: asyncio.run(coro),
- )
-
- result = runner.invoke(openclaw, ["deploy", "--strictest"])
-
- assert result.exit_code == 0, result.output
- assert captured["security_profile"] == "strictest"
-
-
-def test_openclaw_deploy_forwards_custom_env_pairs(monkeypatch):
- runner = CliRunner()
- captured: Dict[str, Any] = {}
-
- async def _fake_deploy_openclaw(**kwargs):
- captured.update(kwargs)
-
- monkeypatch.setattr("ksadk.cli.cmd_openclaw._deploy_openclaw", _fake_deploy_openclaw)
- monkeypatch.setattr(
- "ksadk.cli.cmd_openclaw.run_async_with_dry_run",
- lambda coro, dry_run: asyncio.run(coro),
- )
-
- result = runner.invoke(
- openclaw,
- [
- "deploy",
- "--env",
- "FOO=bar",
- "--env",
- "OPENCLAW_GATEWAY_PORT=9090",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert captured["extra_env"] == ("FOO=bar", "OPENCLAW_GATEWAY_PORT=9090")
-
-
-def test_openclaw_deploy_forwards_explicit_memory_config(monkeypatch):
- runner = CliRunner()
- captured: Dict[str, Any] = {}
-
- async def _fake_deploy_openclaw(**kwargs):
- captured.update(kwargs)
-
- monkeypatch.setattr("ksadk.cli.cmd_openclaw._deploy_openclaw", _fake_deploy_openclaw)
- monkeypatch.setattr(
- "ksadk.cli.cmd_openclaw.run_async_with_dry_run",
- lambda coro, dry_run: asyncio.run(coro),
- )
-
- result = runner.invoke(
- openclaw,
- [
- "deploy",
- "--memory-system",
- "mem0",
- "--mem0-instance-id",
- "c17b20b1-faf7-4c98-91a7-38d1ee581ba1",
- "--mem0-instance-name",
- "mem-demo",
- "--mem0-region",
- "pre-online",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert captured["memory_system"] == "mem0"
- assert captured["mem0_instance_id"] == "c17b20b1-faf7-4c98-91a7-38d1ee581ba1"
- assert captured["mem0_instance_name"] == "mem-demo"
- assert captured["mem0_region"] == "pre-online"
-
-
-def test_openclaw_deploy_forwards_network_cli_options(monkeypatch):
- runner = CliRunner()
- captured: Dict[str, Any] = {}
-
- async def _fake_deploy_openclaw(**kwargs):
- captured.update(kwargs)
-
- monkeypatch.setattr("ksadk.cli.cmd_openclaw._deploy_openclaw", _fake_deploy_openclaw)
- monkeypatch.setattr(
- "ksadk.cli.cmd_openclaw.run_async_with_dry_run",
- lambda coro, dry_run: asyncio.run(coro),
- )
-
- result = runner.invoke(
- openclaw,
- [
- "deploy",
- "--disable-public-access",
- "--enable-vpc-access",
- "--vpc-id",
- "vpc-cli",
- "--subnet-id",
- "subnet-cli",
- "--security-group-id",
- "sg-cli",
- "--availability-zone",
- "cn-beijing-6b",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert captured["enable_public_access"] is False
- assert captured["enable_vpc_access"] is True
- assert captured["vpc_id"] == "vpc-cli"
- assert captured["subnet_id"] == "subnet-cli"
- assert captured["security_group_id"] == "sg-cli"
- assert captured["availability_zone"] == "cn-beijing-6b"
-
-
-def test_openclaw_deploy_rejects_mem0_without_instance_id():
- runner = CliRunner()
-
- result = runner.invoke(
- openclaw,
- [
- "deploy",
- "--memory-system",
- "mem0",
- ],
- )
-
- assert result.exit_code != 0
- assert "--mem0-instance-id" in result.output
-
-
-def test_openclaw_default_image_ref_tracks_current_runtime_tag():
- from ksadk.cli.cmd_openclaw import _resolve_image_ref
-
- assert _resolve_image_ref(None) == "ghcr.io/kingsoftcloud/agentengine-public/openclaw:2026.6.1"
-
-
-def test_openclaw_deploy_create_payload_includes_network(monkeypatch, tmp_path):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
- monkeypatch.chdir(tmp_path)
- _FakeOpenClawCreateClient.create_payload = None
- _FakeOpenClawCreateClient.update_payload = None
- _FakeOpenClawCreateClient.get_agent_calls = 0
-
- result = runner.invoke(
- openclaw,
- [
- "deploy",
- "--name",
- "demo-openclaw",
- "--image",
- "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test",
- "--disable-public-access",
- "--enable-vpc-access",
- "--vpc-id",
- "vpc-cli",
- "--subnet-id",
- "subnet-cli",
- "--security-group-id",
- "sg-cli",
- "--availability-zone",
- "cn-beijing-6b",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert _FakeOpenClawCreateClient.create_payload["network"] == {
- "enable_public_access": False,
- "enable_vpc_access": True,
- "vpc_id": "vpc-cli",
- "subnet_id": "subnet-cli",
- "security_group_id": "sg-cli",
- "availability_zone": "cn-beijing-6b",
- }
-
-
-def test_openclaw_deploy_uses_init_project_name_when_name_is_omitted(monkeypatch, tmp_path):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
- monkeypatch.chdir(tmp_path)
- (tmp_path / "agentengine.yaml").write_text(
- yaml.safe_dump(
- {
- "name": "custom-openclaw",
- "framework": "openclaw",
- "entry_point": "custom_openclaw/agent.py",
- }
- ),
- encoding="utf-8",
- )
- _FakeOpenClawCreateClient.create_payload = None
- _FakeOpenClawCreateClient.update_payload = None
- _FakeOpenClawCreateClient.get_agent_calls = 0
-
- result = runner.invoke(
- openclaw,
- [
- "deploy",
- "--image",
- "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert _FakeOpenClawCreateClient.create_payload["name"] == "custom-openclaw"
-
-
-def test_openclaw_deploy_update_payload_includes_network(monkeypatch, tmp_path):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
- monkeypatch.chdir(tmp_path)
- (tmp_path / ".agentengine.state").write_text(
- yaml.safe_dump(
- {
- "type": "openclaw",
- "agent_id": "ar-existing-1",
- "name": "demo-openclaw",
- "endpoint": "https://existing.example.com",
- "api_key": "ak-existing",
- }
- ),
- encoding="utf-8",
- )
- _FakeOpenClawCreateClient.create_payload = None
- _FakeOpenClawCreateClient.update_payload = None
- _FakeOpenClawCreateClient.get_agent_calls = 0
-
- result = runner.invoke(
- openclaw,
- [
- "deploy",
- "--name",
- "demo-openclaw",
- "--image",
- "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test",
- "--enable-vpc-access",
- "--vpc-id",
- "vpc-cli",
- "--subnet-id",
- "subnet-cli",
- "--security-group-id",
- "sg-cli",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert _FakeOpenClawCreateClient.create_payload is None
- assert _FakeOpenClawCreateClient.update_payload["network"] == {
- "enable_vpc_access": True,
- "vpc_id": "vpc-cli",
- "subnet_id": "subnet-cli",
- "security_group_id": "sg-cli",
- }
-
-
-def test_openclaw_deploy_update_payload_preserves_existing_config_by_default(monkeypatch, tmp_path):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
- monkeypatch.chdir(tmp_path)
- (tmp_path / ".agentengine.state").write_text(
- yaml.safe_dump(
- {
- "type": "openclaw",
- "agent_id": "ar-existing-1",
- "name": "demo-openclaw",
- "endpoint": "https://existing.example.com",
- "api_key": "ak-existing",
- }
- ),
- encoding="utf-8",
- )
- _FakeOpenClawCreateClient.create_payload = None
- _FakeOpenClawCreateClient.update_payload = None
- _FakeOpenClawCreateClient.get_agent_calls = 0
-
- result = runner.invoke(
- openclaw,
- [
- "deploy",
- "--image",
- "ghcr.io/kingsoftcloud/agentengine-public/openclaw:new",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert _FakeOpenClawCreateClient.create_payload is None
- assert _FakeOpenClawCreateClient.update_payload["artifact_path"] == (
- "ghcr.io/kingsoftcloud/agentengine-public/openclaw:new"
- )
- assert "env_vars" not in _FakeOpenClawCreateClient.update_payload
- assert "storage" not in _FakeOpenClawCreateClient.update_payload
- assert "network" not in _FakeOpenClawCreateClient.update_payload
- assert "memory_config" not in _FakeOpenClawCreateClient.update_payload
-
-
-def test_openclaw_deploy_update_payload_includes_explicit_config(monkeypatch, tmp_path):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
- monkeypatch.chdir(tmp_path)
- (tmp_path / ".agentengine.state").write_text(
- yaml.safe_dump(
- {
- "type": "openclaw",
- "agent_id": "ar-existing-1",
- "name": "demo-openclaw",
- "endpoint": "https://existing.example.com",
- "api_key": "ak-existing",
- }
- ),
- encoding="utf-8",
- )
- _FakeOpenClawCreateClient.create_payload = None
- _FakeOpenClawCreateClient.update_payload = None
- _FakeOpenClawCreateClient.get_agent_calls = 0
-
- result = runner.invoke(
- openclaw,
- [
- "deploy",
- "--image",
- "ghcr.io/kingsoftcloud/agentengine-public/openclaw:new",
- "--model-base-url",
- "https://model.example.com/v1",
- "--default-model",
- "glm-test",
- "--env",
- "APP_MODE=prod",
- "--storage-size-gi",
- "50",
- "--memory-system",
- "openclaw_default",
- ],
- )
-
- assert result.exit_code == 0, result.output
- payload = _FakeOpenClawCreateClient.update_payload
- assert any(item["Key"] == "APP_MODE" and item["Value"] == "prod" for item in payload["env_vars"])
- assert payload["storage"]["size_gi"] == 50
- assert payload["memory_config"] == {"memory_system": "openclaw_default"}
-
-
-def test_openclaw_deploy_network_ids_imply_vpc_access(monkeypatch, tmp_path):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
- monkeypatch.chdir(tmp_path)
- _FakeOpenClawCreateClient.create_payload = None
- _FakeOpenClawCreateClient.update_payload = None
- _FakeOpenClawCreateClient.get_agent_calls = 0
-
- result = runner.invoke(
- openclaw,
- [
- "deploy",
- "--name",
- "demo-openclaw",
- "--image",
- "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test",
- "--vpc-id",
- "vpc-cli",
- "--subnet-id",
- "subnet-cli",
- "--security-group-id",
- "sg-cli",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert _FakeOpenClawCreateClient.create_payload["network"]["enable_vpc_access"] is True
-
-
-def test_openclaw_deploy_rejects_incomplete_vpc_network():
- runner = CliRunner()
-
- result = runner.invoke(
- openclaw,
- [
- "deploy",
- "--enable-vpc-access",
- "--vpc-id",
- "vpc-cli",
- ],
- )
-
- assert result.exit_code != 0
- assert "VpcId、SubnetId、SecurityGroupId" in result.output
-
-
-def test_openclaw_deploy_does_not_query_get_agent_when_quick_access_is_already_complete(monkeypatch, tmp_path):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
- monkeypatch.chdir(tmp_path)
- _FakeOpenClawCreateClient.get_agent_calls = 0
-
- result = runner.invoke(
- openclaw,
- [
- "deploy",
- "--name",
- "demo-openclaw",
- "--image",
- "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert _FakeOpenClawCreateClient.get_agent_calls == 0
-
-
-def test_openclaw_deploy_persists_gateway_token_from_extra_env(monkeypatch, tmp_path):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
- monkeypatch.chdir(tmp_path)
- _FakeOpenClawCreateClient.get_agent_calls = 0
-
- result = runner.invoke(
- openclaw,
- [
- "deploy",
- "--name",
- "demo-openclaw",
- "--image",
- "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test",
- "--env",
- "OPENCLAW_GATEWAY_AUTH_MODE=token",
- "--env",
- "OPENCLAW_GATEWAY_TOKEN=gw-token-from-deploy",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert "gw-token-from-deploy" not in result.output
- state = yaml.safe_load((tmp_path / ".agentengine.state").read_text())
- assert state["openclaw_auth_mode"] == "token"
- assert state["openclaw_gateway_token"] == "gw-token-from-deploy"
-
-
-def test_openclaw_deploy_writes_only_configured_model_from_provider_catalog(monkeypatch, tmp_path):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
- monkeypatch.chdir(tmp_path)
- monkeypatch.setenv("OPENAI_MODEL_NAME", "deepseek-v4-pro")
- monkeypatch.setenv("OPENAI_BASE_URL", "https://kspmas.ksyun.com/v1")
- monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
- _FakeOpenClawCreateClient.create_payload = None
- _FakeOpenClawCreateClient.get_agent_calls = 0
-
- async def _fake_fetch_provider_model_catalog(**_kwargs):
- return [
- {
- "id": "glm-5.1",
- "context_window_tokens": 128_000,
- "max_output_tokens": 8_192,
- },
- {
- "id": "deepseek-v4-pro",
- "context_window_tokens": 1_000_000,
- "max_output_tokens": 384_000,
- },
- {
- "id": "kimi-k2.6",
- "context_window_tokens": 256_000,
- "max_output_tokens": 32_000,
- },
- ]
-
- monkeypatch.setattr(cmd_openclaw, "fetch_provider_model_catalog", _fake_fetch_provider_model_catalog)
-
- result = runner.invoke(
- openclaw,
- [
- "deploy",
- "--name",
- "demo-openclaw",
- "--image",
- "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test",
- ],
- )
-
- assert result.exit_code == 0, result.output
- env_vars = {
- item["Key"]: item["Value"]
- for item in _FakeOpenClawCreateClient.create_payload["env_vars"]
- }
- catalog = json.loads(env_vars["OPENCLAW_MODEL_CATALOG_JSON"])
- assert [item["id"] for item in catalog] == ["glm-5.2", "kimi-k2.7-code", "deepseek-v4-pro"]
- assert catalog[1]["options"] == {"temperature": 1}
-
-
-def test_openclaw_deploy_writes_allowlisted_models_from_provider_catalog(monkeypatch, tmp_path):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawCreateClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
- monkeypatch.chdir(tmp_path)
- monkeypatch.setenv("OPENAI_MODEL_NAME", "deepseek-v4-pro")
- monkeypatch.setenv("OPENAI_BASE_URL", "https://kspmas.ksyun.com/v1")
- monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
- monkeypatch.setenv("OPENCLAW_MODEL_ALLOWLIST", "deepseek-v4-pro,glm-5.1")
- _FakeOpenClawCreateClient.create_payload = None
- _FakeOpenClawCreateClient.get_agent_calls = 0
-
- async def _fake_fetch_provider_model_catalog(**_kwargs):
- return [
- {
- "id": "glm-5.1",
- "context_window_tokens": 128_000,
- "max_output_tokens": 8_192,
- },
- {
- "id": "deepseek-v4-pro",
- "context_window_tokens": 1_000_000,
- "max_output_tokens": 384_000,
- },
- {
- "id": "kimi-k2.6",
- "context_window_tokens": 256_000,
- "max_output_tokens": 32_000,
- },
- ]
-
- monkeypatch.setattr(cmd_openclaw, "fetch_provider_model_catalog", _fake_fetch_provider_model_catalog)
-
- result = runner.invoke(
- openclaw,
- [
- "deploy",
- "--name",
- "demo-openclaw",
- "--image",
- "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test",
- ],
- )
-
- assert result.exit_code == 0, result.output
- env_vars = {
- item["Key"]: item["Value"]
- for item in _FakeOpenClawCreateClient.create_payload["env_vars"]
- }
- catalog = json.loads(env_vars["OPENCLAW_MODEL_CATALOG_JSON"])
- assert [item["id"] for item in catalog] == ["glm-5.2", "kimi-k2.7-code", "deepseek-v4-pro"]
- assert "kimi-k2.6" not in {item["id"] for item in catalog}
-
-
-def test_openclaw_deploy_refreshes_quick_access_when_agent_id_is_immediate(monkeypatch, tmp_path):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawImmediateAgentIdClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
- monkeypatch.chdir(tmp_path)
- _FakeOpenClawImmediateAgentIdClient.get_agent_calls = 0
-
- result = runner.invoke(
- openclaw,
- [
- "deploy",
- "--name",
- "demo-openclaw",
- "--image",
- "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert _FakeOpenClawImmediateAgentIdClient.get_agent_calls == 1
- state = yaml.safe_load((tmp_path / ".agentengine.state").read_text())
- assert state["agent_id"] == "ar-created-2"
- assert state["endpoint"] == "https://fresh-openclaw.example.com"
- assert state["api_key"] == "ak-fresh-openclaw"
-
-
-def test_openclaw_deploy_retries_transient_get_agent_not_found_until_api_key_is_ready(monkeypatch, tmp_path):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDelayedAccessClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
- monkeypatch.chdir(tmp_path)
- _FakeOpenClawDelayedAccessClient.get_agent_calls = 0
- _FakeOpenClawDelayedAccessClient.suppression_used = False
-
- result = runner.invoke(
- openclaw,
- [
- "deploy",
- "--name",
- "demo-openclaw",
- "--image",
- "ghcr.io/kingsoftcloud/agentengine-public/openclaw:test",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert _FakeOpenClawDelayedAccessClient.suppression_used is True
- assert _FakeOpenClawDelayedAccessClient.get_agent_calls == 4
- state = yaml.safe_load((tmp_path / ".agentengine.state").read_text())
- assert state["agent_id"] == "ar-created-delayed"
- assert state["endpoint"] == "https://ready-openclaw.example.com"
- assert state["api_key"] == "ak-ready-openclaw"
-
-
-def test_openclaw_flatten_agent_detail_reads_framework_and_region_from_deployment():
- detail = cmd_openclaw._flatten_agent_detail(
- {
- "basic": {
- "agent_id": "ar-openclaw-demo",
- "name": "demo-openclaw",
- "status": "running",
- },
- "quick_access": {
- "public_endpoint": "https://demo-openclaw.example.com",
- "api_key": "ak-demo-openclaw",
- },
- "deployment": {
- "framework": "openclaw",
- "region": "pre-online",
- "artifact_path": "hub/openclaw:test",
- },
- }
- )
-
- assert detail["framework"] == "openclaw"
- assert detail["region"] == "pre-online"
- assert detail["api_key"] == "ak-demo-openclaw"
-
-
-def test_version_list_supports_dry_run(monkeypatch):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeDryRunClient)
- monkeypatch.setenv("KSYUN_REGION", "cn-beijing-6")
-
- result = runner.invoke(version, ["list", "--agent", "demo-agent", "--dry-run"])
-
- assert result.exit_code == 0, result.output
- assert "Dry Run Completed" in result.output
- assert _FakeDryRunClient.last_init_kwargs.get("dry_run") is True
-
-
-def test_top_level_delete_accepts_force_alias(monkeypatch):
- runner = CliRunner()
- provider = _FakeDeleteProvider()
- monkeypatch.setattr("ksadk.cli.cmd_destroy.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- result = runner.invoke(
- destroy_delete,
- ["ar-123", "--account-id", "2000003485", "--force", "--dry-run"],
- )
-
- assert result.exit_code == 0, result.output
- assert provider.calls
- assert provider.calls[0][0] == "ar-123"
-
-
-def test_top_level_destroy_accepts_yes_alias(monkeypatch):
- runner = CliRunner()
- provider = _FakeDeleteProvider()
- monkeypatch.setattr("ksadk.cli.cmd_destroy.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- result = runner.invoke(
- destroy_cmd,
- ["ar-456", "--account-id", "2000003485", "--yes", "--dry-run"],
- )
-
- assert result.exit_code == 0, result.output
- assert provider.calls
- assert provider.calls[0][0] == "ar-456"
-
-
-def test_openclaw_destroy_accepts_force_alias(monkeypatch):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeDryRunClient)
-
- result = runner.invoke(openclaw, ["destroy", "ar-demo-1", "--force", "--dry-run"])
-
- assert result.exit_code == 0, result.output
- assert "Dry Run Completed" in result.output
- assert _FakeDryRunClient.last_init_kwargs.get("dry_run") is True
-
-
-def test_mcp_destroy_accepts_force_alias(monkeypatch):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeDryRunClient)
-
- result = runner.invoke(
- mcp,
- ["destroy", "mcp-123", "--force", "--dry-run"],
- env={"AGENTENGINE_SERVER_URL": "http://example.com"},
- )
-
- assert result.exit_code == 0, result.output
- assert "Dry Run Completed" in result.output
- assert _FakeDryRunClient.last_init_kwargs.get("dry_run") is True
-
-
-def test_root_cli_registers_delete_alias():
- _register_commands()
- assert "agent" in cli.commands
- assert "delete" in cli.commands
- assert "destroy" in cli.commands
- assert cli.get_command(None, "delete").hidden is True
- assert cli.get_command(None, "destroy").hidden is True
-
-
-def test_root_help_shows_canonical_commands_only():
- runner = CliRunner()
- _register_commands()
-
- result = runner.invoke(cli, ["--help"])
-
- assert result.exit_code == 0, result.output
- assert "agentengine agent" in result.output
- assert "agentengine status" not in result.output
- assert "agentengine invoke" not in result.output
- assert "agentengine delete" not in result.output
- assert "agentengine destroy" not in result.output
-
-
-def test_agent_group_exposes_canonical_subcommands():
- runner = CliRunner()
-
- result = runner.invoke(agent, ["--help"])
-
- assert result.exit_code == 0, result.output
- assert "list" in result.output
- assert "status" in result.output
- assert "invoke" in result.output
- assert "delete" in result.output
-
-
-def test_root_status_all_routes_with_compatibility_hint(monkeypatch):
- runner = CliRunner()
- _register_commands()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeDryRunClient)
-
- result = runner.invoke(
- cli,
- ["status", "--all", "--account-id", "2000003485", "--dry-run"],
- )
-
- assert result.exit_code == 0, result.output
- assert "agentengine agent list" in result.output
- assert "Dry Run Completed" in result.output
-
-
-def test_root_invoke_alias_still_callable_with_hint(monkeypatch):
- runner = CliRunner()
- _register_commands()
- invoked = {}
-
- def fake_invoke_tui(
- endpoint,
- api_key,
- session_id,
- insecure,
- model,
- show_thinking,
- api_format=None,
- responses_session_header=None,
- ):
- return invoked.setdefault("endpoint", endpoint)
-
- monkeypatch.setattr(
- "ksadk.cli.cmd_invoke._invoke_tui",
- fake_invoke_tui,
- )
-
- result = runner.invoke(cli, ["invoke", "--endpoint", "http://demo.local"])
-
- assert result.exit_code == 0, result.output
- assert "agentengine agent invoke" in result.output
- assert invoked["endpoint"] == "http://demo.local"
-
-
-def test_legacy_root_help_points_to_canonical_commands():
- runner = CliRunner()
- _register_commands()
-
- result = runner.invoke(cli, ["status", "--help"])
-
- assert result.exit_code == 0, result.output
- assert "这是兼容入口" in result.output
- assert "agentengine agent status --help" in result.output
-
-
-def test_top_level_delete_supports_multiple_ids(monkeypatch):
- runner = CliRunner()
- provider = _FakeDeleteProvider()
- monkeypatch.setattr("ksadk.cli.cmd_destroy.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- result = runner.invoke(
- destroy_delete,
- ["ar-123", "ar-456", "--account-id", "2000003485", "--force", "--dry-run"],
- )
-
- assert result.exit_code == 0, result.output
- assert [call[0] for call in provider.calls] == ["ar-123", "ar-456"]
-
-
-def test_top_level_destroy_supports_repeated_agent_option(monkeypatch):
- runner = CliRunner()
- provider = _FakeDeleteProvider()
- monkeypatch.setattr("ksadk.cli.cmd_destroy.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- result = runner.invoke(
- destroy_cmd,
- ["--agent", "ar-123", "--agent", "ar-456", "--account-id", "2000003485", "--yes", "--dry-run"],
- )
-
- assert result.exit_code == 0, result.output
- assert [call[0] for call in provider.calls] == ["ar-123", "ar-456"]
-
-
-def test_openclaw_destroy_supports_multiple_ids(monkeypatch):
- runner = CliRunner()
- _FakeBatchDeleteClient.deleted_agents = []
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeBatchDeleteClient)
- monkeypatch.setattr(
- "ksadk.cli.cmd_openclaw.run_async_with_dry_run",
- lambda coro, dry_run: asyncio.run(coro),
- )
-
- result = runner.invoke(openclaw, ["destroy", "ar-demo-1", "ar-demo-2", "--force"])
-
- assert result.exit_code == 0, result.output
- assert _FakeBatchDeleteClient.deleted_agents == ["ar-demo-1", "ar-demo-2"]
-
-
-def test_openclaw_delete_passes_result_styles_to_descriptor(monkeypatch):
- runner = CliRunner()
- _FakeBatchDeleteClient.deleted_agents = []
- captured = {}
-
- def _fake_render_descriptor_status(*args, **kwargs):
- captured.update(kwargs)
-
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeBatchDeleteClient)
- monkeypatch.setattr(
- "ksadk.cli.cmd_openclaw.run_async_with_dry_run",
- lambda coro, dry_run: asyncio.run(coro),
- )
- monkeypatch.setattr(
- "ksadk.cli.cmd_openclaw.render_descriptor_status",
- _fake_render_descriptor_status,
- )
-
- result = runner.invoke(openclaw, ["delete", "ar-demo-1", "--yes"])
-
- assert result.exit_code == 0, result.output
- assert captured["fields"][1] == ("已删除", "ar-demo-1", "ok")
- assert captured["fields"][2] == ("失败", "-", "muted")
- assert captured["next_steps"] == (
- "agentengine openclaw list",
- "agentengine openclaw deploy",
- )
-
-
-def test_openclaw_status_shows_langfuse_trace_url(monkeypatch):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawDetailClient)
-
- result = runner.invoke(openclaw, ["status", "ar-demo-1"])
-
- assert result.exit_code == 0, result.output
- assert "Langfuse" in result.output
- assert "https://trace.example.com/project/aropenclaw1/traces" in result.output
-
-
-def test_mcp_destroy_supports_multiple_ids(monkeypatch):
- runner = CliRunner()
- _FakeBatchDeleteClient.deleted_mcps = []
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeBatchDeleteClient)
- monkeypatch.setattr(
- "ksadk.cli.cmd_mcp.run_async_with_dry_run",
- lambda coro, dry_run: asyncio.run(coro),
- )
-
- result = runner.invoke(
- mcp,
- ["destroy", "mcp-123", "mcp-456", "--force"],
- env={"AGENTENGINE_SERVER_URL": "http://example.com"},
- )
-
- assert result.exit_code == 0, result.output
- assert _FakeBatchDeleteClient.deleted_mcps == ["mcp-123", "mcp-456"]
-
-
-def test_mcp_delete_passes_result_styles_to_descriptor(monkeypatch):
- runner = CliRunner()
- _FakeBatchDeleteClient.deleted_mcps = []
- captured = {}
-
- def _fake_render_descriptor_status(*args, **kwargs):
- captured.update(kwargs)
-
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeBatchDeleteClient)
- monkeypatch.setattr(
- "ksadk.cli.cmd_mcp.run_async_with_dry_run",
- lambda coro, dry_run: asyncio.run(coro),
- )
- monkeypatch.setattr(
- "ksadk.cli.cmd_mcp.render_descriptor_status",
- _fake_render_descriptor_status,
- )
-
- result = runner.invoke(
- mcp,
- ["delete", "mcp-123", "--yes"],
- env={"AGENTENGINE_SERVER_URL": "http://example.com"},
- )
-
- assert result.exit_code == 0, result.output
- assert captured["fields"][1] == ("已删除", "mcp-123", "ok")
- assert captured["fields"][2] == ("失败", "-", "muted")
- assert captured["next_steps"] == (
- "agentengine mcp list",
- "agentengine mcp deploy",
- )
-
-
-def test_agent_delete_json_requires_yes(monkeypatch):
- runner = CliRunner()
- _register_commands()
-
- async def _resolve(ids, _region, _account_id):
- return ids
-
- monkeypatch.setattr("ksadk.cli.cmd_destroy._resolve_agent_ids", _resolve)
-
- result = runner.invoke(
- cli,
- ["--output", "json", "agent", "delete", "ar-123", "--account-id", "2000003485"],
- )
-
- assert result.exit_code == 2, result.output
- payload = json.loads(result.output.strip())
- assert payload["ok"] is False
- assert payload["error"]["code"] == "usage_error"
- assert "--yes" in payload["error"]["message"]
-
-
-def test_agent_delete_json_returns_error_on_partial_failure(monkeypatch):
- runner = CliRunner()
- _register_commands()
- provider = _FakePartialDeleteProvider({"ar-1": True, "ar-2": False})
-
- async def _resolve(ids, _region, _account_id):
- return ids
-
- monkeypatch.setattr("ksadk.cli.cmd_destroy._resolve_agent_ids", _resolve)
- monkeypatch.setattr("ksadk.cli.cmd_destroy.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- result = runner.invoke(
- cli,
- ["--output", "json", "agent", "delete", "ar-1", "ar-2", "--account-id", "2000003485", "--yes"],
- )
-
- assert result.exit_code == 6, result.output
- payload = json.loads(result.output.strip())
- assert payload["ok"] is False
- assert payload["error"]["code"] == "remote_error"
- assert payload["error"]["details"]["deleted"] == ["ar-1"]
- assert payload["error"]["details"]["failed"] == ["ar-2"]
-
-
-def test_agent_delete_cancel_returns_cancelled_exit_code(monkeypatch):
- runner = CliRunner()
- _register_commands()
-
- async def _resolve(ids, _region, _account_id):
- return ids
-
- monkeypatch.setattr("ksadk.cli.cmd_destroy._resolve_agent_ids", _resolve)
-
- result = runner.invoke(
- cli,
- ["agent", "delete", "ar-1", "--account-id", "2000003485"],
- input="n\n",
- )
-
- assert result.exit_code == 7, result.output
- assert "已取消" in result.output
-
-
-def test_serverless_destroy_cleans_local_state_only_after_success(tmp_path, monkeypatch):
- provider = ServerlessProvider()
- state_file = tmp_path / ".agentengine.state"
- state_file.write_text(yaml.safe_dump({"agent_id": "ar-demo"}), encoding="utf-8")
- _FakeDeleteClient.deleted_agents = []
- _FakeDeleteClient.should_succeed = True
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr("ksadk.deployment.providers.serverless.AgentEngineClient", _FakeDeleteClient)
-
- success = asyncio.run(
- provider.destroy(
- "ar-demo",
- DeployTarget(provider="serverless", region="cn-beijing-6", extra={"dry_run": False}),
- )
- )
-
- assert success is True
- assert _FakeDeleteClient.deleted_agents == ["ar-demo"]
- assert state_file.exists() is False
-
-
-def test_serverless_destroy_keeps_local_state_on_dry_run(tmp_path, monkeypatch):
- provider = ServerlessProvider()
- state_file = tmp_path / ".agentengine.state"
- state_file.write_text(yaml.safe_dump({"agent_id": "ar-demo"}), encoding="utf-8")
- monkeypatch.chdir(tmp_path)
-
- class _DryRunDeleteClient(_FakeDeleteClient):
- async def delete_agent(self, agent_id):
- raise DryRunExit(
- "dry-run",
- payload={"method": "POST", "url": "https://example.com", "curl": "curl -X POST https://example.com"},
- )
-
- monkeypatch.setattr("ksadk.deployment.providers.serverless.AgentEngineClient", _DryRunDeleteClient)
-
- try:
- asyncio.run(
- provider.destroy(
- "ar-demo",
- DeployTarget(provider="serverless", region="cn-beijing-6", extra={"dry_run": True}),
- )
- )
- except DryRunExit:
- pass
- else:
- raise AssertionError("DryRunExit should bubble for CLI handling")
-
- assert state_file.exists() is True
-
-
-def test_serverless_destroy_keeps_local_state_when_remote_delete_fails(tmp_path, monkeypatch):
- provider = ServerlessProvider()
- state_file = tmp_path / ".agentengine.state"
- state_file.write_text(yaml.safe_dump({"agent_id": "ar-demo"}), encoding="utf-8")
- _FakeDeleteClient.deleted_agents = []
- _FakeDeleteClient.should_succeed = False
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr("ksadk.deployment.providers.serverless.AgentEngineClient", _FakeDeleteClient)
-
- success = asyncio.run(
- provider.destroy(
- "ar-demo",
- DeployTarget(provider="serverless", region="cn-beijing-6", extra={"dry_run": False}),
- )
- )
-
- assert success is False
- assert state_file.exists() is True
-
-
-def test_serverless_destroy_uses_explicit_project_dir_for_state_cleanup(tmp_path, monkeypatch):
- provider = ServerlessProvider()
- project_dir = tmp_path / "project"
- other_dir = tmp_path / "other"
- project_dir.mkdir()
- other_dir.mkdir()
-
- project_state = project_dir / ".agentengine.state"
- project_state.write_text(yaml.safe_dump({"agent_id": "ar-demo"}), encoding="utf-8")
- other_state = other_dir / ".agentengine.state"
- other_state.write_text(yaml.safe_dump({"agent_id": "ar-demo"}), encoding="utf-8")
-
- _FakeDeleteClient.deleted_agents = []
- _FakeDeleteClient.should_succeed = True
- monkeypatch.chdir(other_dir)
- monkeypatch.setattr("ksadk.deployment.providers.serverless.AgentEngineClient", _FakeDeleteClient)
-
- success = asyncio.run(
- provider.destroy(
- "ar-demo",
- DeployTarget(
- provider="serverless",
- region="cn-beijing-6",
- extra={"dry_run": False, "project_dir": str(project_dir)},
- ),
- )
- )
-
- assert success is True
- assert project_state.exists() is False
- assert other_state.exists() is True
diff --git a/tests/test_cli_global_options.py b/tests/test_cli_global_options.py
deleted file mode 100644
index 7687a958..00000000
--- a/tests/test_cli_global_options.py
+++ /dev/null
@@ -1,156 +0,0 @@
-from __future__ import annotations
-
-import json
-
-from click.testing import CliRunner
-
-from ksadk.cli import _register_commands, cli
-from ksadk.cli.dry_run import effective_dry_run
-from ksadk.cli.ui import emit_json, is_color_disabled, is_json_output
-
-
-def _parse_json(output: str) -> dict:
- return json.loads(output.strip())
-
-
-def test_global_options_are_accepted_at_root_group_and_command_positions(monkeypatch):
- _register_commands()
- runner = CliRunner()
-
- def fake_run_status_command(*, dry_run: bool, **kwargs): # noqa: ARG001
- emit_json(
- {
- "json": is_json_output(),
- "dry_run": effective_dry_run(dry_run),
- "no_color": is_color_disabled(),
- }
- )
-
- monkeypatch.setattr("ksadk.cli.cmd_agent.run_status_command", fake_run_status_command)
-
- cases = [
- ["--output", "json", "--dry-run", "--no-color", "agent", "list", "--account-id", "2000003485"],
- ["agent", "--output", "json", "--dry-run", "--no-color", "list", "--account-id", "2000003485"],
- ["agent", "list", "--output", "json", "--dry-run", "--no-color", "--account-id", "2000003485"],
- ]
-
- for argv in cases:
- result = runner.invoke(cli, argv)
- assert result.exit_code == 0, result.output
- assert _parse_json(result.output) == {
- "json": True,
- "dry_run": True,
- "no_color": True,
- }
-
-
-def test_group_level_output_option_uses_canonical_cli_error_for_unsupported_command():
- _register_commands()
- runner = CliRunner()
-
- result = runner.invoke(cli, ["agent", "--output", "json", "invoke"])
-
- assert result.exit_code == 2, result.output
- payload = _parse_json(result.output)
- assert payload["ok"] is False
- assert payload["error"]["code"] == "usage_error"
- assert "--output json" in payload["error"]["message"]
-
-
-def test_group_level_dry_run_option_uses_canonical_cli_error_for_unsupported_command():
- _register_commands()
- runner = CliRunner()
-
- result = runner.invoke(cli, ["agent", "--dry-run", "invoke"])
-
- assert result.exit_code == 2, result.output
- assert "--dry-run" in result.output
-
-
-def test_agent_list_passes_framework_filter_to_status_command(monkeypatch):
- _register_commands()
- runner = CliRunner()
-
- def fake_run_status_command(*, framework: str | None, **kwargs): # noqa: ARG001
- emit_json({"framework": framework})
-
- monkeypatch.setattr("ksadk.cli.cmd_agent.run_status_command", fake_run_status_command)
-
- result = runner.invoke(
- cli,
- [
- "agent",
- "list",
- "--account-id",
- "2000003485",
- "--framework",
- " langgraph, adk ",
- "--output",
- "json",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert _parse_json(result.output) == {"framework": " langgraph, adk "}
-
-
-def test_agent_list_hides_openclaw_and_hermes_by_default(monkeypatch):
- _register_commands()
- runner = CliRunner()
-
- class FakeAgentClient:
- def __init__(self, *args, **kwargs):
- pass
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
- async def list_agents(self, **kwargs):
- assert kwargs.get("framework") is None
- return {
- "agents": [
- {
- "agent_id": "ar-langgraph",
- "name": "regular-agent",
- "status": "RUNNING",
- "framework": "langgraph",
- },
- {
- "agent_id": "ar-openclaw",
- "name": "openclaw-agent",
- "status": "RUNNING",
- "framework": "openclaw",
- },
- {
- "agent_id": "ar-hermes",
- "name": "hermes-agent",
- "status": "RUNNING",
- "framework": "hermes",
- },
- ],
- "total": 3,
- }
-
- async def close(self):
- return None
-
- monkeypatch.setattr("ksadk.api.AgentEngineClient", FakeAgentClient)
-
- result = runner.invoke(
- cli,
- [
- "agent",
- "list",
- "--account-id",
- "2000003485",
- "--output",
- "json",
- ],
- )
-
- assert result.exit_code == 0, result.output
- payload = _parse_json(result.output)
- assert [item["framework"] for item in payload["items"]] == ["langgraph"]
diff --git a/tests/test_cli_platform_refactor.py b/tests/test_cli_platform_refactor.py
deleted file mode 100644
index 827e7d06..00000000
--- a/tests/test_cli_platform_refactor.py
+++ /dev/null
@@ -1,277 +0,0 @@
-from pathlib import Path
-
-import yaml
-from click.testing import CliRunner
-
-from ksadk.cli.cmd_agent import agent
-from ksadk.cli.cmd_mcp import mcp
-from ksadk.cli.cmd_openclaw import openclaw
-from ksadk.cli.cmd_version import version
-
-
-class _FakeMCPClient:
- last_name_lookup = None
-
- def __init__(self, *args, **kwargs):
- self.kwargs = kwargs
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
- async def get_mcp(self, mcp_id):
- return {
- "mcp_id": mcp_id,
- "name": "demo-mcp",
- "status": "running",
- "region": "cn-beijing-6",
- "endpoint": "https://demo.example.com",
- "mcp_endpoint": "https://demo.example.com/mcp",
- "enable_auth": True,
- "tools": ["search"],
- "created_at": "2026-03-20T12:00:00Z",
- "updated_at": "2026-03-20T12:05:00Z",
- }
-
- async def get_mcp_by_name(self, name, region=None):
- type(self).last_name_lookup = {"name": name, "region": region}
- return {
- "mcp_id": "mcp-by-name",
- "name": name,
- "status": "running",
- "region": region or "cn-beijing-6",
- "endpoint": "https://demo.example.com",
- "mcp_endpoint": "https://demo.example.com/mcp",
- "enable_auth": False,
- "created_at": "2026-03-20T12:00:00Z",
- "updated_at": "2026-03-20T12:05:00Z",
- }
-
- async def list_mcps(self, **kwargs):
- page = int(kwargs.get("page", 1))
- page_size = int(kwargs.get("page_size", 20))
- all_items = [
- {"mcp_id": "mcp-1", "name": "first", "status": "running", "mcp_endpoint": "https://demo1.example.com/mcp"},
- {"mcp_id": "mcp-2", "name": "second", "status": "failed", "mcp_endpoint": "https://demo2.example.com/mcp"},
- {"mcp_id": "mcp-3", "name": "third", "status": "creating", "mcp_endpoint": "https://demo3.example.com/mcp"},
- ]
- start = (page - 1) * page_size
- end = start + page_size
- return {
- "mcps": all_items[start:end],
- "total": 3,
- }
-
- async def close(self):
- return None
-
-
-class _FakeAgentStatusClient:
- def __init__(self, *args, **kwargs):
- self.kwargs = kwargs
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
- async def get_agent(self, agent_id=None, name=None):
- return {
- "basic": {
- "agent_id": agent_id or "ar-openclaw-local",
- "name": name or "openclaw-local",
- "status": "RUNNING",
- "framework": "openclaw",
- "replicas": 1,
- "ready_replicas": 1,
- },
- "quick_access": {
- "public_endpoint": "https://openclaw.example.com",
- },
- "advanced": {
- "observability_url": "https://trace.example.com/project/aropenclawlocal/traces",
- },
- }
-
-
-def test_mcp_status_falls_back_to_local_state(monkeypatch, tmp_path: Path):
- runner = CliRunner()
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeMCPClient)
- state_path = tmp_path / ".agentengine.state"
- state_path.write_text(
- yaml.safe_dump({"type": "mcp", "mcp_id": "mcp-local", "region": "cn-beijing-6"}),
- encoding="utf-8",
- )
-
- result = runner.invoke(mcp, ["status"])
-
- assert result.exit_code == 0, result.output
- assert "mcp-local" in result.output
- assert "MCP 状态" in result.output
-
-
-def test_agent_status_falls_back_to_openclaw_local_state(monkeypatch, tmp_path: Path):
- runner = CliRunner()
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeAgentStatusClient)
- state_path = tmp_path / ".agentengine.state"
- state_path.write_text(
- yaml.safe_dump({"type": "openclaw", "agent_id": "ar-openclaw-local", "region": "pre-online"}),
- encoding="utf-8",
- )
-
- result = runner.invoke(agent, ["status", "--account-id", "2000003485"])
-
- assert result.exit_code == 0, result.output
- assert "ar-openclaw-local" in result.output
- assert "Langfuse" in result.output
- assert "https://trace.example.com/project/aropenclawlocal/traces" in result.output
-
-
-def test_mcp_list_supports_pagination(monkeypatch):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeMCPClient)
-
- result = runner.invoke(mcp, ["list", "--page", "2", "--size", "1"])
-
- assert result.exit_code == 0, result.output
- assert "mcp-2" in result.output
- assert "mcp-1" not in result.output
- assert "MCP总数: 3 页码: 2 每页: 1" in result.output
-
-
-def test_mcp_status_passes_region_to_name_lookup(monkeypatch):
- runner = CliRunner()
-
- class _FallbackToNameClient(_FakeMCPClient):
- last_name_lookup = None
-
- async def get_mcp(self, mcp_id):
- raise RuntimeError("not found")
-
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FallbackToNameClient)
-
- result = runner.invoke(mcp, ["status", "demo-mcp", "--region", "cn-shanghai-2"])
-
- assert result.exit_code == 0, result.output
- assert _FallbackToNameClient.last_name_lookup == {
- "name": "demo-mcp",
- "region": "cn-shanghai-2",
- }
-
-
-def test_agent_list_fills_visible_page_after_filtering_openclaw(monkeypatch):
- runner = CliRunner()
-
- async def _fake_list_agent_runtimes(
- region,
- account_id,
- dry_run=False,
- *,
- page=1,
- page_size=20,
- framework=None,
- ):
- assert framework is None
- if page == 1:
- return {
- "agents": [
- {
- "agentRuntimeId": f"ar-openclaw-{idx}",
- "agentRuntimeName": f"openclaw-{idx}",
- "status": "RUNNING",
- "replicas": 1,
- "readyReplicas": 1,
- "endpoint": f"https://openclaw-{idx}.example.com",
- "framework": "openclaw",
- }
- for idx in range(100)
- ],
- "total": 101,
- }
- if page == 2:
- return {
- "agents": [
- {
- "agentRuntimeId": "ar-agent-1",
- "agentRuntimeName": "visible-agent",
- "status": "RUNNING",
- "replicas": 1,
- "readyReplicas": 1,
- "endpoint": "https://agent.example.com",
- "framework": "langgraph",
- }
- ],
- "total": 101,
- }
- return {"agents": [], "total": 101}
-
- monkeypatch.setattr("ksadk.cli.cmd_status._list_agent_runtimes", _fake_list_agent_runtimes)
-
- result = runner.invoke(agent, ["list", "--page", "1", "--size", "1", "--account-id", "2000003485"])
-
- assert result.exit_code == 0, result.output
- assert "visible-agent" in result.output
- assert "Agent总数: 1 页码: 1 每页: 1" in result.output
-
-
-def test_agent_list_with_explicit_openclaw_framework_does_not_hide_results(monkeypatch):
- runner = CliRunner()
-
- async def _fake_list_agent_runtimes(
- region,
- account_id,
- dry_run=False,
- *,
- page=1,
- page_size=20,
- framework=None,
- ):
- assert framework == "openclaw"
- return {
- "agents": [
- {
- "agentRuntimeId": "ar-openclaw-1",
- "agentRuntimeName": "openclaw-visible",
- "status": "RUNNING",
- "replicas": 1,
- "readyReplicas": 1,
- "endpoint": "https://openclaw.example.com",
- "framework": "openclaw",
- }
- ],
- "total": 1,
- }
-
- monkeypatch.setattr("ksadk.cli.cmd_status._list_agent_runtimes", _fake_list_agent_runtimes)
-
- result = runner.invoke(
- agent,
- [
- "list",
- "--page",
- "1",
- "--size",
- "20",
- "--account-id",
- "2000003485",
- "--framework",
- "openclaw",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert "openclaw-visible" in result.output
- assert "已隐藏" not in result.output
-
-
-def test_resource_groups_support_short_help():
- runner = CliRunner()
-
- for command in (mcp, openclaw, version):
- result = runner.invoke(command, ["-h"])
- assert result.exit_code == 0, result.output
diff --git a/tests/test_cli_root_entrypoint.py b/tests/test_cli_root_entrypoint.py
deleted file mode 100644
index 473c3f39..00000000
--- a/tests/test_cli_root_entrypoint.py
+++ /dev/null
@@ -1,18 +0,0 @@
-import sys
-
-import pytest
-
-from ksadk.cli import main
-
-
-def test_main_without_args_shows_help_without_error_prefix(monkeypatch, capsys):
- monkeypatch.setattr(sys, "argv", ["agentengine"])
-
- with pytest.raises(SystemExit) as exc_info:
- main()
-
- captured = capsys.readouterr()
-
- assert exc_info.value.code == 0
- assert "AgentEngine CLI" in captured.out
- assert "错误:" not in captured.out
diff --git a/tests/test_client_framework_passthrough.py b/tests/test_client_framework_passthrough.py
deleted file mode 100644
index 6d29ef69..00000000
--- a/tests/test_client_framework_passthrough.py
+++ /dev/null
@@ -1,460 +0,0 @@
-"""Client framework tests."""
-
-import pytest
-
-from ksadk.api.client import AgentEngineClient
-
-
-def _build_create_payload() -> dict:
- return {
- "name": "deepagents-demo",
- "framework": "deepagents",
- "artifact_type": "Code",
- "artifact_path": "ks3://bucket/path/code.zip",
- "region": "cn-beijing-6",
- }
-
-
-@pytest.mark.asyncio
-async def test_create_agent_preserves_deepagents_when_server_supports_it(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- calls = []
-
- def fake_action(action: str, params: dict):
- calls.append((action, params.copy()))
- return {"agent_id": "ar-new"}
-
- monkeypatch.setattr(client, "_action", fake_action)
-
- result = await client.create_agent(_build_create_payload())
-
- assert result["agent_id"] == "ar-new"
- assert len(calls) == 1
- assert calls[0][0] == "CreateAgentProduct"
- assert calls[0][1]["Framework"] == "deepagents"
-
-
-@pytest.mark.asyncio
-async def test_create_agent_forwards_network_configuration(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- calls = []
-
- def fake_action(action: str, params: dict):
- calls.append((action, params.copy()))
- return {"agent_id": "ar-network"}
-
- monkeypatch.setattr(client, "_action", fake_action)
-
- payload = _build_create_payload()
- payload["network"] = {
- "enable_public_access": False,
- "enable_vpc_access": True,
- "vpc_id": "vpc-demo",
- "subnet_id": "subnet-demo",
- "security_group_id": "sg-demo",
- "availability_zone": "cn-beijing-6a",
- }
-
- await client.create_agent(payload)
-
- assert calls[0][1]["Network"] == {
- "EnablePublicAccess": False,
- "EnableVpcAccess": True,
- "VpcId": "vpc-demo",
- "SubnetId": "subnet-demo",
- "SecurityGroupId": "sg-demo",
- "AvailabilityZone": "cn-beijing-6a",
- }
-
-
-@pytest.mark.asyncio
-async def test_create_agent_forwards_ui_config(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- calls = []
-
- def fake_action(action: str, params: dict):
- calls.append((action, params.copy()))
- return {"agent_id": "ar-ui"}
-
- monkeypatch.setattr(client, "_action", fake_action)
-
- payload = _build_create_payload()
- payload["ui_config"] = {
- "profile": "custom",
- "path": "/chat",
- "url": "https://ui.example.com/custom-ui/",
- }
-
- await client.create_agent(payload)
-
- assert calls[0][1]["UiConfig"] == {
- "Profile": "custom",
- "Path": "/chat",
- "Url": "https://ui.example.com/custom-ui/",
- }
-
-
-@pytest.mark.asyncio
-async def test_create_dashboard_access_link_can_omit_path(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- calls = []
-
- def fake_action(action: str, params: dict):
- calls.append((action, params.copy()))
- return {"link_id": "dash-link"}
-
- monkeypatch.setattr(client, "_action", fake_action)
-
- await client.create_dashboard_access_link(
- agent_id="ar-openclaw",
- link_type="private",
- path=None,
- expires_seconds=3600,
- )
-
- assert calls == [
- (
- "CreateDashboardAccessLink",
- {
- "AgentId": "ar-openclaw",
- "LinkType": "private",
- "ForceNew": False,
- "ExpiresSeconds": 3600,
- },
- )
- ]
-
-
-@pytest.mark.asyncio
-async def test_create_agent_forwards_storage_configuration(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- calls = []
-
- def fake_action(action: str, params: dict):
- calls.append((action, params.copy()))
- return {"agent_id": "ar-storage"}
-
- monkeypatch.setattr(client, "_action", fake_action)
-
- payload = _build_create_payload()
- payload["storage"] = {
- "mount_path": "/home/node/.agentengine",
- "size_gi": 20,
- }
-
- await client.create_agent(payload)
-
- assert calls[0][1]["Storage"] == {
- "MountPath": "/home/node/.agentengine",
- "SizeGi": 20,
- }
-
-
-@pytest.mark.asyncio
-async def test_create_agent_forwards_memory_configuration(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- calls = []
-
- def fake_action(action: str, params: dict):
- calls.append((action, params.copy()))
- return {"agent_id": "ar-memory"}
-
- monkeypatch.setattr(client, "_action", fake_action)
-
- payload = _build_create_payload()
- payload["memory_config"] = {
- "memory_system": "mem0",
- "mem0_instance_id": "c17b20b1-faf7-4c98-91a7-38d1ee581ba1",
- "mem0_instance_name": "mem-demo",
- "mem0_region": "pre-online",
- }
-
- await client.create_agent(payload)
-
- assert calls[0][1]["MemoryConfig"] == {
- "MemorySystem": "mem0",
- "Mem0InstanceId": "c17b20b1-faf7-4c98-91a7-38d1ee581ba1",
- "Mem0InstanceName": "mem-demo",
- "Mem0Region": "pre-online",
- }
-
-
-@pytest.mark.asyncio
-async def test_update_agent_forwards_network_configuration(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- calls = []
-
- def fake_action(action: str, params: dict):
- calls.append((action, params.copy()))
- return {"agent_id": "ar-network"}
-
- monkeypatch.setattr(client, "_action", fake_action)
-
- await client.update_agent(
- "ar-network",
- {
- "network": {
- "enable_public_access": True,
- "enable_vpc_access": True,
- "vpc_id": "vpc-demo",
- "subnet_id": "subnet-demo",
- "security_group_id": "sg-demo",
- }
- },
- )
-
- assert calls[0][0] == "UpdateAgent"
- assert calls[0][1]["Network"] == {
- "EnablePublicAccess": True,
- "EnableVpcAccess": True,
- "VpcId": "vpc-demo",
- "SubnetId": "subnet-demo",
- "SecurityGroupId": "sg-demo",
- }
-
-
-@pytest.mark.asyncio
-async def test_update_agent_forwards_ui_config(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- calls = []
-
- def fake_action(action: str, params: dict):
- calls.append((action, params.copy()))
- return {"agent_id": "ar-ui"}
-
- monkeypatch.setattr(client, "_action", fake_action)
-
- await client.update_agent(
- "ar-ui",
- {
- "ui_config": {
- "profile": "custom",
- "path": "/chat",
- "url": "https://ui.example.com/custom-ui/",
- }
- },
- )
-
- assert calls[0][0] == "UpdateAgent"
- assert calls[0][1]["UiConfig"] == {
- "Profile": "custom",
- "Path": "/chat",
- "Url": "https://ui.example.com/custom-ui/",
- }
-
-
-@pytest.mark.asyncio
-async def test_update_agent_forwards_storage_disable_configuration(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- calls = []
-
- def fake_action(action: str, params: dict):
- calls.append((action, params.copy()))
- return {"agent_id": "ar-storage"}
-
- monkeypatch.setattr(client, "_action", fake_action)
-
- await client.update_agent(
- "ar-storage",
- {
- "storage": {
- "mount_path": "/home/node/.agentengine",
- "size_gi": 64,
- }
- },
- )
-
- assert calls[0][0] == "UpdateAgent"
- assert calls[0][1]["Storage"] == {
- "MountPath": "/home/node/.agentengine",
- "SizeGi": 64,
- }
-
-
-@pytest.mark.asyncio
-async def test_update_agent_forwards_memory_configuration(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- calls = []
-
- def fake_action(action: str, params: dict):
- calls.append((action, params.copy()))
- return {"agent_id": "ar-memory"}
-
- monkeypatch.setattr(client, "_action", fake_action)
-
- await client.update_agent(
- "ar-memory",
- {
- "memory_config": {
- "memory_system": "openclaw_default",
- }
- },
- )
-
- assert calls[0][0] == "UpdateAgent"
- assert calls[0][1]["MemoryConfig"] == {
- "MemorySystem": "openclaw_default",
- }
-
-
-@pytest.mark.asyncio
-async def test_update_agent_forwards_observability_configuration(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- calls = []
-
- def fake_action(action: str, params: dict):
- calls.append((action, params.copy()))
- return {"agent_id": "ar-observable"}
-
- monkeypatch.setattr(client, "_action", fake_action)
-
- await client.update_agent(
- "ar-observable",
- {
- "observability": {
- "langfuse_enabled": True,
- }
- },
- )
-
- assert calls[0][0] == "UpdateAgent"
- assert calls[0][1]["Advanced"]["EnableObservability"] is True
-
-
-@pytest.mark.asyncio
-async def test_list_agents_normalizes_multi_framework_string(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- calls = []
-
- def fake_action(action: str, params: dict):
- calls.append((action, params.copy()))
- return {"Agents": [], "Total": 0}
-
- monkeypatch.setattr(client, "_action", fake_action)
-
- await client.list_agents(framework=" langgraph, adk ")
-
- assert calls[0][0] == "ListAgents"
- assert calls[0][1]["Framework"] == "langgraph,adk"
-
-
-@pytest.mark.asyncio
-async def test_list_agents_accepts_framework_sequences(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- calls = []
-
- def fake_action(action: str, params: dict):
- calls.append((action, params.copy()))
- return {"Agents": [], "Total": 0}
-
- monkeypatch.setattr(client, "_action", fake_action)
-
- await client.list_agents(framework=["langgraph", "adk"])
-
- assert calls[0][0] == "ListAgents"
- assert calls[0][1]["Framework"] == "langgraph,adk"
-
-
-@pytest.mark.asyncio
-async def test_run_openclaw_repair_forwards_control_plane_action(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- calls = []
-
- def fake_action(action: str, params: dict):
- calls.append((action, params.copy()))
- return {"ok": True, "repair_action": "doctor-fix"}
-
- monkeypatch.setattr(client, "_action", fake_action)
-
- result = await client.run_openclaw_repair("ar-openclaw-1")
-
- assert result == {"ok": True, "repair_action": "doctor-fix"}
- assert calls == [
- (
- "RunOpenClawRepair",
- {
- "AgentId": "ar-openclaw-1",
- "RepairAction": "doctor-fix",
- },
- )
- ]
-
-
-@pytest.mark.asyncio
-async def test_create_agent_detects_enterprise_registry_from_image_addr(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- calls = []
-
- def fake_action(action: str, params: dict):
- calls.append((action, params.copy()))
- return {"agent_id": "ar-enterprise"}
-
- monkeypatch.setattr(client, "_action", fake_action)
-
- await client.create_agent(
- {
- "name": "enterprise-demo",
- "framework": "langgraph",
- "artifact_type": "Container",
- "artifact_path": "agenthzzqy-vpc.ksyunkcr.com/testagent-pub/0606agent:v6",
- "image_credential": {"username": "kcr-user", "password": "kcr-pass"},
- }
- )
-
- assert calls[0][1]["ContainerConfig"] == {
- "ImageType": "Enterprise",
- "EnterpriseInstance": "agenthzzqy",
- "NameSpace": "testagent-pub",
- "ImageRepo": "0606agent",
- "ImageVersion": "v6",
- "ImageAddr": "agenthzzqy-vpc.ksyunkcr.com/testagent-pub/0606agent:v6",
- "UserName": "kcr-user",
- "Password": "kcr-pass",
- }
-
-
-def test_enterprise_registry_detection_uses_exact_hostname():
- assert (
- AgentEngineClient._enterprise_instance_from_image_ref(
- "agenthzzqy-vpc.ksyunkcr.com/testagent-pub/0606agent:v6"
- )
- == "agenthzzqy"
- )
- assert (
- AgentEngineClient._enterprise_instance_from_image_ref(
- "evil.example.com/agenthzzqy-vpc.ksyunkcr.com/demo:v1"
- )
- is None
- )
-
-
-@pytest.mark.asyncio
-async def test_create_agent_keeps_third_party_registry_as_personal_with_credentials(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- calls = []
-
- def fake_action(action: str, params: dict):
- calls.append((action, params.copy()))
- return {"agent_id": "ar-third-party"}
-
- monkeypatch.setattr(client, "_action", fake_action)
-
- await client.create_agent(
- {
- "name": "dockerhub-demo",
- "framework": "langgraph",
- "artifact_type": "Container",
- "artifact_path": "registry-1.docker.io/acme/agent-runtime:v1",
- "image_credential": {"username": "docker-user", "password": "docker-pass"},
- }
- )
-
- assert calls[0][1]["ContainerConfig"] == {
- "ImageType": "Personal",
- "NameSpace": "acme",
- "ImageRepo": "agent-runtime",
- "ImageVersion": "v1",
- "ImageAddr": "registry-1.docker.io/acme/agent-runtime:v1",
- "UserName": "docker-user",
- "Password": "docker-pass",
- }
diff --git a/tests/test_client_get_agent_name.py b/tests/test_client_get_agent_name.py
deleted file mode 100644
index 7a972078..00000000
--- a/tests/test_client_get_agent_name.py
+++ /dev/null
@@ -1,84 +0,0 @@
-import pytest
-
-from ksadk.api.client import AgentEngineClient
-
-
-@pytest.mark.asyncio
-async def test_get_agent_name_uses_get_agent_only(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- calls = []
-
- def fake_action(action: str, params: dict):
- calls.append((action, params.copy()))
- return {"AgentId": "ar-demo"}
-
- monkeypatch.setattr(client, "_action", fake_action)
-
- result = await client.get_agent(name="demo", include_api_key=True)
-
- assert result["AgentId"] == "ar-demo"
- assert calls == [("GetAgent", {"Name": "demo", "IncludeApiKey": True})]
-
-
-@pytest.mark.asyncio
-async def test_get_agent_name_does_not_fallback_to_legacy_action(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- calls = []
-
- def fake_action(action: str, params: dict):
- calls.append((action, params.copy()))
- if action == "GetAgent":
- raise Exception("HTTP 404 Not Found")
- raise Exception("unexpected legacy action")
-
- monkeypatch.setattr(client, "_action", fake_action)
-
- with pytest.raises(Exception, match="HTTP 404"):
- await client.get_agent(name="missing-agent")
-
- assert calls == [("GetAgent", {"Name": "missing-agent"})]
-
-
-@pytest.mark.asyncio
-async def test_get_agent_by_id_does_not_fallback_on_not_found_with_request_id(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- calls = []
-
- def fake_action(action: str, params: dict):
- calls.append((action, params.copy()))
- raise Exception(
- 'HTTP 404 POST http://example.com/?Action=GetAgent&Version=2024-06-12: '
- '{"Code":404,"Message":"未找到对应的 Agent","RequestId":"abc-id-123"}'
- )
-
- monkeypatch.setattr(client, "_action", fake_action)
-
- with pytest.raises(Exception, match="HTTP 404"):
- await client.get_agent(agent_id="ar-missing")
-
- assert calls == [("GetAgent", {"AgentId": "ar-missing"})]
-
-
-@pytest.mark.asyncio
-async def test_get_agent_by_id_falls_back_only_for_legacy_field_compat(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- calls = []
-
- def fake_action(action: str, params: dict):
- calls.append((action, params.copy()))
- if len(calls) == 1:
- raise Exception(
- 'HTTP 422 POST http://example.com/?Action=GetAgent&Version=2024-06-12: '
- '{"detail":[{"loc":["body","AgentId"],"msg":"extra inputs are not permitted"}]}'
- )
- return {"AgentId": "ar-demo"}
-
- monkeypatch.setattr(client, "_action", fake_action)
-
- result = await client.get_agent(agent_id="ar-demo")
-
- assert result["AgentId"] == "ar-demo"
- assert calls == [
- ("GetAgent", {"AgentId": "ar-demo"}),
- ("GetAgent", {"Id": "ar-demo"}),
- ]
diff --git a/tests/test_client_http_error_logging.py b/tests/test_client_http_error_logging.py
deleted file mode 100644
index fa9b5f5a..00000000
--- a/tests/test_client_http_error_logging.py
+++ /dev/null
@@ -1,59 +0,0 @@
-import logging
-
-from ksadk.api.client import AgentEngineClient
-
-
-def test_client_can_suppress_selected_http_error_logs(caplog):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
-
- with caplog.at_level(logging.ERROR, logger="ksadk.api.client"):
- with client.suppress_http_error_logging(
- lambda *, method, full_url, status_code, resp_text, details: (
- method == "POST"
- and "Action=GetAgent" in full_url
- and status_code == 404
- and "未找到对应的 Agent" in str(details.get("remote_error_message") or "")
- )
- ):
- client._log_http_error(
- method="POST",
- full_url="http://example.com/?Action=GetAgent&Version=2024-06-12",
- status_code=404,
- details={"remote_error_message": "未找到对应的 Agent", "http_status": 404},
- )
-
- assert "Request failed" not in caplog.text
-
-
-def test_client_error_log_redacts_url_query(caplog):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
-
- with caplog.at_level(logging.ERROR, logger="ksadk.api.client"):
- client._log_http_error(
- method="POST",
- full_url="http://example.com/?Action=GetAgent&Password=secret",
- status_code=500,
- details={"http_status": 500},
- )
-
- assert "status=500" in caplog.text
- assert "example.com" not in caplog.text
- assert "Password=secret" not in caplog.text
-
-
-def test_client_error_log_redacts_sensitive_response_body(caplog):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
-
- with caplog.at_level(logging.ERROR, logger="ksadk.api.client"):
- client._log_http_error(
- method="POST",
- full_url="http://example.com/?Action=GetAgent",
- status_code=500,
- details={"http_status": 500},
- )
-
- assert "response body omitted" not in caplog.text
- assert "password" not in caplog.text
- assert "token" not in caplog.text
- assert "secret" not in caplog.text
- assert "abc" not in caplog.text
diff --git a/tests/test_client_mcp_payloads.py b/tests/test_client_mcp_payloads.py
deleted file mode 100644
index 04e05084..00000000
--- a/tests/test_client_mcp_payloads.py
+++ /dev/null
@@ -1,221 +0,0 @@
-"""Client MCP payload contract tests."""
-
-import pytest
-
-from ksadk.api.client import AgentEngineClient
-
-
-@pytest.mark.asyncio
-async def test_create_mcp_code_uses_nested_server_schema(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- calls: list[tuple[str, dict]] = []
-
- def fake_action(action: str, params: dict):
- calls.append((action, params.copy()))
- return {"mcp_id": "mcp-created"}
-
- monkeypatch.setattr(client, "_action", fake_action)
-
- result = await client.create_mcp(
- {
- "name": "demo-mcp",
- "description": "demo",
- "artifact_type": "Code",
- "artifact_path": "ks3://demo-bucket/mcps/demo-mcp/code_20260324120000.zip",
- "region": "pre-online",
- "enable_auth": True,
- "resources": {"cpu": "2", "memory": "4Gi"},
- "scaling": {"min_replicas": 2, "max_replicas": 8, "concurrency": 35},
- "metadata": {"mcp_variable": "server", "tools": ["ping", "add"]},
- "ks3": {
- "access_key": "ak",
- "secret_key": "sk",
- "region": "pre-online",
- "bucket": "demo-bucket",
- },
- }
- )
-
- assert result["mcp_id"] == "mcp-created"
- assert calls == [
- (
- "CreateMCP",
- {
- "Name": "demo-mcp",
- "Description": "demo",
- "Region": "cn-beijing-6",
- "DeploymentType": "Code",
- "Resource": {"Cpu": 2, "Memory": 4},
- "Scaling": {"MinReplicas": 2, "MaxReplicas": 8, "QpsPerInstance": 35},
- "Access": {"AuthType": "ApiKey"},
- "Advanced": {"McpVariable": "server", "Tools": ["ping", "add"]},
- "CodeConfig": {
- "Path": "ks3://demo-bucket/mcps/demo-mcp/code_20260324120000.zip",
- "AccessKey": "ak",
- "SecretKey": "sk",
- "Region": "cn-beijing-6",
- "Bucket": "demo-bucket",
- },
- },
- )
- ]
-
-
-@pytest.mark.asyncio
-async def test_create_mcp_container_uses_nested_container_config(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- calls: list[tuple[str, dict]] = []
-
- def fake_action(action: str, params: dict):
- calls.append((action, params.copy()))
- return {"mcp_id": "mcp-created"}
-
- monkeypatch.setattr(client, "_action", fake_action)
-
- await client.create_mcp(
- {
- "name": "demo-mcp",
- "artifact_type": "Container",
- "artifact_path": "hub.kce.ksyun.com/agentengine/demo-mcp:v0.3.6",
- "region": "cn-beijing-6",
- "enable_auth": False,
- "metadata": {"mcp_variable": "mcp", "tools": ["ping"]},
- "image_credential": {"username": "demo-user", "password": "demo-pass"},
- }
- )
-
- assert calls[0][0] == "CreateMCP"
- payload = calls[0][1]
- assert payload["DeploymentType"] == "Container"
- assert payload["Access"] == {"AuthType": "None"}
- assert payload["ContainerConfig"] == {
- "ImageType": "Personal",
- "NameSpace": "agentengine",
- "ImageRepo": "demo-mcp",
- "ImageVersion": "v0.3.6",
- "ImageAddr": "hub.kce.ksyun.com/agentengine/demo-mcp:v0.3.6",
- "UserName": "demo-user",
- "Password": "demo-pass",
- }
-
-
-@pytest.mark.asyncio
-async def test_create_mcp_includes_network_only_when_explicit(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- calls: list[tuple[str, dict]] = []
-
- def fake_action(action: str, params: dict):
- calls.append((action, params.copy()))
- return {"mcp_id": "mcp-created"}
-
- monkeypatch.setattr(client, "_action", fake_action)
-
- await client.create_mcp(
- {
- "name": "demo-mcp",
- "artifact_type": "Code",
- "artifact_path": "ks3://demo-bucket/mcps/demo-mcp/code.zip",
- }
- )
- await client.create_mcp(
- {
- "name": "demo-mcp",
- "artifact_type": "Code",
- "artifact_path": "ks3://demo-bucket/mcps/demo-mcp/code.zip",
- "network": {
- "enable_public_access": False,
- "enable_vpc_access": True,
- "vpc_id": "vpc-cli",
- "subnet_id": "subnet-cli",
- "security_group_id": "sg-cli",
- "availability_zone": "cn-beijing-6b",
- },
- }
- )
-
- assert "Network" not in calls[0][1]
- assert calls[1][1]["Network"] == {
- "EnablePublicAccess": False,
- "EnableVpcAccess": True,
- "VpcId": "vpc-cli",
- "SubnetId": "subnet-cli",
- "SecurityGroupId": "sg-cli",
- "AvailabilityZone": "cn-beijing-6b",
- }
-
-
-@pytest.mark.asyncio
-async def test_update_mcp_uses_nested_partial_sections(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- calls: list[tuple[str, dict]] = []
-
- def fake_action(action: str, params: dict):
- calls.append((action, params.copy()))
- return {"mcp_id": "mcp-updated"}
-
- monkeypatch.setattr(client, "_action", fake_action)
-
- await client.update_mcp(
- "mcp-123",
- {
- "artifact_type": "Container",
- "artifact_path": "hub.kce.ksyun.com/agentengine/demo-mcp:v0.3.7",
- "enable_auth": True,
- "scaling": {"min_replicas": 1, "max_replicas": 3, "concurrency": 12},
- "metadata": {"mcp_variable": "svc", "tools": ["ping", "health"]},
- },
- )
-
- assert calls[0][0] == "UpdateMCP"
- assert calls[0][1] == {
- "Id": "mcp-123",
- "DeploymentType": "Container",
- "ContainerConfig": {
- "ImageType": "Personal",
- "NameSpace": "agentengine",
- "ImageRepo": "demo-mcp",
- "ImageVersion": "v0.3.7",
- "ImageAddr": "hub.kce.ksyun.com/agentengine/demo-mcp:v0.3.7",
- },
- "Scaling": {"MinReplicas": 1, "MaxReplicas": 3, "QpsPerInstance": 12},
- "Access": {"AuthType": "ApiKey"},
- "Advanced": {"McpVariable": "svc", "Tools": ["ping", "health"]},
- }
-
-
-@pytest.mark.asyncio
-async def test_update_mcp_can_send_network_without_artifact(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- calls: list[tuple[str, dict]] = []
-
- def fake_action(action: str, params: dict):
- calls.append((action, params.copy()))
- return {"mcp_id": "mcp-updated"}
-
- monkeypatch.setattr(client, "_action", fake_action)
-
- await client.update_mcp(
- "mcp-123",
- {
- "network": {
- "enable_public_access": False,
- "vpc_id": "vpc-cli",
- "subnet_id": "subnet-cli",
- "security_group_id": "sg-cli",
- },
- },
- )
-
- assert calls[0] == (
- "UpdateMCP",
- {
- "Id": "mcp-123",
- "Network": {
- "EnablePublicAccess": False,
- "EnableVpcAccess": False,
- "VpcId": "vpc-cli",
- "SubnetId": "subnet-cli",
- "SecurityGroupId": "sg-cli",
- },
- },
- )
diff --git a/tests/test_client_permission_precheck.py b/tests/test_client_permission_precheck.py
deleted file mode 100644
index e15e77c8..00000000
--- a/tests/test_client_permission_precheck.py
+++ /dev/null
@@ -1,434 +0,0 @@
-from __future__ import annotations
-
-import logging
-
-import pytest
-
-from ksadk.api.client import AgentEngineAPIError, AgentEngineClient
-
-
-@pytest.fixture(autouse=True)
-def clear_permission_probe_cache():
- cache = getattr(AgentEngineClient, "_permission_probe_cache", None)
- if isinstance(cache, dict):
- cache.clear()
- yield
- cache = getattr(AgentEngineClient, "_permission_probe_cache", None)
- if isinstance(cache, dict):
- cache.clear()
-
-
-@pytest.fixture(autouse=True)
-def _stub_identity_resolve(monkeypatch):
- """这些测试用假 AK/SK,mock 掉身份反查避免联网 + warning 干扰 caplog 断言。"""
- monkeypatch.setattr(
- "ksadk.identity.resolve_identity", lambda **kw: None
- )
- monkeypatch.setattr(
- "ksadk.identity.get_cached_identity", lambda ak: None
- )
-
-
-def _build_client() -> AgentEngineClient:
- return AgentEngineClient(
- base_url="https://aicp.api.ksyun.com",
- access_key="ak",
- secret_key="sk",
- region="cn-beijing-6",
- )
-
-
-@pytest.mark.asyncio
-async def test_list_agents_prechecks_default_role(monkeypatch):
- client = _build_client()
- monkeypatch.setenv("KSYUN_ACCOUNT_ID", "2000003485")
- calls: list[tuple[str, str, dict]] = []
-
- def fake_request(method: str, path: str, body: dict | None = None):
- calls.append((method, path, dict(body or {})))
- if path.endswith("/CheckIamRole"):
- return {
- "Code": 0,
- "Message": "Success",
- "Data": {"HasPermission": True, "RoleName": "KsyunAgentEngineDefaultRole"},
- }
- if path.endswith("/ListAgents"):
- return {
- "Code": 0,
- "Message": "Success",
- "Data": {"Agents": [], "Total": 0, "Page": 1, "PageSize": 20},
- }
- raise AssertionError(f"unexpected path: {path}")
-
- monkeypatch.setattr(client, "_request", fake_request)
-
- result = await client.list_agents()
-
- assert result["agents"] == []
- assert calls[0][1].endswith("/CheckIamRole")
- assert calls[0][2] == {"RoleName": "KsyunAgentEngineDefaultRole"}
- assert calls[1][1].endswith("/ListAgents")
-
-
-@pytest.mark.asyncio
-async def test_permission_denied_stops_main_request(monkeypatch):
- client = _build_client()
- monkeypatch.setenv("KSYUN_ACCOUNT_ID", "2000003485")
- calls: list[tuple[str, str, dict]] = []
-
- def fake_request(method: str, path: str, body: dict | None = None):
- calls.append((method, path, dict(body or {})))
- if path.endswith("/CheckIamRole"):
- return {
- "Code": 403,
- "Message": "当前账号没有 KsyunAgentEngineDefaultRole 权限",
- "Data": {"HasPermission": False, "RoleName": "KsyunAgentEngineDefaultRole"},
- }
- raise AssertionError("main request should not be sent")
-
- monkeypatch.setattr(client, "_request", fake_request)
-
- with pytest.raises(AgentEngineAPIError, match="当前账号没有 KsyunAgentEngineDefaultRole 权限"):
- await client.list_agents()
-
- assert calls == [
- (
- "POST",
- "/agentengine/api/v1/CheckIamRole",
- {"RoleName": "KsyunAgentEngineDefaultRole"},
- )
- ]
-
-
-@pytest.mark.asyncio
-async def test_probe_failure_is_fail_open(monkeypatch):
- client = _build_client()
- monkeypatch.setenv("KSYUN_ACCOUNT_ID", "2000003485")
- calls: list[tuple[str, str, dict]] = []
-
- def fake_request(method: str, path: str, body: dict | None = None):
- calls.append((method, path, dict(body or {})))
- if path.endswith("/CheckIamRole"):
- raise RuntimeError("HTTP 503 POST https://aicp.api.ksyun.com: probe unavailable")
- if path.endswith("/GetAgent"):
- return {
- "Code": 0,
- "Message": "Success",
- "Data": {"Basic": {"AgentId": "ar-demo"}},
- }
- raise AssertionError(f"unexpected path: {path}")
-
- monkeypatch.setattr(client, "_request", fake_request)
-
- result = await client.get_agent(agent_id="ar-demo")
-
- assert result["basic"]["agent_id"] == "ar-demo"
- assert [path for _, path, _ in calls] == [
- "/agentengine/api/v1/CheckIamRole",
- "/agentengine/api/v1/GetAgent",
- ]
-
-
-@pytest.mark.asyncio
-async def test_permission_probe_uses_cache(monkeypatch):
- client = _build_client()
- monkeypatch.setenv("KSYUN_ACCOUNT_ID", "2000003485")
- calls: list[tuple[str, str, dict]] = []
-
- def fake_request(method: str, path: str, body: dict | None = None):
- calls.append((method, path, dict(body or {})))
- if path.endswith("/CheckIamRole"):
- return {
- "Code": 0,
- "Message": "Success",
- "Data": {"HasPermission": True, "RoleName": "KsyunAgentEngineDefaultRole"},
- }
- if path.endswith("/ListAgents"):
- return {
- "Code": 0,
- "Message": "Success",
- "Data": {"Agents": [], "Total": 0, "Page": 1, "PageSize": 20},
- }
- if path.endswith("/GetAgent"):
- return {
- "Code": 0,
- "Message": "Success",
- "Data": {"Basic": {"AgentId": "ar-demo"}},
- }
- raise AssertionError(f"unexpected path: {path}")
-
- monkeypatch.setattr(client, "_request", fake_request)
-
- await client.list_agents()
- await client.get_agent(agent_id="ar-demo")
-
- assert [path for _, path, _ in calls].count("/agentengine/api/v1/CheckIamRole") == 1
-
-
-@pytest.mark.asyncio
-async def test_create_agent_precheck_uses_explicit_iam_role(monkeypatch):
- client = _build_client()
- monkeypatch.setenv("KSYUN_ACCOUNT_ID", "2000003485")
- calls: list[tuple[str, str, dict]] = []
-
- def fake_request(method: str, path: str, body: dict | None = None):
- calls.append((method, path, dict(body or {})))
- if path.endswith("/CheckIamRole"):
- return {
- "Code": 0,
- "Message": "Success",
- "Data": {"HasPermission": True, "RoleName": "CustomRuntimeRole"},
- }
- if path.endswith("/CreateAgentProduct"):
- return {
- "Code": 0,
- "Message": "Success",
- "Data": {"AgentId": "ar-new"},
- }
- raise AssertionError(f"unexpected path: {path}")
-
- monkeypatch.setattr(client, "_request", fake_request)
-
- await client.create_agent(
- {
- "name": "demo-agent",
- "framework": "langgraph",
- "artifact_type": "Code",
- "artifact_path": "ks3://demo-bucket/code.zip",
- "region": "cn-beijing-6",
- "auth_type": "Iam",
- "iam_role": "CustomRuntimeRole",
- }
- )
-
- assert calls[0] == (
- "POST",
- "/agentengine/api/v1/CheckIamRole",
- {"RoleName": "CustomRuntimeRole"},
- )
-
-
-def test_request_parses_kop_auth_error_payload(monkeypatch, caplog):
- client = AgentEngineClient(
- base_url="https://aicp.api.ksyun.com",
- access_key="ak",
- secret_key="sk",
- region="cn-beijing-6",
- )
-
- class _FakeResponse:
- status_code = 400
- text = (
- '{"RequestId":"req-missing-ak","Error":{"Code":"MissingAccesskey",'
- '"Message":"Access Key is Missing","Type":"Sender"}}'
- )
-
- def json(self):
- return {
- "RequestId": "req-missing-ak",
- "Error": {
- "Code": "MissingAccesskey",
- "Message": "Access Key is Missing",
- "Type": "Sender",
- },
- }
-
- class _FakeSession:
- def request(self, **_kwargs):
- return _FakeResponse()
-
- monkeypatch.setattr(client, "_get_session", lambda: _FakeSession())
-
- with caplog.at_level(logging.WARNING, logger="ksadk.api.client"):
- with pytest.raises(AgentEngineAPIError) as exc:
- client._request("POST", "/agentengine/api/v1/GetAgent", {"AgentId": "ar-demo"})
-
- assert exc.value.code == 400
- assert exc.value.details["remote_error_code"] == "MissingAccesskey"
- assert exc.value.details["request_id"] == "req-missing-ak"
- assert not [record for record in caplog.records if record.levelno >= logging.WARNING]
-
-
-def test_request_honors_curl_ssl_insecure_for_control_plane(monkeypatch):
- client = AgentEngineClient(
- base_url="https://aicp.api.ksyun.com",
- access_key="ak",
- secret_key="sk",
- region="cn-beijing-6",
- )
- captured = {}
-
- class _FakeResponse:
- status_code = 200
- text = '{"Code":0,"Data":{"Ok":true}}'
-
- def json(self):
- return {"Code": 0, "Data": {"Ok": True}}
-
- class _FakeSession:
- def request(self, **kwargs):
- captured.update(kwargs)
- return _FakeResponse()
-
- monkeypatch.setenv("CURL_SSL_INSECURE", "1")
- monkeypatch.setattr(client, "_get_session", lambda: _FakeSession())
-
- result = client._request("POST", "/agentengine/api/v1/GetAgent", {"AgentId": "ar-demo"})
-
- assert result["Data"]["Ok"] is True
- assert captured["verify"] is False
-
-
-def test_request_retries_inner_endpoint_for_inner_account(monkeypatch):
- client = AgentEngineClient(
- base_url="https://aicp.api.ksyun.com",
- access_key="ak",
- secret_key="sk",
- region="cn-beijing-6",
- )
- urls: list[str] = []
-
- class _FakeResponse:
- def __init__(self, status_code: int, text: str):
- self.status_code = status_code
- self.text = text
-
- def json(self):
- return {"Code": 0, "Data": {"AgentId": "ar-inner"}}
-
- class _FakeSession:
- def request(self, **kwargs):
- urls.append(kwargs["url"])
- if len(urls) == 1:
- return _FakeResponse(
- 403,
- (
- '{"RequestId":"req-inner","Error":{'
- '"Code":"InnerAccountCanOnlyAccessThroughIntranet",'
- '"Message":"The inner account can only access through intranet",'
- '"Type":"Sender"}}'
- ),
- )
- return _FakeResponse(200, '{"Code":0,"Data":{"AgentId":"ar-inner"}}')
-
- monkeypatch.setattr(client, "_get_session", lambda: _FakeSession())
-
- result = client._request("POST", "/agentengine/api/v1/CreateAgentProduct", {"Name": "demo"})
-
- assert result["Data"]["AgentId"] == "ar-inner"
- assert urls == [
- "https://aicp.api.ksyun.com/?Action=CreateAgentProduct&Version=2024-06-12",
- "http://aicp.inner.api.ksyun.com/?Action=CreateAgentProduct&Version=2024-06-12",
- ]
- assert client.base_url == "http://aicp.inner.api.ksyun.com"
-
-
-def test_auto_detected_public_endpoint_retries_inner_for_inner_account(monkeypatch):
- monkeypatch.delenv("AGENTENGINE_SERVER_URL", raising=False)
- monkeypatch.setattr(AgentEngineClient, "_is_connectable", staticmethod(lambda *_args, **_kwargs: False))
- client = AgentEngineClient(
- access_key="ak",
- secret_key="sk",
- region="cn-beijing-6",
- )
- urls: list[str] = []
-
- class _FakeResponse:
- def __init__(self, status_code: int, text: str):
- self.status_code = status_code
- self.text = text
-
- def json(self):
- return {"Code": 0, "Data": {"AgentId": "ar-inner"}}
-
- class _FakeSession:
- def request(self, **kwargs):
- urls.append(kwargs["url"])
- if len(urls) == 1:
- return _FakeResponse(
- 403,
- (
- '{"RequestId":"req-inner","Error":{'
- '"Code":"InnerAccountCanOnlyAccessThroughIntranet",'
- '"Message":"The inner account can only access through intranet",'
- '"Type":"Sender"}}'
- ),
- )
- return _FakeResponse(200, '{"Code":0,"Data":{"AgentId":"ar-inner"}}')
-
- assert client.base_url == "https://aicp.api.ksyun.com"
- monkeypatch.setattr(client, "_get_session", lambda: _FakeSession())
-
- result = client._request("POST", "/agentengine/api/v1/CreateAgentProduct", {"Name": "demo"})
-
- assert result["Data"]["AgentId"] == "ar-inner"
- assert urls == [
- "https://aicp.api.ksyun.com/?Action=CreateAgentProduct&Version=2024-06-12",
- "http://aicp.inner.api.ksyun.com/?Action=CreateAgentProduct&Version=2024-06-12",
- ]
- assert client.base_url == "http://aicp.inner.api.ksyun.com"
-
-
-def test_action_raw_request_retries_inner_endpoint_for_inner_account(monkeypatch):
- client = AgentEngineClient(
- base_url="https://aicp.api.ksyun.com",
- access_key="ak",
- secret_key="sk",
- region="cn-beijing-6",
- )
- urls: list[str] = []
-
- class _FakeResponse:
- def __init__(self, status_code: int, text: str):
- self.status_code = status_code
- self.text = text
-
- class _FakeSession:
- def request(self, **kwargs):
- urls.append(kwargs["url"])
- if len(urls) == 1:
- return _FakeResponse(
- 403,
- (
- '{"RequestId":"req-inner","Error":{'
- '"Code":"InnerAccountCanOnlyAccessThroughIntranet",'
- '"Message":"The inner account can only access through intranet",'
- '"Type":"Sender"}}'
- ),
- )
- return _FakeResponse(200, '{"Code":0}')
-
- monkeypatch.setattr(client, "_get_session", lambda: _FakeSession())
-
- response = client._action_raw_request("GET", "ExportWorkspaceZip")
-
- assert response.status_code == 200
- assert urls == [
- "https://aicp.api.ksyun.com/?Action=ExportWorkspaceZip&Version=2024-06-12",
- "http://aicp.inner.api.ksyun.com/?Action=ExportWorkspaceZip&Version=2024-06-12",
- ]
- assert client.base_url == "http://aicp.inner.api.ksyun.com"
-
-
-def test_permission_probe_auth_failure_is_quiet(monkeypatch, caplog):
- client = _build_client()
- monkeypatch.setenv("KSYUN_ACCOUNT_ID", "2000003485")
-
- def fake_request(_method: str, _path: str, _body: dict | None = None):
- raise AgentEngineAPIError(
- 400,
- "Access Key is Missing",
- details={
- "http_status": 400,
- "remote_error_code": "MissingAccesskey",
- "remote_error_message": "Access Key is Missing",
- "request_id": "req-missing-ak",
- },
- )
-
- monkeypatch.setattr(client, "_request", fake_request)
-
- with caplog.at_level(logging.WARNING, logger="ksadk.api.client"):
- client._maybe_precheck_permission("GetAgent", {"AgentId": "ar-demo"})
-
- assert not [record for record in caplog.records if "Permission probe failed" in record.message]
diff --git a/tests/test_client_user_uuid_header.py b/tests/test_client_user_uuid_header.py
deleted file mode 100644
index c27e8486..00000000
--- a/tests/test_client_user_uuid_header.py
+++ /dev/null
@@ -1,179 +0,0 @@
-"""client.py X-Ksc-User-uuid header 注入测试。"""
-
-from __future__ import annotations
-
-from unittest.mock import MagicMock
-
-import pytest
-
-from ksadk.api.client import AgentEngineClient
-from ksadk.identity.resolver import ResolvedIdentity
-
-
-@pytest.fixture(autouse=True)
-def _isolate_identity_env(monkeypatch):
- """每个测试隔离 env + 缓存,避免互相影响。"""
- monkeypatch.delenv("KSYUN_ACCOUNT_ID", raising=False)
- monkeypatch.delenv("KSYUN_ACCESS_KEY", raising=False)
- monkeypatch.delenv("KSYUN_SECRET_KEY", raising=False)
- # 隔离缓存(patch resolve_identity/get_cached_identity 避免真实文件读写)
- yield
-
-
-def _make_client_with_creds(monkeypatch, *, access_key="AKLTtest", secret_key="SKtest", dry_run=False):
- """构造带凭证的 client,绕过真实 AK/SK env 依赖。"""
- client = AgentEngineClient(region="cn-beijing-6", dry_run=dry_run)
- # 注入凭证到 _auth
- client._auth.access_key_id = access_key
- client._auth.secret_access_key = secret_key
- return client
-
-
-def test_build_headers_no_user_uuid_when_resolve_fails(monkeypatch):
- """反查失败时不注入 X-Ksc-User-uuid,其他 header 正常。"""
- client = _make_client_with_creds(monkeypatch)
- monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: None)
-
- headers = client._build_headers(action="Test")
-
- assert "X-Ksc-User-uuid" not in headers
- assert headers["X-Ksc-Source"] == "ksadk-cli"
- assert "X-Ksc-Region" in headers
-
-
-def test_build_headers_includes_user_uuid_after_resolve(monkeypatch):
- """反查成功时注入 X-Ksc-User-uuid。"""
- client = _make_client_with_creds(monkeypatch)
- fake = ResolvedIdentity(
- user_uuid="uuid-xyz",
- main_account_id="2000003485",
- user_name="xiayu",
- krn="krn:ksc:iam::2000003485:user/xiayu",
- ak_fingerprint="abc",
- )
- monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: fake)
-
- headers = client._build_headers(action="Test")
-
- assert headers["X-Ksc-User-uuid"] == "uuid-xyz"
- # account_id 也从反查拿到
- assert headers["X-Ksc-Account-Id"] == "2000003485"
-
-
-def test_build_headers_extra_headers_override_user_uuid(monkeypatch):
- """extra_headers 显式覆盖 user uuid。"""
- client = _make_client_with_creds(monkeypatch)
- client.extra_headers = {"X-Ksc-User-uuid": "custom-uuid", "X-Ksc-Account-Id": "custom-acct"}
- monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: MagicMock(user_uuid="should-not-use"))
-
- headers = client._build_headers(action="Test")
-
- # extra_headers 在 _resolve_user_uuid 里优先返回,且 _build_headers 末尾 update 覆盖
- assert headers["X-Ksc-User-uuid"] == "custom-uuid"
- assert headers["X-Ksc-Account-Id"] == "custom-acct"
-
-
-def test_build_headers_lowercase_extra_headers_normalized(monkeypatch):
- """extra_headers 用小写 key 时归一为 Title-Case,避免重复 header。"""
- client = _make_client_with_creds(monkeypatch)
- client.extra_headers = {"x-ksc-user-uuid": "custom", "x-ksc-account-id": "custom-acct"}
- monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: MagicMock(user_uuid="should-not-use"))
-
- headers = client._build_headers(action="Test")
-
- # 只应有一个 X-Ksc-User-uuid(Title-Case),不应有小写 key 共存
- uuid_keys = [k for k in headers if k.lower() == "x-ksc-user-uuid"]
- assert len(uuid_keys) == 1
- assert uuid_keys[0] == "X-Ksc-User-uuid"
- assert headers["X-Ksc-User-uuid"] == "custom"
- acct_keys = [k for k in headers if k.lower() == "x-ksc-account-id"]
- assert len(acct_keys) == 1
- assert headers["X-Ksc-Account-Id"] == "custom-acct"
-
-
-def test_dry_run_does_not_invoke_resolve(monkeypatch):
- """dry-run 不调 resolve_identity,只读缓存。"""
- client = _make_client_with_creds(monkeypatch, dry_run=True)
- called = MagicMock()
- monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: called())
- monkeypatch.setattr("ksadk.identity.get_cached_identity", lambda ak: None)
-
- client._build_headers(action="Test")
-
- assert called.call_count == 0 # dry-run 不联网反查
-
-
-def test_dry_run_uses_cached_identity(monkeypatch):
- """dry-run 时从缓存读 identity 注入 header。"""
- client = _make_client_with_creds(monkeypatch, dry_run=True)
- fake = ResolvedIdentity(
- user_uuid="cached-uuid",
- main_account_id="2000003485",
- user_name="u",
- krn=None,
- ak_fingerprint="abc",
- )
- monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: None)
- monkeypatch.setattr("ksadk.identity.get_cached_identity", lambda ak: fake)
-
- headers = client._build_headers(action="Test")
-
- assert headers["X-Ksc-User-uuid"] == "cached-uuid"
-
-
-def test_resolve_user_uuid_cached_on_instance(monkeypatch):
- """同一 client 多次 _build_headers 只反查一次。"""
- client = _make_client_with_creds(monkeypatch)
- call_count = {"n": 0}
-
- def fake_resolve(**kw):
- call_count["n"] += 1
- return ResolvedIdentity(
- user_uuid="uuid-x", main_account_id=None, user_name="u", krn=None, ak_fingerprint="abc"
- )
-
- monkeypatch.setattr("ksadk.identity.resolve_identity", fake_resolve)
-
- client._build_headers(action="Test1")
- client._build_headers(action="Test2")
- client._build_headers(action="Test3")
-
- assert call_count["n"] == 1 # 实例缓存,只调一次
-
-
-def test_account_id_env_overrides_resolve(monkeypatch):
- """KSYUN_ACCOUNT_ID env 优先于反查。"""
- monkeypatch.setenv("KSYUN_ACCOUNT_ID", "env-acct")
- client = _make_client_with_creds(monkeypatch)
- fake = ResolvedIdentity(
- user_uuid="uuid-x", main_account_id="resolved-acct", user_name="u", krn=None, ak_fingerprint="abc"
- )
- monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: fake)
-
- headers = client._build_headers(action="Test")
-
- assert headers["X-Ksc-Account-Id"] == "env-acct" # env 覆盖反查
-
-
-def test_account_id_falls_back_to_resolved_main_account(monkeypatch):
- """无 env 时 X-Ksc-Account-Id 从反查 main_account_id 拿。"""
- client = _make_client_with_creds(monkeypatch)
- fake = ResolvedIdentity(
- user_uuid="uuid-x", main_account_id="2000003485", user_name="u", krn=None, ak_fingerprint="abc"
- )
- monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: fake)
-
- headers = client._build_headers(action="Test")
-
- assert headers["X-Ksc-Account-Id"] == "2000003485"
-
-
-def test_no_credentials_no_user_uuid_no_account_id(monkeypatch):
- """无 AK/SK 时 user_uuid/account_id 都为 None,不注入,不报错。"""
- client = AgentEngineClient(region="cn-beijing-6") # 无凭证
- monkeypatch.setattr("ksadk.identity.resolve_identity", lambda **kw: None)
-
- headers = client._build_headers(action="Test")
-
- assert "X-Ksc-User-uuid" not in headers
- assert "X-Ksc-Account-Id" not in headers
diff --git a/tests/test_client_workspace_files.py b/tests/test_client_workspace_files.py
deleted file mode 100644
index a801933e..00000000
--- a/tests/test_client_workspace_files.py
+++ /dev/null
@@ -1,473 +0,0 @@
-from __future__ import annotations
-
-import json
-from pathlib import Path
-
-import pytest
-
-from ksadk.api import AttachmentContent
-from ksadk.api.client import AgentEngineAPIError, AgentEngineClient
-
-
-class _FakeRuntimeResponse:
- def __init__(
- self,
- *,
- status_code: int = 200,
- json_payload=None,
- content: bytes = b"",
- headers: dict[str, str] | None = None,
- ):
- self.status_code = status_code
- self._json_payload = json_payload
- self.content = content
- self.headers = headers or {"content-type": "application/json"}
- self.text = content.decode("utf-8", errors="ignore")
-
- def json(self):
- if self._json_payload is None:
- raise json.JSONDecodeError("Expecting value", self.text or "", 0)
- return self._json_payload
-
-
-class _FakeRuntimeSession:
- def __init__(self, responses: list[_FakeRuntimeResponse]):
- self._responses = list(responses)
- self.calls: list[dict] = []
-
- def request(self, method, url, **kwargs):
- self.calls.append(
- {
- "method": method,
- "url": url,
- "headers": kwargs.get("headers"),
- "params": kwargs.get("params"),
- "files": kwargs.get("files"),
- "stream": kwargs.get("stream"),
- }
- )
- if not self._responses:
- raise AssertionError("unexpected runtime request")
- return self._responses.pop(0)
-
-
-def test_attachment_content_is_exported_from_api_package():
- assert AttachmentContent.__name__ == "AttachmentContent"
-
-
-def test_download_attachment_content_uses_signed_attachment_action(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- calls: list[dict] = []
-
- def _fake_action_raw_request(
- method,
- action,
- *,
- params=None,
- accept="application/json",
- **kwargs,
- ):
- calls.append(
- {
- "method": method,
- "action": action,
- "params": params,
- "accept": accept,
- "extra": kwargs,
- }
- )
- return _FakeRuntimeResponse(
- content=b"# hosted",
- headers={
- "content-type": "text/markdown; charset=utf-8",
- "content-disposition": "inline; filename*=UTF-8''%E6%B5%8B%E8%AF%95.md",
- },
- )
-
- monkeypatch.setattr(client, "_action_raw_request", _fake_action_raw_request)
-
- content = client.download_attachment_content("ae-upload://hosted123.md")
-
- assert calls == [
- {
- "method": "GET",
- "action": "AttachmentContent",
- "params": {"FileUri": "ae-upload://hosted123.md"},
- "accept": "application/octet-stream",
- "extra": {},
- }
- ]
- assert content.data == b"# hosted"
- assert content.content_type == "text/markdown; charset=utf-8"
- assert content.display_name == "测试.md"
-
-
-@pytest.mark.asyncio
-async def test_list_workspace_files_uses_direct_runtime_endpoint(monkeypatch):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
-
- async def _fake_get_agent(**kwargs):
- assert kwargs == {"agent_id": "ar-demo", "name": None, "include_api_key": True}
- return {
- "basic": {"agent_id": "ar-demo", "name": "demo"},
- "quick_access": {
- "public_endpoint": "https://agent.example.com",
- "api_key": "ak-demo",
- },
- }
-
- session = _FakeRuntimeSession(
- [
- _FakeRuntimeResponse(
- json_payload={
- "Root": "workspace",
- "Path": "docs",
- "Entries": [{"Name": "guide.md", "Path": "docs/guide.md", "Type": "file"}],
- }
- )
- ]
- )
- monkeypatch.setattr(client, "get_agent", _fake_get_agent)
- monkeypatch.setattr(client, "_get_session", lambda: session)
-
- payload = await client.list_workspace_files(agent_id="ar-demo", path="docs", recursive=True)
-
- assert payload["path"] == "docs"
- assert payload["entries"][0]["path"] == "docs/guide.md"
- assert session.calls == [
- {
- "method": "GET",
- "url": "https://agent.example.com/_ksadk/workspace/v1/entries",
- "headers": {"Authorization": "Bearer ak-demo"},
- "params": {"path": "docs", "recursive": "true"},
- "files": None,
- "stream": False,
- }
- ]
-
-
-@pytest.mark.asyncio
-async def test_upload_download_and_delete_workspace_file_use_runtime_data_plane(
- monkeypatch,
- tmp_path: Path,
-):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
-
- async def _fake_get_agent(**kwargs):
- assert kwargs["include_api_key"] is True
- return {
- "basic": {"agent_id": "ar-demo", "name": "demo"},
- "quick_access": {
- "public_endpoint": "https://agent.example.com",
- "api_key": "ak-demo",
- },
- }
-
- local_file = tmp_path / "report.txt"
- local_file.write_text("workspace hello", encoding="utf-8")
- session = _FakeRuntimeSession(
- [
- _FakeRuntimeResponse(
- json_payload={
- "Entry": {
- "Name": "report.txt",
- "Path": "reports/report.txt",
- "Type": "file",
- "SizeBytes": 15,
- }
- }
- ),
- _FakeRuntimeResponse(
- content=b"workspace hello",
- headers={"content-type": "text/plain"},
- ),
- _FakeRuntimeResponse(json_payload={"Deleted": True}),
- ]
- )
- monkeypatch.setattr(client, "get_agent", _fake_get_agent)
- monkeypatch.setattr(client, "_get_session", lambda: session)
-
- upload_payload = await client.upload_workspace_file(
- agent_id="ar-demo",
- remote_path="reports/report.txt",
- local_path=local_file,
- )
- download_payload = await client.download_workspace_file(
- agent_id="ar-demo",
- remote_path="reports/report.txt",
- )
- delete_payload = await client.delete_workspace_file(
- agent_id="ar-demo",
- remote_path="reports/report.txt",
- )
-
- assert upload_payload["entry"]["path"] == "reports/report.txt"
- assert download_payload == b"workspace hello"
- assert delete_payload["deleted"] is True
- assert delete_payload["transport_mode"] == "runtime_direct"
- assert session.calls[0]["method"] == "POST"
- assert session.calls[0]["url"] == "https://agent.example.com/_ksadk/workspace/v1/files/reports/report.txt"
- assert session.calls[0]["headers"] == {"Authorization": "Bearer ak-demo"}
- assert session.calls[0]["files"] is not None
- assert session.calls[1] == {
- "method": "GET",
- "url": "https://agent.example.com/_ksadk/workspace/v1/files/reports/report.txt",
- "headers": {"Authorization": "Bearer ak-demo"},
- "params": None,
- "files": None,
- "stream": False,
- }
- assert session.calls[2] == {
- "method": "DELETE",
- "url": "https://agent.example.com/_ksadk/workspace/v1/files/reports/report.txt",
- "headers": {"Authorization": "Bearer ak-demo"},
- "params": None,
- "files": None,
- "stream": False,
- }
-
-
-@pytest.mark.asyncio
-async def test_list_workspace_files_surfaces_invalid_runtime_json_with_actionable_error(
- monkeypatch,
-):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
-
- async def _fake_get_agent(**kwargs):
- assert kwargs["include_api_key"] is True
- return {
- "basic": {"agent_id": "ar-demo", "name": "demo"},
- "quick_access": {
- "public_endpoint": "https://agent.example.com",
- "api_key": "ak-demo",
- },
- }
-
- session = _FakeRuntimeSession(
- [
- _FakeRuntimeResponse(
- content=b"",
- headers={"content-type": "application/json"},
- )
- ]
- )
- monkeypatch.setattr(client, "get_agent", _fake_get_agent)
- monkeypatch.setattr(client, "_get_session", lambda: session)
-
- with pytest.raises(AgentEngineAPIError) as excinfo:
- await client.list_workspace_files(agent_id="ar-demo", path="docs")
-
- assert excinfo.value.code == 502
- assert "workspace runtime returned invalid JSON" in excinfo.value.message
- assert "https://agent.example.com/_ksadk/workspace/v1/entries" in excinfo.value.message
-
-
-@pytest.mark.asyncio
-async def test_list_workspace_files_uses_action_proxy_for_openclaw_without_runtime_api_key(
- monkeypatch,
-):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- recorded: dict[str, object] = {}
-
- async def _fake_get_agent(**kwargs):
- assert kwargs["include_api_key"] is True
- return {
- "basic": {"agent_id": "ar-openclaw", "name": "demo-openclaw"},
- "deployment": {"framework": "openclaw"},
- "quick_access": {
- "public_endpoint": "https://openclaw.example.com",
- },
- }
-
- def _fake_action(action, params=None):
- recorded["action"] = action
- recorded["params"] = params
- return {
- "root": "workspace",
- "path": "docs",
- "entries": [{"name": "guide.md", "path": "docs/guide.md", "type": "file"}],
- }
-
- monkeypatch.setattr(client, "get_agent", _fake_get_agent)
- monkeypatch.setattr(client, "_action", _fake_action)
- monkeypatch.setattr(
- client,
- "_workspace_runtime_request",
- lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("runtime direct path must not be used")),
- )
-
- payload = await client.list_workspace_files(agent_id="ar-openclaw", path="docs", recursive=True)
-
- assert payload["path"] == "docs"
- assert payload["entries"][0]["path"] == "docs/guide.md"
- assert recorded == {
- "action": "ListWorkspaceFiles",
- "params": {
- "AgentId": "ar-openclaw",
- "Name": "demo-openclaw",
- "Path": "docs",
- "Recursive": True,
- },
- }
-
-
-@pytest.mark.asyncio
-async def test_list_workspace_files_uses_action_proxy_for_openclaw_even_with_api_key(
- monkeypatch,
-):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- recorded: dict[str, object] = {}
-
- async def _fake_get_agent(**kwargs):
- assert kwargs["include_api_key"] is True
- return {
- "basic": {"agent_id": "ar-openclaw", "name": "demo-openclaw"},
- "deployment": {"framework": "openclaw"},
- "quick_access": {
- "public_endpoint": "https://openclaw.example.com",
- "api_key": "ak-openclaw",
- },
- }
-
- def _fake_action(action, params=None):
- recorded["action"] = action
- recorded["params"] = params
- return {
- "root": "workspace",
- "path": ".",
- "entries": [{"name": "guide.md", "path": "guide.md", "type": "file"}],
- }
-
- monkeypatch.setattr(client, "get_agent", _fake_get_agent)
- monkeypatch.setattr(client, "_action", _fake_action)
- monkeypatch.setattr(
- client,
- "_workspace_runtime_request",
- lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("runtime direct path must not be used")),
- )
-
- payload = await client.list_workspace_files(agent_id="ar-openclaw")
-
- assert payload["entries"][0]["path"] == "guide.md"
- assert recorded == {
- "action": "ListWorkspaceFiles",
- "params": {
- "AgentId": "ar-openclaw",
- "Name": "demo-openclaw",
- "Path": ".",
- "Recursive": False,
- },
- }
-
-
-@pytest.mark.asyncio
-async def test_workspace_file_data_plane_uses_action_proxy_for_openclaw_without_runtime_api_key(
- monkeypatch,
- tmp_path: Path,
-):
- client = AgentEngineClient(base_url="http://example.com", access_key="", secret_key="")
- recorded: list[dict[str, object]] = []
-
- async def _fake_get_agent(**kwargs):
- assert kwargs["include_api_key"] is True
- return {
- "basic": {"agent_id": "ar-openclaw", "name": "demo-openclaw"},
- "deployment": {"framework": "openclaw"},
- "quick_access": {
- "public_endpoint": "https://openclaw.example.com",
- },
- }
-
- def _fake_action(action, params=None):
- recorded.append({"action": action, "params": params})
- if action == "DeleteWorkspaceFile":
- return {"deleted": True}
- raise AssertionError(f"unexpected json action {action}")
-
- def _fake_action_raw_request(method, action, *, params=None, data=None, files=None, accept="application/json"):
- recorded.append(
- {
- "method": method,
- "action": action,
- "params": params,
- "data": data,
- "files": files,
- "accept": accept,
- }
- )
- if action == "AddWorkspaceFile":
- return _FakeRuntimeResponse(
- json_payload={
- "Entry": {
- "Name": "report.txt",
- "Path": "reports/report.txt",
- "Type": "file",
- "SizeBytes": 15,
- }
- }
- )
- if action == "GetWorkspaceFileContent":
- return _FakeRuntimeResponse(
- content=b"workspace hello",
- headers={"content-type": "text/plain"},
- )
- raise AssertionError(f"unexpected raw action {action}")
-
- local_file = tmp_path / "report.txt"
- local_file.write_text("workspace hello", encoding="utf-8")
-
- monkeypatch.setattr(client, "get_agent", _fake_get_agent)
- monkeypatch.setattr(client, "_action", _fake_action)
- monkeypatch.setattr(client, "_action_raw_request", _fake_action_raw_request)
- monkeypatch.setattr(
- client,
- "_workspace_runtime_request",
- lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("runtime direct path must not be used")),
- )
-
- upload_payload = await client.upload_workspace_file(
- agent_id="ar-openclaw",
- remote_path="reports/report.txt",
- local_path=local_file,
- )
- download_payload = await client.download_workspace_file(
- agent_id="ar-openclaw",
- remote_path="reports/report.txt",
- )
- delete_payload = await client.delete_workspace_file(
- agent_id="ar-openclaw",
- remote_path="reports/report.txt",
- )
-
- assert upload_payload["entry"]["path"] == "reports/report.txt"
- assert download_payload == b"workspace hello"
- assert delete_payload["deleted"] is True
- assert delete_payload["transport_mode"] == "action_proxy"
- assert recorded[0]["action"] == "AddWorkspaceFile"
- assert recorded[0]["method"] == "POST"
- assert recorded[0]["data"] == {
- "AgentId": "ar-openclaw",
- "Name": "demo-openclaw",
- "Path": "reports/report.txt",
- }
- assert recorded[0]["files"] is not None
- assert recorded[1] == {
- "method": "GET",
- "action": "GetWorkspaceFileContent",
- "params": {
- "AgentId": "ar-openclaw",
- "Name": "demo-openclaw",
- "FilePath": "reports/report.txt",
- },
- "data": None,
- "files": None,
- "accept": "application/octet-stream",
- }
- assert recorded[2] == {
- "action": "DeleteWorkspaceFile",
- "params": {
- "AgentId": "ar-openclaw",
- "Name": "demo-openclaw",
- "Path": "reports/report.txt",
- },
- }
diff --git a/tests/test_cmd_build_upload_urls.py b/tests/test_cmd_build_upload_urls.py
deleted file mode 100644
index 096b52d4..00000000
--- a/tests/test_cmd_build_upload_urls.py
+++ /dev/null
@@ -1,103 +0,0 @@
-import asyncio
-import json
-from pathlib import Path
-
-from ksadk.builders import BuildResult
-from ksadk.cli import cmd_build
-
-
-class _FakeNow:
- def strftime(self, _fmt: str) -> str:
- return "20260308154645"
-
-
-class _FakeDatetime:
- @staticmethod
- def now():
- return _FakeNow()
-
-
-class _FakeCodeBuilder:
- last_config: dict | None = None
-
- def __init__(self, project_dir: Path, config: dict = None):
- self.project_dir = Path(project_dir)
- self.config = config or {}
- self.__class__.last_config = self.config
-
- def build(self) -> BuildResult:
- return BuildResult(
- success=True,
- artifact_path=self.project_dir / ".agentengine" / "code_build" / "demo.zip",
- artifact_size=1234,
- metadata={"agent_name": "demo_agent", "framework": "langgraph"},
- )
-
-
-class _FakeKS3Uploader:
- last_object_key: str | None = None
-
- def __init__(self, region: str, bucket: str = None):
- self.region = region
- self.bucket = bucket
-
- async def upload(self, _file_path: Path, object_key: str):
- self.__class__.last_object_key = object_key
- return f"ks3://agentengine-test/{object_key}"
-
- def get_public_url_by_key(self, object_key: str) -> str:
- return f"https://public.example.com/{object_key.lstrip('/')}"
-
- def get_internal_url_by_key(self, object_key: str) -> str:
- return f"https://internal.example.com/{object_key.lstrip('/')}"
-
-
-def test_build_push_prints_object_key_urls_and_never_prints_code_zip(tmp_path: Path, monkeypatch, capsys):
- import ksadk.builders as builders_module
-
- monkeypatch.setattr(builders_module, "CodeBuilder", _FakeCodeBuilder)
- monkeypatch.setattr(builders_module, "KS3Uploader", _FakeKS3Uploader)
- monkeypatch.setattr(cmd_build, "datetime", _FakeDatetime)
-
- asyncio.run(
- cmd_build._build_code(
- agent_path=tmp_path,
- push=True,
- region="cn-beijing-6",
- ks3_bucket="agentengine-test",
- no_cache=True,
- repackage=False,
- )
- )
-
- out = capsys.readouterr().out
- expected_name = "code_20260308154645.zip"
- expected_key = f"agents/demo_agent/{expected_name}"
-
- assert _FakeKS3Uploader.last_object_key == expected_key
- assert expected_name in out
- assert "/code.zip" not in out
- assert "回滚请使用历史不可变包路径 (ks3_path)" in out
-
- metadata_path = tmp_path / ".agentengine" / "build-metadata.json"
- metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
- assert metadata["metadata"]["ks3_path"].endswith(expected_key)
-
-
-def test_build_code_passes_repackage_to_code_builder(tmp_path: Path, monkeypatch):
- import ksadk.builders as builders_module
-
- monkeypatch.setattr(builders_module, "CodeBuilder", _FakeCodeBuilder)
-
- asyncio.run(
- cmd_build._build_code(
- agent_path=tmp_path,
- push=False,
- region="cn-beijing-6",
- ks3_bucket=None,
- no_cache=False,
- repackage=True,
- )
- )
-
- assert _FakeCodeBuilder.last_config == {"no_cache": False, "repackage": True}
diff --git a/tests/test_cmd_completion.py b/tests/test_cmd_completion.py
deleted file mode 100644
index 6ce64957..00000000
--- a/tests/test_cmd_completion.py
+++ /dev/null
@@ -1,117 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-from types import SimpleNamespace
-import sys
-
-from click.testing import CliRunner
-
-from ksadk.cli.cmd_completion import completion
-
-
-def test_completion_bash_script_strips_click_typed_prefix():
- runner = CliRunner()
- result = runner.invoke(completion, ["bash"])
-
- assert result.exit_code == 0, result.output
- assert 'line="${line#*,}"' in result.output
- assert "_AGENTENGINE_COMPLETE=bash_complete" in result.output
-
-
-def test_completion_install_rewrites_zshrc_to_source_after_compinit(tmp_path: Path, monkeypatch):
- home = tmp_path
- monkeypatch.setenv("HOME", str(home))
-
- zshrc = home / ".zshrc"
- zshrc.write_text(
- """
-if command -v agentengine >/dev/null 2>&1; then
- eval "$(_AGENTENGINE_COMPLETE=zsh_source agentengine)"
-fi
-
-source /tmp/placeholder
-source /Users/test/.agentengine-complete.zsh
-
-autoload -Uz compinit && compinit
-""".lstrip(),
- encoding="utf-8",
- )
-
- monkeypatch.setattr(
- "subprocess.run",
- lambda *args, **kwargs: SimpleNamespace(stdout="#compdef agentengine\n", returncode=0),
- )
-
- runner = CliRunner()
- result = runner.invoke(completion, ["install", "--shell", "zsh"])
-
- assert result.exit_code == 0, result.output
-
- expected_source = f'source "{home / ".agentengine-complete.zsh"}"'
- updated = zshrc.read_text(encoding="utf-8")
-
- assert 'eval "$(_AGENTENGINE_COMPLETE=zsh_source agentengine)"' not in updated
- assert updated.count(expected_source) == 1
- assert updated.rfind("compinit") < updated.rfind(expected_source)
-
-
-def test_completion_install_prefers_bash_profile_on_macos(tmp_path: Path, monkeypatch):
- home = tmp_path
- monkeypatch.setenv("HOME", str(home))
- monkeypatch.setenv("SHELL", "/bin/bash")
- monkeypatch.setattr(sys, "platform", "darwin", raising=False)
-
- bash_profile = home / ".bash_profile"
- bash_profile.write_text("# existing profile\n", encoding="utf-8")
-
- monkeypatch.setattr(
- "subprocess.run",
- lambda *args, **kwargs: SimpleNamespace(stdout="_agentengine_completion() { :; }\n", returncode=0),
- )
-
- runner = CliRunner()
- result = runner.invoke(completion, ["install", "--shell", "auto"])
-
- assert result.exit_code == 0, result.output
- updated = bash_profile.read_text(encoding="utf-8")
- assert f'source "{home / ".agentengine-complete.bash"}"' in updated
-
-
-def test_completion_install_auto_detects_git_bash_without_shell_env(tmp_path: Path, monkeypatch):
- home = tmp_path
- monkeypatch.setenv("HOME", str(home))
- monkeypatch.delenv("SHELL", raising=False)
- monkeypatch.setenv("MSYSTEM", "MINGW64")
-
- monkeypatch.setattr(
- "subprocess.run",
- lambda *args, **kwargs: SimpleNamespace(stdout="_agentengine_completion() { :; }\n", returncode=0),
- )
-
- runner = CliRunner()
- result = runner.invoke(completion, ["install", "--shell", "auto"])
-
- assert result.exit_code == 0, result.output
- bashrc = home / ".bashrc"
- assert bashrc.exists()
- assert f'source "{home / ".agentengine-complete.bash"}"' in bashrc.read_text(encoding="utf-8")
-
-
-def test_completion_install_auto_detects_wsl_without_shell_env(tmp_path: Path, monkeypatch):
- home = tmp_path
- monkeypatch.setenv("HOME", str(home))
- monkeypatch.delenv("SHELL", raising=False)
- monkeypatch.setenv("WSL_DISTRO_NAME", "Ubuntu")
-
- monkeypatch.setattr(
- "subprocess.run",
- lambda *args, **kwargs: SimpleNamespace(stdout="_agentengine_completion() { :; }\n", returncode=0),
- )
-
- runner = CliRunner()
- result = runner.invoke(completion, ["install", "--shell", "auto"])
-
- assert result.exit_code == 0, result.output
- bashrc = home / ".bashrc"
- assert bashrc.exists()
- assert f'source "{home / ".agentengine-complete.bash"}"' in bashrc.read_text(encoding="utf-8")
diff --git a/tests/test_cmd_config_wizard.py b/tests/test_cmd_config_wizard.py
deleted file mode 100644
index e48b63cf..00000000
--- a/tests/test_cmd_config_wizard.py
+++ /dev/null
@@ -1,108 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-import yaml
-
-from ksadk.cli import cmd_config
-
-
-class _Prompt:
- def __init__(self, value):
- self.value = value
-
- def ask(self):
- return self.value
-
-
-def test_config_wizard_accepts_existing_hermes_framework(monkeypatch, tmp_path: Path):
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr(cmd_config, "is_stdout_tty", lambda: True)
- monkeypatch.setattr("ksadk.configs.global_config.global_config_exists", lambda: True)
-
- (tmp_path / "agentengine.yaml").write_text(
- yaml.safe_dump(
- {
- "name": "hermes",
- "description": "existing description",
- "framework": "hermes",
- "entry_point": "hermes/agent.py",
- "agent_variable": "root_agent",
- "region": "pre-online",
- },
- allow_unicode=True,
- ),
- encoding="utf-8",
- )
-
- def _text(_message, *, default="", **_kwargs):
- return _Prompt(default)
-
- def _password(_message, *, default="", **_kwargs):
- return _Prompt(default)
-
- def _confirm(message, *, default=False, **_kwargs):
- assert message in {"是否配置金山云凭证?", "是否使用 container 模式部署?"}
- return _Prompt(default)
-
- def _select(_message, *, choices, default=None, **_kwargs):
- if default not in choices:
- raise ValueError(f"default {default!r} is not a valid choice")
- return _Prompt(default)
-
- monkeypatch.setattr(cmd_config.questionary, "text", _text)
- monkeypatch.setattr(cmd_config.questionary, "password", _password)
- monkeypatch.setattr(cmd_config.questionary, "confirm", _confirm)
- monkeypatch.setattr(cmd_config.questionary, "select", _select)
-
- cmd_config.run_config_wizard(config_file=None, set_items=(), is_global=False)
-
- updated = yaml.safe_load((tmp_path / "agentengine.yaml").read_text(encoding="utf-8-sig"))
- assert updated["framework"] == "hermes"
-
-
-def test_config_wizard_prompts_for_kcr_username(monkeypatch, tmp_path: Path):
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr(cmd_config, "is_stdout_tty", lambda: True)
- monkeypatch.setattr("ksadk.configs.global_config.global_config_exists", lambda: True)
-
- text_answers = {
- "Agent 名称:": "demo-agent",
- "Agent 描述:": "demo",
- "Base URL (OPENAI_BASE_URL) [选填,默认使用金山云星流平台URL]:": "",
- "模型名称 (OPENAI_MODEL_NAME) [选填,默认使用金山云星流平台glm-5.2]:": "",
- "KCR 用户名 (企业版请填写访问凭证用户名):": "enterprise-user",
- "镜像仓库地址 [选填,如: agenthzzqy-vpc.ksyunkcr.com/testagent-pub]:": "agenthzzqy-vpc.ksyunkcr.com/testagent-pub",
- }
- password_answers = {
- "API Key (OPENAI_API_KEY):": "",
- "KCR 密码或 Token:": "enterprise-pass",
- }
-
- def _text(message, *, default="", **_kwargs):
- return _Prompt(text_answers.get(message, default))
-
- def _password(message, *, default="", **_kwargs):
- return _Prompt(password_answers.get(message, default))
-
- def _confirm(message, *, default=False, **_kwargs):
- if message == "是否配置金山云凭证?":
- return _Prompt(False)
- if message == "是否使用 container 模式部署?":
- return _Prompt(True)
- raise AssertionError(f"unexpected confirm prompt: {message}")
-
- def _select(_message, *, default=None, **_kwargs):
- return _Prompt(default)
-
- monkeypatch.setattr(cmd_config.questionary, "text", _text)
- monkeypatch.setattr(cmd_config.questionary, "password", _password)
- monkeypatch.setattr(cmd_config.questionary, "confirm", _confirm)
- monkeypatch.setattr(cmd_config.questionary, "select", _select)
-
- cmd_config.run_config_wizard(config_file=None, set_items=(), is_global=False)
-
- env_text = (tmp_path / ".env").read_text(encoding="utf-8-sig")
- assert "KCR_USERNAME=enterprise-user" in env_text
- assert "KCR_PASSWORD=enterprise-pass" in env_text
- assert "KCR_REGISTRY=agenthzzqy-vpc.ksyunkcr.com/testagent-pub" in env_text
diff --git a/tests/test_cmd_create_from_agent.py b/tests/test_cmd_create_from_agent.py
deleted file mode 100644
index e760254c..00000000
--- a/tests/test_cmd_create_from_agent.py
+++ /dev/null
@@ -1,507 +0,0 @@
-from pathlib import Path
-import asyncio
-import importlib
-import py_compile
-import sys
-
-from click.testing import CliRunner
-
-from ksadk.cli import cmd_create
-from ksadk.cli.cmd_deploy import _resolve_artifact_type_input
-
-
-def test_quick_start_command_lines_quote_posix_project_paths():
- lines = cmd_create._quick_start_command_lines(
- "Demo Agent",
- ["agentengine config"],
- system="Linux",
- )
-
- assert lines == ["cd 'Demo Agent' && agentengine config"]
-
-
-def test_quick_start_command_lines_support_windows_powershell_and_cmd():
- lines = cmd_create._quick_start_command_lines(
- "Demo Agent",
- ["agentengine config"],
- system="Windows",
- )
-
- assert lines == [
- "PowerShell:",
- "Set-Location -LiteralPath 'Demo Agent'",
- "agentengine config",
- "cmd.exe:",
- 'cd /d "Demo Agent" && agentengine config',
- ]
-
-
-def test_find_entry_file_from_agentengine_yaml(tmp_path: Path):
- src = tmp_path / "src"
- src.mkdir(parents=True)
- entry = src / "agentengine_adapter.py"
- entry.write_text("root_agent = object()\n", encoding="utf-8")
- (tmp_path / "agentengine.yaml").write_text(
- "framework: langgraph\nentry_point: src/agentengine_adapter.py\nagent_variable: root_agent\n",
- encoding="utf-8",
- )
-
- found = cmd_create._find_entry_file(tmp_path)
- assert found is not None
- found_file, found_var = found
- assert found_file == entry
- assert found_var == "root_agent"
-
-
-def test_find_entry_file_ignores_config_when_agent_variable_missing(tmp_path: Path):
- src = tmp_path / "src" / "demo"
- src.mkdir(parents=True)
- (src / "main.py").write_text(
- "from fastapi import FastAPI\n"
- "app = FastAPI()\n",
- encoding="utf-8",
- )
- entry = src / "agent.py"
- entry.write_text(
- "from google.adk.agents import Agent\n"
- "root_agent = Agent(name='demo')\n",
- encoding="utf-8",
- )
- (tmp_path / "agentengine.yaml").write_text(
- "framework: adk\nentry_point: src/demo/main.py\nagent_variable: root_agent\n",
- encoding="utf-8",
- )
-
- found = cmd_create._find_entry_file(tmp_path)
-
- assert found is not None
- found_file, found_var = found
- assert found_file == entry
- assert found_var == "root_agent"
-
-
-def test_find_entry_file_prefers_valid_langgraph_json(tmp_path: Path):
- src = tmp_path / "src" / "demo"
- src.mkdir(parents=True)
- entry = src / "graph.py"
- entry.write_text(
- "from deepagents import create_deep_agent\n"
- "graph = create_deep_agent(model=None)\n",
- encoding="utf-8",
- )
- (tmp_path / "agentengine.yaml").write_text(
- "framework: deepagents\nentry_point: src/demo/main.py\nagent_variable: root_agent\n",
- encoding="utf-8",
- )
- (tmp_path / "langgraph.json").write_text(
- '{"graphs": {"agent": "./src/demo/graph.py:graph"}}\n',
- encoding="utf-8",
- )
-
- found = cmd_create._find_entry_file(tmp_path)
-
- assert found is not None
- found_file, found_var = found
- assert found_file == entry
- assert found_var == "graph"
-
-
-def test_find_entry_file_ignores_langgraph_json_local_variable(tmp_path: Path):
- src = tmp_path / "src" / "demo"
- src.mkdir(parents=True)
- graph_file = src / "graph.py"
- graph_file.write_text(
- "from deepagents import create_deep_agent\n"
- "async def init_agent_resources():\n"
- " graph = create_deep_agent(model=None)\n"
- " return graph\n",
- encoding="utf-8",
- )
- adapter = src / "agentengine_adapter.py"
- adapter.write_text("root_agent = object()\n", encoding="utf-8")
- (tmp_path / "langgraph.json").write_text(
- '{"graphs": {"agent": "./src/demo/graph.py:graph"}}\n',
- encoding="utf-8",
- )
-
- found = cmd_create._find_entry_file(tmp_path)
-
- assert found is not None
- found_file, found_var = found
- assert found_file == adapter
- assert found_var == "root_agent"
-
-
-def test_find_entry_file_recursive_scan(tmp_path: Path):
- entry = tmp_path / "src" / "nested" / "custom_entry.py"
- entry.parent.mkdir(parents=True)
- entry.write_text("root_agent = object()\n", encoding="utf-8")
-
- found = cmd_create._find_entry_file(tmp_path)
- assert found is not None
- found_file, found_var = found
- assert found_file == entry
- assert found_var == "root_agent"
-
-
-def test_wrap_agent_directory_ignores_venv_and_exports_nested_entry(tmp_path: Path, monkeypatch):
- source = tmp_path / "source"
- entry = source / "src" / "agentengine_adapter.py"
- entry.parent.mkdir(parents=True)
- entry.write_text(
- "def build_agent():\n"
- " return {\"ok\": True}\n"
- "root_agent = build_agent()\n",
- encoding="utf-8",
- )
-
- # Should be excluded by copytree ignore rules
- venv_file = source / ".venv-ae" / "lib" / "dummy.py"
- venv_file.parent.mkdir(parents=True)
- venv_file.write_text("x = 1\n", encoding="utf-8")
-
- monkeypatch.setattr("ksadk.configs.global_config.global_config_exists", lambda: False)
- monkeypatch.setattr("ksadk.configs.global_config.get_env_from_global_config", lambda: {})
-
- project_path = tmp_path / "wrapped-project"
- cmd_create._wrap_agent_directory(source, str(project_path), "langgraph", entry, "root_agent")
-
- package_dir = project_path / "wrapped_project"
- assert package_dir.exists()
- assert not (package_dir / ".venv-ae").exists()
-
- init_content = (package_dir / "__init__.py").read_text(encoding="utf-8")
- assert "from .src.agentengine_adapter import root_agent as root_agent" in init_content
-
-
-def test_wrap_langgraph_messages_directory_does_not_generate_adapter(tmp_path: Path, monkeypatch):
- source = tmp_path / "source"
- entry = source / "agent.py"
- source.mkdir()
- entry.write_text(
- "from langgraph.graph import MessagesState\n"
- "def node(state):\n"
- " return {\"messages\": []}\n"
- "root_agent = object()\n",
- encoding="utf-8",
- )
- monkeypatch.setattr("ksadk.configs.global_config.global_config_exists", lambda: False)
- monkeypatch.setattr("ksadk.configs.global_config.get_env_from_global_config", lambda: {})
-
- project_path = tmp_path / "wrapped-messages"
- cmd_create._wrap_agent_directory(source, str(project_path), "langgraph", entry, "root_agent")
-
- package_dir = project_path / "wrapped_messages"
- assert not (package_dir / "agentengine_adapter.py").exists()
- config_text = (project_path / "agentengine.yaml").read_text(encoding="utf-8-sig")
- assert "entry_point: wrapped_messages/agent.py" in config_text
-
-
-def test_wrap_langgraph_custom_state_directory_generates_adapter(tmp_path: Path, monkeypatch):
- source = tmp_path / "source"
- entry = source / "agent.py"
- source.mkdir()
- entry.write_text(
- "from typing import TypedDict\n"
- "class State(TypedDict):\n"
- " query: str\n"
- "def node(state: State):\n"
- " return {\"answer\": state[\"query\"]}\n"
- "workflow = 'StateGraph(State)'\n"
- "root_agent = object()\n",
- encoding="utf-8",
- )
- monkeypatch.setattr("ksadk.configs.global_config.global_config_exists", lambda: False)
- monkeypatch.setattr("ksadk.configs.global_config.get_env_from_global_config", lambda: {})
-
- project_path = tmp_path / "wrapped-custom"
- cmd_create._wrap_agent_directory(source, str(project_path), "langgraph", entry, "root_agent")
-
- package_dir = project_path / "wrapped_custom"
- adapter_text = (package_dir / "agentengine_adapter.py").read_text(encoding="utf-8")
- assert "from .agent import root_agent as root_agent" in adapter_text
- assert '"query": payload.get("input", "")' in adapter_text
- config_text = (project_path / "agentengine.yaml").read_text(encoding="utf-8-sig")
- assert "entry_point: wrapped_custom/agentengine_adapter.py" in config_text
- assert "agent_variable: root_agent" in config_text
-
-
-def test_wrap_langgraph_custom_state_directory_detects_state_outside_entry(tmp_path: Path, monkeypatch):
- source = tmp_path / "source"
- source.mkdir()
- (source / "agent.py").write_text("from .graph import root_agent\n", encoding="utf-8")
- (source / "graph.py").write_text(
- "from typing import TypedDict\n"
- "from langgraph.graph import StateGraph\n"
- "class State(TypedDict):\n"
- " question: str\n"
- "def node(state: State):\n"
- " return {\"answer\": state[\"question\"]}\n"
- "root_agent = object()\n",
- encoding="utf-8",
- )
- monkeypatch.setattr("ksadk.configs.global_config.global_config_exists", lambda: False)
- monkeypatch.setattr("ksadk.configs.global_config.get_env_from_global_config", lambda: {})
-
- project_path = tmp_path / "wrapped-split-custom"
- cmd_create._wrap_agent_directory(source, str(project_path), "langgraph", source / "agent.py", "root_agent")
-
- package_dir = project_path / "wrapped_split_custom"
- adapter_text = (package_dir / "agentengine_adapter.py").read_text(encoding="utf-8")
- assert '"question": payload.get("input", "")' in adapter_text
- config_text = (project_path / "agentengine.yaml").read_text(encoding="utf-8-sig")
- assert "entry_point: wrapped_split_custom/agentengine_adapter.py" in config_text
-
-
-def test_wrap_langgraph_ambiguous_file_generates_review_adapter(tmp_path: Path, monkeypatch):
- source = tmp_path / "agent.py"
- source.write_text(
- "from langgraph.graph import StateGraph\n"
- "root_agent = object()\n",
- encoding="utf-8",
- )
- monkeypatch.setattr("ksadk.configs.global_config.global_config_exists", lambda: False)
- monkeypatch.setattr("ksadk.configs.global_config.get_env_from_global_config", lambda: {})
-
- project_path = tmp_path / "wrapped-ambiguous"
- cmd_create._wrap_agent_file(source, str(project_path), "langgraph", "root_agent")
-
- package_dir = project_path / "wrapped_ambiguous"
- adapter_text = (package_dir / "agentengine_adapter.py").read_text(encoding="utf-8")
- assert "TODO: Map AgentEngine's chat payload" in adapter_text
- assert "return dict(payload)" in adapter_text
- config_text = (project_path / "agentengine.yaml").read_text(encoding="utf-8-sig")
- assert "entry_point: wrapped_ambiguous/agentengine_adapter.py" in config_text
-
-
-def test_wrap_deepagents_service_directory_generates_runtime_adapter(tmp_path: Path, monkeypatch):
- source = tmp_path / "source"
- pkg = source / "src" / "bill_diagnosis"
- pkg.mkdir(parents=True)
- (pkg / "main.py").write_text(
- "from fastapi import FastAPI\n"
- "from .lifespan import lifespan\n"
- "app = FastAPI(lifespan=lifespan)\n",
- encoding="utf-8",
- )
- (pkg / "graph.py").write_text(
- "from deepagents import create_deep_agent\n"
- "async def init_agent_resources():\n"
- " return create_deep_agent(model=None), None, None, None\n",
- encoding="utf-8",
- )
- (pkg / "lifespan.py").write_text(
- "class DeepAgentRunnable:\n"
- " def __init__(self, agent, langfuse_mgr=None):\n"
- " self.agent = agent\n"
- " async def _ainvoke(self, input, config=None, **kwargs):\n"
- " return {\"response\": input.get(\"message\", \"\")}\n",
- encoding="utf-8",
- )
- (source / "agentengine.yaml").write_text(
- "framework: deepagents\nentry_point: src/bill_diagnosis/main.py\nagent_variable: root_agent\n",
- encoding="utf-8",
- )
- monkeypatch.setattr("ksadk.configs.global_config.global_config_exists", lambda: False)
- monkeypatch.setattr("ksadk.configs.global_config.get_env_from_global_config", lambda: {})
-
- project_path = tmp_path / "wrapped-service"
- cmd_create._wrap_agent_directory(source, str(project_path), "deepagents", source / "src" / "bill_diagnosis" / "main.py", "root_agent")
-
- package_dir = project_path / "wrapped_service"
- adapter_text = (package_dir / "agentengine_adapter.py").read_text(encoding="utf-8")
- assert "class AgentEngineDeepAgentsServiceAdapter" in adapter_text
- assert "async def ainvoke" in adapter_text
- assert '"message": message' in adapter_text
- assert 'INIT_MODULE = ".src.bill_diagnosis.graph"' in adapter_text
- assert "importlib.import_module(INIT_MODULE, __package__)" in adapter_text
- config_text = (project_path / "agentengine.yaml").read_text(encoding="utf-8-sig")
- assert "entry_point: wrapped_service/agentengine_adapter.py" in config_text
- assert "agent_variable: root_agent" in config_text
-
-
-def test_wrap_deepagents_service_directory_ignores_langgraph_json_local_graph(tmp_path: Path, monkeypatch):
- source = tmp_path / "source"
- pkg = source / "src" / "bill_diagnosis"
- pkg.mkdir(parents=True)
- graph_file = pkg / "graph.py"
- graph_file.write_text(
- "from deepagents import create_deep_agent\n"
- "async def init_agent_resources():\n"
- " graph = create_deep_agent(model=None)\n"
- " return graph, None, None, None\n",
- encoding="utf-8",
- )
- (pkg / "main.py").write_text(
- "from fastapi import FastAPI\n"
- "from .lifespan import lifespan\n"
- "app = FastAPI(lifespan=lifespan)\n",
- encoding="utf-8",
- )
- (pkg / "lifespan.py").write_text(
- "class DeepAgentRunnable:\n"
- " async def _ainvoke(self, input, config=None, **kwargs):\n"
- " return {\"response\": input.get(\"message\", \"\")}\n",
- encoding="utf-8",
- )
- (source / "langgraph.json").write_text(
- '{"graphs": {"agent": "./src/bill_diagnosis/graph.py:graph"}}\n',
- encoding="utf-8",
- )
- (source / "agentengine.yaml").write_text(
- "framework: deepagents\n"
- "entry_point: src/bill_diagnosis/graph.py\n"
- "agent_variable: graph\n",
- encoding="utf-8",
- )
- monkeypatch.setattr("ksadk.configs.global_config.global_config_exists", lambda: False)
- monkeypatch.setattr("ksadk.configs.global_config.get_env_from_global_config", lambda: {})
-
- found = cmd_create._find_entry_file(source)
- assert found is not None
- found_file, found_var = found
- assert found_file == graph_file
- assert found_var == "root_agent"
-
- project_path = tmp_path / "wrapped-service-local-graph"
- cmd_create._wrap_agent_directory(source, str(project_path), "deepagents", found_file, found_var)
-
- package_dir = project_path / "wrapped_service_local_graph"
- config_text = (project_path / "agentengine.yaml").read_text(encoding="utf-8-sig")
- assert "entry_point: wrapped_service_local_graph/agentengine_adapter.py" in config_text
- assert "agent_variable: root_agent" in config_text
- adapter_text = (package_dir / "agentengine_adapter.py").read_text(encoding="utf-8")
- assert 'INIT_MODULE = ".src.bill_diagnosis.graph"' in adapter_text
- assert not (package_dir / "ksadk_agentengine_adapter.py").exists()
-
-
-def test_generated_deepagents_service_adapter_invokes_fake_service(tmp_path: Path, monkeypatch):
- source = tmp_path / "source"
- pkg = source / "src" / "bill_diagnosis"
- pkg.mkdir(parents=True)
- (pkg / "main.py").write_text(
- "from fastapi import FastAPI\n"
- "from .lifespan import lifespan\n"
- "app = FastAPI(lifespan=lifespan)\n",
- encoding="utf-8",
- )
- (pkg / "graph.py").write_text(
- "# deepagents create_deep_agent(\n"
- "class FakeGraph:\n"
- " async def ainvoke(self, payload, **kwargs):\n"
- " return {\"messages\": [{\"content\": payload.get(\"message\", \"\")}]}\n"
- "async def init_agent_resources():\n"
- " return FakeGraph(), None, None, None\n",
- encoding="utf-8",
- )
- (pkg / "lifespan.py").write_text(
- "class DeepAgentRunnable:\n"
- " def __init__(self, agent, langfuse_mgr=None):\n"
- " self.agent = agent\n"
- " async def _ainvoke(self, input, config=None, **kwargs):\n"
- " return {\"response\": \"service:\" + input.get(\"message\", \"\")}\n",
- encoding="utf-8",
- )
- monkeypatch.setattr("ksadk.configs.global_config.global_config_exists", lambda: False)
- monkeypatch.setattr("ksadk.configs.global_config.get_env_from_global_config", lambda: {})
-
- project_path = tmp_path / "wrapped-service"
- cmd_create._wrap_agent_directory(source, str(project_path), "deepagents", source / "src" / "bill_diagnosis" / "main.py", "root_agent")
-
- sys.path.insert(0, str(project_path))
- try:
- module = importlib.import_module("wrapped_service.agentengine_adapter")
- result = asyncio.run(module.root_agent.ainvoke({"input": "hello", "session_id": "s1"}))
- finally:
- sys.path.remove(str(project_path))
-
- assert result["output"] == "service:hello"
-
-
-def test_create_openclaw_only_generates_env_file(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr("ksadk.configs.global_config.global_config_exists", lambda: True)
- monkeypatch.setattr(
- "ksadk.configs.global_config.get_env_from_global_config",
- lambda: {
- "OPENAI_API_KEY": "sk-openclaw",
- "OPENAI_BASE_URL": "https://model.example.com/v1",
- "OPENAI_MODEL_NAME": "glm-5.1",
- "LANGFUSE_PUBLIC_KEY": "pk-should-not-exist",
- "LANGFUSE_SECRET_KEY": "sk-should-not-exist",
- "LANGFUSE_BASE_URL": "https://langfuse.example.com",
- "KSYUN_ACCESS_KEY": "ak-demo",
- "KSYUN_SECRET_KEY": "sk-demo",
- "KSYUN_REGION": "cn-beijing-6",
- "KSYUN_ACCOUNT_ID": "1234567890",
- },
- )
-
- result = runner.invoke(cmd_create.create, ["demo-openclaw", "-f", "openclaw"])
-
- assert result.exit_code == 0, result.output
-
- project_dir = tmp_path / "demo-openclaw"
- assert project_dir.exists()
- assert sorted(path.name for path in project_dir.iterdir()) == [".env"]
-
- env_text = (project_dir / ".env").read_text(encoding="utf-8-sig")
- assert "KSYUN_ACCESS_KEY=ak-demo" in env_text
- assert "KSYUN_SECRET_KEY=sk-demo" in env_text
- assert "KSYUN_REGION=cn-beijing-6" in env_text
- assert "KSYUN_ACCOUNT_ID=1234567890" in env_text
- assert "OPENAI_API_KEY=sk-openclaw" in env_text
- assert "OPENAI_BASE_URL=https://model.example.com/v1" in env_text
- assert "OPENAI_MODEL_NAME=glm-5.1" in env_text
- assert "LANGFUSE_" not in env_text
-
-
-def test_create_hermes_generates_container_first_template(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr("ksadk.configs.global_config.global_config_exists", lambda: True)
- monkeypatch.setattr(
- "ksadk.configs.global_config.get_env_from_global_config",
- lambda: {
- "OPENAI_API_KEY": "sk-hermes",
- "OPENAI_BASE_URL": "https://model.example.com/v1",
- "OPENAI_MODEL_NAME": "glm-hermes",
- "KSYUN_ACCESS_KEY": "ak-demo",
- "KSYUN_SECRET_KEY": "sk-demo",
- "KSYUN_REGION": "cn-beijing-6",
- },
- )
-
- result = runner.invoke(cmd_create.create, ["demo-hermes", "-f", "hermes"])
-
- assert result.exit_code == 0, result.output
- project_dir = tmp_path / "demo-hermes"
- assert (project_dir / ".env").exists()
- assert (project_dir / ".env.example").exists()
- assert (project_dir / "agentengine.yaml").exists()
- assert (project_dir / "Dockerfile").exists()
- assert (project_dir / "entrypoint.sh").exists()
- assert (project_dir / "runtime" / "app.py").exists()
- assert (project_dir / "README.md").exists()
- assert not (project_dir / "demo_hermes" / "agent.py").exists()
-
- config_text = (project_dir / "agentengine.yaml").read_text(encoding="utf-8-sig")
- assert "framework: hermes" in config_text
- assert "artifact_type: Container" in config_text
- assert "ui_profile: hermes" in config_text
-
- readme_text = (project_dir / "README.md").read_text(encoding="utf-8-sig")
- assert "agentengine hermes deploy" in readme_text
- assert "agentengine launch . --artifact-type Container" not in readme_text
-
- env_text = (project_dir / ".env").read_text(encoding="utf-8-sig")
- assert "OPENAI_API_KEY=sk-hermes" in env_text
- assert "OPENAI_BASE_URL=https://model.example.com/v1" in env_text
- assert "OPENAI_MODEL_NAME=glm-hermes" in env_text
- py_compile.compile(str(project_dir / "runtime" / "app.py"), doraise=True)
-
-
-def test_deploy_artifact_type_defaults_to_config_for_hermes_template():
- assert _resolve_artifact_type_input({"artifact_type": "Container"}, None) == "Container"
- assert _resolve_artifact_type_input({"artifact_type": "Container"}, "Code") == "Code"
diff --git a/tests/test_cmd_dashboard_fallback.py b/tests/test_cmd_dashboard_fallback.py
deleted file mode 100644
index 97ae803e..00000000
--- a/tests/test_cmd_dashboard_fallback.py
+++ /dev/null
@@ -1,718 +0,0 @@
-import json
-from pathlib import Path
-
-from click.testing import CliRunner
-
-from ksadk.cli import cmd_dashboard
-
-
-async def _fake_resolve_agent_detail(*_args, **_kwargs):
- return (
- {
- "agent_id": "ar-test",
- "name": "demo-agent",
- "framework": "langgraph",
- "endpoint": "http://demo.example.com",
- },
- type("Ref", (), {"source": "cli", "source_text": "CLI", "value": "ar-test"})(),
- False,
- )
-
-
-async def _fake_create_access_link(*_args, **_kwargs):
- return {
- "link_id": "lnk-1",
- "access_url": "http://demo.example.com/s/lnk-1",
- "expires_at": "2026-03-09T00:00:00Z",
- }
-
-
-def test_dashboard_uses_access_link_by_default(monkeypatch):
- opened = {}
- captured = {}
- runner = CliRunner()
-
- monkeypatch.setattr(cmd_dashboard, "load_state", lambda _cwd: {})
- monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve_agent_detail)
- async def _fake_create(*_args, **kwargs):
- captured.update(kwargs)
- return await _fake_create_access_link()
-
- monkeypatch.setattr(cmd_dashboard, "_create_dashboard_access_link", _fake_create)
- monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda url: opened.setdefault("url", url))
-
- result = runner.invoke(cmd_dashboard.dashboard, ["ar-test"])
- assert result.exit_code == 0, result.output
- assert opened == {}
- assert captured["path"] is None
- assert "http://demo.example.com/s/lnk-1" in result.output
-
-
-def test_dashboard_open_is_canonical_command(monkeypatch):
- opened = {}
- runner = CliRunner()
-
- monkeypatch.setattr(cmd_dashboard, "load_state", lambda _cwd: {})
- monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve_agent_detail)
- monkeypatch.setattr(cmd_dashboard, "_create_dashboard_access_link", _fake_create_access_link)
- monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda url: opened.setdefault("url", url))
-
- result = runner.invoke(cmd_dashboard.dashboard, ["open", "ar-test"])
- assert result.exit_code == 0, result.output
- assert opened == {}
- assert "http://demo.example.com/s/lnk-1" in result.output
-
-
-def test_dashboard_open_uses_state_region_when_region_is_not_explicit(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- captured = {}
-
- (tmp_path / ".agentengine.state").write_text(
- "agent_id: ar-test\n"
- "region: pre-online\n",
- encoding="utf-8",
- )
- monkeypatch.chdir(tmp_path)
- monkeypatch.delenv("KSYUN_REGION", raising=False)
- monkeypatch.setattr(
- cmd_dashboard,
- "load_state",
- lambda _cwd: {"agent_id": "ar-test", "region": "pre-online"},
- )
-
- async def _fake_resolve(region, primary_ref, fallback_ref):
- captured["region"] = region
- return await _fake_resolve_agent_detail(region, primary_ref, fallback_ref)
-
- monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve)
- monkeypatch.setattr(cmd_dashboard, "_create_dashboard_access_link", _fake_create_access_link)
- monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda _url: None)
-
- result = runner.invoke(cmd_dashboard.dashboard, ["open"])
-
- assert result.exit_code == 0, result.output
- assert captured["region"] == "pre-online"
-
-
-def test_dashboard_open_explicit_region_overrides_state_region(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- captured = {}
-
- (tmp_path / ".agentengine.state").write_text(
- "agent_id: ar-test\n"
- "region: pre-online\n",
- encoding="utf-8",
- )
- monkeypatch.chdir(tmp_path)
- monkeypatch.delenv("KSYUN_REGION", raising=False)
- monkeypatch.setattr(
- cmd_dashboard,
- "load_state",
- lambda _cwd: {"agent_id": "ar-test", "region": "pre-online"},
- )
-
- async def _fake_resolve(region, primary_ref, fallback_ref):
- captured["region"] = region
- return await _fake_resolve_agent_detail(region, primary_ref, fallback_ref)
-
- monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve)
- monkeypatch.setattr(cmd_dashboard, "_create_dashboard_access_link", _fake_create_access_link)
- monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda _url: None)
-
- result = runner.invoke(cmd_dashboard.dashboard, ["open", "--region", "cn-beijing-6"])
-
- assert result.exit_code == 0, result.output
- assert captured["region"] == "cn-beijing-6"
-
-
-def test_dashboard_open_prefers_state_region_over_global_config_injected_region(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- captured = {}
-
- (tmp_path / ".agentengine.state").write_text(
- "agent_id: ar-test\n"
- "region: pre-online\n",
- encoding="utf-8",
- )
- monkeypatch.chdir(tmp_path)
- monkeypatch.setenv("KSYUN_REGION", "cn-beijing-6")
- monkeypatch.setenv("KSADK_GLOBAL_CONFIG_ENV_KEYS", "KSYUN_REGION")
- monkeypatch.setattr(
- cmd_dashboard,
- "load_state",
- lambda _cwd: {"agent_id": "ar-test", "region": "pre-online"},
- )
-
- async def _fake_resolve(region, primary_ref, fallback_ref):
- captured["region"] = region
- return await _fake_resolve_agent_detail(region, primary_ref, fallback_ref)
-
- monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve)
- monkeypatch.setattr(cmd_dashboard, "_create_dashboard_access_link", _fake_create_access_link)
- monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda _url: None)
-
- result = runner.invoke(cmd_dashboard.dashboard, ["open"])
-
- assert result.exit_code == 0, result.output
- assert captured["region"] == "pre-online"
-
-
-def test_dashboard_open_env_region_overrides_state_region(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- captured = {}
-
- (tmp_path / ".agentengine.state").write_text(
- "agent_id: ar-test\n"
- "region: pre-online\n",
- encoding="utf-8",
- )
- monkeypatch.chdir(tmp_path)
- monkeypatch.setenv("KSYUN_REGION", "cn-shanghai-3")
- monkeypatch.delenv("KSADK_GLOBAL_CONFIG_ENV_KEYS", raising=False)
- monkeypatch.setattr(
- cmd_dashboard,
- "load_state",
- lambda _cwd: {"agent_id": "ar-test", "region": "pre-online"},
- )
-
- async def _fake_resolve(region, primary_ref, fallback_ref):
- captured["region"] = region
- return await _fake_resolve_agent_detail(region, primary_ref, fallback_ref)
-
- monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve)
- monkeypatch.setattr(cmd_dashboard, "_create_dashboard_access_link", _fake_create_access_link)
- monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda _url: None)
-
- result = runner.invoke(cmd_dashboard.dashboard, ["open"])
-
- assert result.exit_code == 0, result.output
- assert captured["region"] == "cn-shanghai-3"
-
-
-def test_dashboard_open_rejects_path_with_embedded_option(monkeypatch):
- runner = CliRunner()
-
- async def _unexpected_resolve(*_args, **_kwargs):
- raise AssertionError("dashboard open should reject malformed --path before remote lookup")
-
- monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _unexpected_resolve)
-
- result = runner.invoke(
- cmd_dashboard.dashboard,
- ["open", "ar-test", "--path", "/chat--share", "--expires-seconds", "3600", "--no-open"],
- )
-
- assert result.exit_code != 0
- assert "--path 的值疑似拼入了 `--share`" in result.output
- assert "agentengine dashboard open --path /chat --share" in result.output
-
-
-def test_dashboard_remote_open_uses_hosted_chat_path_even_with_custom_ui_state(monkeypatch):
- runner = CliRunner()
- captured = {}
-
- monkeypatch.setattr(
- cmd_dashboard,
- "load_state",
- lambda _cwd: {
- "ui_profile": "custom",
- "ui_path": "/custom-chat",
- "ui_url": "https://ui.example.com/custom-chat/",
- },
- )
- monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve_agent_detail)
-
- async def _fake_create(*_args, **kwargs):
- captured.update(kwargs)
- return await _fake_create_access_link()
-
- monkeypatch.setattr(cmd_dashboard, "_create_dashboard_access_link", _fake_create)
- monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda _url: None)
-
- result = runner.invoke(cmd_dashboard.dashboard, ["open", "ar-test"])
-
- assert result.exit_code == 0, result.output
- assert captured["path"] == "/custom-chat"
-
-
-def test_dashboard_open_uses_custom_ui_path_for_custom_ui_state(monkeypatch):
- runner = CliRunner()
- captured = {}
-
- monkeypatch.setattr(
- cmd_dashboard,
- "load_state",
- lambda _cwd: {
- "ui_profile": "custom",
- "ui_path": "/research",
- "ui_url": None,
- },
- )
- monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve_agent_detail)
-
- async def _fake_create(*_args, **kwargs):
- captured.update(kwargs)
- return await _fake_create_access_link()
-
- monkeypatch.setattr(cmd_dashboard, "_create_dashboard_access_link", _fake_create)
- monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda _url: None)
-
- result = runner.invoke(cmd_dashboard.dashboard, ["open", "ar-test"])
-
- assert result.exit_code == 0, result.output
- assert captured["path"] == "/research"
-
-
-def test_dashboard_open_resolves_openclaw_state_from_cwd(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- opened = {}
- captured = {}
-
- state_path = tmp_path / ".agentengine.state"
- state_path.write_text(
- "agent_id: ar-openclaw-1\n"
- "name: demo-openclaw\n"
- "type: openclaw\n",
- encoding="utf-8",
- )
-
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr(
- cmd_dashboard,
- "load_state",
- lambda _cwd: {"agent_id": "ar-openclaw-1", "name": "demo-openclaw", "type": "openclaw"},
- )
-
- async def _fake_resolve(_region, primary_ref, fallback_ref):
- assert primary_ref.value == "ar-openclaw-1"
- assert primary_ref.source == "state.agent_id"
- assert fallback_ref is None
- return (
- {
- "agent_id": "ar-openclaw-1",
- "name": "demo-openclaw",
- "framework": "openclaw",
- "endpoint": "http://demo.example.com",
- },
- primary_ref,
- False,
- )
-
- class _FakeGateway:
- async def build_access_info(self, *, path="/", expires_seconds=None, link_type="private", force_new=False):
- captured.update(
- {
- "path": path,
- "expires_seconds": expires_seconds,
- "link_type": link_type,
- "force_new": force_new,
- }
- )
- return type(
- "Info",
- (),
- {
- "access_url": "http://demo.example.com/s/gateway-1",
- "ws_url": "ws://demo.example.com/",
- "link_id": "gateway-1",
- "expires_at": None,
- },
- )()
-
- async def close(self):
- return None
-
- monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve)
- monkeypatch.setattr(cmd_dashboard, "_build_openclaw_gateway_client", lambda _region, _detail: _FakeGateway())
- monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda url: opened.setdefault("url", url))
-
- result = runner.invoke(cmd_dashboard.dashboard, ["open"])
-
- assert result.exit_code == 0, result.output
- assert opened == {}
- assert captured == {"path": None, "expires_seconds": None, "link_type": "private", "force_new": False}
- assert "未显式指定 Agent,使用 .agentengine.state 的 agent_id: ar-openclaw-1" in result.output
- assert "http://demo.example.com/s/gateway-1" in result.output
-
-
-def test_dashboard_open_omits_path_for_hermes_generic_access_link(monkeypatch):
- runner = CliRunner()
- captured = {}
-
- async def _fake_resolve(_region, primary_ref, fallback_ref):
- return (
- {
- "agent_id": "ar-hermes-1",
- "name": "demo-hermes",
- "framework": "hermes",
- "endpoint": "http://hermes.example.com",
- },
- primary_ref,
- False,
- )
-
- async def _fake_create(*_args, **kwargs):
- captured.update(kwargs)
- return await _fake_create_access_link()
-
- monkeypatch.setattr(cmd_dashboard, "load_state", lambda _cwd: {})
- monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve)
- monkeypatch.setattr(cmd_dashboard, "_create_dashboard_access_link", _fake_create)
- monkeypatch.setattr(
- cmd_dashboard,
- "_create_openclaw_gateway_access_link",
- lambda **_kwargs: (_ for _ in ()).throw(AssertionError("Hermes must not use OpenClaw gateway link")),
- )
- monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda _url: None)
-
- result = runner.invoke(cmd_dashboard.dashboard, ["open", "ar-hermes-1"])
-
- assert result.exit_code == 0, result.output
- assert captured["path"] is None
- assert captured["expires_seconds"] is None
-
-
-def test_dashboard_open_force_new_passes_through(monkeypatch):
- runner = CliRunner()
- captured = {}
-
- async def _fake_resolve(_region, primary_ref, fallback_ref):
- return (
- {
- "agent_id": "ar-hermes-1",
- "name": "demo-hermes",
- "framework": "hermes",
- "endpoint": "http://hermes.example.com",
- },
- primary_ref,
- False,
- )
-
- async def _fake_create(*_args, **kwargs):
- captured.update(kwargs)
- return await _fake_create_access_link()
-
- monkeypatch.setattr(cmd_dashboard, "load_state", lambda _cwd: {})
- monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve)
- monkeypatch.setattr(cmd_dashboard, "_create_dashboard_access_link", _fake_create)
- monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda _url: None)
-
- result = runner.invoke(
- cmd_dashboard.dashboard,
- ["open", "ar-hermes-1", "--path", "/", "--share", "--expires-seconds", "86400", "--force-new", "--no-open"],
- )
-
- assert result.exit_code == 0, result.output
- assert captured["path"] == "/"
- assert captured["link_type"] == "share"
- assert captured["expires_seconds"] == 86400
- assert captured["force_new"] is True
-
-
-def test_dashboard_open_private_expires_supports_one_year(monkeypatch):
- runner = CliRunner()
- captured = {}
-
- async def _fake_create(*_args, **kwargs):
- captured.update(kwargs)
- return await _fake_create_access_link()
-
- monkeypatch.setattr(cmd_dashboard, "load_state", lambda _cwd: {})
- monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve_agent_detail)
- monkeypatch.setattr(cmd_dashboard, "_create_dashboard_access_link", _fake_create)
- monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda _url: None)
-
- result = runner.invoke(
- cmd_dashboard.dashboard,
- ["open", "ar-test", "--expires-seconds", "31536000", "--no-open"],
- )
-
- assert result.exit_code == 0, result.output
- assert captured["link_type"] == "private"
- assert captured["expires_seconds"] == 31536000
-
-
-def test_dashboard_open_routes_openclaw_to_gateway_short_link(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- opened = {}
- captured = {}
-
- (tmp_path / ".agentengine.state").write_text(
- "agent_id: ar-openclaw-1\n"
- "name: demo-openclaw\n"
- "type: openclaw\n",
- encoding="utf-8",
- )
-
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr(
- cmd_dashboard,
- "load_state",
- lambda _cwd: {"agent_id": "ar-openclaw-1", "name": "demo-openclaw", "type": "openclaw"},
- )
-
- async def _fake_resolve(_region, primary_ref, fallback_ref):
- return (
- {
- "agent_id": "ar-openclaw-1",
- "name": "demo-openclaw",
- "framework": "-",
- "endpoint": "http://demo.example.com",
- },
- primary_ref,
- False,
- )
-
- class _FakeGateway:
- async def build_access_info(self, *, path="/", expires_seconds=None, link_type="private", force_new=False):
- captured.update(
- {
- "path": path,
- "expires_seconds": expires_seconds,
- "link_type": link_type,
- "force_new": force_new,
- }
- )
- return type(
- "Info",
- (),
- {
- "access_url": "http://demo.example.com/s/gateway-1",
- "ws_url": "ws://demo.example.com/",
- "link_id": "gateway-1",
- "expires_at": None,
- },
- )()
-
- async def close(self):
- return None
-
- monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve)
- monkeypatch.setattr(cmd_dashboard, "_build_openclaw_gateway_client", lambda _region, _detail: _FakeGateway())
- monkeypatch.setattr(
- cmd_dashboard,
- "_create_dashboard_access_link",
- lambda **_kwargs: (_ for _ in ()).throw(AssertionError("should not create generic dashboard link")),
- )
- monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda url: opened.setdefault("url", url))
-
- result = runner.invoke(
- cmd_dashboard.dashboard,
- ["--share", "--expires-seconds", "0", "--no-open"],
- )
-
- assert result.exit_code == 0, result.output
- assert opened == {}
- assert captured == {"path": None, "expires_seconds": 0, "link_type": "share", "force_new": False}
- assert "http://demo.example.com/s/gateway-1" in result.output
-
-
-def test_dashboard_open_passes_custom_path_to_openclaw_gateway_link(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- captured = {}
-
- (tmp_path / ".agentengine.state").write_text(
- "agent_id: ar-openclaw-1\n"
- "name: demo-openclaw\n"
- "type: openclaw\n",
- encoding="utf-8",
- )
-
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr(
- cmd_dashboard,
- "load_state",
- lambda _cwd: {"agent_id": "ar-openclaw-1", "name": "demo-openclaw", "type": "openclaw"},
- )
-
- async def _fake_resolve(_region, primary_ref, fallback_ref):
- return (
- {
- "agent_id": "ar-openclaw-1",
- "name": "demo-openclaw",
- "framework": "openclaw",
- "endpoint": "http://demo.example.com",
- },
- primary_ref,
- False,
- )
-
- class _FakeGateway:
- async def build_access_info(self, *, path="/", expires_seconds=None, link_type="private", force_new=False):
- captured.update(
- {
- "path": path,
- "expires_seconds": expires_seconds,
- "link_type": link_type,
- "force_new": force_new,
- }
- )
- return type(
- "Info",
- (),
- {
- "access_url": "http://demo.example.com/s/gateway-chat",
- "ws_url": "ws://demo.example.com/",
- "link_id": "gateway-chat",
- "expires_at": None,
- },
- )()
-
- async def close(self):
- return None
-
- monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve)
- monkeypatch.setattr(cmd_dashboard, "_build_openclaw_gateway_client", lambda _region, _detail: _FakeGateway())
-
- result = runner.invoke(
- cmd_dashboard.dashboard,
- ["open", "--share", "--path", "/chat", "--expires-seconds", "0", "--no-open"],
- )
-
- assert result.exit_code == 0, result.output
- assert captured == {"path": "/chat", "expires_seconds": 0, "link_type": "share", "force_new": False}
-
-
-def test_dashboard_open_passes_force_new_to_openclaw_gateway_link(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- captured = {}
-
- (tmp_path / ".agentengine.state").write_text(
- "agent_id: ar-openclaw-1\n"
- "name: demo-openclaw\n"
- "type: openclaw\n",
- encoding="utf-8",
- )
-
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr(
- cmd_dashboard,
- "load_state",
- lambda _cwd: {"agent_id": "ar-openclaw-1", "name": "demo-openclaw", "type": "openclaw"},
- )
-
- async def _fake_resolve(_region, primary_ref, fallback_ref):
- return (
- {
- "agent_id": "ar-openclaw-1",
- "name": "demo-openclaw",
- "framework": "openclaw",
- "endpoint": "http://demo.example.com",
- },
- primary_ref,
- False,
- )
-
- class _FakeGateway:
- async def build_access_info(self, *, path="/", expires_seconds=None, link_type="private", force_new=False):
- captured.update(
- {
- "path": path,
- "expires_seconds": expires_seconds,
- "link_type": link_type,
- "force_new": force_new,
- }
- )
- return type(
- "Info",
- (),
- {
- "access_url": "http://demo.example.com/s/gateway-2",
- "ws_url": "ws://demo.example.com/",
- "link_id": "gateway-2",
- "expires_at": None,
- },
- )()
-
- async def close(self):
- return None
-
- monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve)
- monkeypatch.setattr(cmd_dashboard, "_build_openclaw_gateway_client", lambda _region, _detail: _FakeGateway())
-
- result = runner.invoke(
- cmd_dashboard.dashboard,
- ["open", "--share", "--expires-seconds", "0", "--force-new", "--no-open"],
- )
-
- assert result.exit_code == 0, result.output
- assert captured == {"path": None, "expires_seconds": 0, "link_type": "share", "force_new": True}
- assert "http://demo.example.com/s/gateway-2" in result.output
-
-
-def test_dashboard_supports_share_subcommand(monkeypatch):
- runner = CliRunner()
-
- async def _fake_list(*_args, **_kwargs):
- return {"total": 1, "links": [{"link_id": "abc123", "link_type": "share", "status": "active", "path": "/", "expires_at": None, "created_at": "2026-03-09T00:00:00Z"}]}
-
- monkeypatch.setattr(cmd_dashboard, "load_state", lambda _cwd: {})
- monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve_agent_detail)
- monkeypatch.setattr(cmd_dashboard, "_list_dashboard_access_links", _fake_list)
-
- result = runner.invoke(cmd_dashboard.dashboard, ["share", "list", "ar-test"])
- assert result.exit_code == 0, result.output
- assert "abc123" in result.output
-
-
-def test_dashboard_list_is_no_longer_ambiguous():
- runner = CliRunner()
-
- result = runner.invoke(cmd_dashboard.dashboard, ["list"])
-
- assert result.exit_code != 0
- assert "dashboard open" in result.output
- assert "dashboard share list" in result.output
-
-
-def test_dashboard_help_shows_canonical_subcommands_only():
- runner = CliRunner()
-
- result = runner.invoke(cmd_dashboard.dashboard, ["--help"])
-
- assert result.exit_code == 0, result.output
- assert "open" in result.output
- assert "share" in result.output
- assert "--agent" not in result.output
-
-
-def test_dashboard_direct_invocation_resets_output_mode_after_json(monkeypatch):
- runner = CliRunner()
-
- monkeypatch.setattr(cmd_dashboard, "load_state", lambda _cwd: {})
- monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve_agent_detail)
- monkeypatch.setattr(cmd_dashboard, "_create_dashboard_access_link", _fake_create_access_link)
- monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda _url: None)
-
- json_result = runner.invoke(cmd_dashboard.dashboard, ["open", "ar-test", "--output", "json"])
- assert json_result.exit_code == 0, json_result.output
- assert json.loads(json_result.output)["ok"] is True
-
- pretty_result = runner.invoke(cmd_dashboard.dashboard, ["ar-test"])
- assert pretty_result.exit_code == 0, pretty_result.output
- assert not pretty_result.output.lstrip().startswith("{")
- assert "Dashboard 打开结果" in pretty_result.output
-
-
-def test_dashboard_open_json_uses_server_returned_link_type(monkeypatch):
- runner = CliRunner()
-
- monkeypatch.setattr(cmd_dashboard, "load_state", lambda _cwd: {})
- monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve_agent_detail)
-
- async def _fake_create_access_link_with_private_type(*_args, **_kwargs):
- return {
- "link_id": "lnk-1",
- "link_type": "private",
- "access_url": "http://demo.example.com/s/lnk-1",
- "expires_at": "2026-03-09T00:00:00Z",
- }
-
- monkeypatch.setattr(cmd_dashboard, "_create_dashboard_access_link", _fake_create_access_link_with_private_type)
- monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda _url: None)
-
- result = runner.invoke(cmd_dashboard.dashboard, ["open", "ar-test", "--share", "--output", "json"])
-
- assert result.exit_code == 0, result.output
- payload = json.loads(result.output)
- assert payload["result"]["type"] == "private"
diff --git a/tests/test_cmd_deploy_no_cache.py b/tests/test_cmd_deploy_no_cache.py
deleted file mode 100644
index 09ec31be..00000000
--- a/tests/test_cmd_deploy_no_cache.py
+++ /dev/null
@@ -1,521 +0,0 @@
-import asyncio
-from pathlib import Path
-
-import pytest
-from click.testing import CliRunner
-
-from ksadk.cli import cmd_deploy
-from ksadk.cli.error_utils import CLIError
-from ksadk.deployment.base import DeployResult, DeployStatus, PackageInfo
-
-
-class _FakeDetectionType:
- value = "langgraph"
-
-
-class _FakeDetectionResult:
- type = _FakeDetectionType()
- name = "langgraph"
- entry_point = "agent.py"
-
-
-class _FakeProvider:
- def __init__(self):
- self.calls = []
- self.package_metadata_file_exists = None
- self.last_target = None
-
- async def validate_config(self, _target):
- self.last_target = _target
- self.calls.append("validate")
- return True, ""
-
- async def package(self, project_dir, _detection_result, _config):
- self.calls.append("package")
- metadata_file = Path(project_dir) / ".agentengine" / "build-metadata.json"
- self.package_metadata_file_exists = metadata_file.exists()
- return PackageInfo(
- name="demo-agent",
- framework="langgraph",
- build_dir=str(Path(project_dir) / ".agentengine" / "build"),
- project_dir=str(project_dir),
- metadata={},
- )
-
- async def build(self, package_info, _target):
- self.calls.append("build")
- package_info.metadata["ks3_path"] = "ks3://bucket/agents/demo-agent/code_20260320170000.zip"
- return package_info
-
- async def deploy(self, package_info, _target):
- self.calls.append("deploy")
- assert package_info.metadata.get("ks3_path")
- return DeployResult(
- status=DeployStatus.DEPLOYING,
- agent_id="ar-demo",
- agent_name="demo-agent",
- endpoint="http://demo-endpoint",
- message="ok",
- )
-
-
-def test_deploy_no_cache_triggers_build_and_clears_metadata(tmp_path: Path, monkeypatch):
- provider = _FakeProvider()
- metadata_dir = tmp_path / ".agentengine"
- metadata_dir.mkdir(parents=True, exist_ok=True)
- (metadata_dir / "build-metadata.json").write_text('{"metadata":{"ks3_path":"ks3://old/path.zip"}}', encoding="utf-8")
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})())
- monkeypatch.setattr("ksadk.cli.cmd_deploy._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"})
- monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- asyncio.run(
- cmd_deploy._deploy_async(
- agent_dir=str(tmp_path),
- target="serverless",
- name=None,
- region="cn-beijing-6",
- account_id="2000003485",
- artifact_type="Code",
- namespace="default",
- port=8000,
- registry=None,
- ks3_path=None,
- ks3_bucket=None,
- image=None,
- ui_profile=None,
- ui_path=None,
- ui_url=None,
- observability=True,
- push=False,
- no_cache=True,
- no_version=True,
- auto_rollback=False,
- dry_run=False,
- )
- )
-
- assert provider.package_metadata_file_exists is False
- assert provider.calls == ["validate", "package", "build", "deploy"]
-
-
-def test_deploy_repackage_triggers_build_and_clears_cached_artifact_metadata(tmp_path: Path, monkeypatch):
- provider = _FakeProvider()
- metadata_dir = tmp_path / ".agentengine"
- metadata_dir.mkdir(parents=True, exist_ok=True)
- (metadata_dir / "build-metadata.json").write_text('{"metadata":{"ks3_path":"ks3://old/path.zip"}}', encoding="utf-8")
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})())
- monkeypatch.setattr("ksadk.cli.cmd_deploy._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"})
- monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- asyncio.run(
- cmd_deploy._deploy_async(
- agent_dir=str(tmp_path),
- target="serverless",
- name=None,
- region="cn-beijing-6",
- account_id="2000003485",
- artifact_type="Code",
- namespace="default",
- port=8000,
- registry=None,
- ks3_path=None,
- ks3_bucket=None,
- image=None,
- ui_profile=None,
- ui_path=None,
- ui_url=None,
- observability=True,
- push=False,
- no_cache=False,
- repackage=True,
- no_version=True,
- auto_rollback=False,
- dry_run=False,
- )
- )
-
- assert provider.package_metadata_file_exists is False
- assert provider.last_target.extra["repackage"] is True
- assert provider.last_target.extra["no_cache"] is False
- assert provider.calls == ["validate", "package", "build", "deploy"]
-
-
-def test_deploy_no_cache_warns_when_explicit_ks3_path_is_supplied(tmp_path: Path, monkeypatch, capsys):
- provider = _FakeProvider()
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})())
- monkeypatch.setattr("ksadk.cli.cmd_deploy._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"})
- monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- asyncio.run(
- cmd_deploy._deploy_async(
- agent_dir=str(tmp_path),
- target="serverless",
- name=None,
- region="cn-beijing-6",
- account_id="2000003485",
- artifact_type="Code",
- namespace="default",
- port=8000,
- registry=None,
- ks3_path="ks3://bucket/agents/demo-agent/code_manual.zip",
- ks3_bucket=None,
- image=None,
- ui_profile=None,
- ui_path=None,
- ui_url=None,
- observability=True,
- push=False,
- no_cache=True,
- no_version=True,
- auto_rollback=False,
- dry_run=False,
- )
- )
-
- out = capsys.readouterr().out
- assert "已显式指定 --ks3-path" in out
- assert provider.calls == ["validate", "package", "deploy"]
-
-
-def test_deploy_reads_network_config_from_agentengine_yaml(tmp_path: Path, monkeypatch):
- provider = _FakeProvider()
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})())
- monkeypatch.setattr(
- "ksadk.cli.cmd_deploy._load_config",
- lambda *_args, **_kwargs: {
- "name": "demo-agent",
- "network": {
- "enable_public_access": False,
- "enable_vpc_access": True,
- "vpc_id": "vpc-demo",
- "subnet_id": "subnet-demo",
- "security_group_id": "sg-demo",
- "availability_zone": "cn-beijing-6a",
- },
- },
- )
- monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- asyncio.run(
- cmd_deploy._deploy_async(
- agent_dir=str(tmp_path),
- target="serverless",
- name=None,
- region="cn-beijing-6",
- account_id="2000003485",
- artifact_type="Code",
- namespace="default",
- port=8000,
- registry=None,
- ks3_path="ks3://bucket/agents/demo-agent/code_manual.zip",
- ks3_bucket=None,
- image=None,
- ui_profile=None,
- ui_path=None,
- ui_url=None,
- observability=True,
- push=False,
- no_cache=False,
- no_version=True,
- auto_rollback=False,
- dry_run=False,
- )
- )
-
- assert provider.last_target is not None
- assert provider.last_target.network.enable_vpc_access is True
- assert provider.last_target.network.vpc_id == "vpc-demo"
- assert provider.last_target.network.subnet_id == "subnet-demo"
- assert provider.last_target.network.security_group_id == "sg-demo"
- assert provider.last_target.network.availability_zone == "cn-beijing-6a"
-
-
-def test_deploy_cli_network_options_override_config(tmp_path: Path, monkeypatch):
- provider = _FakeProvider()
- runner = CliRunner()
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})())
- monkeypatch.setattr(
- "ksadk.cli.cmd_deploy._load_config",
- lambda *_args, **_kwargs: {
- "name": "demo-agent",
- "network": {
- "enable_public_access": True,
- "enable_vpc_access": True,
- "vpc_id": "vpc-config",
- "subnet_id": "subnet-config",
- "security_group_id": "sg-config",
- "availability_zone": "cn-beijing-6a",
- },
- },
- )
- monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- result = runner.invoke(
- cmd_deploy.deploy,
- [
- str(tmp_path),
- "--ks3-path",
- "ks3://bucket/agents/demo-agent/code_manual.zip",
- "--disable-public-access",
- "--enable-vpc-access",
- "--vpc-id",
- "vpc-cli",
- "--subnet-id",
- "subnet-cli",
- "--security-group-id",
- "sg-cli",
- "--availability-zone",
- "cn-beijing-6b",
- "--no-version",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert provider.last_target is not None
- assert provider.last_target.network.enable_public_access is False
- assert provider.last_target.network.enable_vpc_access is True
- assert provider.last_target.network.vpc_id == "vpc-cli"
- assert provider.last_target.network.subnet_id == "subnet-cli"
- assert provider.last_target.network.security_group_id == "sg-cli"
- assert provider.last_target.network.availability_zone == "cn-beijing-6b"
-
-
-def test_deploy_cli_forwards_explicit_env_and_env_file(tmp_path: Path, monkeypatch):
- provider = _FakeProvider()
- runner = CliRunner()
- env_file = tmp_path / "runtime-env.json"
- env_file.write_text(
- '{"APP_MODE":"file","FILE_ONLY":"1","OVERRIDE_ME":"from-file"}',
- encoding="utf-8",
- )
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})())
- monkeypatch.setattr("ksadk.cli.cmd_deploy._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"})
- monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- result = runner.invoke(
- cmd_deploy.deploy,
- [
- str(tmp_path),
- "--ks3-path",
- "ks3://bucket/agents/demo-agent/code_manual.zip",
- "--env-file",
- str(env_file),
- "--env",
- "OVERRIDE_ME=from-cli",
- "--env",
- "CLI_ONLY=yes",
- "--no-version",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert provider.last_target is not None
- assert provider.last_target.extra["env_vars"] == {
- "APP_MODE": "file",
- "FILE_ONLY": "1",
- "OVERRIDE_ME": "from-cli",
- "CLI_ONLY": "yes",
- }
-
-
-def test_deploy_rejects_hermes_and_openclaw_frameworks(tmp_path: Path, monkeypatch):
- runner = CliRunner()
-
- monkeypatch.setattr(
- "ksadk.detection.FrameworkDetector",
- lambda *_args, **_kwargs: type(
- "D",
- (),
- {"detect": lambda self: type("R", (), {"type": type("T", (), {"value": "hermes"})(), "name": "hermes"})()},
- )(),
- )
- monkeypatch.setattr("ksadk.cli.cmd_deploy._load_config", lambda *_args, **_kwargs: {"name": "demo-hermes"})
-
- result = runner.invoke(
- cmd_deploy.deploy,
- [
- str(tmp_path),
- "--target",
- "serverless",
- "--ks3-path",
- "ks3://bucket/agents/demo-hermes/code.zip",
- ],
- )
-
- assert result.exit_code != 0
- assert "Hermes 项目请使用" in str(result.exception)
-
- monkeypatch.setattr(
- "ksadk.detection.FrameworkDetector",
- lambda *_args, **_kwargs: type(
- "D",
- (),
- {"detect": lambda self: type("R", (), {"type": type("T", (), {"value": "openclaw"})(), "name": "openclaw"})()},
- )(),
- )
- monkeypatch.setattr("ksadk.cli.cmd_deploy._load_config", lambda *_args, **_kwargs: {"name": "demo-openclaw"})
-
- result = runner.invoke(
- cmd_deploy.deploy,
- [
- str(tmp_path),
- "--target",
- "serverless",
- "--ks3-path",
- "ks3://bucket/agents/demo-openclaw/code.zip",
- ],
- )
-
- assert result.exit_code != 0
- assert "OpenClaw 项目请使用" in str(result.exception)
-
-
-def test_deploy_rejects_incomplete_vpc_network_config(tmp_path: Path, monkeypatch):
- provider = _FakeProvider()
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})())
- monkeypatch.setattr(
- "ksadk.cli.cmd_deploy._load_config",
- lambda *_args, **_kwargs: {
- "name": "demo-agent",
- "network": {
- "enable_vpc_access": True,
- "vpc_id": "vpc-demo",
- },
- },
- )
- monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- with pytest.raises(CLIError) as exc_info:
- asyncio.run(
- cmd_deploy._deploy_async(
- agent_dir=str(tmp_path),
- target="serverless",
- name=None,
- region="cn-beijing-6",
- account_id="2000003485",
- artifact_type="Code",
- namespace="default",
- port=8000,
- registry=None,
- ks3_path="ks3://bucket/agents/demo-agent/code_manual.zip",
- ks3_bucket=None,
- image=None,
- ui_profile=None,
- ui_path=None,
- ui_url=None,
- observability=True,
- push=False,
- no_cache=False,
- no_version=True,
- auto_rollback=False,
- dry_run=False,
- )
- )
-
- assert exc_info.value.code == "validation_error"
- assert "VpcId、SubnetId、SecurityGroupId" in exc_info.value.message
-
-
-def test_deploy_network_ids_imply_vpc_access(tmp_path: Path, monkeypatch):
- provider = _FakeProvider()
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})())
- monkeypatch.setattr(
- "ksadk.cli.cmd_deploy._load_config",
- lambda *_args, **_kwargs: {
- "name": "demo-agent",
- "deploy": {
- "network": {
- "vpc_id": "vpc-demo",
- "subnet_id": "subnet-demo",
- "security_group_id": "sg-demo",
- },
- },
- },
- )
- monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- asyncio.run(
- cmd_deploy._deploy_async(
- agent_dir=str(tmp_path),
- target="serverless",
- name=None,
- region="cn-beijing-6",
- account_id="2000003485",
- artifact_type="Code",
- namespace="default",
- port=8000,
- registry=None,
- ks3_path="ks3://bucket/agents/demo-agent/code_manual.zip",
- ks3_bucket=None,
- image=None,
- ui_profile=None,
- ui_path=None,
- ui_url=None,
- observability=True,
- push=False,
- no_cache=False,
- no_version=True,
- auto_rollback=False,
- dry_run=False,
- )
- )
-
- assert provider.last_target is not None
- assert provider.last_target.network.enable_vpc_access is True
-
-
-def test_deploy_reads_ui_config_from_agentengine_yaml_when_cli_not_set(tmp_path: Path, monkeypatch):
- provider = _FakeProvider()
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})())
- monkeypatch.setattr(
- "ksadk.cli.cmd_deploy._load_config",
- lambda *_args, **_kwargs: {
- "name": "demo-agent",
- "ui": {
- "profile": "custom",
- "path": "/custom-chat",
- "url": "https://ui.example.com/custom-chat",
- },
- },
- )
- monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- asyncio.run(
- cmd_deploy._deploy_async(
- agent_dir=str(tmp_path),
- target="serverless",
- name=None,
- region="cn-beijing-6",
- account_id="2000003485",
- artifact_type="Code",
- namespace="default",
- port=8000,
- registry=None,
- ks3_path="ks3://bucket/agents/demo-agent/code_manual.zip",
- ks3_bucket=None,
- image=None,
- ui_profile=None,
- ui_path=None,
- ui_url=None,
- observability=True,
- push=False,
- no_cache=False,
- no_version=True,
- auto_rollback=False,
- dry_run=False,
- )
- )
-
- assert provider.last_target is not None
- assert provider.last_target.extra["ui_profile"] == "custom"
- assert provider.last_target.extra["ui_path"] == "/custom-chat"
- assert provider.last_target.extra["ui_url"] == "https://ui.example.com/custom-chat"
diff --git a/tests/test_cmd_files.py b/tests/test_cmd_files.py
deleted file mode 100644
index 52146ab4..00000000
--- a/tests/test_cmd_files.py
+++ /dev/null
@@ -1,942 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-import json
-from pathlib import Path
-
-import pytest
-from click.testing import CliRunner
-
-from ksadk.api import AgentEngineAPIError
-from ksadk.cli import _register_commands, cli
-
-
-@pytest.fixture(autouse=True)
-def _isolate_region_env(monkeypatch):
- monkeypatch.delenv("KSYUN_REGION", raising=False)
-
-
-class _FakeFilesClient:
- init_calls: list[dict] = []
- list_calls: list[dict] = []
- upload_calls: list[dict] = []
- download_calls: list[dict] = []
- delete_calls: list[dict] = []
- list_results: dict[str, object] = {}
- download_payloads: dict[str, bytes] = {}
- workspace_health: dict[str, object] = {
- "root": "workspace",
- "workspace_path": "/home/node/.hermes/workspace",
- }
-
- def __init__(self, *args, **kwargs):
- self.kwargs = kwargs
- self.__class__.init_calls.append(kwargs)
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
- async def list_workspace_files(self, **kwargs):
- self.__class__.list_calls.append(kwargs)
- path = kwargs["path"]
- if path in self.__class__.list_results:
- result = self.__class__.list_results[path]
- if isinstance(result, Exception):
- raise result
- return result
- return {
- "root": "workspace",
- "path": kwargs["path"],
- "entries": [
- {"name": "inputs", "path": "inputs", "type": "directory"},
- {"name": "report.txt", "path": "report.txt", "type": "file", "size_bytes": 7},
- ],
- }
-
- async def upload_workspace_file(self, **kwargs):
- self.__class__.upload_calls.append(kwargs)
- return {"entry": {"path": kwargs["remote_path"], "type": "file", "size_bytes": 7}}
-
- async def download_workspace_file(self, **kwargs):
- self.__class__.download_calls.append(kwargs)
- payload = self.__class__.download_payloads.get(kwargs["remote_path"])
- if payload is not None:
- return payload
- return b"payload"
-
- async def delete_workspace_file(self, **kwargs):
- self.__class__.delete_calls.append(kwargs)
- return {"deleted": True}
-
- async def get_workspace_health(self, **kwargs):
- return dict(self.__class__.workspace_health)
-
-
-def _reset_fake_files_client() -> None:
- _FakeFilesClient.init_calls = []
- _FakeFilesClient.list_calls = []
- _FakeFilesClient.upload_calls = []
- _FakeFilesClient.download_calls = []
- _FakeFilesClient.delete_calls = []
- _FakeFilesClient.list_results = {}
- _FakeFilesClient.download_payloads = {}
- _FakeFilesClient.workspace_health = {
- "root": "workspace",
- "workspace_path": "/home/node/.hermes/workspace",
- }
-
-
-def test_files_list_command_supports_json_output(monkeypatch):
- from ksadk.cli import cmd_files
-
- _reset_fake_files_client()
- _register_commands()
- monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient)
-
- runner = CliRunner()
- result = runner.invoke(
- cli,
- [
- "--output",
- "json",
- "files",
- "list",
- "--agent",
- "demo-agent",
- "--path",
- "docs",
- "--region",
- "cn-beijing-6",
- ],
- )
-
- assert result.exit_code == 0, result.output
- payload = json.loads(result.output)
- assert payload["ok"] is True
- assert payload["action"] == "list"
- assert payload["workspace_root"] == "workspace"
- assert payload["path"] == "docs"
- assert payload["workspace_display_path"] == "workspace:/docs"
- assert payload["workspace_real_root"] == "/home/node/.hermes/workspace"
- assert payload["workspace_real_path"] == "/home/node/.hermes/workspace/docs"
- assert payload["summary"] == {
- "entry_count": 2,
- "directory_count": 1,
- "file_count": 1,
- }
- assert payload["entries"][0]["path"] == "inputs"
- assert payload["entries"][0]["display_path"] == "workspace:/inputs"
- assert payload["entries"][0]["real_path"] == "/home/node/.hermes/workspace/inputs"
- assert payload["entries"][1]["size_human"] == "7 B"
- assert _FakeFilesClient.list_calls == [
- {"agent_id": "demo-agent", "path": "docs", "recursive": False}
- ]
- assert _FakeFilesClient.init_calls == [{"region": "cn-beijing-6"}]
-
-
-def test_files_list_command_supports_direct_runtime_access(monkeypatch):
- from ksadk.cli import cmd_files
-
- _reset_fake_files_client()
- _register_commands()
- monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient)
-
- runner = CliRunner()
- result = runner.invoke(
- cli,
- [
- "--output",
- "json",
- "files",
- "list",
- "--endpoint",
- "http://127.0.0.1:18080",
- "--api-key",
- "ak-direct-demo",
- "--path",
- "docs",
- ],
- )
-
- assert result.exit_code == 0, result.output
- payload = json.loads(result.output)
- assert payload["path"] == "docs"
- assert _FakeFilesClient.list_calls == [
- {
- "agent_id": None,
- "path": "docs",
- "recursive": False,
- "endpoint": "http://127.0.0.1:18080",
- "api_key": "ak-direct-demo",
- }
- ]
- assert _FakeFilesClient.init_calls == [{"region": "cn-beijing-6"}]
-
-
-def test_files_list_command_accepts_workspace_style_absolute_path(monkeypatch):
- from ksadk.cli import cmd_files
-
- _reset_fake_files_client()
- _register_commands()
- monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient)
-
- runner = CliRunner()
- result = runner.invoke(
- cli,
- [
- "--output",
- "json",
- "files",
- "list",
- "--agent",
- "demo-agent",
- "--path",
- "/tmp",
- ],
- )
-
- assert result.exit_code == 0, result.output
- payload = json.loads(result.output)
- assert payload["path"] == "tmp"
- assert payload["workspace_display_path"] == "workspace:/tmp"
- assert _FakeFilesClient.list_calls == [
- {"agent_id": "demo-agent", "path": "tmp", "recursive": False}
- ]
-
-
-def test_files_list_command_prefers_openclaw_state_runtime_access_when_api_key_is_ready(
- monkeypatch,
- tmp_path: Path,
-):
- from ksadk.cli import cmd_files
-
- _reset_fake_files_client()
- _FakeFilesClient.workspace_health = {
- "root": "workspace",
- "workspace_path": "/home/node/.openclaw/workspace",
- }
- _register_commands()
- monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient)
- monkeypatch.chdir(tmp_path)
- (tmp_path / ".agentengine.state").write_text(
- "\n".join(
- [
- "type: openclaw",
- "framework: openclaw",
- "agent_id: ar-openclaw-1",
- "name: demo-openclaw",
- "endpoint: https://openclaw.example.com",
- "api_key: ak-openclaw",
- "region: pre-online",
- "",
- ]
- ),
- encoding="utf-8",
- )
-
- runner = CliRunner()
- result = runner.invoke(
- cli,
- [
- "--output",
- "json",
- "files",
- "list",
- "--path",
- "docs",
- ],
- )
-
- assert result.exit_code == 0, result.output
- payload = json.loads(result.output)
- assert payload["path"] == "docs"
- assert payload["workspace_real_root"] == "/home/node/.openclaw/workspace"
- assert payload["workspace_real_path"] == "/home/node/.openclaw/workspace/docs"
- assert _FakeFilesClient.init_calls == [{"region": "pre-online"}]
- assert _FakeFilesClient.list_calls == [
- {
- "agent_id": "ar-openclaw-1",
- "path": "docs",
- "recursive": False,
- "endpoint": "https://openclaw.example.com",
- "api_key": "ak-openclaw",
- }
- ]
-
-
-def test_files_list_command_falls_back_to_project_config(monkeypatch, tmp_path: Path):
- from ksadk.cli import cmd_files
-
- _reset_fake_files_client()
- _register_commands()
- monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient)
- monkeypatch.chdir(tmp_path)
- (tmp_path / "agentengine.yaml").write_text("name: demo-agent\nframework: langgraph\n", encoding="utf-8")
-
- runner = CliRunner()
- result = runner.invoke(
- cli,
- [
- "--output",
- "json",
- "files",
- "list",
- "--path",
- "docs",
- ],
- )
-
- assert result.exit_code == 0, result.output
- payload = json.loads(result.output)
- assert payload["path"] == "docs"
- assert _FakeFilesClient.init_calls == [{"region": "cn-beijing-6"}]
- assert _FakeFilesClient.list_calls == [
- {
- "agent_id": "demo-agent",
- "path": "docs",
- "recursive": False,
- }
- ]
-
-
-def test_files_upload_download_and_delete_commands(monkeypatch, tmp_path: Path):
- from ksadk.cli import cmd_files
-
- _reset_fake_files_client()
- _register_commands()
- monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient)
-
- local_file = tmp_path / "report.txt"
- local_file.write_text("payload", encoding="utf-8")
- download_path = tmp_path / "downloaded.txt"
-
- runner = CliRunner()
- upload_result = runner.invoke(
- cli,
- [
- "files",
- "upload",
- "--agent",
- "demo-agent",
- "--local-path",
- str(local_file),
- "--remote-path",
- "reports/report.txt",
- ],
- )
- download_result = runner.invoke(
- cli,
- [
- "files",
- "download",
- "--agent",
- "demo-agent",
- "--remote-path",
- "reports/report.txt",
- "--output-path",
- str(download_path),
- ],
- )
- delete_result = runner.invoke(
- cli,
- [
- "files",
- "delete",
- "--agent",
- "demo-agent",
- "--remote-path",
- "reports/report.txt",
- "--yes",
- ],
- )
-
- assert upload_result.exit_code == 0, upload_result.output
- assert download_result.exit_code == 0, download_result.output
- assert delete_result.exit_code == 0, delete_result.output
- assert download_path.read_text(encoding="utf-8") == "payload"
- assert _FakeFilesClient.upload_calls == [
- {
- "agent_id": "demo-agent",
- "remote_path": "reports/report.txt",
- "local_path": local_file,
- }
- ]
- assert _FakeFilesClient.download_calls == [
- {
- "agent_id": "demo-agent",
- "remote_path": "reports/report.txt",
- }
- ]
- assert _FakeFilesClient.delete_calls == [
- {
- "agent_id": "demo-agent",
- "remote_path": "reports/report.txt",
- }
- ]
- assert _FakeFilesClient.init_calls == [
- {"region": "cn-beijing-6"},
- {"region": "cn-beijing-6"},
- {"region": "cn-beijing-6"},
- ]
-
-
-def test_files_upload_pretty_output_shows_local_and_remote_paths(monkeypatch, tmp_path: Path):
- from ksadk.cli import cmd_files
-
- _reset_fake_files_client()
- _register_commands()
- monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient)
-
- local_file = tmp_path / "resume.pdf"
- local_file.write_bytes(b"pdf-data")
-
- runner = CliRunner()
- result = runner.invoke(
- cli,
- [
- "files",
- "upload",
- "--agent",
- "demo-agent",
- "--local-path",
- str(local_file),
- "--remote-path",
- "pdf",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert "上传完成" in result.output
- assert f"本地文件:{local_file}" in result.output
- assert "远端文件:workspace:/pdf" in result.output
- assert "文件大小:7 B" in result.output
-
-
-def test_files_upload_json_output_includes_agent_friendly_fields(monkeypatch, tmp_path: Path):
- from ksadk.cli import cmd_files
-
- _reset_fake_files_client()
- _register_commands()
- monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient)
-
- local_file = tmp_path / "resume.pdf"
- local_file.write_bytes(b"pdf-data")
-
- runner = CliRunner()
- result = runner.invoke(
- cli,
- [
- "--output",
- "json",
- "files",
- "upload",
- "--agent",
- "demo-agent",
- "--local-path",
- str(local_file),
- "--remote-path",
- "pdf",
- ],
- )
-
- assert result.exit_code == 0, result.output
- payload = json.loads(result.output)
- assert payload["ok"] is True
- assert payload["action"] == "upload"
- assert payload["workspace_root"] == "workspace"
- assert payload["local_path"] == str(local_file)
- assert payload["remote_path"] == "pdf"
- assert payload["remote_display_path"] == "workspace:/pdf"
- assert payload["summary"] == {
- "uploaded": 1,
- "size_bytes": 7,
- "size_human": "7 B",
- }
- assert payload["entry"]["display_path"] == "workspace:/pdf"
-
-
-def test_files_list_pretty_output_uses_readable_entry_lines(monkeypatch):
- from ksadk.cli import cmd_files
-
- _reset_fake_files_client()
- _register_commands()
- monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient)
-
- runner = CliRunner()
- result = runner.invoke(
- cli,
- [
- "files",
- "list",
- "--agent",
- "demo-agent",
- "--path",
- ".",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert "工作空间:workspace" in result.output
- assert "当前目录:workspace:/" in result.output
- assert "实际目录:/home/node/.hermes/workspace" in result.output
- assert "条目数量:2" in result.output
- assert "目录(1)" in result.output
- assert " workspace:/inputs" in result.output
- assert "文件(1)" in result.output
- assert " workspace:/report.txt 7 B" in result.output
-
-
-def test_files_commands_support_direct_runtime_access(monkeypatch, tmp_path: Path):
- from ksadk.cli import cmd_files
-
- _reset_fake_files_client()
- _register_commands()
- monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient)
-
- local_file = tmp_path / "report.txt"
- local_file.write_text("payload", encoding="utf-8")
- download_path = tmp_path / "downloaded.txt"
-
- runner = CliRunner()
- upload_result = runner.invoke(
- cli,
- [
- "files",
- "upload",
- "--endpoint",
- "http://127.0.0.1:18080",
- "--api-key",
- "ak-direct-demo",
- "--local-path",
- str(local_file),
- "--remote-path",
- "reports/report.txt",
- ],
- )
- download_result = runner.invoke(
- cli,
- [
- "files",
- "download",
- "--endpoint",
- "http://127.0.0.1:18080",
- "--api-key",
- "ak-direct-demo",
- "--remote-path",
- "reports/report.txt",
- "--output-path",
- str(download_path),
- ],
- )
- delete_result = runner.invoke(
- cli,
- [
- "files",
- "delete",
- "--endpoint",
- "http://127.0.0.1:18080",
- "--api-key",
- "ak-direct-demo",
- "--remote-path",
- "reports/report.txt",
- "--yes",
- ],
- )
-
- assert upload_result.exit_code == 0, upload_result.output
- assert download_result.exit_code == 0, download_result.output
- assert delete_result.exit_code == 0, delete_result.output
- assert download_path.read_text(encoding="utf-8") == "payload"
- assert _FakeFilesClient.upload_calls == [
- {
- "agent_id": None,
- "remote_path": "reports/report.txt",
- "local_path": local_file,
- "endpoint": "http://127.0.0.1:18080",
- "api_key": "ak-direct-demo",
- }
- ]
- assert _FakeFilesClient.download_calls == [
- {
- "agent_id": None,
- "remote_path": "reports/report.txt",
- "endpoint": "http://127.0.0.1:18080",
- "api_key": "ak-direct-demo",
- }
- ]
- assert _FakeFilesClient.delete_calls == [
- {
- "agent_id": None,
- "remote_path": "reports/report.txt",
- "endpoint": "http://127.0.0.1:18080",
- "api_key": "ak-direct-demo",
- }
- ]
- assert _FakeFilesClient.init_calls == [
- {"region": "cn-beijing-6"},
- {"region": "cn-beijing-6"},
- {"region": "cn-beijing-6"},
- ]
-
-
-def test_files_upload_accepts_positional_agent(monkeypatch, tmp_path: Path):
- from ksadk.cli import cmd_files
-
- _reset_fake_files_client()
- _register_commands()
- monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient)
-
- local_file = tmp_path / "report.txt"
- local_file.write_text("payload", encoding="utf-8")
-
- runner = CliRunner()
- result = runner.invoke(
- cli,
- [
- "files",
- "upload",
- "demo-agent",
- "--local-path",
- str(local_file),
- "--remote-path",
- "reports/report.txt",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert _FakeFilesClient.upload_calls == [
- {
- "agent_id": "demo-agent",
- "remote_path": "reports/report.txt",
- "local_path": local_file,
- }
- ]
-
-
-def test_files_push_uploads_new_files_and_skips_existing_targets_by_default(monkeypatch, tmp_path: Path):
- from ksadk.cli import cmd_files
-
- _reset_fake_files_client()
- _register_commands()
- monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient)
-
- local_dir = tmp_path / "skills"
- local_dir.mkdir()
- (local_dir / "README.md").write_text("local readme", encoding="utf-8")
- nested_dir = local_dir / "nested"
- nested_dir.mkdir()
- (nested_dir / "tool.py").write_text("print('ok')\n", encoding="utf-8")
-
- _FakeFilesClient.list_results["bundle"] = {
- "root": "workspace",
- "path": "bundle",
- "entries": [
- {"name": "README.md", "path": "bundle/README.md", "type": "file", "size_bytes": 12},
- ],
- }
-
- runner = CliRunner()
- result = runner.invoke(
- cli,
- [
- "--output",
- "json",
- "files",
- "push",
- "--agent",
- "demo-agent",
- "--local-dir",
- str(local_dir),
- "--remote-path",
- "bundle",
- ],
- )
-
- assert result.exit_code == 0, result.output
- payload = json.loads(result.output)
- assert payload["ok"] is True
- assert payload["action"] == "push"
- assert payload["direction"] == "push"
- assert payload["remote_display_path"] == "workspace:/bundle"
- assert payload["summary"] == {
- "created_count": 1,
- "overwritten_count": 0,
- "skipped_count": 1,
- "total_files": 2,
- }
- assert payload["created"] == ["bundle/nested/tool.py"]
- assert payload["skipped"] == ["bundle/README.md"]
- assert payload["overwritten"] == []
- assert payload["results"]["created"][0]["display_path"] == "workspace:/bundle/nested/tool.py"
- assert payload["results"]["skipped"][0]["display_path"] == "workspace:/bundle/README.md"
- assert _FakeFilesClient.upload_calls == [
- {
- "agent_id": "demo-agent",
- "remote_path": "bundle/nested/tool.py",
- "local_path": nested_dir / "tool.py",
- }
- ]
-
-
-def test_push_workspace_files_can_ignore_local_dev_artifacts(monkeypatch, tmp_path: Path):
- from ksadk.cli import cmd_files
-
- _reset_fake_files_client()
- monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient)
-
- local_dir = tmp_path / "bundle"
- local_dir.mkdir()
- (local_dir / "app.py").write_text("print('ok')\n", encoding="utf-8")
-
- git_object = local_dir / ".git" / "objects" / "ab"
- git_object.mkdir(parents=True)
- (git_object / "blob").write_bytes(b"x" * 2048)
-
- agentengine_ui = local_dir / ".agentengine" / "ui"
- agentengine_ui.mkdir(parents=True)
- (agentengine_ui / "sessions.sqlite").write_bytes(b"sqlite-data")
-
- payload = asyncio.run(
- cmd_files._push_workspace_files(
- agent_ref="demo-agent",
- local_dir=local_dir,
- remote_path="bundle",
- force=True,
- region="cn-beijing-6",
- endpoint=None,
- api_key=None,
- ignore_dev_artifacts=True,
- )
- )
-
- assert payload["created"] == ["bundle/app.py"]
- assert payload["total_files"] == 1
- assert _FakeFilesClient.upload_calls == [
- {
- "agent_id": "demo-agent",
- "remote_path": "bundle/app.py",
- "local_path": local_dir / "app.py",
- }
- ]
-
-
-def test_files_push_pretty_output_is_readable_in_chinese(monkeypatch, tmp_path: Path):
- from ksadk.cli import cmd_files
-
- _reset_fake_files_client()
- _register_commands()
- monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient)
-
- local_dir = tmp_path / "bundle"
- local_dir.mkdir()
- (local_dir / "hello.txt").write_text("hello", encoding="utf-8")
-
- runner = CliRunner()
- result = runner.invoke(
- cli,
- [
- "files",
- "push",
- "--agent",
- "demo-agent",
- "--local-dir",
- str(local_dir),
- "--remote-path",
- "bundle",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert "推送完成" in result.output
- assert f"本地目录:{local_dir}" in result.output
- assert "远端目录:workspace:/bundle" in result.output
- assert "统计:新增 1,覆盖 0,跳过 0,共 1" in result.output
- assert "已新增:workspace:/bundle/hello.txt" in result.output
-
-
-def test_files_push_pretty_output_shows_action_proxy_transport_hint(monkeypatch, tmp_path: Path):
- from ksadk.cli import cmd_files
-
- _reset_fake_files_client()
- _register_commands()
- monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient)
-
- local_dir = tmp_path / "bundle"
- local_dir.mkdir()
- (local_dir / "hello.txt").write_text("hello", encoding="utf-8")
- _FakeFilesClient.list_results["bundle"] = {
- "root": "workspace",
- "path": "bundle",
- "entries": [],
- "transport_mode": "action_proxy",
- }
-
- runner = CliRunner()
- result = runner.invoke(
- cli,
- [
- "files",
- "push",
- "--agent",
- "demo-agent",
- "--local-dir",
- str(local_dir),
- "--remote-path",
- "bundle",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert "访问链路:通过平台 action 代理访问远端 workspace" in result.output
-
-
-def test_files_push_treats_missing_remote_directory_as_empty(monkeypatch, tmp_path: Path):
- from ksadk.cli import cmd_files
-
- _reset_fake_files_client()
- _register_commands()
- monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient)
-
- local_dir = tmp_path / "bundle"
- local_dir.mkdir()
- (local_dir / "hello.txt").write_text("hello", encoding="utf-8")
- _FakeFilesClient.list_results["new-bundle"] = AgentEngineAPIError(404, "workspace path not found")
-
- runner = CliRunner()
- result = runner.invoke(
- cli,
- [
- "--output",
- "json",
- "files",
- "push",
- "--agent",
- "demo-agent",
- "--local-dir",
- str(local_dir),
- "--remote-path",
- "new-bundle",
- ],
- )
-
- assert result.exit_code == 0, result.output
- payload = json.loads(result.output)
- assert payload["created"] == ["new-bundle/hello.txt"]
- assert payload["skipped"] == []
- assert _FakeFilesClient.upload_calls == [
- {
- "agent_id": "demo-agent",
- "remote_path": "new-bundle/hello.txt",
- "local_path": local_dir / "hello.txt",
- }
- ]
-
-
-def test_files_pull_downloads_new_files_and_overwrites_with_force(monkeypatch, tmp_path: Path):
- from ksadk.cli import cmd_files
-
- _reset_fake_files_client()
- _register_commands()
- monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient)
-
- local_dir = tmp_path / "mirror"
- local_dir.mkdir()
- existing_file = local_dir / "README.md"
- existing_file.write_text("old local", encoding="utf-8")
-
- _FakeFilesClient.list_results["bundle"] = {
- "root": "workspace",
- "path": "bundle",
- "entries": [
- {"name": "README.md", "path": "bundle/README.md", "type": "file", "size_bytes": 12},
- {"name": "tool.py", "path": "bundle/nested/tool.py", "type": "file", "size_bytes": 11},
- ],
- }
- _FakeFilesClient.download_payloads = {
- "bundle/README.md": b"new remote",
- "bundle/nested/tool.py": b"print('ok')\n",
- }
-
- runner = CliRunner()
- result = runner.invoke(
- cli,
- [
- "--output",
- "json",
- "files",
- "pull",
- "--agent",
- "demo-agent",
- "--remote-path",
- "bundle",
- "--local-dir",
- str(local_dir),
- "--force",
- ],
- )
-
- assert result.exit_code == 0, result.output
- payload = json.loads(result.output)
- assert payload["direction"] == "pull"
- assert payload["created"] == ["nested/tool.py"]
- assert payload["overwritten"] == ["README.md"]
- assert payload["skipped"] == []
- assert existing_file.read_text(encoding="utf-8") == "new remote"
- assert (local_dir / "nested" / "tool.py").read_text(encoding="utf-8") == "print('ok')\n"
- assert _FakeFilesClient.download_calls == [
- {
- "agent_id": "demo-agent",
- "remote_path": "bundle/README.md",
- },
- {
- "agent_id": "demo-agent",
- "remote_path": "bundle/nested/tool.py",
- },
- ]
-
-
-def test_files_pull_json_output_includes_transport_metadata(monkeypatch, tmp_path: Path):
- from ksadk.cli import cmd_files
-
- _reset_fake_files_client()
- _register_commands()
- monkeypatch.setattr(cmd_files, "AgentEngineClient", _FakeFilesClient)
-
- local_dir = tmp_path / "mirror"
- local_dir.mkdir()
- _FakeFilesClient.list_results["bundle"] = {
- "root": "workspace",
- "path": "bundle",
- "transport_mode": "action_proxy",
- "entries": [
- {"name": "tool.py", "path": "bundle/nested/tool.py", "type": "file", "size_bytes": 11},
- ],
- }
- _FakeFilesClient.download_payloads = {
- "bundle/nested/tool.py": b"print('ok')\n",
- }
-
- runner = CliRunner()
- result = runner.invoke(
- cli,
- [
- "--output",
- "json",
- "files",
- "pull",
- "--agent",
- "demo-agent",
- "--remote-path",
- "bundle",
- "--local-dir",
- str(local_dir),
- ],
- )
-
- assert result.exit_code == 0, result.output
- payload = json.loads(result.output)
- assert payload["transport_mode"] == "action_proxy"
- assert payload["transport_hint"] == "通过平台 action 代理访问远端 workspace"
diff --git a/tests/test_cmd_hermes.py b/tests/test_cmd_hermes.py
deleted file mode 100644
index 21a95f47..00000000
--- a/tests/test_cmd_hermes.py
+++ /dev/null
@@ -1,1606 +0,0 @@
-import asyncio
-import json
-from contextlib import contextmanager
-from pathlib import Path
-
-import pytest
-from click.testing import CliRunner
-
-from ksadk.api.client import AgentEngineAPIError, DryRunExit
-from ksadk.cli import cmd_hermes
-from ksadk.cli.ui import OUTPUT_MODE_PRETTY, configure_ui_runtime, status_rich_style
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-MAKEFILE = REPO_ROOT / "Makefile"
-
-
-@pytest.fixture(autouse=True)
-def _isolate_hermes_model_env(monkeypatch):
- for key in (
- "OPENAI_API_KEY",
- "OPENAI_BASE_URL",
- "OPENAI_MODEL_NAME",
- "HERMES_CONTEXT_LENGTH",
- "OPENAI_CONTEXT_LENGTH",
- "MODEL_CONTEXT_LENGTH",
- "HERMES_FALLBACK_MODEL",
- "OPENAI_FALLBACK_MODEL_NAME",
- "HERMES_FALLBACK_BASE_URL",
- "API_SERVER_KEY",
- "HERMES_API_SERVER_KEY",
- "LANGFUSE_PUBLIC_KEY",
- "LANGFUSE_SECRET_KEY",
- "LANGFUSE_BASE_URL",
- "LANGFUSE_HOST",
- "LANGFUSE_ENV",
- "LANGFUSE_RELEASE",
- "HERMES_LANGFUSE_PUBLIC_KEY",
- "HERMES_LANGFUSE_SECRET_KEY",
- "HERMES_LANGFUSE_BASE_URL",
- "HERMES_LANGFUSE_ENV",
- "HERMES_LANGFUSE_RELEASE",
- "HERMES_LANGFUSE_SAMPLE_RATE",
- "HERMES_LANGFUSE_MAX_CHARS",
- "HERMES_LANGFUSE_DEBUG",
- "KSYUN_REGION",
- "WPSXIEZUO_APP_ID",
- "WPSXIEZUO_APP_KEY",
- "WPSXIEZUO_API_BASE",
- "WPSXIEZUO_WS_ENDPOINT",
- "WPSXIEZUO_GROUP_AT_ONLY",
- "WPSXIEZUO_ALLOWED_USERS",
- "WPSXIEZUO_ALLOW_ALL_USERS",
- "WPSXIEZUO_HOME_CHANNEL",
- ):
- monkeypatch.delenv(key, raising=False)
- cmd_hermes._HERMES_GLOBAL_ENV_CACHE = None
- yield
- cmd_hermes._HERMES_GLOBAL_ENV_CACHE = None
-
-
-class _FakeHermesClient:
- create_payload = None
- update_payload = None
- updated_agent_id = None
- deleted = []
-
- def __init__(self, *args, **kwargs):
- self.kwargs = kwargs
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
- async def create_agent(self, payload):
- self.__class__.create_payload = payload
- return {
- "agent_id": "ar-hermes-1",
- "name": payload["name"],
- "endpoint": "https://hermes.example.com",
- "api_key": "ak-hermes",
- }
-
- async def update_agent(self, agent_id, payload):
- self.__class__.updated_agent_id = agent_id
- self.__class__.update_payload = payload
- return {
- "agent_id": agent_id,
- "name": "demo-hermes",
- "endpoint": "https://hermes.example.com",
- }
-
- async def list_agents(self, **kwargs):
- assert kwargs["framework"] == "hermes"
- return {
- "agents": [
- {
- "agent_id": "ar-hermes-1",
- "name": "demo-hermes",
- "status": "RUNNING",
- "endpoint": "https://hermes.example.com",
- "region": kwargs["region"],
- }
- ],
- "total": 1,
- }
-
- async def get_client_bootstrap_config(self, **kwargs):
- assert kwargs["product"] == "hermes"
- assert kwargs["framework"] == "hermes"
- return {"configs": {}}
-
- async def get_agent(self, agent_id=None, name=None, include_api_key=False):
- return {
- "basic": {
- "agent_id": agent_id or "ar-hermes-1",
- "name": name or "demo-hermes",
- "status": "RUNNING",
- "framework": "hermes",
- "region": "cn-beijing-6",
- },
- "quick_access": {
- "public_endpoint": "https://hermes.example.com",
- "api_key": "ak-hermes" if include_api_key else None,
- },
- "advanced": {
- "observability_url": "https://trace.example.com/project/arhermes1/traces",
- },
- }
-
- async def delete_agent(self, agent_id):
- self.__class__.deleted.append(agent_id)
- return True
-
-
-class _FakeHermesOrderClient(_FakeHermesClient):
- get_agent_calls = 0
-
- async def create_agent(self, payload):
- self.__class__.create_payload = payload
- return {"order_id": "order-hermes-1"}
-
- async def get_agent(self, agent_id=None, name=None, include_api_key=False):
- self.__class__.get_agent_calls += 1
- return {
- "basic": {
- "agent_id": "ar-hermes-from-order",
- "name": name or "demo-hermes",
- "status": "RUNNING",
- "framework": "hermes",
- "region": "cn-beijing-6",
- },
- "quick_access": {
- "public_endpoint": "https://order-hermes.example.com",
- "api_key": "ak-order-hermes",
- },
- }
-
-
-class _FakeHermesImmediateAgentIdClient(_FakeHermesClient):
- get_agent_calls = 0
-
- async def create_agent(self, payload):
- self.__class__.create_payload = payload
- return {
- "agent_id": "ar-hermes-immediate",
- "name": payload["name"],
- "endpoint": None,
- "api_key": None,
- "order_id": "order-hermes-2",
- }
-
- async def get_agent(self, agent_id=None, name=None, include_api_key=False):
- self.__class__.get_agent_calls += 1
- return {
- "basic": {
- "agent_id": agent_id or "ar-hermes-immediate",
- "name": name or "demo-hermes",
- "status": "RUNNING",
- "framework": "hermes",
- "region": "cn-beijing-6",
- },
- "quick_access": {
- "public_endpoint": "https://fresh-hermes.example.com",
- "api_key": "ak-fresh-hermes" if include_api_key else None,
- },
- }
-
-
-class _FakeHermesDelayedAccessClient(_FakeHermesClient):
- get_agent_calls = 0
- suppression_used = False
-
- async def create_agent(self, payload):
- self.__class__.create_payload = payload
- return {
- "agent_id": "ar-hermes-delayed",
- "name": payload["name"],
- "endpoint": "https://created-hermes.example.com",
- "api_key": None,
- "status": 200,
- }
-
- @contextmanager
- def suppress_http_error_logging(self, predicate=None):
- self.__class__.suppression_used = predicate is not None
- yield
-
- async def get_agent(self, agent_id=None, name=None, include_api_key=False):
- self.__class__.get_agent_calls += 1
- if self.__class__.get_agent_calls < 4:
- raise AgentEngineAPIError(
- 404,
- "未找到对应的 Agent",
- details={
- "http_status": 404,
- "remote_error_message": "未找到对应的 Agent",
- },
- )
- return {
- "basic": {
- "agent_id": agent_id or "ar-hermes-delayed",
- "name": name or "demo-hermes",
- "status": "RUNNING",
- "framework": "hermes",
- "region": "cn-beijing-6",
- },
- "quick_access": {
- "public_endpoint": "https://ready-hermes.example.com",
- "api_key": "ak-ready-hermes" if include_api_key else None,
- },
- }
-
-
-class _FakeNonHermesClient(_FakeHermesClient):
- async def get_agent(self, agent_id=None, name=None, include_api_key=False):
- return {
- "basic": {
- "agent_id": agent_id or "ar-langgraph-1",
- "name": name or "demo-langgraph",
- "status": "RUNNING",
- "framework": "langgraph",
- },
- "quick_access": {
- "public_endpoint": "https://langgraph.example.com",
- },
- }
-
-
-class _FakeHermesDryRunClient(_FakeHermesClient):
- async def create_agent(self, payload):
- raise DryRunExit(
- "dry-run",
- payload={
- "method": "POST",
- "url": "http://example.com/?Action=CreateAgentProduct&Version=2024-06-12",
- "headers": {
- "Authorization": "Bearer sk-live-secret",
- "Content-Type": "application/json",
- },
- "body": {
- "Advanced": {
- "EnvironmentVariables": [
- {"Key": "OPENAI_API_KEY", "Value": "sk-test-secret", "IsSensitive": True},
- {"Key": "OPENAI_MODEL_NAME", "Value": "glm-test", "IsSensitive": False},
- ]
- }
- },
- "curl": """curl -X POST "http://example.com" \\
- -H "Authorization: Bearer sk-live-secret" \\
- -d '{"Advanced":{"EnvironmentVariables":[{"Key":"OPENAI_API_KEY","Value":"sk-test-secret","IsSensitive":true}]}}'""",
- },
- )
-
-
-class _FakeHermesBootstrapImageClient(_FakeHermesClient):
- bootstrap_kwargs = None
-
- async def get_client_bootstrap_config(self, **kwargs):
- self.__class__.bootstrap_kwargs = dict(kwargs)
- assert kwargs["product"] == "hermes"
- assert kwargs["framework"] == "hermes"
- return {
- "configs": {
- "bootstrap.default_image": "registry.example.com/agentengine-public/hermes-agent:db-meta"
- }
- }
-
-
-def test_hermes_build_defaults_are_externalized_to_agentengine_images_repo():
- makefile = MAKEFILE.read_text(encoding="utf-8")
-
- assert not (REPO_ROOT / "deploy" / "hermes" / "Dockerfile").exists()
- assert "AGENTENGINE_IMAGES_DIR ?= ../agentengine-images" in makefile
- assert '$(MAKE) -C "$(AGENTENGINE_IMAGES_DIR)" $@' in makefile
- assert "-f deploy/hermes/Dockerfile" not in makefile
- assert cmd_hermes.DEFAULT_HERMES_IMAGE.endswith(':2026.5.29.2-ksadk-v1')
-
-
-def test_hermes_deploy_refreshes_quick_access_when_agent_id_is_immediate(monkeypatch, tmp_path: Path):
- runner = CliRunner()
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesImmediateAgentIdClient)
- monkeypatch.chdir(tmp_path)
- _FakeHermesImmediateAgentIdClient.get_agent_calls = 0
-
- result = runner.invoke(
- cmd_hermes.hermes,
- [
- "deploy",
- "--name",
- "demo-hermes",
- "--image",
- "ghcr.io/kingsoftcloud/hermes-agent:test",
- "--model-base-url",
- "https://model.example.com/v1",
- "--model-api-key",
- "sk-demo",
- "--default-model",
- "glm-test",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert _FakeHermesImmediateAgentIdClient.get_agent_calls == 1
- state = (tmp_path / ".agentengine.state").read_text(encoding="utf-8")
- assert "agent_id: ar-hermes-immediate" in state
- assert "endpoint: https://fresh-hermes.example.com" in state
- assert "api_key: ak-fresh-hermes" in state
-
-
-def test_hermes_deploy_retries_transient_get_agent_not_found_without_showing_numeric_status(
- monkeypatch,
- tmp_path: Path,
-):
- runner = CliRunner()
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesDelayedAccessClient)
- monkeypatch.chdir(tmp_path)
- _FakeHermesDelayedAccessClient.get_agent_calls = 0
- _FakeHermesDelayedAccessClient.suppression_used = False
-
- result = runner.invoke(
- cmd_hermes.hermes,
- [
- "deploy",
- "--name",
- "demo-hermes",
- "--image",
- "ghcr.io/kingsoftcloud/hermes-agent:test",
- "--model-base-url",
- "https://model.example.com/v1",
- "--model-api-key",
- "sk-demo",
- "--default-model",
- "glm-test",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert _FakeHermesDelayedAccessClient.suppression_used is True
- assert _FakeHermesDelayedAccessClient.get_agent_calls == 4
- assert "当前状态: RUNNING" in result.output
- assert "当前状态: 200" not in result.output
- state = (tmp_path / ".agentengine.state").read_text(encoding="utf-8")
- assert "agent_id: ar-hermes-delayed" in state
- assert "endpoint: https://ready-hermes.example.com" in state
- assert "api_key: ak-ready-hermes" in state
-
-
-def test_hermes_exec_accepts_readonly_subcommand_and_uses_remote_terminal(monkeypatch):
- runner = CliRunner()
- captured = {}
-
- async def _fake_exec(**kwargs):
- captured.update(kwargs)
-
- monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _fake_exec)
- monkeypatch.setattr(cmd_hermes, "_resolve_hermes_access", lambda **_kwargs: {
- "endpoint": "https://hermes.example.com",
- "api_key": "ak-hermes",
- })
-
- result = runner.invoke(cmd_hermes.hermes, ["exec", "ar-hermes-1", "--", "status"])
-
- assert result.exit_code == 0, result.output
- assert captured["endpoint"] == "https://hermes.example.com"
- assert captured["api_key"] == "ak-hermes"
- assert captured["mode"] == "exec"
- assert captured["argv"] == ["status"]
-
-
-def test_hermes_exec_rejects_mutating_subcommand_before_remote_call(monkeypatch):
- runner = CliRunner()
-
- async def _forbidden_exec(**_kwargs):
- raise AssertionError("remote terminal should not be called")
-
- monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _forbidden_exec)
-
- result = runner.invoke(cmd_hermes.hermes, ["exec", "ar-hermes-1", "--", "gateway", "restart"])
-
- assert result.exit_code != 0
- assert "不允许" in result.output or "not allowed" in result.output
-
-
-def test_hermes_exec_exits_cleanly_on_keyboard_interrupt(monkeypatch):
- runner = CliRunner()
-
- def _fake_exec(**_kwargs):
- return object()
-
- def _raise_keyboard_interrupt(_awaitable):
- raise KeyboardInterrupt
-
- monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _fake_exec)
- monkeypatch.setattr(cmd_hermes, "_resolve_hermes_access", lambda **_kwargs: {
- "endpoint": "https://hermes.example.com",
- "api_key": "ak-hermes",
- })
- monkeypatch.setattr(cmd_hermes.asyncio, "run", _raise_keyboard_interrupt)
-
- result = runner.invoke(cmd_hermes.hermes, ["exec", "ar-hermes-1", "--", "status"])
-
- assert result.exit_code == 130
- assert "Traceback" not in result.output
-
-
-def test_hermes_pairing_accepts_safe_subcommand_and_uses_remote_terminal(monkeypatch):
- runner = CliRunner()
- captured = {}
-
- async def _fake_pairing(**kwargs):
- captured.update(kwargs)
-
- monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _fake_pairing)
- monkeypatch.setattr(
- cmd_hermes,
- "_resolve_hermes_access",
- lambda **_kwargs: {
- "endpoint": "https://hermes.example.com",
- "api_key": "ak-hermes",
- },
- )
-
- result = runner.invoke(
- cmd_hermes.hermes,
- ["pairing", "ar-hermes-1", "--", "approve", "feishu", "ABC123"],
- )
-
- assert result.exit_code == 0, result.output
- assert captured["mode"] == "pairing"
- assert captured["argv"] == ["approve", "feishu", "ABC123"]
-
-
-def test_hermes_pairing_without_agent_ref_uses_state_resolution(monkeypatch):
- runner = CliRunner()
- captured = {}
- resolved = {}
-
- async def _fake_pairing(**kwargs):
- captured.update(kwargs)
-
- def _fake_resolve(**kwargs):
- resolved.update(kwargs)
- return {
- "endpoint": "https://hermes.example.com",
- "api_key": "ak-hermes",
- }
-
- monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _fake_pairing)
- monkeypatch.setattr(cmd_hermes, "_resolve_hermes_access", _fake_resolve)
-
- result = runner.invoke(
- cmd_hermes.hermes,
- ["pairing", "--", "approve", "feishu", "ABC123"],
- )
-
- assert result.exit_code == 0, result.output
- assert resolved["agent_ref"] is None
- assert captured["mode"] == "pairing"
- assert captured["argv"] == ["approve", "feishu", "ABC123"]
-
-
-def test_hermes_pairing_rejects_invalid_platform_before_remote_call(monkeypatch):
- runner = CliRunner()
-
- async def _forbidden_pairing(**_kwargs):
- raise AssertionError("remote terminal should not be called")
-
- monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _forbidden_pairing)
-
- result = runner.invoke(
- cmd_hermes.hermes,
- ["pairing", "ar-hermes-1", "--", "approve", "unknown-platform", "ABC123"],
- )
-
- assert result.exit_code != 0
- assert "不允许" in result.output or "not allowed" in result.output
-
-
-def test_hermes_exec_without_agent_ref_uses_state_resolution(monkeypatch):
- runner = CliRunner()
- captured = {}
- resolved = {}
-
- async def _fake_exec(**kwargs):
- captured.update(kwargs)
-
- def _fake_resolve(**kwargs):
- resolved.update(kwargs)
- return {
- "endpoint": "https://hermes.example.com",
- "api_key": "ak-hermes",
- }
-
- monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _fake_exec)
- monkeypatch.setattr(cmd_hermes, "_resolve_hermes_access", _fake_resolve)
-
- result = runner.invoke(
- cmd_hermes.hermes,
- ["exec", "--", "status"],
- )
-
- assert result.exit_code == 0, result.output
- assert resolved["agent_ref"] is None
- assert captured["mode"] == "exec"
- assert captured["argv"] == ["status"]
-
-
-def test_hermes_connect_enters_remote_gateway_setup(monkeypatch):
- runner = CliRunner()
- captured = {}
-
- async def _fake_connect(**kwargs):
- captured.update(kwargs)
-
- monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _fake_connect)
- monkeypatch.setattr(
- cmd_hermes,
- "_resolve_hermes_access",
- lambda **_kwargs: {
- "endpoint": "https://hermes.example.com",
- "api_key": "ak-hermes",
- },
- )
-
- result = runner.invoke(cmd_hermes.hermes, ["connect", "ar-hermes-1"])
-
- assert result.exit_code == 0, result.output
- assert captured["mode"] == "connect"
- assert captured["endpoint"] == "https://hermes.example.com"
- assert captured["api_key"] == "ak-hermes"
-
-
-def test_hermes_exec_dry_run_does_not_resolve_or_connect(monkeypatch):
- runner = CliRunner()
-
- async def _forbidden_exec(**_kwargs):
- raise AssertionError("remote terminal should not be called")
-
- monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _forbidden_exec)
- monkeypatch.setattr(
- cmd_hermes,
- "_resolve_hermes_access",
- lambda **_kwargs: (_ for _ in ()).throw(AssertionError("agent access should not be resolved")),
- )
-
- result = runner.invoke(
- cmd_hermes.hermes,
- ["exec", "ar-hermes-1", "--dry-run", "--output", "json", "--", "status"],
- )
-
- assert result.exit_code == 0, result.output
- payload = json.loads(result.output)
- assert payload["kind"] == "dry_run"
- assert payload["resource"] == "hermes"
- assert payload["action"] == "exec"
- assert payload["request"]["argv"] == ["status"]
-
-
-def test_hermes_connect_dry_run_does_not_resolve_or_connect(monkeypatch):
- runner = CliRunner()
-
- async def _forbidden_connect(**_kwargs):
- raise AssertionError("remote terminal should not be called")
-
- monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _forbidden_connect)
- monkeypatch.setattr(
- cmd_hermes,
- "_resolve_hermes_access",
- lambda **_kwargs: (_ for _ in ()).throw(AssertionError("agent access should not be resolved")),
- )
-
- result = runner.invoke(
- cmd_hermes.hermes,
- ["connect", "ar-hermes-1", "--dry-run", "--output", "json"],
- )
-
- assert result.exit_code == 0, result.output
- payload = json.loads(result.output)
- assert payload["kind"] == "dry_run"
- assert payload["resource"] == "hermes"
- assert payload["action"] == "connect"
- assert payload["request"]["mode"] == "connect"
-
-
-def test_hermes_pairing_dry_run_does_not_resolve_or_connect(monkeypatch):
- runner = CliRunner()
-
- async def _forbidden_pairing(**_kwargs):
- raise AssertionError("remote terminal should not be called")
-
- monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _forbidden_pairing)
- monkeypatch.setattr(
- cmd_hermes,
- "_resolve_hermes_access",
- lambda **_kwargs: (_ for _ in ()).throw(AssertionError("agent access should not be resolved")),
- )
-
- result = runner.invoke(
- cmd_hermes.hermes,
- ["pairing", "ar-hermes-1", "--dry-run", "--output", "json", "--", "approve", "feishu", "ABC123"],
- )
-
- assert result.exit_code == 0, result.output
- payload = json.loads(result.output)
- assert payload["kind"] == "dry_run"
- assert payload["resource"] == "hermes"
- assert payload["action"] == "pairing"
- assert payload["request"]["argv"] == ["approve", "feishu", "ABC123"]
-
-
-def test_hermes_pairing_dry_run_accepts_wpsxiezuo_platform(monkeypatch):
- runner = CliRunner()
-
- async def _forbidden_pairing(**_kwargs):
- raise AssertionError("remote terminal should not be called")
-
- monkeypatch.setattr(cmd_hermes, "run_hermes_terminal_session", _forbidden_pairing)
- monkeypatch.setattr(
- cmd_hermes,
- "_resolve_hermes_access",
- lambda **_kwargs: (_ for _ in ()).throw(AssertionError("agent access should not be resolved")),
- )
-
- result = runner.invoke(
- cmd_hermes.hermes,
- ["pairing", "ar-hermes-1", "--dry-run", "--output", "json", "--", "approve", "wpsxiezuo", "WPS123"],
- )
-
- assert result.exit_code == 0, result.output
- payload = json.loads(result.output)
- assert payload["request"]["argv"] == ["approve", "wpsxiezuo", "WPS123"]
-
-
-def test_hermes_open_defaults_to_manage_and_supports_chat_override(monkeypatch):
- runner = CliRunner()
- opened = []
-
- monkeypatch.setattr(
- cmd_hermes,
- "_get_hermes_detail",
- lambda *args, **kwargs: asyncio.sleep(
- 0,
- result={
- "agent_id": "ar-hermes-1",
- "framework": "hermes",
- },
- ),
- )
- monkeypatch.setattr(
- cmd_hermes,
- "_open_dashboard",
- lambda **kwargs: opened.append(kwargs),
- )
-
- manage_result = runner.invoke(cmd_hermes.hermes, ["open", "ar-hermes-1", "--manage", "--no-open"])
- chat_result = runner.invoke(cmd_hermes.hermes, ["open", "ar-hermes-1", "--chat", "--no-open"])
-
- assert manage_result.exit_code == 0, manage_result.output
- assert chat_result.exit_code == 0, chat_result.output
- assert opened[0]["ui_path"] == "/"
- assert opened[1]["ui_path"] == "/chat"
- assert opened[0]["region_source"] == "default"
- assert opened[1]["region_source"] == "default"
-
-
-def test_hermes_open_force_new_forwards_to_dashboard(monkeypatch):
- runner = CliRunner()
- opened = []
-
- monkeypatch.setattr(
- cmd_hermes,
- "_get_hermes_detail",
- lambda *args, **kwargs: asyncio.sleep(
- 0,
- result={
- "agent_id": "ar-hermes-1",
- "framework": "hermes",
- },
- ),
- )
- monkeypatch.setattr(
- cmd_hermes,
- "_open_dashboard",
- lambda **kwargs: opened.append(kwargs),
- )
-
- result = runner.invoke(
- cmd_hermes.hermes,
- ["open", "ar-hermes-1", "--chat", "--share", "--expires-seconds", "86400", "--force-new", "--no-open"],
- )
-
- assert result.exit_code == 0, result.output
- assert opened[0]["ui_path"] == "/chat"
- assert opened[0]["share"] is True
- assert opened[0]["expires_seconds"] == 86400
- assert opened[0]["force_new"] is True
- assert opened[0]["region_source"] == "default"
-
-
-def test_hermes_open_dry_run_does_not_resolve_or_open(monkeypatch):
- runner = CliRunner()
-
- monkeypatch.setattr(
- cmd_hermes,
- "_get_hermes_detail",
- lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("agent detail should not be resolved")),
- )
- monkeypatch.setattr(
- cmd_hermes,
- "_open_dashboard",
- lambda **kwargs: (_ for _ in ()).throw(AssertionError("dashboard should not open")),
- )
-
- result = runner.invoke(
- cmd_hermes.hermes,
- ["open", "ar-hermes-1", "--chat", "--dry-run", "--output", "json"],
- )
-
- assert result.exit_code == 0, result.output
- payload = json.loads(result.output)
- assert payload["kind"] == "dry_run"
- assert payload["action"] == "open"
- assert payload["request"]["path"] == "/chat"
-
-
-def test_hermes_open_rejects_manage_and_chat_together(monkeypatch):
- runner = CliRunner()
- monkeypatch.setattr(
- cmd_hermes,
- "_get_hermes_detail",
- lambda *args, **kwargs: asyncio.sleep(
- 0,
- result={
- "agent_id": "ar-hermes-1",
- "framework": "hermes",
- },
- ),
- )
-
- result = runner.invoke(cmd_hermes.hermes, ["open", "ar-hermes-1", "--manage", "--chat", "--no-open"])
-
- assert result.exit_code != 0
-
-
-def test_hermes_deploy_creates_container_framework_and_persists_state(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- _FakeHermesClient.create_payload = None
- _FakeHermesClient.update_payload = None
- _FakeHermesClient.updated_agent_id = None
- monkeypatch.chdir(tmp_path)
- monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
- monkeypatch.setenv("OPENAI_BASE_URL", "https://model.example.com/v1")
- monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-test")
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
-
- result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes", "--image", "registry/hermes:test"])
-
- assert result.exit_code == 0, result.output
- assert _FakeHermesClient.create_payload["framework"] == "hermes"
- assert _FakeHermesClient.create_payload["artifact_type"] == "Container"
- assert _FakeHermesClient.create_payload["artifact_path"] == "registry/hermes:test"
- assert _FakeHermesClient.create_payload["ui_config"] == {"profile": "hermes", "path": "/", "url": None}
- assert any(item["Key"] == "OPENAI_API_KEY" and item["Value"] == "sk-test" for item in _FakeHermesClient.create_payload["env_vars"])
- assert "agent_id: ar-hermes-1" in (tmp_path / ".agentengine.state").read_text(encoding="utf-8")
-
-
-def test_hermes_deploy_create_payload_includes_explicit_network(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- _FakeHermesClient.create_payload = None
- monkeypatch.chdir(tmp_path)
- monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
- monkeypatch.setenv("OPENAI_BASE_URL", "https://model.example.com/v1")
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
-
- result = runner.invoke(
- cmd_hermes.hermes,
- [
- "deploy",
- "--name",
- "demo-hermes",
- "--image",
- "registry/hermes:test",
- "--disable-public-access",
- "--enable-vpc-access",
- "--vpc-id",
- "vpc-cli",
- "--subnet-id",
- "subnet-cli",
- "--security-group-id",
- "sg-cli",
- "--availability-zone",
- "cn-beijing-6b",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert _FakeHermesClient.create_payload["network"] == {
- "enable_public_access": False,
- "enable_vpc_access": True,
- "vpc_id": "vpc-cli",
- "subnet_id": "subnet-cli",
- "security_group_id": "sg-cli",
- "availability_zone": "cn-beijing-6b",
- }
-
-
-def test_hermes_deploy_infers_availability_zone_from_subnet(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- _FakeHermesClient.create_payload = None
- monkeypatch.chdir(tmp_path)
- monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
- monkeypatch.setenv("OPENAI_BASE_URL", "https://model.example.com/v1")
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
- monkeypatch.setattr(
- "ksadk.cli.network_options._resolve_subnet_availability_zone",
- lambda *, subnet_id, region: (
- "cn-beijing-6e"
- if subnet_id == "subnet-cli" and region == "cn-beijing-6"
- else None
- ),
- )
-
- result = runner.invoke(
- cmd_hermes.hermes,
- [
- "deploy",
- "--name",
- "demo-hermes",
- "--region",
- "cn-beijing-6",
- "--image",
- "registry/hermes:test",
- "--enable-vpc-access",
- "--vpc-id",
- "vpc-cli",
- "--subnet-id",
- "subnet-cli",
- "--security-group-id",
- "sg-cli",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert _FakeHermesClient.create_payload["network"] == {
- "enable_vpc_access": True,
- "vpc_id": "vpc-cli",
- "subnet_id": "subnet-cli",
- "security_group_id": "sg-cli",
- "availability_zone": "cn-beijing-6e",
- }
-
-
-def test_hermes_deploy_omits_network_when_not_configured(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- _FakeHermesClient.create_payload = None
- monkeypatch.chdir(tmp_path)
- monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
- monkeypatch.setenv("OPENAI_BASE_URL", "https://model.example.com/v1")
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
-
- result = runner.invoke(
- cmd_hermes.hermes,
- ["deploy", "--name", "demo-hermes", "--image", "registry/hermes:test"],
- )
-
- assert result.exit_code == 0, result.output
- assert "network" not in _FakeHermesClient.create_payload
-
-
-def test_hermes_deploy_defaults_model_base_url_and_omits_api_key(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- _FakeHermesClient.create_payload = None
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr(cmd_hermes, "_get_hermes_global_env", lambda: {}, raising=False)
- monkeypatch.delenv("OPENAI_API_KEY", raising=False)
- monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
- monkeypatch.delenv("OPENAI_MODEL_NAME", raising=False)
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
-
- result = runner.invoke(cmd_hermes.hermes, ["deploy"])
-
- assert result.exit_code == 0, result.output
- assert "https://kspmas.ksyun.com/v1/" in result.output
- assert "glm-5.2" in result.output
- assert any(
- item["Key"] == "OPENAI_BASE_URL" and item["Value"] == "https://kspmas.ksyun.com/v1/"
- for item in _FakeHermesClient.create_payload["env_vars"]
- )
- assert any(
- item["Key"] == "OPENAI_MODEL_NAME" and item["Value"] == "glm-5.2"
- for item in _FakeHermesClient.create_payload["env_vars"]
- )
- env_vars = {item["Key"]: item["Value"] for item in _FakeHermesClient.create_payload["env_vars"]}
- assert json.loads(env_vars["AGENTENGINE_MODEL_POLICY_JSON"])["fallback"]["model"] == "deepseek-v4-pro"
- assert env_vars["HERMES_FALLBACK_MODEL"] == "deepseek-v4-pro"
- assert not any(item["Key"] == "OPENAI_API_KEY" for item in _FakeHermesClient.create_payload["env_vars"])
-
-
-def test_hermes_deploy_reads_model_config_from_global_settings(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- _FakeHermesClient.create_payload = None
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr(
- cmd_hermes,
- "_get_hermes_global_env",
- lambda: {
- "OPENAI_API_KEY": "sk-global",
- "OPENAI_BASE_URL": "https://model.example.com/v1",
- "OPENAI_MODEL_NAME": "glm-global",
- },
- raising=False,
- )
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
-
- result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes", "--image", "registry/hermes:test"])
-
- assert result.exit_code == 0, result.output
- assert any(item["Key"] == "OPENAI_API_KEY" and item["Value"] == "sk-global" for item in _FakeHermesClient.create_payload["env_vars"])
- assert any(item["Key"] == "OPENAI_BASE_URL" and item["Value"] == "https://model.example.com/v1" for item in _FakeHermesClient.create_payload["env_vars"])
- assert any(item["Key"] == "OPENAI_MODEL_NAME" and item["Value"] == "glm-global" for item in _FakeHermesClient.create_payload["env_vars"])
-
-
-def test_hermes_deploy_defaults_kspmas_base_url_when_missing(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- _FakeHermesClient.create_payload = None
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr(cmd_hermes, "_get_hermes_global_env", lambda: {}, raising=False)
- monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1")
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
-
- result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"])
-
- assert result.exit_code == 0, result.output
- assert "https://kspmas.ksyun.com/v1/" in result.output
- assert any(
- item["Key"] == "OPENAI_BASE_URL" and item["Value"] == "https://kspmas.ksyun.com/v1/"
- for item in _FakeHermesClient.create_payload["env_vars"]
- )
-
-
-def test_hermes_deploy_output_json_emits_result_envelope(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- _FakeHermesClient.create_payload = None
- monkeypatch.chdir(tmp_path)
- monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
- monkeypatch.setenv("OPENAI_BASE_URL", "https://model.example.com/v1")
- monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-test")
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
-
- result = runner.invoke(
- cmd_hermes.hermes,
- ["deploy", "--name", "demo-hermes", "--image", "registry/hermes:test", "--output", "json"],
- )
-
- assert result.exit_code == 0, result.output
- payload = json.loads(result.output)
- assert payload["kind"] == "result"
- assert payload["resource"] == "hermes"
- assert payload["action"] == "deploy"
- assert payload["result"]["id"] == "ar-hermes-1"
- assert payload["result"]["image"] == "registry/hermes:test"
- assert payload["result"]["framework"] == "hermes"
- assert payload["result"]["endpoint"] == "https://hermes.example.com"
-
-
-def test_hermes_deploy_preserves_configured_public_kspmas_url(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- _FakeHermesClient.create_payload = None
- monkeypatch.chdir(tmp_path)
- monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
- monkeypatch.setenv("OPENAI_BASE_URL", "http://kspmas.ksyun.com/v1")
- monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-test")
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
-
- result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"])
-
- assert result.exit_code == 0, result.output
- assert (
- _FakeHermesClient.create_payload["artifact_path"]
- == "ghcr.io/kingsoftcloud/hermes-agent:2026.5.29.2-ksadk-v1"
- )
- assert any(
- item["Key"] == "OPENAI_BASE_URL" and item["Value"] == "http://kspmas.ksyun.com/v1"
- for item in _FakeHermesClient.create_payload["env_vars"]
- )
-
-
-def test_hermes_deploy_uses_model_policy_fallback_for_kspmas(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- _FakeHermesClient.create_payload = None
- monkeypatch.chdir(tmp_path)
- monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
- monkeypatch.setenv("OPENAI_BASE_URL", "http://kspmas.ksyun.com/v1")
- monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.2")
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
-
- result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"])
-
- assert result.exit_code == 0, result.output
- env_vars = {item["Key"]: item["Value"] for item in _FakeHermesClient.create_payload["env_vars"]}
- assert env_vars["HERMES_FALLBACK_MODEL"] == "deepseek-v4-pro"
- assert "kimi-k2.6" not in env_vars.values()
-
-
-def test_hermes_deploy_forwards_explicit_fallback_model(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- _FakeHermesClient.create_payload = None
- monkeypatch.chdir(tmp_path)
- monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
- monkeypatch.setenv("OPENAI_BASE_URL", "http://kspmas.ksyun.com/v1")
- monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1")
- monkeypatch.setenv("HERMES_FALLBACK_MODEL", "explicit-fallback")
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
-
- result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"])
-
- assert result.exit_code == 0, result.output
- env_vars = {item["Key"]: item["Value"] for item in _FakeHermesClient.create_payload["env_vars"]}
- assert env_vars["HERMES_FALLBACK_MODEL"] == "explicit-fallback"
- assert env_vars["HERMES_FALLBACK_PROVIDER"] == "custom"
- assert env_vars["HERMES_FALLBACK_BASE_URL"] == "http://kspmas.ksyun.com/v1"
-
-
-def test_hermes_deploy_uses_provider_context_length_for_configured_model(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- _FakeHermesClient.create_payload = None
- monkeypatch.chdir(tmp_path)
- monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
- monkeypatch.setenv("OPENAI_BASE_URL", "http://kspmas.ksyun.com/v1")
- monkeypatch.setenv("OPENAI_MODEL_NAME", "deepseek-v4-pro")
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
-
- async def _fake_fetch_provider_model_metadata(**_kwargs):
- return {
- "id": "deepseek-v4-pro",
- "context_window_tokens": 1_000_000,
- "max_output_tokens": 384_000,
- }
-
- monkeypatch.setattr(
- cmd_hermes,
- "fetch_provider_model_metadata",
- _fake_fetch_provider_model_metadata,
- )
-
- result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"])
-
- assert result.exit_code == 0, result.output
- assert any(
- item["Key"] == "HERMES_CONTEXT_LENGTH" and item["Value"] == "1000000"
- for item in _FakeHermesClient.create_payload["env_vars"]
- )
-
-
-def test_hermes_deploy_forwards_langfuse_env_when_configured(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- _FakeHermesClient.create_payload = None
- monkeypatch.chdir(tmp_path)
- monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
- monkeypatch.setenv("OPENAI_BASE_URL", "http://kspmas.ksyun.com/v1")
- monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1")
- monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-lf-test")
- monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-lf-test")
- monkeypatch.setenv("LANGFUSE_BASE_URL", "https://langfuse.pre.example.com")
- monkeypatch.setenv("LANGFUSE_ENV", "pre")
- monkeypatch.setenv("HERMES_LANGFUSE_SAMPLE_RATE", "0.5")
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
-
- result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"])
-
- assert result.exit_code == 0, result.output
- env_vars = {
- item["Key"]: item for item in _FakeHermesClient.create_payload["env_vars"]
- }
- assert env_vars["HERMES_LANGFUSE_PUBLIC_KEY"]["Value"] == "pk-lf-test"
- assert env_vars["HERMES_LANGFUSE_PUBLIC_KEY"]["IsSensitive"] is True
- assert env_vars["HERMES_LANGFUSE_SECRET_KEY"]["Value"] == "sk-lf-test"
- assert env_vars["HERMES_LANGFUSE_SECRET_KEY"]["IsSensitive"] is True
- assert env_vars["HERMES_LANGFUSE_BASE_URL"]["Value"] == "https://langfuse.pre.example.com"
- assert env_vars["HERMES_LANGFUSE_ENV"]["Value"] == "pre"
- assert env_vars["HERMES_LANGFUSE_SAMPLE_RATE"]["Value"] == "0.5"
-
-
-def test_hermes_deploy_forwards_wpsxiezuo_env_when_configured(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- _FakeHermesClient.create_payload = None
- monkeypatch.chdir(tmp_path)
- monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
- monkeypatch.setenv("OPENAI_BASE_URL", "http://kspmas.ksyun.com/v1")
- monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1")
- monkeypatch.setenv("WPSXIEZUO_APP_ID", "AK-wps-test")
- monkeypatch.setenv("WPSXIEZUO_APP_KEY", "wps-app-key")
- monkeypatch.setenv("WPSXIEZUO_API_BASE", "https://openapi.wps.cn")
- monkeypatch.setenv("WPSXIEZUO_GROUP_AT_ONLY", "true")
- monkeypatch.setenv("WPSXIEZUO_ALLOWED_USERS", "u1,u2")
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
-
- result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"])
-
- assert result.exit_code == 0, result.output
- env_vars = {item["Key"]: item for item in _FakeHermesClient.create_payload["env_vars"]}
- assert env_vars["WPSXIEZUO_APP_ID"]["Value"] == "AK-wps-test"
- assert env_vars["WPSXIEZUO_APP_ID"]["IsSensitive"] is False
- assert env_vars["WPSXIEZUO_APP_KEY"]["Value"] == "wps-app-key"
- assert env_vars["WPSXIEZUO_APP_KEY"]["IsSensitive"] is True
- assert env_vars["WPSXIEZUO_API_BASE"]["Value"] == "https://openapi.wps.cn"
- assert env_vars["WPSXIEZUO_GROUP_AT_ONLY"]["Value"] == "true"
- assert env_vars["WPSXIEZUO_ALLOWED_USERS"]["Value"] == "u1,u2"
-
-
-def test_hermes_deploy_defaults_ui_locale_to_zh(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- _FakeHermesClient.create_payload = None
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr(cmd_hermes, "_get_hermes_global_env", lambda: {}, raising=False)
- monkeypatch.delenv("HERMES_UI_LOCALE", raising=False)
- monkeypatch.delenv("LANG", raising=False)
- monkeypatch.delenv("LC_ALL", raising=False)
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
-
- result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"])
-
- assert result.exit_code == 0, result.output
- assert any(
- item["Key"] == "HERMES_UI_LOCALE" and item["Value"] == "zh"
- for item in _FakeHermesClient.create_payload["env_vars"]
- )
-
-
-def test_hermes_deploy_normalizes_ui_locale_from_lang(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- _FakeHermesClient.create_payload = None
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr(cmd_hermes, "_get_hermes_global_env", lambda: {}, raising=False)
- monkeypatch.delenv("HERMES_UI_LOCALE", raising=False)
- monkeypatch.setenv("LANG", "en_US.UTF-8")
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
-
- result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"])
-
- assert result.exit_code == 0, result.output
- assert any(
- item["Key"] == "HERMES_UI_LOCALE" and item["Value"] == "en"
- for item in _FakeHermesClient.create_payload["env_vars"]
- )
-
-
-def test_hermes_deploy_prefers_bootstrap_default_image(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- _FakeHermesBootstrapImageClient.create_payload = None
- _FakeHermesBootstrapImageClient.bootstrap_kwargs = None
- monkeypatch.chdir(tmp_path)
- monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
- monkeypatch.setenv("OPENAI_BASE_URL", "https://model.example.com/v1")
- monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-test")
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesBootstrapImageClient)
-
- result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes"])
-
- assert result.exit_code == 0, result.output
- assert (
- _FakeHermesBootstrapImageClient.create_payload["artifact_path"]
- == "registry.example.com/agentengine-public/hermes-agent:db-meta"
- )
- assert _FakeHermesBootstrapImageClient.bootstrap_kwargs["ignore_dry_run"] is True
-
-
-def test_hermes_deploy_dry_run_still_reads_bootstrap_default_image(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- _FakeHermesBootstrapImageClient.create_payload = None
- _FakeHermesBootstrapImageClient.bootstrap_kwargs = None
- monkeypatch.chdir(tmp_path)
- monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
- monkeypatch.setenv("OPENAI_BASE_URL", "https://model.example.com/v1")
- monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-test")
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesBootstrapImageClient)
-
- result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes", "--dry-run"])
-
- assert result.exit_code == 0, result.output
- assert _FakeHermesBootstrapImageClient.bootstrap_kwargs["ignore_dry_run"] is True
- assert (
- _FakeHermesBootstrapImageClient.create_payload["artifact_path"]
- == "registry.example.com/agentengine-public/hermes-agent:db-meta"
- )
-
-
-def test_hermes_deploy_updates_existing_hermes_state(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- _FakeHermesClient.create_payload = None
- _FakeHermesClient.update_payload = None
- _FakeHermesClient.updated_agent_id = None
- monkeypatch.chdir(tmp_path)
- (tmp_path / ".agentengine.state").write_text(
- "type: hermes\nframework: hermes\nagent_id: ar-hermes-existing\nname: demo-hermes\nendpoint: https://old.example.com\n",
- encoding="utf-8",
- )
- monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
- monkeypatch.setenv("OPENAI_BASE_URL", "https://model.example.com/v1")
- monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-test")
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
-
- result = runner.invoke(cmd_hermes.hermes, ["deploy", "--image", "registry/hermes:new"])
-
- assert result.exit_code == 0, result.output
- assert _FakeHermesClient.create_payload is None
- assert _FakeHermesClient.updated_agent_id == "ar-hermes-existing"
- assert _FakeHermesClient.update_payload["framework"] == "hermes"
- assert _FakeHermesClient.update_payload["artifact_type"] == "Container"
- assert _FakeHermesClient.update_payload["artifact_path"] == "registry/hermes:new"
-
-
-def test_hermes_deploy_update_payload_preserves_existing_config_by_default(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- _FakeHermesClient.create_payload = None
- _FakeHermesClient.update_payload = None
- _FakeHermesClient.updated_agent_id = None
- monkeypatch.chdir(tmp_path)
- (tmp_path / ".agentengine.state").write_text(
- "type: hermes\nframework: hermes\nagent_id: ar-hermes-existing\nname: demo-hermes\nendpoint: https://old.example.com\n",
- encoding="utf-8",
- )
- monkeypatch.setenv("OPENAI_API_KEY", "sk-local-shell")
- monkeypatch.setenv("OPENAI_BASE_URL", "https://local-shell.example.com/v1")
- monkeypatch.setenv("OPENAI_MODEL_NAME", "local-shell-model")
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
-
- result = runner.invoke(cmd_hermes.hermes, ["deploy", "--image", "registry/hermes:new"])
-
- assert result.exit_code == 0, result.output
- payload = _FakeHermesClient.update_payload
- assert payload["artifact_path"] == "registry/hermes:new"
- assert "env_vars" not in payload
- assert "storage" not in payload
- assert "network" not in payload
-
-
-def test_hermes_deploy_update_payload_includes_explicit_config(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- _FakeHermesClient.create_payload = None
- _FakeHermesClient.update_payload = None
- _FakeHermesClient.updated_agent_id = None
- monkeypatch.chdir(tmp_path)
- (tmp_path / ".agentengine.state").write_text(
- "type: hermes\nframework: hermes\nagent_id: ar-hermes-existing\nname: demo-hermes\nendpoint: https://old.example.com\n",
- encoding="utf-8",
- )
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
-
- result = runner.invoke(
- cmd_hermes.hermes,
- [
- "deploy",
- "--image",
- "registry/hermes:new",
- "--model-base-url",
- "https://model.example.com/v1",
- "--default-model",
- "glm-test",
- "--storage-size-gi",
- "50",
- "--enable-vpc-access",
- "--vpc-id",
- "vpc-cli",
- "--subnet-id",
- "subnet-cli",
- "--security-group-id",
- "sg-cli",
- ],
- )
-
- assert result.exit_code == 0, result.output
- payload = _FakeHermesClient.update_payload
- assert any(item["Key"] == "OPENAI_MODEL_NAME" and item["Value"] == "glm-test" for item in payload["env_vars"])
- assert payload["storage"]["size_gi"] == 50
- assert payload["network"] == {
- "enable_vpc_access": True,
- "vpc_id": "vpc-cli",
- "subnet_id": "subnet-cli",
- "security_group_id": "sg-cli",
- }
-
-
-def test_hermes_deploy_dry_run_redacts_sensitive_values(monkeypatch, tmp_path: Path):
- runner = CliRunner()
- monkeypatch.chdir(tmp_path)
- monkeypatch.setenv("OPENAI_BASE_URL", "https://model.example.com/v1")
- monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-test")
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesDryRunClient)
-
- result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes", "--dry-run"])
-
- assert result.exit_code == 0, result.output
- assert "sk-test-secret" not in result.output
- assert "sk-live-secret" not in result.output
- assert "***" in result.output
- assert "glm-test" in result.output
-
-
-def test_hermes_deploy_polls_order_until_agent_access_is_available(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- _FakeHermesOrderClient.get_agent_calls = 0
- monkeypatch.chdir(tmp_path)
- monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
- monkeypatch.setenv("OPENAI_BASE_URL", "https://model.example.com/v1")
- monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-test")
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesOrderClient)
-
- async def _fake_sleep(*_args, **_kwargs):
- return None
-
- monkeypatch.setattr(cmd_hermes.asyncio, "sleep", _fake_sleep)
-
- result = runner.invoke(cmd_hermes.hermes, ["deploy", "--name", "demo-hermes", "--image", "registry/hermes:test"])
-
- assert result.exit_code == 0, result.output
- assert _FakeHermesOrderClient.get_agent_calls == 1
- state = (tmp_path / ".agentengine.state").read_text(encoding="utf-8")
- assert "agent_id: ar-hermes-from-order" in state
- assert "endpoint: https://order-hermes.example.com" in state
- assert "api_key: ak-order-hermes" in state
-
-
-def test_hermes_list_status_and_delete_use_hermes_resource(monkeypatch):
- runner = CliRunner()
- _FakeHermesClient.deleted = []
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
- monkeypatch.setattr(cmd_hermes, "confirm_destructive", lambda **_kwargs: True)
-
- list_result = runner.invoke(cmd_hermes.hermes, ["list"])
- status_result = runner.invoke(cmd_hermes.hermes, ["status", "ar-hermes-1"])
- delete_result = runner.invoke(cmd_hermes.hermes, ["delete", "ar-hermes-1", "-y"])
-
- assert list_result.exit_code == 0, list_result.output
- assert status_result.exit_code == 0, status_result.output
- assert delete_result.exit_code == 0, delete_result.output
- assert "ar-hermes-1" in list_result.output
- assert "RUNNING" in status_result.output
- assert _FakeHermesClient.deleted == ["ar-hermes-1"]
-
-
-def test_hermes_status_passes_status_style_to_descriptor(monkeypatch):
- runner = CliRunner()
- configure_ui_runtime(output_mode=OUTPUT_MODE_PRETTY, no_color=False, stdout_is_tty=True)
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
- captured = {}
-
- def _fake_render_descriptor_status(*args, **kwargs):
- captured.update(kwargs)
-
- monkeypatch.setattr(cmd_hermes, "render_descriptor_status", _fake_render_descriptor_status)
-
- result = runner.invoke(cmd_hermes.hermes, ["status", "ar-hermes-1"])
-
- assert result.exit_code == 0, result.output
- assert captured["fields"][1] == ("状态", "RUNNING", status_rich_style("RUNNING"))
-
-
-def test_hermes_status_shows_langfuse_trace_url(monkeypatch):
- runner = CliRunner()
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
-
- result = runner.invoke(cmd_hermes.hermes, ["status", "ar-hermes-1"])
-
- assert result.exit_code == 0, result.output
- assert "Langfuse" in result.output
- assert "https://trace.example.com/project/arhermes1/traces" in result.output
-
-
-class _FakeHermesUpdatingClient(_FakeHermesClient):
- async def get_agent(self, agent_id=None, name=None, include_api_key=False):
- return {
- "basic": {
- "agent_id": agent_id or "ar-hermes-1",
- "name": name or "demo-hermes",
- "status": "UPDATING",
- "phase": "Updating",
- "message": "Container 'agent-runtime' failed: ImagePullBackOff",
- "framework": "hermes",
- "region": "cn-beijing-6",
- "replicas": 1,
- "ready_replicas": 0,
- },
- "quick_access": {
- "public_endpoint": "https://hermes.example.com",
- "api_key": "ak-hermes" if include_api_key else None,
- },
- "advanced": {
- "observability_url": "https://trace.example.com/project/arhermes1/traces",
- },
- }
-
-
-def test_hermes_status_shows_message_and_replicas_when_not_running(monkeypatch):
- runner = CliRunner()
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesUpdatingClient)
-
- result = runner.invoke(cmd_hermes.hermes, ["status", "ar-hermes-1"])
-
- assert result.exit_code == 0, result.output
- assert "UPDATING" in result.output
- assert "0/1" in result.output
- assert "ImagePullBackOff" in result.output
-
-
-def test_hermes_status_hides_message_when_running(monkeypatch):
- runner = CliRunner()
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
-
- result = runner.invoke(cmd_hermes.hermes, ["status", "ar-hermes-1"])
-
- assert result.exit_code == 0, result.output
- assert "RUNNING" in result.output
- assert "消息" not in result.output
- assert "副本" not in result.output
-
-
-def test_hermes_delete_uses_delete_specific_next_steps(monkeypatch):
- runner = CliRunner()
- _FakeHermesClient.deleted = []
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
- monkeypatch.setattr(cmd_hermes, "confirm_destructive", lambda **_kwargs: True)
-
- result = runner.invoke(cmd_hermes.hermes, ["delete", "ar-hermes-1", "-y"])
-
- assert result.exit_code == 0, result.output
- assert "agentengine hermes list" in result.output
- assert "agentengine hermes deploy" in result.output
- assert "agentengine hermes connect" not in result.output
- assert "agentengine hermes pairing" not in result.output
-
-
-def test_hermes_delete_passes_result_styles_to_descriptor(monkeypatch):
- runner = CliRunner()
- _FakeHermesClient.deleted = []
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
- monkeypatch.setattr(cmd_hermes, "confirm_destructive", lambda **_kwargs: True)
- captured = {}
-
- def _fake_render_descriptor_status(*args, **kwargs):
- captured.update(kwargs)
-
- monkeypatch.setattr(cmd_hermes, "render_descriptor_status", _fake_render_descriptor_status)
-
- result = runner.invoke(cmd_hermes.hermes, ["delete", "ar-hermes-1", "-y"])
-
- assert result.exit_code == 0, result.output
- assert captured["fields"][1] == ("已删除", "ar-hermes-1", "ok")
- assert captured["fields"][2] == ("失败", "-", "muted")
-
-
-def test_hermes_delete_resolves_name_to_agent_id_and_rejects_non_hermes(monkeypatch):
- runner = CliRunner()
- _FakeHermesClient.deleted = []
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesClient)
- monkeypatch.setattr(cmd_hermes, "confirm_destructive", lambda **_kwargs: True)
-
- delete_by_name = runner.invoke(cmd_hermes.hermes, ["delete", "demo-hermes", "-y"])
-
- assert delete_by_name.exit_code == 0, delete_by_name.output
- assert _FakeHermesClient.deleted == ["ar-hermes-1"]
-
- _FakeHermesClient.deleted = []
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeNonHermesClient)
-
- non_hermes = runner.invoke(cmd_hermes.hermes, ["delete", "ar-langgraph-1", "-y"])
-
- assert non_hermes.exit_code != 0
- assert _FakeHermesClient.deleted == []
-
-
-class _FakeHermesUpdateNotFoundClient(_FakeHermesClient):
- """update_agent 对已删除 agent 抛 404,create_agent 返回新 agent。"""
-
- update_called = False
- create_called = False
-
- async def update_agent(self, agent_id, payload):
- self.__class__.update_called = True
- raise AgentEngineAPIError(
- 404,
- "未找到对应的 Agent",
- details={"http_status": 404, "remote_error_message": "未找到对应的 Agent"},
- )
-
- async def create_agent(self, payload):
- self.__class__.create_called = True
- self.__class__.create_payload = payload
- return {
- "agent_id": "ar-hermes-recreated",
- "name": payload["name"],
- "endpoint": "https://recreated-hermes.example.com",
- "api_key": "ak-recreated",
- }
-
-
-def test_hermes_deploy_falls_back_to_create_when_state_points_to_deleted_agent(
- monkeypatch, tmp_path: Path
-):
- """本地 .agentengine.state 缓存的 agent 已在服务端删除时,deploy 应自动回退为新建。"""
- runner = CliRunner()
- monkeypatch.setattr(cmd_hermes, "AgentEngineClient", _FakeHermesUpdateNotFoundClient)
- monkeypatch.chdir(tmp_path)
- _FakeHermesUpdateNotFoundClient.update_called = False
- _FakeHermesUpdateNotFoundClient.create_called = False
-
- # 预置失效的 state(指向一个已删除的 agent)
- (tmp_path / ".agentengine.state").write_text(
- "agent_id: ar-20260623102747-3aef1bf8\n"
- "api_key: ak-stale\n"
- "endpoint: http://ar-20260623102747-3aef1bf8.agent-pre.kspmas.ksyun.com\n"
- "framework: hermes\n"
- "image: hub.kce.ksyun.com/agentengine-public/hermes-agent:stale\n"
- "name: demo-hermes\n"
- "region: pre-online\n"
- "type: hermes\n"
- "ui_path: /\n"
- "ui_profile: hermes\n",
- encoding="utf-8",
- )
-
- result = runner.invoke(
- cmd_hermes.hermes,
- [
- "deploy",
- "--name",
- "demo-hermes",
- "--image",
- "ghcr.io/kingsoftcloud/hermes-agent:test",
- "--model-base-url",
- "https://model.example.com/v1",
- "--model-api-key",
- "sk-demo",
- "--default-model",
- "glm-test",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert _FakeHermesUpdateNotFoundClient.update_called is True
- assert _FakeHermesUpdateNotFoundClient.create_called is True
- # state 应被清理并写入新 agent_id
- state = (tmp_path / ".agentengine.state").read_text(encoding="utf-8")
- assert "ar-20260623102747-3aef1bf8" not in state
- assert "agent_id: ar-hermes-recreated" in state
- assert "endpoint: https://recreated-hermes.example.com" in state
- assert "api_key: ak-recreated" in state
- # 应有回退提示
- assert "本地状态失效" in result.output
-
-
-def test_is_agent_not_found_error_matches_structured_404():
- from ksadk.deployment.agent_access import is_agent_not_found_error
-
- # AgentEngineAPIError code=404 → 命中
- assert is_agent_not_found_error(
- AgentEngineAPIError(404, "未找到对应的 Agent", details={"http_status": 404})
- ) is True
- # details.http_status=404(code 非 int)→ 命中
- assert is_agent_not_found_error(
- AgentEngineAPIError("NotFound", "x", details={"http_status": 404})
- ) is True
-
-
-def test_is_agent_not_found_error_matches_text_fallback():
- from ksadk.deployment.agent_access import is_agent_not_found_error
-
- # 裸 Exception 文案含 404 + 未找到对应的 agent → 命中
- assert is_agent_not_found_error(Exception("HTTP 404: 未找到对应的 agent")) is True
- # code: 404 大写 + agent not found → 命中
- assert is_agent_not_found_error(Exception("Code: 404 - agent not found")) is True
-
-
-def test_is_agent_not_found_error_rejects_non_agent_404():
- from ksadk.deployment.agent_access import is_agent_not_found_error
-
- # 404 但文案不含 agent-not-found(裸 AgentEngineAPIError code=404 结构化判定仍命中,
- # 因为 Action API code=404 语义就是 agent not found)—— 这是预期行为
- # 但纯文案 404 无 agent 文案 → 不命中(避免误判鉴权/路由 404)
- assert is_agent_not_found_error(Exception("HTTP 404: Forbidden")) is False
- assert is_agent_not_found_error(Exception("model not found")) is False
- assert is_agent_not_found_error(Exception("network error")) is False
- assert is_agent_not_found_error(None) is False # type: ignore[arg-type]
diff --git a/tests/test_cmd_invoke.py b/tests/test_cmd_invoke.py
deleted file mode 100644
index 98d988f8..00000000
--- a/tests/test_cmd_invoke.py
+++ /dev/null
@@ -1,1595 +0,0 @@
-import asyncio
-import sys
-from pathlib import Path
-
-import click
-import pytest
-import yaml
-
-from ksadk.api import AgentEngineAPIError
-from ksadk.cli import cmd_invoke
-from ksadk.cli.cmd_invoke import (
- _extract_content,
- _extract_response_content,
- _invoke_hermes_terminal_tui,
- _invoke_openclaw_terminal_tui,
- _resolve_remote_api_format,
- _select_remote_api_format,
- run_invoke_command,
-)
-
-
-class _FakeInvokeClient:
- calls = []
-
- def __init__(self, *args, **kwargs):
- self.kwargs = kwargs
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
- async def get_agent(self, agent_id=None, name=None, include_api_key=False):
- self.__class__.calls.append(
- {
- "agent_id": agent_id,
- "name": name,
- "include_api_key": include_api_key,
- }
- )
- return {
- "basic": {
- "agent_id": "ar-demo",
- "name": "demo-agent",
- },
- "quick_access": {
- "public_endpoint": "https://fresh.example.com",
- "api_key": "ak-fresh",
- },
- }
-
-
-class _FakeOpenClawInvokeClient:
- calls = []
-
- def __init__(self, *args, **kwargs):
- self.kwargs = kwargs
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
- async def get_agent(self, agent_id=None, name=None, include_api_key=False):
- self.__class__.calls.append(
- {
- "agent_id": agent_id,
- "name": name,
- "include_api_key": include_api_key,
- }
- )
- return {
- "basic": {
- "agent_id": "ar-openclaw-demo",
- "name": "demo-openclaw",
- },
- "deployment": {
- "framework": "openclaw",
- },
- "quick_access": {
- "public_endpoint": "https://openclaw.example.com",
- "api_key": "ak-openclaw",
- },
- }
-
-
-class _FakeStreamResponse:
- def __init__(self, lines):
- self._lines = lines
-
- def raise_for_status(self):
- return None
-
- async def aiter_lines(self):
- for line in self._lines:
- yield line
-
-
-class _FakeStreamContext:
- def __init__(self, response):
- self._response = response
-
- async def __aenter__(self):
- return self._response
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
-
-class _FakeStreamClient:
- def __init__(self, *args, **kwargs):
- self.kwargs = kwargs
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
- def stream(self, *_args, **_kwargs):
- return _FakeStreamContext(
- _FakeStreamResponse(
- [
- 'data: {"choices":[{"delta":{"content":"ok"}}],"error":null}',
- "data: [DONE]",
- ]
- )
- )
-
-
-def test_run_invoke_command_refreshes_stale_state_from_remote(monkeypatch, tmp_path: Path):
- state_file = tmp_path / ".agentengine.state"
- state_file.write_text(
- yaml.safe_dump(
- {
- "agent_id": "ar-demo",
- "name": "demo-agent",
- "endpoint": "http://stale.example.com",
- "api_key": None,
- }
- ),
- encoding="utf-8",
- )
-
- captured = {}
-
- async def _fake_invoke_once(endpoint, message, api_key, session_id, stream, insecure, model, api_format="chat_completions"):
- captured["endpoint"] = endpoint
- captured["api_key"] = api_key
- captured["message"] = message
-
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeInvokeClient)
- monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_once", _fake_invoke_once)
-
- run_invoke_command(
- agent_ref=None,
- agent_option=None,
- endpoint=None,
- api_key=None,
- message="hello",
- session=None,
- region="pre-online",
- local=False,
- insecure=False,
- transport="auto",
- model=None,
- show_thinking=False,
- )
-
- state = yaml.safe_load(state_file.read_text(encoding="utf-8"))
- assert captured["endpoint"] == "https://fresh.example.com"
- assert captured["api_key"] == "ak-fresh"
- assert state["endpoint"] == "https://fresh.example.com"
- assert state["api_key"] == "ak-fresh"
- assert _FakeInvokeClient.calls[-1] == {
- "agent_id": "ar-demo",
- "name": None,
- "include_api_key": True,
- }
-
-
-def test_run_invoke_command_persists_generated_session_id(monkeypatch, tmp_path: Path):
- captured_sessions = []
-
- async def _fake_invoke_once(endpoint, message, api_key, session_id, stream, insecure, model, api_format="chat_completions"):
- captured_sessions.append(session_id)
-
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_once", _fake_invoke_once)
-
- run_invoke_command(
- agent_ref=None,
- agent_option=None,
- endpoint=None,
- api_key=None,
- message="hello",
- session=None,
- region="pre-online",
- local=True,
- insecure=False,
- transport="auto",
- model=None,
- show_thinking=False,
- )
-
- state_file = tmp_path / ".agentengine.state"
- state = yaml.safe_load(state_file.read_text(encoding="utf-8"))
- assert captured_sessions[0]
- assert state["session_id"] == captured_sessions[0]
-
- run_invoke_command(
- agent_ref=None,
- agent_option=None,
- endpoint=None,
- api_key=None,
- message="continue",
- session=None,
- region="pre-online",
- local=True,
- insecure=False,
- transport="auto",
- model=None,
- show_thinking=False,
- )
-
- assert captured_sessions[1] == captured_sessions[0]
-
-
-def test_extract_content_supports_response_output_text_delta():
- content, reasoning = _extract_content(
- {
- "_event": "response.output_text.delta",
- "delta": "你好",
- }
- )
-
- assert content == "你好"
- assert reasoning == ""
-
-
-def test_extract_content_supports_response_reasoning_delta():
- content, reasoning = _extract_content(
- {
- "_event": "response.reasoning.delta",
- "delta": "先分析一下",
- }
- )
-
- assert content == ""
- assert reasoning == "先分析一下"
-
-
-def test_extract_content_ignores_response_completed_payload():
- content, reasoning = _extract_content(
- {
- "_event": "response.completed",
- "output_text": "最终答案",
- }
- )
-
- assert content == ""
- assert reasoning == ""
-
-
-async def test_stream_chat_ignores_null_error_field(monkeypatch, capsys):
- monkeypatch.setitem(
- sys.modules,
- "httpx",
- type("HttpxModule", (), {"AsyncClient": _FakeStreamClient}),
- )
-
- chunks = [
- chunk
- async for chunk in cmd_invoke._stream_chat(
- "https://agent.example.com",
- "hello",
- api_key="ak-demo",
- )
- ]
-
- assert chunks == [{"choices": [{"delta": {"content": "ok"}}], "error": None}]
- captured = capsys.readouterr()
- assert "Error: None" not in captured.out
- assert "Error: None" not in captured.err
-
-
-def test_extract_response_content_supports_responses_payload():
- assert (
- _extract_response_content(
- {
- "output": [
- {
- "content": [
- {
- "type": "output_text",
- "text": "最终答案",
- }
- ]
- }
- ]
- }
- )
- == "最终答案"
- )
-
-
-def test_select_remote_api_format_prefers_responses_for_openclaw():
- state = {"framework": "openclaw"}
-
- assert _select_remote_api_format(state, {}) == "responses"
-
-
-def test_select_remote_api_format_prefers_responses_for_hermes():
- state = {"framework": "hermes"}
-
- assert _select_remote_api_format(state, {}) == "responses"
-
-
-def test_select_remote_api_format_keeps_chat_completions_for_default_agents():
- assert _select_remote_api_format({}, {}) == "chat_completions"
-
-
-def test_resolve_remote_api_format_rejects_openclaw_when_responses_route_missing(monkeypatch):
- async def _fake_probe(**_kwargs):
- return False
-
- monkeypatch.setattr(cmd_invoke, "_probe_openclaw_responses_route", _fake_probe)
-
- with pytest.raises(click.ClickException) as exc_info:
- asyncio.run(
- _resolve_remote_api_format(
- endpoint="https://openclaw.example.com",
- api_key="ak-openclaw",
- insecure=False,
- state={"framework": "openclaw"},
- latest_access={},
- )
- )
-
- assert "/v1/responses" in str(exc_info.value)
- assert "agentengine dashboard open" in str(exc_info.value)
-
-
-def test_resolve_remote_api_format_probes_openclaw_with_runtime_gateway_token(monkeypatch):
- captured = {}
-
- async def _fake_probe(**kwargs):
- captured.update(kwargs)
- return True
-
- monkeypatch.setattr(cmd_invoke, "_probe_openclaw_responses_route", _fake_probe)
-
- api_format = asyncio.run(
- _resolve_remote_api_format(
- endpoint="https://openclaw.example.com",
- api_key="ak-openclaw",
- runtime_api_key="gateway-token",
- insecure=False,
- state={"framework": "openclaw"},
- latest_access={},
- )
- )
-
- assert api_format == "responses"
- assert captured["api_key"] == "gateway-token"
-
-
-def test_run_invoke_command_defaults_to_hermes_native_tui_for_hermes_state(monkeypatch, tmp_path: Path):
- (tmp_path / ".agentengine.state").write_text(
- yaml.safe_dump(
- {
- "type": "hermes",
- "framework": "hermes",
- "endpoint": "https://hermes.example.com",
- "api_key": "ak-hermes",
- }
- ),
- encoding="utf-8",
- )
-
- captured = {"native": 0, "chat": 0}
-
- def _fake_native(endpoint, api_key=None, session_id=None, insecure=False):
- captured["native"] += 1
- captured["endpoint"] = endpoint
- captured["api_key"] = api_key
-
- def _fake_chat(*_args, **_kwargs):
- captured["chat"] += 1
-
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_hermes_terminal_tui", _fake_native)
- monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_tui", _fake_chat)
-
- run_invoke_command(
- agent_ref=None,
- agent_option=None,
- endpoint="https://hermes.example.com",
- api_key=None,
- message=None,
- session=None,
- region="cn-beijing-6",
- local=False,
- insecure=False,
- model=None,
- show_thinking=False,
- transport="auto",
- )
-
- assert captured["native"] == 1
- assert captured["chat"] == 0
- assert captured["endpoint"] == "https://hermes.example.com"
- assert captured["api_key"] == "ak-hermes"
-
-
-def test_run_invoke_command_defaults_to_openclaw_native_tui_for_openclaw_state(monkeypatch, tmp_path: Path):
- (tmp_path / ".agentengine.state").write_text(
- yaml.safe_dump(
- {
- "type": "openclaw",
- "framework": "openclaw",
- "endpoint": "https://openclaw.example.com",
- "api_key": "ak-openclaw",
- }
- ),
- encoding="utf-8",
- )
-
- captured = {"native": 0, "chat": 0}
-
- def _fake_native(endpoint, api_key=None, session_id=None, insecure=False):
- captured["native"] += 1
- captured["endpoint"] = endpoint
- captured["api_key"] = api_key
-
- def _fake_chat(
- endpoint,
- api_key=None,
- session_id=None,
- insecure=False,
- model=None,
- show_thinking=False,
- api_format=None,
- responses_session_header=None,
- ):
- captured["chat"] += 1
- captured["endpoint"] = endpoint
- captured["api_key"] = api_key
- captured["api_format"] = api_format
- captured["responses_session_header"] = responses_session_header
-
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_openclaw_terminal_tui", _fake_native)
- monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_tui", _fake_chat)
- monkeypatch.setattr(
- "ksadk.cli.cmd_invoke._resolve_remote_api_format",
- lambda **_kwargs: pytest.fail("native OpenClaw TUI must not probe /v1/responses"),
- )
-
- run_invoke_command(
- agent_ref=None,
- agent_option=None,
- endpoint="https://openclaw.example.com",
- api_key=None,
- message=None,
- session=None,
- region="cn-beijing-6",
- local=False,
- insecure=False,
- model=None,
- show_thinking=False,
- transport="auto",
- )
-
- assert captured["native"] == 1
- assert captured["chat"] == 0
- assert captured["endpoint"] == "https://openclaw.example.com"
- assert captured["api_key"] == "ak-openclaw"
-
-
-def test_run_invoke_command_transport_chat_uses_responses_tui_for_openclaw_state(monkeypatch, tmp_path: Path):
- (tmp_path / ".agentengine.state").write_text(
- yaml.safe_dump(
- {
- "type": "openclaw",
- "framework": "openclaw",
- "endpoint": "https://openclaw.example.com",
- "api_key": "ak-openclaw",
- }
- ),
- encoding="utf-8",
- )
-
- captured = {"native": 0, "chat": 0}
-
- def _fake_native(*_args, **_kwargs):
- captured["native"] += 1
-
- def _fake_chat(
- endpoint,
- api_key=None,
- session_id=None,
- insecure=False,
- model=None,
- show_thinking=False,
- api_format=None,
- responses_session_header=None,
- ):
- captured["chat"] += 1
- captured["endpoint"] = endpoint
- captured["api_key"] = api_key
- captured["api_format"] = api_format
- captured["responses_session_header"] = responses_session_header
-
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_openclaw_terminal_tui", _fake_native)
- monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_tui", _fake_chat)
- monkeypatch.setattr(
- "ksadk.cli.cmd_invoke._resolve_remote_api_format",
- lambda **_kwargs: asyncio.sleep(0, result="responses"),
- )
-
- run_invoke_command(
- agent_ref=None,
- agent_option=None,
- endpoint="https://openclaw.example.com",
- api_key=None,
- message=None,
- session=None,
- region="cn-beijing-6",
- local=False,
- insecure=False,
- model=None,
- show_thinking=False,
- transport="chat",
- )
-
- assert captured["native"] == 0
- assert captured["chat"] == 1
- assert captured["endpoint"] == "https://openclaw.example.com"
- assert captured["api_key"] == "ak-openclaw"
- assert captured["api_format"] == "responses"
- assert captured["responses_session_header"] == "x-openclaw-session-key"
-
-
-def test_run_invoke_command_uses_openclaw_gateway_token_env_for_runtime_calls(monkeypatch, tmp_path: Path):
- (tmp_path / ".agentengine.state").write_text(
- yaml.safe_dump(
- {
- "type": "openclaw",
- "framework": "openclaw",
- "endpoint": "https://openclaw.example.com",
- "api_key": "ak-openclaw",
- "openclaw_auth_mode": "token",
- }
- ),
- encoding="utf-8",
- )
-
- captured = {}
-
- def _fake_chat(
- endpoint,
- api_key=None,
- session_id=None,
- insecure=False,
- model=None,
- show_thinking=False,
- api_format=None,
- responses_session_header=None,
- ):
- captured["endpoint"] = endpoint
- captured["runtime_api_key"] = api_key
- captured["api_format"] = api_format
- captured["responses_session_header"] = responses_session_header
-
- async def _fake_resolve_remote_api_format(**kwargs):
- captured["probe_api_key"] = kwargs["runtime_api_key"]
- return "responses"
-
- monkeypatch.chdir(tmp_path)
- monkeypatch.setenv("OPENCLAW_GATEWAY_TOKEN", "gateway-token")
- monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_tui", _fake_chat)
- monkeypatch.setattr("ksadk.cli.cmd_invoke._resolve_remote_api_format", _fake_resolve_remote_api_format)
-
- run_invoke_command(
- agent_ref=None,
- agent_option=None,
- endpoint="https://openclaw.example.com",
- api_key=None,
- message=None,
- session=None,
- region="cn-beijing-6",
- local=False,
- insecure=False,
- model=None,
- show_thinking=False,
- transport="chat",
- )
-
- assert captured["endpoint"] == "https://openclaw.example.com"
- assert captured["probe_api_key"] == "gateway-token"
- assert captured["runtime_api_key"] == "gateway-token"
- assert captured["api_format"] == "responses"
- assert captured["responses_session_header"] == "x-openclaw-session-key"
-
-
-def test_run_invoke_command_uses_openclaw_gateway_token_state_for_runtime_calls(monkeypatch, tmp_path: Path):
- (tmp_path / ".agentengine.state").write_text(
- yaml.safe_dump(
- {
- "type": "openclaw",
- "framework": "openclaw",
- "endpoint": "https://openclaw.example.com",
- "api_key": "ak-openclaw",
- "openclaw_auth_mode": "token",
- "openclaw_gateway_token": "gateway-token-from-state",
- }
- ),
- encoding="utf-8",
- )
-
- captured = {}
-
- def _fake_chat(
- endpoint,
- api_key=None,
- session_id=None,
- insecure=False,
- model=None,
- show_thinking=False,
- api_format=None,
- responses_session_header=None,
- ):
- captured["endpoint"] = endpoint
- captured["runtime_api_key"] = api_key
- captured["api_format"] = api_format
- captured["responses_session_header"] = responses_session_header
-
- async def _fake_resolve_remote_api_format(**kwargs):
- captured["probe_api_key"] = kwargs["runtime_api_key"]
- return "responses"
-
- monkeypatch.chdir(tmp_path)
- monkeypatch.delenv("OPENCLAW_GATEWAY_TOKEN", raising=False)
- monkeypatch.delenv("OPENCLAW_GATEWAY_PASSWORD", raising=False)
- monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_tui", _fake_chat)
- monkeypatch.setattr("ksadk.cli.cmd_invoke._resolve_remote_api_format", _fake_resolve_remote_api_format)
-
- run_invoke_command(
- agent_ref=None,
- agent_option=None,
- endpoint="https://openclaw.example.com",
- api_key=None,
- message=None,
- session=None,
- region="cn-beijing-6",
- local=False,
- insecure=False,
- model=None,
- show_thinking=False,
- transport="chat",
- )
-
- assert captured["endpoint"] == "https://openclaw.example.com"
- assert captured["probe_api_key"] == "gateway-token-from-state"
- assert captured["runtime_api_key"] == "gateway-token-from-state"
- assert captured["api_format"] == "responses"
- assert captured["responses_session_header"] == "x-openclaw-session-key"
-
-
-def test_run_invoke_command_rejects_openclaw_token_mode_without_gateway_token(monkeypatch, tmp_path: Path):
- (tmp_path / ".agentengine.state").write_text(
- yaml.safe_dump(
- {
- "type": "openclaw",
- "framework": "openclaw",
- "endpoint": "https://openclaw.example.com",
- "api_key": "ak-openclaw",
- "openclaw_auth_mode": "token",
- }
- ),
- encoding="utf-8",
- )
-
- monkeypatch.chdir(tmp_path)
- monkeypatch.delenv("OPENCLAW_GATEWAY_TOKEN", raising=False)
- monkeypatch.delenv("OPENCLAW_GATEWAY_PASSWORD", raising=False)
-
- with pytest.raises(click.ClickException) as exc_info:
- run_invoke_command(
- agent_ref=None,
- agent_option=None,
- endpoint="https://openclaw.example.com",
- api_key=None,
- message=None,
- session=None,
- region="cn-beijing-6",
- local=False,
- insecure=False,
- model=None,
- show_thinking=False,
- transport="auto",
- )
-
- assert "OPENCLAW_GATEWAY_TOKEN" in str(exc_info.value)
- assert "--gateway-token" in str(exc_info.value)
-
-
-def test_run_invoke_command_resolves_openclaw_state_without_explicit_agent(monkeypatch, tmp_path: Path):
- state_file = tmp_path / ".agentengine.state"
- state_file.write_text(
- yaml.safe_dump(
- {
- "agent_id": "ar-openclaw-demo",
- "type": "openclaw",
- "framework": "openclaw",
- "endpoint": "https://stale-openclaw.example.com",
- "api_key": "ak-stale",
- }
- ),
- encoding="utf-8",
- )
-
- captured = {}
-
- async def _fake_invoke_once(endpoint, message, api_key, session_id, stream, insecure, model, api_format="chat_completions"):
- captured["endpoint"] = endpoint
- captured["api_key"] = api_key
- captured["api_format"] = api_format
-
- async def _fake_resolve_remote_api_format(**_kwargs):
- return "responses"
-
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawInvokeClient)
- monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_once", _fake_invoke_once)
- monkeypatch.setattr("ksadk.cli.cmd_invoke._resolve_remote_api_format", _fake_resolve_remote_api_format)
-
- run_invoke_command(
- agent_ref=None,
- agent_option=None,
- endpoint=None,
- api_key=None,
- message="hello",
- session=None,
- region="pre-online",
- local=False,
- insecure=False,
- transport="auto",
- model=None,
- show_thinking=False,
- )
-
- state = yaml.safe_load(state_file.read_text(encoding="utf-8"))
- assert captured["endpoint"] == "https://openclaw.example.com"
- assert captured["api_key"] == "ak-openclaw"
- assert captured["api_format"] == "responses"
- assert state["type"] == "openclaw"
- assert state["framework"] == "openclaw"
- assert _FakeOpenClawInvokeClient.calls[-1] == {
- "agent_id": "ar-openclaw-demo",
- "name": None,
- "include_api_key": True,
- }
-
-
-def test_run_invoke_command_transport_chat_rejects_generic_chat_tui_for_hermes(monkeypatch, tmp_path: Path):
- (tmp_path / ".agentengine.state").write_text(
- yaml.safe_dump(
- {
- "type": "hermes",
- "framework": "hermes",
- "endpoint": "https://hermes.example.com",
- }
- ),
- encoding="utf-8",
- )
-
- captured = {"native": 0, "chat": 0}
-
- def _fake_native(*_args, **_kwargs):
- captured["native"] += 1
-
- def _fake_chat(*_args, **_kwargs):
- captured["chat"] += 1
-
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_hermes_terminal_tui", _fake_native)
- monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_tui", _fake_chat)
-
- with pytest.raises(SystemExit) as exc_info:
- run_invoke_command(
- agent_ref=None,
- agent_option=None,
- endpoint="https://hermes.example.com",
- api_key=None,
- message=None,
- session=None,
- region="cn-beijing-6",
- local=False,
- insecure=False,
- model=None,
- show_thinking=False,
- transport="chat",
- )
-
- assert exc_info.value.code == 1
- assert captured["native"] == 0
- assert captured["chat"] == 0
-
-
-def test_run_invoke_command_message_mode_keeps_http_chat_path(monkeypatch, tmp_path: Path):
- (tmp_path / ".agentengine.state").write_text(
- yaml.safe_dump(
- {
- "type": "hermes",
- "framework": "hermes",
- "endpoint": "https://hermes.example.com",
- }
- ),
- encoding="utf-8",
- )
-
- captured = {"once": 0, "native": 0, "chat": 0}
-
- async def _fake_invoke_once(endpoint, message, api_key, session_id, stream, insecure, model, api_format="chat_completions"):
- captured["once"] += 1
- captured["endpoint"] = endpoint
- captured["message"] = message
-
- def _fake_native(*_args, **_kwargs):
- captured["native"] += 1
-
- def _fake_chat(*_args, **_kwargs):
- captured["chat"] += 1
-
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_once", _fake_invoke_once)
- monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_hermes_terminal_tui", _fake_native)
- monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_tui", _fake_chat)
-
- run_invoke_command(
- agent_ref=None,
- agent_option=None,
- endpoint="https://hermes.example.com",
- api_key=None,
- message="hello",
- session=None,
- region="cn-beijing-6",
- local=False,
- insecure=False,
- model="glm-5",
- show_thinking=False,
- transport="auto",
- )
-
- assert captured["once"] == 1
- assert captured["native"] == 0
- assert captured["chat"] == 0
- assert captured["endpoint"] == "https://hermes.example.com"
- assert captured["message"] == "hello"
-
-
-def test_invoke_hermes_terminal_tui_exits_cleanly_on_keyboard_interrupt(monkeypatch):
- class _ImmediateAwaitable:
- def __await__(self):
- if False:
- yield None
- return 0
-
- def _fake_terminal_session(**_kwargs):
- return _ImmediateAwaitable()
-
- def _raise_keyboard_interrupt(_awaitable):
- raise KeyboardInterrupt
-
- monkeypatch.setattr("ksadk.cli.cmd_invoke._warmup_hermes_terminal", lambda **_kwargs: None)
- monkeypatch.setattr("ksadk.cli.cmd_invoke.run_hermes_terminal_session", _fake_terminal_session)
- monkeypatch.setattr("ksadk.cli.cmd_invoke.asyncio.run", _raise_keyboard_interrupt)
-
- with pytest.raises(SystemExit) as exc_info:
- _invoke_hermes_terminal_tui(
- endpoint="https://hermes.example.com",
- api_key="ak-hermes",
- session_id="sess-1",
- insecure=False,
- )
-
- assert exc_info.value.code == 130
-
-
-def test_invoke_hermes_terminal_tui_warms_up_with_status_before_tui(monkeypatch):
- calls = []
-
- async def _fake_terminal_session(**kwargs):
- calls.append(kwargs)
- return 0
-
- monkeypatch.setattr("ksadk.cli.cmd_invoke.run_hermes_terminal_session", _fake_terminal_session)
-
- _invoke_hermes_terminal_tui(
- endpoint="https://hermes.example.com",
- api_key="ak-hermes",
- session_id="sess-1",
- insecure=False,
- )
-
- assert [call["mode"] for call in calls] == ["exec", "tui"]
- assert calls[0]["argv"] == ["status"]
- assert calls[0]["endpoint"] == "https://hermes.example.com"
- assert calls[0]["api_key"] == "ak-hermes"
- assert calls[0]["session_id"] == "sess-1"
- assert calls[1]["argv"] == []
-
-
-def test_invoke_openclaw_terminal_tui_uses_common_terminal_client(monkeypatch):
- captured = {}
-
- def _fake_terminal_session(**kwargs):
- captured.update(kwargs)
- return object()
-
- def _run_success(_awaitable):
- return 0
-
- monkeypatch.setattr("ksadk.cli.cmd_invoke.run_terminal_session", _fake_terminal_session)
- monkeypatch.setattr("ksadk.cli.cmd_invoke.asyncio.run", _run_success)
-
- _invoke_openclaw_terminal_tui(
- endpoint="https://openclaw.example.com",
- api_key="gateway-token",
- session_id="sess-1",
- insecure=True,
- )
-
- assert captured["endpoint"] == "https://openclaw.example.com"
- assert captured["api_key"] == "gateway-token"
- assert captured["session_id"] == "sess-1"
- assert captured["mode"] == "tui"
-
-
-def test_run_invoke_command_syncs_local_workspace_before_hermes_native_tui(monkeypatch, tmp_path: Path):
- workspace_dir = tmp_path / "demo-workspace"
- workspace_dir.mkdir()
- (workspace_dir / "notes.txt").write_text("hello workspace", encoding="utf-8")
- (tmp_path / ".agentengine.state").write_text(
- yaml.safe_dump(
- {
- "agent_id": "ar-hermes-1",
- "type": "hermes",
- "framework": "hermes",
- "endpoint": "https://hermes.example.com",
- "api_key": "ak-hermes",
- }
- ),
- encoding="utf-8",
- )
-
- captured = {}
-
- async def _fake_sync_local_workspace_for_hermes_invoke(**kwargs):
- captured["sync_kwargs"] = kwargs
- return {
- "remote_path": "demo-workspace",
- "local_dir": str(workspace_dir),
- "created": ["demo-workspace/notes.txt"],
- "overwritten": [],
- "skipped": [],
- "total_files": 1,
- "direction": "push",
- }
-
- def _fake_emit_sync_payload(payload, _output_mode):
- captured["sync_payload"] = payload
-
- def _fake_native(endpoint, api_key=None, session_id=None, insecure=False, cwd=None):
- captured["native"] = {
- "endpoint": endpoint,
- "api_key": api_key,
- "session_id": session_id,
- "cwd": cwd,
- }
-
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr(
- "ksadk.cli.cmd_invoke._sync_local_workspace_for_hermes_invoke",
- _fake_sync_local_workspace_for_hermes_invoke,
- )
- monkeypatch.setattr("ksadk.cli.cmd_invoke._emit_sync_payload", _fake_emit_sync_payload)
- monkeypatch.setattr("ksadk.cli.cmd_invoke._build_sync_payload", lambda payload: payload)
- monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_hermes_terminal_tui", _fake_native)
- monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_tui", lambda *_args, **_kwargs: None)
-
- run_invoke_command(
- agent_ref=None,
- agent_option=None,
- endpoint="https://hermes.example.com",
- api_key=None,
- message=None,
- session=None,
- region="pre-online",
- local=False,
- insecure=False,
- transport="auto",
- model=None,
- show_thinking=False,
- local_workspace=str(workspace_dir),
- remote_workspace_path=None,
- )
-
- assert captured["sync_kwargs"]["remote_path"] == "demo-workspace"
- assert captured["native"]["cwd"] == "demo-workspace"
-
-
-def test_run_invoke_command_rejects_local_workspace_outside_hermes_native(monkeypatch, tmp_path: Path):
- workspace_dir = tmp_path / "demo-workspace"
- workspace_dir.mkdir()
- (tmp_path / ".agentengine.state").write_text(
- yaml.safe_dump(
- {
- "type": "hermes",
- "framework": "hermes",
- "endpoint": "https://hermes.example.com",
- }
- ),
- encoding="utf-8",
- )
-
- monkeypatch.chdir(tmp_path)
-
- with pytest.raises(SystemExit) as exc_info:
- run_invoke_command(
- agent_ref=None,
- agent_option=None,
- endpoint="https://hermes.example.com",
- api_key=None,
- message="hello",
- session=None,
- region="pre-online",
- local=False,
- insecure=False,
- transport="auto",
- model=None,
- show_thinking=False,
- local_workspace=str(workspace_dir),
- remote_workspace_path=None,
- )
-
- assert exc_info.value.code == 1
-
-
-def test_run_invoke_command_rejects_remote_workspace_path_without_local_workspace(monkeypatch, tmp_path: Path):
- monkeypatch.chdir(tmp_path)
-
- with pytest.raises(SystemExit) as exc_info:
- run_invoke_command(
- agent_ref=None,
- agent_option=None,
- endpoint="https://hermes.example.com",
- api_key="ak-hermes",
- message=None,
- session=None,
- region="pre-online",
- local=False,
- insecure=False,
- transport="native",
- model=None,
- show_thinking=False,
- local_workspace=None,
- remote_workspace_path="demo-workspace",
- )
-
- assert exc_info.value.code == 1
-
-
-def test_sync_local_workspace_for_hermes_invoke_rejects_single_file_over_limit(monkeypatch, tmp_path: Path):
- from ksadk.cli.cmd_invoke import _sync_local_workspace_for_hermes_invoke
-
- workspace_dir = tmp_path / "demo-workspace"
- workspace_dir.mkdir()
- oversized = workspace_dir / "large.bin"
- oversized.write_bytes(b"0123456789")
-
- async def _fake_lookup_workspace_upload_limit(**_kwargs):
- return 5
-
- async def _fake_push_workspace_files(**_kwargs):
- raise AssertionError("should reject before uploading")
-
- monkeypatch.setattr(
- "ksadk.cli.cmd_invoke._lookup_workspace_upload_limit",
- _fake_lookup_workspace_upload_limit,
- )
- monkeypatch.setattr(
- "ksadk.cli.cmd_invoke._push_workspace_files",
- _fake_push_workspace_files,
- )
-
- with pytest.raises(click.ClickException) as exc_info:
- asyncio.run(
- _sync_local_workspace_for_hermes_invoke(
- agent_ref="ar-hermes-1",
- local_workspace=workspace_dir,
- remote_path="demo-workspace",
- region="pre-online",
- endpoint="https://hermes.example.com",
- api_key="ak-hermes",
- )
- )
-
- assert "超过" in str(exc_info.value)
-
-
-def test_sync_local_workspace_for_hermes_invoke_rejects_total_directory_size_over_limit(monkeypatch, tmp_path: Path):
- from ksadk.cli.cmd_invoke import _sync_local_workspace_for_hermes_invoke
-
- workspace_dir = tmp_path / "demo-workspace"
- workspace_dir.mkdir()
- (workspace_dir / "a.txt").write_bytes(b"1234")
- (workspace_dir / "b.txt").write_bytes(b"5678")
-
- async def _fake_lookup_workspace_upload_limit(**_kwargs):
- return 7
-
- async def _fake_push_workspace_files(**_kwargs):
- raise AssertionError("should reject before uploading")
-
- monkeypatch.setattr(
- "ksadk.cli.cmd_invoke._lookup_workspace_upload_limit",
- _fake_lookup_workspace_upload_limit,
- )
- monkeypatch.setattr(
- "ksadk.cli.cmd_invoke._push_workspace_files",
- _fake_push_workspace_files,
- )
-
- with pytest.raises(click.ClickException) as exc_info:
- asyncio.run(
- _sync_local_workspace_for_hermes_invoke(
- agent_ref="ar-hermes-1",
- local_workspace=workspace_dir,
- remote_path="demo-workspace",
- region="pre-online",
- endpoint="https://hermes.example.com",
- api_key="ak-hermes",
- )
- )
-
- assert "目录总大小" in str(exc_info.value)
-
-
-def test_sync_local_workspace_for_hermes_invoke_reports_progress(monkeypatch, tmp_path: Path):
- from ksadk.cli.cmd_invoke import _sync_local_workspace_for_hermes_invoke
-
- workspace_dir = tmp_path / "demo-workspace"
- workspace_dir.mkdir()
- (workspace_dir / "a.txt").write_text("hello", encoding="utf-8")
- events: list[dict] = []
-
- async def _fake_lookup_workspace_upload_limit(**_kwargs):
- return 100
-
- async def _fake_push_workspace_files(**kwargs):
- kwargs["progress_callback"](
- {
- "phase": "upload_start",
- "current": 1,
- "total": 1,
- "remote_path": "demo-workspace/a.txt",
- "local_path": str(workspace_dir / "a.txt"),
- "size_bytes": 5,
- }
- )
- kwargs["progress_callback"](
- {
- "phase": "upload_done",
- "current": 1,
- "total": 1,
- "remote_path": "demo-workspace/a.txt",
- }
- )
- return {
- "remote_path": "demo-workspace",
- "local_dir": str(workspace_dir),
- "created": ["demo-workspace/a.txt"],
- "overwritten": [],
- "skipped": [],
- "total_files": 1,
- "direction": "push",
- }
-
- monkeypatch.setattr(
- "ksadk.cli.cmd_invoke._lookup_workspace_upload_limit",
- _fake_lookup_workspace_upload_limit,
- )
- monkeypatch.setattr(
- "ksadk.cli.cmd_invoke._push_workspace_files",
- _fake_push_workspace_files,
- )
-
- payload = asyncio.run(
- _sync_local_workspace_for_hermes_invoke(
- agent_ref="ar-hermes-1",
- local_workspace=workspace_dir,
- remote_path="demo-workspace",
- region="pre-online",
- endpoint="https://hermes.example.com",
- api_key="ak-hermes",
- progress_callback=events.append,
- )
- )
-
- assert payload["total_files"] == 1
- assert [event["phase"] for event in events] == [
- "limit_done",
- "scan_done",
- "upload_start",
- "upload_done",
- ]
- assert events[1]["total_files"] == 1
- assert events[1]["total_bytes"] == 5
-
-
-def test_sync_local_workspace_for_hermes_invoke_ignores_local_dev_artifacts(monkeypatch, tmp_path: Path):
- from ksadk.cli.cmd_invoke import _sync_local_workspace_for_hermes_invoke
-
- workspace_dir = tmp_path / "demo-workspace"
- workspace_dir.mkdir()
- (workspace_dir / "app.py").write_text("print('ok')\n", encoding="utf-8")
-
- git_object = workspace_dir / ".git" / "objects" / "aa"
- git_object.mkdir(parents=True)
- (git_object / "blob").write_bytes(b"x" * 4096)
-
- events: list[dict] = []
- captured: dict[str, object] = {}
-
- async def _fake_lookup_workspace_upload_limit(**_kwargs):
- return 1024
-
- async def _fake_push_workspace_files(**kwargs):
- captured["ignore_dev_artifacts"] = kwargs["ignore_dev_artifacts"]
- return {
- "remote_path": "demo-workspace",
- "local_dir": str(workspace_dir),
- "created": ["demo-workspace/app.py"],
- "overwritten": [],
- "skipped": [],
- "total_files": 1,
- "direction": "push",
- }
-
- monkeypatch.setattr(
- "ksadk.cli.cmd_invoke._lookup_workspace_upload_limit",
- _fake_lookup_workspace_upload_limit,
- )
- monkeypatch.setattr(
- "ksadk.cli.cmd_invoke._push_workspace_files",
- _fake_push_workspace_files,
- )
-
- payload = asyncio.run(
- _sync_local_workspace_for_hermes_invoke(
- agent_ref="ar-hermes-1",
- local_workspace=workspace_dir,
- remote_path="demo-workspace",
- region="pre-online",
- endpoint="https://hermes.example.com",
- api_key="ak-hermes",
- progress_callback=events.append,
- )
- )
-
- assert payload["total_files"] == 1
- assert events[1]["total_files"] == 1
- assert events[1]["total_bytes"] == 12
- assert captured["ignore_dev_artifacts"] is True
-
-
-def test_emit_workspace_sync_progress_shows_percentage_bar(capsys):
- from ksadk.cli.cmd_invoke import _build_workspace_sync_progress_emitter
-
- emitter = _build_workspace_sync_progress_emitter(verbose=True)
- emitter(
- {
- "phase": "upload_start",
- "current": 2,
- "total": 4,
- "remote_path": "demo-workspace/app.py",
- "size_bytes": 12,
- }
- )
-
- output = capsys.readouterr().out
- assert "50%" in output
- assert "2/4" in output
- assert "上传 demo-workspace/app.py" in output
- assert "[" in output and "]" in output
-
-
-def test_workspace_sync_progress_emitter_uses_inline_updates_by_default(monkeypatch):
- from ksadk.cli import cmd_invoke
-
- echo_calls: list[dict] = []
-
- def _fake_echo(message="", **kwargs):
- echo_calls.append({"message": message, "kwargs": kwargs})
-
- monkeypatch.setattr(cmd_invoke.click, "echo", _fake_echo)
- monkeypatch.setattr(cmd_invoke.click, "secho", lambda *args, **kwargs: None)
-
- emitter = cmd_invoke._build_workspace_sync_progress_emitter(verbose=False)
- emitter(
- {
- "phase": "upload_start",
- "current": 1,
- "total": 3,
- "remote_path": "demo-workspace/a-very-long-file-name.txt",
- "size_bytes": 5,
- }
- )
- emitter(
- {
- "phase": "upload_start",
- "current": 2,
- "total": 3,
- "remote_path": "b.txt",
- "size_bytes": 5,
- }
- )
- emitter(
- {
- "phase": "upload_done",
- "current": 3,
- "total": 3,
- "remote_path": "demo-workspace/c.txt",
- }
- )
-
- upload_calls = [call for call in echo_calls if "上传 " in str(call["message"])]
- assert len(upload_calls) == 2
- assert all(call["kwargs"].get("nl") is False for call in upload_calls)
- assert any(call["message"] == "" for call in echo_calls)
- assert str(upload_calls[1]["message"]).endswith(" ")
-
-
-def test_workspace_sync_progress_emitter_supports_verbose_file_logs(monkeypatch):
- from ksadk.cli import cmd_invoke
-
- echo_calls: list[dict] = []
-
- def _fake_echo(message="", **kwargs):
- echo_calls.append({"message": message, "kwargs": kwargs})
-
- monkeypatch.setattr(cmd_invoke.click, "echo", _fake_echo)
- monkeypatch.setattr(cmd_invoke.click, "secho", lambda *args, **kwargs: None)
-
- emitter = cmd_invoke._build_workspace_sync_progress_emitter(verbose=True)
- emitter(
- {
- "phase": "upload_start",
- "current": 2,
- "total": 4,
- "remote_path": "demo-workspace/app.py",
- "size_bytes": 12,
- }
- )
-
- assert echo_calls
- assert "上传 demo-workspace/app.py" in str(echo_calls[0]["message"])
- assert echo_calls[0]["kwargs"].get("nl", True) is True
-
-
-def test_run_invoke_command_builds_verbose_workspace_sync_emitter(monkeypatch, tmp_path: Path):
- workspace_dir = tmp_path / "demo-workspace"
- workspace_dir.mkdir()
- (workspace_dir / "notes.txt").write_text("hello workspace", encoding="utf-8")
- (tmp_path / ".agentengine.state").write_text(
- yaml.safe_dump(
- {
- "agent_id": "ar-hermes-1",
- "type": "hermes",
- "framework": "hermes",
- "endpoint": "https://hermes.example.com",
- "api_key": "ak-hermes",
- }
- ),
- encoding="utf-8",
- )
-
- captured: dict[str, object] = {}
-
- async def _fake_sync_local_workspace_for_hermes_invoke(**kwargs):
- captured["progress_callback"] = kwargs["progress_callback"]
- return {
- "remote_path": "demo-workspace",
- "local_dir": str(workspace_dir),
- "created": ["demo-workspace/notes.txt"],
- "overwritten": [],
- "skipped": [],
- "total_files": 1,
- "direction": "push",
- }
-
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr(
- "ksadk.cli.cmd_invoke._sync_local_workspace_for_hermes_invoke",
- _fake_sync_local_workspace_for_hermes_invoke,
- )
- monkeypatch.setattr("ksadk.cli.cmd_invoke._emit_sync_payload", lambda *_args, **_kwargs: None)
- monkeypatch.setattr("ksadk.cli.cmd_invoke._build_sync_payload", lambda payload: payload)
- monkeypatch.setattr("ksadk.cli.cmd_invoke._invoke_hermes_terminal_tui", lambda **_kwargs: None)
- monkeypatch.setattr(
- "ksadk.cli.cmd_invoke._build_workspace_sync_progress_emitter",
- lambda verbose: captured.setdefault("verbose_workspace_sync", verbose) or (lambda _event: None),
- )
-
- run_invoke_command(
- agent_ref=None,
- agent_option=None,
- endpoint="https://hermes.example.com",
- api_key=None,
- message=None,
- session=None,
- region="pre-online",
- local=False,
- insecure=False,
- transport="auto",
- model=None,
- show_thinking=False,
- local_workspace=str(workspace_dir),
- remote_workspace_path=None,
- verbose_workspace_sync=True,
- )
-
- assert captured["verbose_workspace_sync"] is True
-
-
-def test_sync_local_workspace_for_hermes_invoke_keeps_git_when_under_limit(monkeypatch, tmp_path: Path):
- from ksadk.cli.cmd_invoke import _sync_local_workspace_for_hermes_invoke
-
- workspace_dir = tmp_path / "demo-workspace"
- workspace_dir.mkdir()
- (workspace_dir / "app.py").write_text("print('ok')\n", encoding="utf-8")
- git_dir = workspace_dir / ".git"
- git_dir.mkdir()
- (git_dir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
-
- captured: dict[str, object] = {}
-
- async def _fake_lookup_workspace_upload_limit(**_kwargs):
- return 1024 * 1024
-
- async def _fake_push_workspace_files(**kwargs):
- captured["ignore_git_artifacts"] = kwargs["ignore_git_artifacts"]
- return {
- "remote_path": "demo-workspace",
- "local_dir": str(workspace_dir),
- "created": ["demo-workspace/app.py", "demo-workspace/.git/HEAD"],
- "overwritten": [],
- "skipped": [],
- "total_files": 2,
- "direction": "push",
- }
-
- monkeypatch.setattr(
- "ksadk.cli.cmd_invoke._lookup_workspace_upload_limit",
- _fake_lookup_workspace_upload_limit,
- )
- monkeypatch.setattr(
- "ksadk.cli.cmd_invoke._push_workspace_files",
- _fake_push_workspace_files,
- )
-
- payload = asyncio.run(
- _sync_local_workspace_for_hermes_invoke(
- agent_ref="ar-hermes-1",
- local_workspace=workspace_dir,
- remote_path="demo-workspace",
- region="pre-online",
- endpoint="https://hermes.example.com",
- api_key="ak-hermes",
- )
- )
-
- assert payload["total_files"] == 2
- assert captured["ignore_git_artifacts"] is False
-
-
-def test_sync_local_workspace_for_hermes_invoke_drops_git_when_needed_for_limit(monkeypatch, tmp_path: Path):
- from ksadk.cli.cmd_invoke import _sync_local_workspace_for_hermes_invoke
-
- workspace_dir = tmp_path / "demo-workspace"
- workspace_dir.mkdir()
- (workspace_dir / "app.py").write_text("print('ok')\n", encoding="utf-8")
- git_objects = workspace_dir / ".git" / "objects"
- git_objects.mkdir(parents=True)
- (git_objects / "blob").write_bytes(b"x" * 600)
-
- events: list[dict] = []
- captured: dict[str, object] = {}
-
- async def _fake_lookup_workspace_upload_limit(**_kwargs):
- return 512
-
- async def _fake_push_workspace_files(**kwargs):
- captured["ignore_git_artifacts"] = kwargs["ignore_git_artifacts"]
- return {
- "remote_path": "demo-workspace",
- "local_dir": str(workspace_dir),
- "created": ["demo-workspace/app.py"],
- "overwritten": [],
- "skipped": [],
- "total_files": 1,
- "direction": "push",
- }
-
- monkeypatch.setattr(
- "ksadk.cli.cmd_invoke._lookup_workspace_upload_limit",
- _fake_lookup_workspace_upload_limit,
- )
- monkeypatch.setattr(
- "ksadk.cli.cmd_invoke._push_workspace_files",
- _fake_push_workspace_files,
- )
-
- payload = asyncio.run(
- _sync_local_workspace_for_hermes_invoke(
- agent_ref="ar-hermes-1",
- local_workspace=workspace_dir,
- remote_path="demo-workspace",
- region="pre-online",
- endpoint="https://hermes.example.com",
- api_key="ak-hermes",
- progress_callback=events.append,
- )
- )
-
- assert payload["total_files"] == 1
- assert captured["ignore_git_artifacts"] is True
- assert ".git" in events[1]["ignored_artifacts"]
-
-
-def test_sync_local_workspace_for_hermes_invoke_wraps_remote_errors(monkeypatch, tmp_path: Path):
- from ksadk.cli.cmd_invoke import _sync_local_workspace_for_hermes_invoke
-
- workspace_dir = tmp_path / "demo-workspace"
- workspace_dir.mkdir()
- (workspace_dir / "a.txt").write_text("hello", encoding="utf-8")
- events: list[dict] = []
-
- async def _fake_lookup_workspace_upload_limit(**_kwargs):
- return 100
-
- async def _fake_push_workspace_files(**kwargs):
- kwargs["progress_callback"](
- {
- "phase": "upload_start",
- "current": 1,
- "total": 1,
- "remote_path": "demo-workspace/a.txt",
- }
- )
- raise AgentEngineAPIError(500, "runtime exploded")
-
- monkeypatch.setattr(
- "ksadk.cli.cmd_invoke._lookup_workspace_upload_limit",
- _fake_lookup_workspace_upload_limit,
- )
- monkeypatch.setattr(
- "ksadk.cli.cmd_invoke._push_workspace_files",
- _fake_push_workspace_files,
- )
-
- with pytest.raises(click.ClickException) as exc_info:
- asyncio.run(
- _sync_local_workspace_for_hermes_invoke(
- agent_ref="ar-hermes-1",
- local_workspace=workspace_dir,
- remote_path="demo-workspace",
- region="pre-online",
- endpoint="https://hermes.example.com",
- api_key="ak-hermes",
- progress_callback=events.append,
- )
- )
-
- message = str(exc_info.value)
- assert "同步远端 workspace 失败" in message
- assert "上传 demo-workspace/a.txt" in message
- assert "runtime exploded" in message
diff --git a/tests/test_cmd_launch_no_cache.py b/tests/test_cmd_launch_no_cache.py
deleted file mode 100644
index 1338cf08..00000000
--- a/tests/test_cmd_launch_no_cache.py
+++ /dev/null
@@ -1,286 +0,0 @@
-import asyncio
-from pathlib import Path
-
-from click.testing import CliRunner
-
-from ksadk.cli import cmd_launch
-from ksadk.deployment.base import DeployResult, DeployStatus, PackageInfo
-
-
-class _FakeDetectionType:
- value = "langgraph"
-
-
-class _FakeDetectionResult:
- type = _FakeDetectionType()
- name = "langgraph"
- entry_point = "agent.py"
-
-
-class _FakeProvider:
- def __init__(self):
- self.calls = []
- self.package_metadata_file_exists = None
- self.last_target = None
-
- async def validate_config(self, _target):
- self.last_target = _target
- self.calls.append("validate")
- return True, ""
-
- async def package(self, project_dir, _detection_result, _config):
- self.calls.append("package")
- metadata_file = Path(project_dir) / ".agentengine" / "build-metadata.json"
- self.package_metadata_file_exists = metadata_file.exists()
- return PackageInfo(
- name="demo-agent",
- framework="langgraph",
- build_dir=str(Path(project_dir) / ".agentengine" / "build"),
- project_dir=str(project_dir),
- metadata={},
- )
-
- async def build(self, package_info, _target):
- self.calls.append("build")
- package_info.metadata["ks3_path"] = "ks3://bucket/agents/demo-agent/code_20260320170000.zip"
- return package_info
-
- async def deploy(self, _package_info, _target):
- self.calls.append("deploy")
- return DeployResult(
- status=DeployStatus.DEPLOYING,
- agent_id="ar-demo",
- agent_name="demo-agent",
- endpoint="http://demo-endpoint",
- message="ok",
- )
-
-
-def test_launch_no_cache_triggers_build_and_clears_metadata(tmp_path: Path, monkeypatch):
- provider = _FakeProvider()
- metadata_dir = tmp_path / ".agentengine"
- metadata_dir.mkdir(parents=True, exist_ok=True)
- (metadata_dir / "build-metadata.json").write_text('{"metadata":{"ks3_path":"ks3://old/path.zip"}}', encoding="utf-8")
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})())
- monkeypatch.setattr("ksadk.cli.cmd_launch._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"})
- monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- asyncio.run(
- cmd_launch._launch_async(
- agent_dir=str(tmp_path),
- target="serverless",
- name=None,
- region="cn-beijing-6",
- account_id="2000003485",
- observability=True,
- no_cache=True,
- port=8000,
- namespace="default",
- registry=None,
- ks3_bucket=None,
- ks3_path=None,
- image=None,
- ui_profile=None,
- ui_path=None,
- ui_url=None,
- dry_run=False,
- artifact_type="Code",
- no_version=True,
- auto_rollback=False,
- )
- )
-
- assert provider.package_metadata_file_exists is False
- assert provider.calls == ["validate", "package", "build", "deploy"]
-
-
-def test_launch_no_cache_warns_when_explicit_ks3_path_is_supplied(tmp_path: Path, monkeypatch, capsys):
- provider = _FakeProvider()
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})())
- monkeypatch.setattr("ksadk.cli.cmd_launch._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"})
- monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- asyncio.run(
- cmd_launch._launch_async(
- agent_dir=str(tmp_path),
- target="serverless",
- name=None,
- region="cn-beijing-6",
- account_id="2000003485",
- observability=True,
- no_cache=True,
- port=8000,
- namespace="default",
- registry=None,
- ks3_bucket=None,
- ks3_path="ks3://bucket/agents/demo-agent/code_manual.zip",
- image=None,
- ui_profile=None,
- ui_path=None,
- ui_url=None,
- dry_run=False,
- artifact_type="Code",
- no_version=True,
- auto_rollback=False,
- )
- )
-
- out = capsys.readouterr().out
- assert "已显式指定 --ks3-path" in out
- assert provider.calls == ["validate", "package", "deploy"]
-
-
-def test_launch_cli_network_options_apply_to_deploy_target(tmp_path: Path, monkeypatch):
- provider = _FakeProvider()
- runner = CliRunner()
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})())
- monkeypatch.setattr("ksadk.cli.cmd_launch._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"})
- monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- result = runner.invoke(
- cmd_launch.launch,
- [
- str(tmp_path),
- "--ks3-path",
- "ks3://bucket/agents/demo-agent/code_manual.zip",
- "--disable-public-access",
- "--enable-vpc-access",
- "--vpc-id",
- "vpc-cli",
- "--subnet-id",
- "subnet-cli",
- "--security-group-id",
- "sg-cli",
- "--availability-zone",
- "cn-beijing-6b",
- "--no-version",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert provider.last_target is not None
- assert provider.last_target.network.enable_public_access is False
- assert provider.last_target.network.enable_vpc_access is True
- assert provider.last_target.network.vpc_id == "vpc-cli"
- assert provider.last_target.network.subnet_id == "subnet-cli"
- assert provider.last_target.network.security_group_id == "sg-cli"
- assert provider.last_target.network.availability_zone == "cn-beijing-6b"
-
-
-def test_launch_network_ids_imply_vpc_access(tmp_path: Path, monkeypatch):
- provider = _FakeProvider()
- runner = CliRunner()
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})())
- monkeypatch.setattr("ksadk.cli.cmd_launch._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"})
- monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- result = runner.invoke(
- cmd_launch.launch,
- [
- str(tmp_path),
- "--ks3-path",
- "ks3://bucket/agents/demo-agent/code_manual.zip",
- "--vpc-id",
- "vpc-cli",
- "--subnet-id",
- "subnet-cli",
- "--security-group-id",
- "sg-cli",
- "--no-version",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert provider.last_target is not None
- assert provider.last_target.network.enable_vpc_access is True
-
-
-def test_launch_cli_forwards_explicit_env_and_env_file(tmp_path: Path, monkeypatch):
- provider = _FakeProvider()
- runner = CliRunner()
- env_file = tmp_path / "runtime.env"
- env_file.write_text(
- "APP_MODE=file\nFILE_ONLY=1\nOVERRIDE_ME=from-file\n",
- encoding="utf-8",
- )
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})())
- monkeypatch.setattr("ksadk.cli.cmd_launch._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"})
- monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- result = runner.invoke(
- cmd_launch.launch,
- [
- str(tmp_path),
- "--ks3-path",
- "ks3://bucket/agents/demo-agent/code_manual.zip",
- "--env-file",
- str(env_file),
- "--env",
- "OVERRIDE_ME=from-cli",
- "--env",
- "CLI_ONLY=yes",
- "--no-version",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert provider.last_target is not None
- assert provider.last_target.extra["env_vars"] == {
- "APP_MODE": "file",
- "FILE_ONLY": "1",
- "OVERRIDE_ME": "from-cli",
- "CLI_ONLY": "yes",
- }
-
-
-def test_launch_reads_ui_config_from_agentengine_yaml_when_cli_not_set(tmp_path: Path, monkeypatch):
- provider = _FakeProvider()
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})())
- monkeypatch.setattr(
- "ksadk.cli.cmd_launch._load_config",
- lambda *_args, **_kwargs: {
- "name": "demo-agent",
- "ui": {
- "profile": "custom",
- "path": "/custom-chat",
- "url": "https://ui.example.com/custom-chat",
- },
- },
- )
- monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- asyncio.run(
- cmd_launch._launch_async(
- agent_dir=str(tmp_path),
- target="serverless",
- name=None,
- region="cn-beijing-6",
- account_id="2000003485",
- observability=True,
- no_cache=False,
- port=8000,
- namespace="default",
- registry=None,
- ks3_bucket=None,
- ks3_path="ks3://bucket/agents/demo-agent/code_manual.zip",
- image=None,
- ui_profile=None,
- ui_path=None,
- ui_url=None,
- dry_run=False,
- artifact_type="Code",
- no_version=True,
- auto_rollback=False,
- )
- )
-
- assert provider.last_target is not None
- assert provider.last_target.extra["ui_profile"] == "custom"
- assert provider.last_target.extra["ui_path"] == "/custom-chat"
- assert provider.last_target.extra["ui_url"] == "https://ui.example.com/custom-chat"
diff --git a/tests/test_cmd_mcp_no_cache.py b/tests/test_cmd_mcp_no_cache.py
deleted file mode 100644
index ab4e229b..00000000
--- a/tests/test_cmd_mcp_no_cache.py
+++ /dev/null
@@ -1,74 +0,0 @@
-import asyncio
-from pathlib import Path
-
-import pytest
-
-from ksadk.api.client import DryRunExit
-from ksadk.cli import cmd_mcp
-
-
-class _FakeMCPDetectionResult:
- is_valid = True
- entry_point = "server.py"
- mcp_variable = "mcp"
- tools = ["tool_a", "tool_b"]
-
-
-class _FakeMCPDetector:
- def __init__(self, *_args, **_kwargs):
- pass
-
- def detect(self):
- return _FakeMCPDetectionResult()
-
-
-class _FakeDryRunClient:
- def __init__(self, *args, **kwargs):
- self.kwargs = kwargs
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
- async def create_mcp(self, request):
- raise DryRunExit("dry-run", payload={"body": request})
-
- async def update_mcp(self, *_args, **_kwargs):
- raise DryRunExit("dry-run", payload={"body": {}})
-
- async def close(self):
- return None
-
-
-def test_mcp_deploy_dry_run_skips_local_build_and_relies_on_plan(tmp_path: Path, monkeypatch):
- monkeypatch.setattr("ksadk.detection.mcp_detector.MCPDetector", _FakeMCPDetector)
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeDryRunClient)
-
- def _should_not_build(*_args, **_kwargs):
- raise AssertionError("Dry run should not trigger artifact build")
-
- monkeypatch.setattr(cmd_mcp, "_build_code_artifact", _should_not_build)
-
- with pytest.raises(DryRunExit) as exc_info:
- asyncio.run(
- cmd_mcp._deploy_mcp_async(
- mcp_dir=str(tmp_path),
- name=None,
- region="cn-beijing-6",
- ks3_bucket="agentengine-test",
- enable_auth=False,
- dry_run=True,
- artifact_type="Code",
- no_cache=True,
- )
- )
-
- payload = exc_info.value.payload or {}
- body = payload.get("body") or {}
-
- assert body["artifact_type"] == "Code"
- assert body["region"] == "cn-beijing-6"
- assert body["artifact_path"].startswith("ks3://agentengine-test/")
- assert "dry-run" in body["artifact_path"]
diff --git a/tests/test_cmd_model.py b/tests/test_cmd_model.py
deleted file mode 100644
index f16c05eb..00000000
--- a/tests/test_cmd_model.py
+++ /dev/null
@@ -1,170 +0,0 @@
-from pathlib import Path
-
-import pytest
-import yaml
-from click.testing import CliRunner
-
-from ksadk.cli import cmd_model
-from ksadk.cli.cmd_config import config
-
-
-@pytest.fixture(autouse=True)
-def _isolate_model_env(monkeypatch):
- for key in (
- "OPENAI_MODEL_NAME",
- "MODEL_NAME",
- "OPENAI_API_BASE",
- "COZE_WORKLOAD_IDENTITY_API_KEY",
- "COZE_INTEGRATION_BASE_URL",
- "COZE_INTEGRATION_MODEL_BASE_URL",
- ):
- monkeypatch.delenv(key, raising=False)
-
-
-def test_config_model_env_prints_openclaw_allowlist_from_state(monkeypatch, tmp_path: Path):
- (tmp_path / ".agentengine.state").write_text(
- yaml.safe_dump({"type": "openclaw", "framework": "openclaw"}),
- encoding="utf-8",
- )
- runner = CliRunner()
- monkeypatch.chdir(tmp_path)
-
- result = runner.invoke(
- config,
- [
- "model",
- "--env",
- "deepseek-v4-pro,glm-5.1",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert result.output.splitlines() == [
- "OPENAI_MODEL_NAME=deepseek-v4-pro",
- "OPENCLAW_MODEL_ALLOWLIST=deepseek-v4-pro,glm-5.1",
- ]
- assert not (tmp_path / ".env").exists()
-
-
-def test_config_model_env_prints_generic_allowlist_for_hermes(monkeypatch, tmp_path: Path):
- (tmp_path / "agentengine.yaml").write_text(
- "framework: hermes\n",
- encoding="utf-8",
- )
- runner = CliRunner()
- monkeypatch.chdir(tmp_path)
-
- result = runner.invoke(
- config,
- [
- "model",
- "--env",
- "deepseek-v4-pro,glm-5.1",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert result.output.splitlines() == [
- "OPENAI_MODEL_NAME=deepseek-v4-pro",
- "AGENTENGINE_MODEL_ALLOWLIST=deepseek-v4-pro,glm-5.1",
- ]
- assert not (tmp_path / ".env").exists()
-
-
-def test_config_model_env_single_model_prints_only_default(monkeypatch, tmp_path: Path):
- (tmp_path / ".agentengine.state").write_text(
- yaml.safe_dump({"type": "openclaw", "framework": "openclaw"}),
- encoding="utf-8",
- )
- runner = CliRunner()
- monkeypatch.chdir(tmp_path)
-
- result = runner.invoke(
- config,
- [
- "model",
- "--env",
- "deepseek-v4-pro",
- ],
- )
-
- assert result.exit_code == 0, result.output
- assert result.output.splitlines() == ["OPENAI_MODEL_NAME=deepseek-v4-pro"]
- assert not (tmp_path / ".env").exists()
-
-
-def test_config_model_multi_select_writes_openclaw_allowlist(monkeypatch, tmp_path: Path):
- (tmp_path / ".agentengine.state").write_text(
- yaml.safe_dump({"type": "openclaw", "framework": "openclaw"}),
- encoding="utf-8",
- )
- runner = CliRunner()
- monkeypatch.chdir(tmp_path)
- monkeypatch.setenv("OPENAI_BASE_URL", "https://kspmas.ksyun.com/v1")
- monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
- monkeypatch.setattr(cmd_model, "is_stdout_tty", lambda: True)
-
- class _Response:
- def raise_for_status(self):
- return None
-
- def json(self):
- return {
- "data": [
- {"id": "glm-5.1"},
- {"id": "deepseek-v4-pro"},
- {"id": "kimi-k2.6"},
- ]
- }
-
- class _Prompt:
- def ask(self):
- return ["deepseek-v4-pro", "glm-5.1"]
-
- monkeypatch.setattr(cmd_model.httpx, "get", lambda *_args, **_kwargs: _Response())
- monkeypatch.setattr(cmd_model.questionary, "checkbox", lambda *_args, **_kwargs: _Prompt())
-
- result = runner.invoke(config, ["model", "--multi"])
-
- assert result.exit_code == 0, result.output
- env_text = (tmp_path / ".env").read_text(encoding="utf-8")
- assert "OPENAI_MODEL_NAME=deepseek-v4-pro" in env_text
- assert "OPENCLAW_MODEL_ALLOWLIST=deepseek-v4-pro,glm-5.1" in env_text
-
-
-def test_config_model_writes_current_project_env_not_parent_env(monkeypatch, tmp_path: Path):
- parent_env = tmp_path / ".env"
- parent_env.write_text(
- "OPENAI_BASE_URL=https://parent.example/v1\nOPENAI_MODEL_NAME=parent-model\n",
- encoding="utf-8",
- )
- project_dir = tmp_path / "demo-agent"
- project_dir.mkdir()
-
- runner = CliRunner()
- monkeypatch.chdir(project_dir)
- monkeypatch.setenv("OPENAI_BASE_URL", "https://kspmas.ksyun.com/v1")
- monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
- monkeypatch.setattr(cmd_model, "is_stdout_tty", lambda: True)
-
- class _Response:
- def raise_for_status(self):
- return None
-
- def json(self):
- return {"data": [{"id": "deepseek-v4-pro"}, {"id": "glm-5.1"}]}
-
- class _Prompt:
- def ask(self):
- return "deepseek-v4-pro"
-
- monkeypatch.setattr(cmd_model.httpx, "get", lambda *_args, **_kwargs: _Response())
- monkeypatch.setattr(cmd_model.questionary, "select", lambda *_args, **_kwargs: _Prompt())
-
- result = runner.invoke(config, ["model"])
-
- assert result.exit_code == 0, result.output
- assert "OPENAI_MODEL_NAME=deepseek-v4-pro" in (project_dir / ".env").read_text(encoding="utf-8")
- assert parent_env.read_text(encoding="utf-8") == (
- "OPENAI_BASE_URL=https://parent.example/v1\nOPENAI_MODEL_NAME=parent-model\n"
- )
diff --git a/tests/test_code_builder_binary_compat.py b/tests/test_code_builder_binary_compat.py
deleted file mode 100644
index 6e07852e..00000000
--- a/tests/test_code_builder_binary_compat.py
+++ /dev/null
@@ -1,31 +0,0 @@
-from ksadk.builders.code_builder import CodeBuilder
-
-
-def test_detect_critical_binary_issues_accepts_target_python_abi(tmp_path):
- builder = CodeBuilder(tmp_path)
- names = ["pydantic_core/_pydantic_core.cpython-312-x86_64-linux-gnu.so"]
-
- issues = builder._detect_critical_binary_issues(names)
-
- assert not issues
-
-
-def test_detect_critical_binary_issues_rejects_python_abi_mismatch(tmp_path):
- builder = CodeBuilder(tmp_path)
- names = ["pydantic_core/_pydantic_core.cpython-313-x86_64-linux-gnu.so"]
-
- issues = builder._detect_critical_binary_issues(names)
-
- assert (
- "python-abi-mismatch:pydantic_core/_pydantic_core:"
- "expected-cpython-312-or-abi3"
- ) in issues
-
-
-def test_detect_critical_binary_issues_rejects_non_linux_binary(tmp_path):
- builder = CodeBuilder(tmp_path)
- names = ["pydantic_core/_pydantic_core.cpython-312-darwin.so"]
-
- issues = builder._detect_critical_binary_issues(names)
-
- assert "missing-linux:pydantic_core/_pydantic_core" in issues
diff --git a/tests/test_code_builder_pip_indexes.py b/tests/test_code_builder_pip_indexes.py
deleted file mode 100644
index 7c5f31ba..00000000
--- a/tests/test_code_builder_pip_indexes.py
+++ /dev/null
@@ -1,528 +0,0 @@
-import io
-import json
-import subprocess
-import sys
-
-from ksadk.builders.code_builder import CodeBuilder
-
-
-def _completed_process(cmd):
- return subprocess.CompletedProcess(cmd, 0, "", "")
-
-
-class _FakePopen:
- def __init__(self, cmd, *, calls, output_lines=None, returncode=0, **_kwargs):
- calls.append(cmd)
- self.args = cmd
- self.returncode = returncode
- self.stdout = io.StringIO("".join(output_lines or []))
-
- def wait(self, timeout=None):
- return self.returncode
-
- def kill(self):
- return None
-
-
-def test_install_dependencies_respects_explicit_pip_index(tmp_path, monkeypatch):
- builder = CodeBuilder(tmp_path)
- builder.deps_dir.mkdir(parents=True, exist_ok=True)
- requirements_path = tmp_path / "requirements.txt"
- requirements_path.write_text("demo==1.0\n", encoding="utf-8")
-
- calls = []
-
- def fake_popen(cmd, **kwargs):
- return _FakePopen(
- cmd,
- calls=calls,
- output_lines=[
- "Collecting demo==1.0\n",
- "Installing collected packages: demo\n",
- "Successfully installed demo-1.0\n",
- ],
- **kwargs,
- )
-
- monkeypatch.setenv("PIP_INDEX_URL", "https://pypi.org/simple")
- monkeypatch.setattr("ksadk.builders.code_builder.subprocess.Popen", fake_popen)
- monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: [])
-
- assert builder._install_dependencies(requirements_path) is True
- assert calls
- assert "-i" not in calls[0]
-
-
-def test_install_dependencies_prefers_target_runtime_wheels(tmp_path, monkeypatch):
- builder = CodeBuilder(tmp_path)
- builder.deps_dir.mkdir(parents=True, exist_ok=True)
- requirements_path = tmp_path / "requirements.txt"
- requirements_path.write_text("demo==1.0\n", encoding="utf-8")
-
- calls = []
-
- def fake_popen(cmd, **kwargs):
- return _FakePopen(
- cmd,
- calls=calls,
- output_lines=[
- "Collecting demo==1.0\n",
- "Downloading demo-1.0-py3-none-any.whl\n",
- "Installing collected packages: demo\n",
- "Successfully installed demo-1.0\n",
- ],
- **kwargs,
- )
-
- monkeypatch.setattr("ksadk.builders.code_builder.subprocess.Popen", fake_popen)
- monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: [])
-
- assert builder._install_dependencies(requirements_path) is True
- assert calls
- assert "--platform" in calls[0]
- assert "manylinux2014_x86_64" in calls[0]
- assert "--python-version" in calls[0]
- assert builder.TARGET_PYTHON_VERSION in calls[0]
- assert "--only-binary=:all:" in calls[0]
-
-
-def test_install_dependencies_uses_persistent_project_pip_cache(tmp_path, monkeypatch):
- builder = CodeBuilder(tmp_path)
- builder.deps_dir.mkdir(parents=True, exist_ok=True)
- requirements_path = tmp_path / "requirements.txt"
- requirements_path.write_text("demo==1.0\n", encoding="utf-8")
-
- calls = []
-
- def fake_popen(cmd, **kwargs):
- return _FakePopen(
- cmd,
- calls=calls,
- output_lines=[
- "Collecting demo==1.0\n",
- "Downloading demo-1.0-py3-none-any.whl\n",
- "Installing collected packages: demo\n",
- "Successfully installed demo-1.0\n",
- ],
- **kwargs,
- )
-
- monkeypatch.setattr("ksadk.builders.code_builder.subprocess.Popen", fake_popen)
- monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: [])
-
- assert builder._install_dependencies(requirements_path) is True
-
- assert "--cache-dir" in calls[0]
- cache_pos = calls[0].index("--cache-dir")
- assert calls[0][cache_pos + 1] == str(builder.build_dir / "pip_cache")
-
-
-def test_install_dependencies_timeout_is_configurable(tmp_path, monkeypatch):
- builder = CodeBuilder(tmp_path)
- builder.deps_dir.mkdir(parents=True, exist_ok=True)
- requirements_path = tmp_path / "requirements.txt"
- requirements_path.write_text("demo==1.0\n", encoding="utf-8")
-
- calls = []
-
- def fake_popen(cmd, **kwargs):
- return _FakePopen(
- cmd,
- calls=calls,
- output_lines=[
- "Collecting demo==1.0\n",
- "Installing collected packages: demo\n",
- "Successfully installed demo-1.0\n",
- ],
- **kwargs,
- )
-
- observed_timeouts = []
-
- def fake_run_streamed(self, install_cmd, *, timeout):
- observed_timeouts.append(timeout)
- return subprocess.CompletedProcess(install_cmd, 0, "", "")
-
- monkeypatch.setenv("KSADK_BUILD_PIP_INSTALL_TIMEOUT_SECONDS", "2700")
- monkeypatch.setattr("ksadk.builders.code_builder.subprocess.Popen", fake_popen)
- monkeypatch.setattr(CodeBuilder, "_run_streamed_pip_install", fake_run_streamed)
- monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: [])
-
- assert builder._install_dependencies(requirements_path) is True
-
- assert observed_timeouts == [2700]
-
-
-def test_replace_platform_binaries_respects_explicit_pip_index(tmp_path, monkeypatch):
- builder = CodeBuilder(tmp_path)
- builder.build_dir.mkdir(parents=True, exist_ok=True)
- builder.deps_dir.mkdir(parents=True, exist_ok=True)
- (builder.deps_dir / "tiktoken").mkdir(parents=True, exist_ok=True)
- (builder.deps_dir / "tiktoken" / "_tiktoken.cpython-314-darwin.so").write_text("", encoding="utf-8")
- (builder.deps_dir / "tiktoken-0.9.0.dist-info").mkdir(parents=True, exist_ok=True)
-
- calls = []
-
- def fake_run(cmd, **kwargs):
- calls.append(cmd)
- return _completed_process(cmd)
-
- monkeypatch.setenv("PIP_INDEX_URL", "https://pypi.org/simple")
- monkeypatch.setattr("ksadk.builders.code_builder.subprocess.run", fake_run)
-
- builder._replace_platform_binaries()
-
- assert calls
- assert "-i" not in calls[0]
-
-
-def test_install_dependencies_reports_percent_bar_and_recent_event(
- tmp_path,
- monkeypatch,
- capsys,
-):
- builder = CodeBuilder(tmp_path)
- builder.deps_dir.mkdir(parents=True, exist_ok=True)
- requirements_path = tmp_path / "requirements.txt"
- requirements_path.write_text("demo==1.0\n", encoding="utf-8")
-
- calls = []
-
- def fake_popen(cmd, **kwargs):
- return _FakePopen(
- cmd,
- calls=calls,
- output_lines=[
- "Collecting demo==1.0\n",
- "Downloading demo-1.0-py3-none-any.whl\n",
- "Installing collected packages: demo\n",
- "Successfully installed demo-1.0\n",
- ],
- **kwargs,
- )
-
- monkeypatch.setattr("ksadk.builders.code_builder.subprocess.Popen", fake_popen)
- monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: [])
-
- assert builder._install_dependencies(requirements_path) is True
-
- output = capsys.readouterr().out
- assert "100%" in output
- assert "安装包: demo" in output
-
-
-def test_install_progress_is_monotonic_and_uses_arrow_style_bar(tmp_path):
- builder = CodeBuilder(tmp_path)
-
- builder._emit_install_progress(40, "下载依赖", "Downloading demo-1.0.whl")
- builder._emit_install_progress(18, "解析依赖", "Collecting demo==1.0")
-
- assert builder._install_progress_percent == 40
- assert builder._install_progress_stage_name == "下载依赖"
- assert builder._install_progress_summary_text == "Downloading demo-1.0.whl"
-
- rendered = builder._render_install_progress(40, "下载依赖", "Downloading demo-1.0.whl")
- assert "#" not in rendered
- assert ">" in rendered
- assert "=" in rendered
-
-
-def test_install_dependencies_aggregates_repeated_download_updates(
- tmp_path,
- monkeypatch,
- capsys,
-):
- builder = CodeBuilder(tmp_path)
- builder.deps_dir.mkdir(parents=True, exist_ok=True)
- requirements_path = tmp_path / "requirements.txt"
- requirements_path.write_text("demo==1.0\n", encoding="utf-8")
-
- calls = []
- download_lines = [
- f"Using cached https://mirror.example/simple/demo-{index}.whl\n"
- for index in range(1, 13)
- ]
-
- def fake_popen(cmd, **kwargs):
- return _FakePopen(
- cmd,
- calls=calls,
- output_lines=[
- "Collecting demo==1.0\n",
- *download_lines,
- "Installing collected packages: demo\n",
- "Successfully installed demo-1.0\n",
- ],
- **kwargs,
- )
-
- monkeypatch.setattr("ksadk.builders.code_builder.subprocess.Popen", fake_popen)
- monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: [])
-
- assert builder._install_dependencies(requirements_path) is True
-
- output = capsys.readouterr().out
- assert "已处理 10 个 wheel" in output
- assert output.count("下载依赖") < len(download_lines)
-
-
-def test_install_dependencies_advances_download_progress_with_wheel_activity(
- tmp_path,
- monkeypatch,
- capsys,
-):
- builder = CodeBuilder(tmp_path)
- builder.deps_dir.mkdir(parents=True, exist_ok=True)
- requirements_path = tmp_path / "requirements.txt"
- requirements_path.write_text("demo==1.0\n", encoding="utf-8")
-
- calls = []
- download_lines = [
- f"Downloading demo-{index}.0-py3-none-any.whl\n"
- for index in range(1, 26)
- ]
-
- def fake_popen(cmd, **kwargs):
- return _FakePopen(
- cmd,
- calls=calls,
- output_lines=[
- "Collecting demo==1.0\n",
- *download_lines,
- "Installing collected packages: demo\n",
- "Successfully installed demo-1.0\n",
- ],
- **kwargs,
- )
-
- monkeypatch.setattr("ksadk.builders.code_builder.subprocess.Popen", fake_popen)
- monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: [])
-
- assert builder._install_dependencies(requirements_path) is True
-
- output = capsys.readouterr().out
- assert "下载依赖" in output
- assert "耗时" in output
- assert "已处理 25 个 wheel" in output
- download_percents = [
- int(line.split("%", 1)[0].rsplit(" ", 1)[-1])
- for line in output.splitlines()
- if "下载依赖" in line and "%" in line
- ]
- assert max(download_percents) >= 60
-
-
-def test_install_dependencies_does_not_pin_long_downloads_at_68_percent(
- tmp_path,
- monkeypatch,
- capsys,
-):
- builder = CodeBuilder(tmp_path)
- builder.deps_dir.mkdir(parents=True, exist_ok=True)
- requirements_path = tmp_path / "requirements.txt"
- requirements_path.write_text("demo==1.0\n", encoding="utf-8")
-
- calls = []
- download_lines = [
- f"Using cached https://mirror.example/simple/demo-{index}.whl\n"
- for index in range(1, 71)
- ]
-
- def fake_popen(cmd, **kwargs):
- return _FakePopen(
- cmd,
- calls=calls,
- output_lines=[
- "Collecting demo==1.0\n",
- *download_lines,
- "Installing collected packages: demo\n",
- "Successfully installed demo-1.0\n",
- ],
- **kwargs,
- )
-
- monkeypatch.setattr("ksadk.builders.code_builder.subprocess.Popen", fake_popen)
- monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: [])
-
- assert builder._install_dependencies(requirements_path) is True
-
- output = capsys.readouterr().out
- download_percents = [
- int(line.split("%", 1)[0].rsplit(" ", 1)[-1])
- for line in output.splitlines()
- if "下载依赖" in line and "%" in line
- ]
- assert max(download_percents) > 68
-
-
-def test_install_dependencies_prefers_fastest_cached_pip_index(tmp_path, monkeypatch):
- home = tmp_path / "home"
- cache_dir = home / ".agentengine"
- cache_dir.mkdir(parents=True, exist_ok=True)
- cache_path = cache_dir / "pip-index-cache.json"
- cache_path.write_text(
- json.dumps(
- {
- "version": 1,
- "updated_at": 999.0,
- "order": [
- "https://mirrors.aliyun.com/pypi/simple",
- "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple",
- "https://mirrors.cloud.tencent.com/pypi/simple",
- "https://pypi.org/simple",
- ],
- }
- ),
- encoding="utf-8",
- )
-
- builder = CodeBuilder(tmp_path)
- builder.deps_dir.mkdir(parents=True, exist_ok=True)
- requirements_path = tmp_path / "requirements.txt"
- requirements_path.write_text("demo==1.0\n", encoding="utf-8")
-
- calls = []
-
- def fake_popen(cmd, **kwargs):
- return _FakePopen(
- cmd,
- calls=calls,
- output_lines=[
- "Collecting demo==1.0\n",
- "Installing collected packages: demo\n",
- "Successfully installed demo-1.0\n",
- ],
- **kwargs,
- )
-
- monkeypatch.setenv("HOME", str(home))
- monkeypatch.delenv("PIP_INDEX_URL", raising=False)
- monkeypatch.delenv("UV_INDEX_URL", raising=False)
- monkeypatch.setattr("ksadk.builders.code_builder.time.time", lambda: 1000.0)
- monkeypatch.setattr("ksadk.builders.code_builder.subprocess.Popen", fake_popen)
- monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: [])
-
- assert builder._install_dependencies(requirements_path) is True
- assert calls
-
- index_pos = calls[0].index("-i")
- assert calls[0][index_pos + 1] == "https://mirrors.aliyun.com/pypi/simple"
-
-
-def test_install_dependencies_download_summary_uses_artifact_name(
- tmp_path,
- monkeypatch,
- capsys,
-):
- builder = CodeBuilder(tmp_path)
- builder.deps_dir.mkdir(parents=True, exist_ok=True)
- requirements_path = tmp_path / "requirements.txt"
- requirements_path.write_text("demo==1.0\n", encoding="utf-8")
-
- calls = []
-
- def fake_popen(cmd, **kwargs):
- return _FakePopen(
- cmd,
- calls=calls,
- output_lines=[
- "Collecting demo==1.0\n",
- "Downloading demo-1.0-py3-none-any.whl.metadata (117 kB)\n",
- "Installing collected packages: demo\n",
- "Successfully installed demo-1.0\n",
- ],
- **kwargs,
- )
-
- monkeypatch.setattr("ksadk.builders.code_builder.subprocess.Popen", fake_popen)
- monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: [])
-
- assert builder._install_dependencies(requirements_path) is True
-
- output = capsys.readouterr().out
- assert "demo-1.0-py3-none-any.whl.metadata" in output
- assert "最近: (117" not in output
-
-
-def test_install_dependencies_bootstraps_pip_when_missing(
- tmp_path,
- monkeypatch,
- capsys,
-):
- builder = CodeBuilder(tmp_path)
- builder.deps_dir.mkdir(parents=True, exist_ok=True)
- requirements_path = tmp_path / "requirements.txt"
- requirements_path.write_text("demo==1.0\n", encoding="utf-8")
-
- calls = []
- popen_attempts = {"count": 0}
-
- def fake_run(cmd, **kwargs):
- calls.append(cmd)
- return subprocess.CompletedProcess(cmd, 0, "", "")
-
- def fake_popen(cmd, **kwargs):
- popen_attempts["count"] += 1
- if popen_attempts["count"] == 1:
- return _FakePopen(
- cmd,
- calls=[],
- output_lines=[f"{sys.executable}: No module named pip\n"],
- returncode=1,
- **kwargs,
- )
- return _FakePopen(
- cmd,
- calls=[],
- output_lines=[
- "Collecting demo==1.0\n",
- "Installing collected packages: demo\n",
- "Successfully installed demo-1.0\n",
- ],
- **kwargs,
- )
-
- monkeypatch.setattr("ksadk.builders.code_builder.subprocess.run", fake_run)
- monkeypatch.setattr("ksadk.builders.code_builder.subprocess.Popen", fake_popen)
- monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: [])
-
- assert builder._install_dependencies(requirements_path) is True
-
- output = capsys.readouterr().out
- assert "pip 工具链缺失" in output
- assert calls
- assert calls[0][:3] == [sys.executable, "-m", "ensurepip"]
-
-
-def test_package_zip_reports_milestone_progress_for_large_dependency_tree(
- tmp_path,
- monkeypatch,
- capsys,
-):
- builder = CodeBuilder(tmp_path)
- builder.deps_dir.mkdir(parents=True, exist_ok=True)
- (tmp_path / "agent.py").write_text("print('ok')\n", encoding="utf-8")
- (tmp_path / "agentengine.yaml").write_text("name: demo-agent\nframework: langgraph\n", encoding="utf-8")
- for index in range(1, 1002):
- (builder.deps_dir / f"dep_{index}.py").write_text("# dep\n", encoding="utf-8")
-
- monkeypatch.setattr(CodeBuilder, "_iter_bundled_source_files", lambda self: iter(()))
-
- detection_result = type(
- "Detection",
- (),
- {
- "package_path": str(tmp_path / "agent.py"),
- "type": type("T", (), {"name": "LANGGRAPH"})(),
- "name": "demo-agent",
- "entry_point": "agent.py",
- "agent_variable": "agent",
- },
- )()
-
- builder._package_zip(builder.build_dir / "demo.zip", detection_result)
-
- output = capsys.readouterr().out
- assert "打包依赖" in output
- assert "100%" in output
- assert "1000/1001 files" not in output
diff --git a/tests/test_code_builder_rebuild_fingerprint.py b/tests/test_code_builder_rebuild_fingerprint.py
deleted file mode 100644
index 27a7ec6e..00000000
--- a/tests/test_code_builder_rebuild_fingerprint.py
+++ /dev/null
@@ -1,313 +0,0 @@
-import time
-import zipfile
-from pathlib import Path
-from types import SimpleNamespace
-
-import ksadk
-
-from ksadk.builders.code_builder import CodeBuilder
-
-
-class _FakeType:
- value = "langgraph"
- name = "LANGGRAPH"
-
-
-class _FakeFrameworkDetector:
- def __init__(self, *_args, **_kwargs):
- pass
-
- def detect(self):
- return SimpleNamespace(
- type=_FakeType(),
- name="demo-agent",
- entry_point="agent.py",
- package_path="agent.py",
- agent_variable="agent",
- )
-
-
-def _fake_package_zip(zip_path: Path, _detection_result):
- with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
- zf.writestr("agent.py", "print('ok')\n")
-
-
-def test_code_builder_skips_rebuild_when_only_mtime_changes(tmp_path: Path, monkeypatch):
- (tmp_path / "agent.py").write_text("print('ok')\n", encoding="utf-8")
- (tmp_path / "agentengine.yaml").write_text("name: demo-agent\nframework: langgraph\n", encoding="utf-8")
-
- package_calls = []
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", _FakeFrameworkDetector)
- monkeypatch.setattr(CodeBuilder, "_install_dependencies", lambda self, _req: True)
- monkeypatch.setattr(
- CodeBuilder,
- "_package_zip",
- lambda self, zip_path, detection_result: (package_calls.append(zip_path), _fake_package_zip(zip_path, detection_result)),
- )
-
- builder = CodeBuilder(tmp_path)
- first = builder.build()
- assert first.success is True
- assert len(package_calls) == 1
-
- time.sleep(0.01)
- agent_file = tmp_path / "agent.py"
- original_content = agent_file.read_text(encoding="utf-8")
- agent_file.write_text(original_content, encoding="utf-8")
-
- second = builder.build()
- assert second.success is True
- assert len(package_calls) == 1
-
-
-def test_code_builder_rebuilds_when_file_content_changes(tmp_path: Path, monkeypatch):
- (tmp_path / "agent.py").write_text("print('ok')\n", encoding="utf-8")
- (tmp_path / "agentengine.yaml").write_text("name: demo-agent\nframework: langgraph\n", encoding="utf-8")
-
- package_calls = []
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", _FakeFrameworkDetector)
- monkeypatch.setattr(CodeBuilder, "_install_dependencies", lambda self, _req: True)
- monkeypatch.setattr(
- CodeBuilder,
- "_package_zip",
- lambda self, zip_path, detection_result: (package_calls.append(zip_path), _fake_package_zip(zip_path, detection_result)),
- )
-
- builder = CodeBuilder(tmp_path)
- first = builder.build()
- assert first.success is True
- assert len(package_calls) == 1
-
- agent_file = tmp_path / "agent.py"
- agent_file.write_text("print('changed')\n", encoding="utf-8")
-
- second = builder.build()
- assert second.success is True
- assert len(package_calls) == 2
-
-
-def test_code_builder_rebuilds_when_ksadk_source_changes(tmp_path: Path, monkeypatch):
- (tmp_path / "agent.py").write_text("print('ok')\n", encoding="utf-8")
- (tmp_path / "agentengine.yaml").write_text("name: demo-agent\nframework: langgraph\n", encoding="utf-8")
-
- fake_ksadk_root = tmp_path.parent / f"{tmp_path.name}_fake_ksadk" / "ksadk"
- (fake_ksadk_root / "configs").mkdir(parents=True, exist_ok=True)
- (fake_ksadk_root / "__init__.py").write_text("__version__ = 'test'\n", encoding="utf-8")
- settings_file = fake_ksadk_root / "configs" / "settings.py"
- settings_file.write_text("VALUE = 'v1'\n", encoding="utf-8")
-
- package_calls = []
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", _FakeFrameworkDetector)
- monkeypatch.setattr(CodeBuilder, "_install_dependencies", lambda self, _req: True)
- monkeypatch.setattr(
- CodeBuilder,
- "_package_zip",
- lambda self, zip_path, detection_result: (package_calls.append(zip_path), _fake_package_zip(zip_path, detection_result)),
- )
- monkeypatch.setattr(ksadk, "__file__", str(fake_ksadk_root / "__init__.py"))
-
- builder = CodeBuilder(tmp_path)
- first = builder.build()
- assert first.success is True
- assert len(package_calls) == 1
-
- settings_file.write_text("VALUE = 'v2'\n", encoding="utf-8")
-
- second = builder.build()
- assert second.success is True
- assert len(package_calls) == 2
-
-
-def test_code_builder_no_cache_reinstalls_dependencies_when_requirements_unchanged(
- tmp_path: Path,
- monkeypatch,
-):
- (tmp_path / "agent.py").write_text("print('ok')\n", encoding="utf-8")
- (tmp_path / "agentengine.yaml").write_text("name: demo-agent\nframework: langgraph\n", encoding="utf-8")
- (tmp_path / "requirements.txt").write_text("httpx==0.28.1\n", encoding="utf-8")
-
- install_calls = []
- package_calls = []
-
- def fake_install(self, _req):
- install_calls.append("install")
- self.deps_dir.mkdir(parents=True, exist_ok=True)
- (self.deps_dir / "httpx.py").write_text("# dep\n", encoding="utf-8")
- return True
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", _FakeFrameworkDetector)
- monkeypatch.setattr(CodeBuilder, "_install_dependencies", fake_install)
- monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: [])
- monkeypatch.setattr(
- CodeBuilder,
- "_package_zip",
- lambda self, zip_path, detection_result: (package_calls.append(zip_path), _fake_package_zip(zip_path, detection_result)),
- )
-
- builder = CodeBuilder(tmp_path, config={"no_cache": True})
- first = builder.build()
- second = builder.build()
-
- assert first.success is True
- assert second.success is True
- assert len(package_calls) == 2
- assert len(install_calls) == 2
-
-
-def test_code_builder_no_cache_reinstalls_dependencies_when_requirements_change(
- tmp_path: Path,
- monkeypatch,
-):
- (tmp_path / "agent.py").write_text("print('ok')\n", encoding="utf-8")
- (tmp_path / "agentengine.yaml").write_text("name: demo-agent\nframework: langgraph\n", encoding="utf-8")
- requirements = tmp_path / "requirements.txt"
- requirements.write_text("httpx==0.28.1\n", encoding="utf-8")
-
- install_calls = []
-
- def fake_install(self, _req):
- install_calls.append("install")
- self.deps_dir.mkdir(parents=True, exist_ok=True)
- marker = self.deps_dir / f"dep-{len(install_calls)}.txt"
- marker.write_text("ok\n", encoding="utf-8")
- return True
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", _FakeFrameworkDetector)
- monkeypatch.setattr(CodeBuilder, "_install_dependencies", fake_install)
- monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: [])
- monkeypatch.setattr(
- CodeBuilder,
- "_package_zip",
- lambda self, zip_path, detection_result: _fake_package_zip(zip_path, detection_result),
- )
-
- builder = CodeBuilder(tmp_path, config={"no_cache": True})
- first = builder.build()
- requirements.write_text("httpx==0.28.1\nrequests==2.32.3\n", encoding="utf-8")
- second = builder.build()
-
- assert first.success is True
- assert second.success is True
- assert len(install_calls) == 2
-
-
-def test_code_builder_repackage_reuses_dependencies_but_rebuilds_zip(
- tmp_path: Path,
- monkeypatch,
-):
- (tmp_path / "agent.py").write_text("print('ok')\n", encoding="utf-8")
- (tmp_path / "agentengine.yaml").write_text("name: demo-agent\nframework: langgraph\n", encoding="utf-8")
- (tmp_path / "requirements.txt").write_text("httpx==0.28.1\n", encoding="utf-8")
-
- install_calls = []
- package_calls = []
-
- def fake_install(self, _req):
- install_calls.append("install")
- self.deps_dir.mkdir(parents=True, exist_ok=True)
- (self.deps_dir / "httpx.py").write_text("# dep\n", encoding="utf-8")
- return True
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", _FakeFrameworkDetector)
- monkeypatch.setattr(CodeBuilder, "_install_dependencies", fake_install)
- monkeypatch.setattr(CodeBuilder, "_scan_incompatible_binaries_in_deps", lambda self: [])
- monkeypatch.setattr(
- CodeBuilder,
- "_package_zip",
- lambda self, zip_path, detection_result: (package_calls.append(zip_path), _fake_package_zip(zip_path, detection_result)),
- )
-
- initial = CodeBuilder(tmp_path).build()
- assert initial.success is True
-
- repackaged = CodeBuilder(tmp_path, config={"repackage": True}).build()
-
- assert repackaged.success is True
- assert len(install_calls) == 1
- assert len(package_calls) == 2
-
-
-def test_code_builder_package_zip_reports_top_size_contributors(
- tmp_path: Path,
- monkeypatch,
- capsys,
-):
- (tmp_path / "agent.py").write_text("print('ok')\n", encoding="utf-8")
-
- builder = CodeBuilder(tmp_path)
- builder.deps_dir.mkdir(parents=True, exist_ok=True)
- (builder.deps_dir / "large_dep").mkdir()
- (builder.deps_dir / "large_dep" / "payload.bin").write_bytes(b"x" * 2048)
- (builder.deps_dir / "small_dep.py").write_text("# dep\n", encoding="utf-8")
-
- monkeypatch.setattr(CodeBuilder, "_iter_bundled_source_files", lambda self: [])
-
- zip_path = builder.build_dir / "demo.zip"
- builder._package_zip(zip_path, _FakeFrameworkDetector().detect())
-
- output = capsys.readouterr().out
- assert "包体积:" in output
- assert "体积 Top" in output
- assert "large_dep" in output
-
-
-def test_code_builder_package_zip_suggests_container_only_for_large_artifacts(
- tmp_path: Path,
- capsys,
-):
- builder = CodeBuilder(tmp_path)
-
- builder._emit_package_size_report_from_entries(
- raw_total=499 * 1024 * 1024,
- compressed_total=299 * 1024 * 1024,
- by_top_level={"deps": 499 * 1024 * 1024},
- )
- assert "建议使用 container 模式" not in capsys.readouterr().out
-
- builder._emit_package_size_report_from_entries(
- raw_total=501 * 1024 * 1024,
- compressed_total=299 * 1024 * 1024,
- by_top_level={"deps": 501 * 1024 * 1024},
- )
- assert "建议使用 container 模式" in capsys.readouterr().out
-
- builder._emit_package_size_report_from_entries(
- raw_total=100 * 1024 * 1024,
- compressed_total=301 * 1024 * 1024,
- by_top_level={"deps": 100 * 1024 * 1024},
- )
- assert "建议使用 container 模式" in capsys.readouterr().out
-
-
-def test_code_builder_reports_rebuild_reason_for_runtime_source_changes(
- tmp_path: Path,
- monkeypatch,
- capsys,
-):
- (tmp_path / "agent.py").write_text("print('ok')\n", encoding="utf-8")
- (tmp_path / "agentengine.yaml").write_text("name: demo-agent\nframework: langgraph\n", encoding="utf-8")
-
- fake_ksadk_root = tmp_path.parent / f"{tmp_path.name}_fake_ksadk_reason" / "ksadk"
- (fake_ksadk_root / "configs").mkdir(parents=True, exist_ok=True)
- (fake_ksadk_root / "__init__.py").write_text("__version__ = 'test'\n", encoding="utf-8")
- settings_file = fake_ksadk_root / "configs" / "settings.py"
- settings_file.write_text("VALUE = 'v1'\n", encoding="utf-8")
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", _FakeFrameworkDetector)
- monkeypatch.setattr(CodeBuilder, "_install_dependencies", lambda self, _req: True)
- monkeypatch.setattr(CodeBuilder, "_package_zip", lambda self, zip_path, detection_result: _fake_package_zip(zip_path, detection_result))
- monkeypatch.setattr(ksadk, "__file__", str(fake_ksadk_root / "__init__.py"))
-
- first = CodeBuilder(tmp_path).build()
- assert first.success is True
- capsys.readouterr()
-
- settings_file.write_text("VALUE = 'v2'\n", encoding="utf-8")
- second = CodeBuilder(tmp_path).build()
- assert second.success is True
-
- output = capsys.readouterr().out
- assert "ksadk runtime 变更" in output
diff --git a/tests/test_code_builder_static_assets.py b/tests/test_code_builder_static_assets.py
deleted file mode 100644
index 6b8a5e12..00000000
--- a/tests/test_code_builder_static_assets.py
+++ /dev/null
@@ -1,127 +0,0 @@
-import zipfile
-from types import SimpleNamespace
-
-from ksadk.builders.code_builder import CodeBuilder
-
-
-class _FakeType:
- name = "LANGGRAPH"
-
-
-def test_code_builder_packages_web_static_assets(tmp_path):
- # 最小项目结构
- (tmp_path / "agent.py").write_text("print('ok')\n", encoding="utf-8")
-
- builder = CodeBuilder(tmp_path)
- builder.build_dir.mkdir(parents=True, exist_ok=True)
- builder.deps_dir.mkdir(parents=True, exist_ok=True)
-
- detection_result = SimpleNamespace(
- package_path=str(tmp_path),
- type=_FakeType(),
- name="demo_agent",
- entry_point="agent.py",
- agent_variable="root_agent",
- )
-
- zip_path = tmp_path / "demo.zip"
- builder._package_zip(zip_path, detection_result)
-
- with zipfile.ZipFile(zip_path) as zf:
- names = zf.namelist()
-
- static_files = [n for n in names if n.startswith("ksadk/server/static/")]
- assert static_files, "应包含 ksadk/server/static 目录下资源"
- assert any(n.endswith(".html") for n in static_files), "应包含 html 入口"
- assert any(n.endswith(".js") for n in static_files), "应包含 js 资源"
- assert any(n.endswith(".css") for n in static_files), "应包含 css 资源"
- assert not any(n.startswith("ksadk/server/web-ui/") for n in names), (
- "runtime 产物不应包含前端源码/node_modules"
- )
-
-
-def test_code_builder_packages_project_custom_ui_dist(tmp_path):
- (tmp_path / "agent.py").write_text("print('ok')\n", encoding="utf-8")
- custom_dist = tmp_path / "research-ui" / "dist" / "assets"
- custom_dist.mkdir(parents=True)
- (tmp_path / "research-ui" / "dist" / "index.html").write_text("Custom UI", encoding="utf-8")
- (custom_dist / "index.js").write_text("console.log('custom ui')\n", encoding="utf-8")
- (tmp_path / "research-ui" / "node_modules").mkdir(parents=True)
- (tmp_path / "research-ui" / "node_modules" / "ignored.js").write_text("ignored\n", encoding="utf-8")
-
- builder = CodeBuilder(tmp_path)
- builder.build_dir.mkdir(parents=True, exist_ok=True)
- builder.deps_dir.mkdir(parents=True, exist_ok=True)
-
- detection_result = SimpleNamespace(
- package_path=str(tmp_path),
- type=_FakeType(),
- name="demo_agent",
- entry_point="agent.py",
- agent_variable="root_agent",
- )
-
- zip_path = tmp_path / "demo.zip"
- builder._package_zip(zip_path, detection_result)
-
- with zipfile.ZipFile(zip_path) as zf:
- names = set(zf.namelist())
-
- assert "research-ui/dist/index.html" in names
- assert "research-ui/dist/assets/index.js" in names
- assert "research-ui/node_modules/ignored.js" not in names
-
-
-def test_code_builder_packages_runtime_common_sources(tmp_path):
- (tmp_path / "agent.py").write_text("print('ok')\n", encoding="utf-8")
-
- builder = CodeBuilder(tmp_path)
- builder.build_dir.mkdir(parents=True, exist_ok=True)
- builder.deps_dir.mkdir(parents=True, exist_ok=True)
-
- detection_result = SimpleNamespace(
- package_path=str(tmp_path),
- type=_FakeType(),
- name="demo_agent",
- entry_point="agent.py",
- agent_variable="root_agent",
- )
-
- zip_path = tmp_path / "demo.zip"
- builder._package_zip(zip_path, detection_result)
-
- with zipfile.ZipFile(zip_path) as zf:
- names = zf.namelist()
-
- assert any(n.startswith("ksadk_runtime_common/") for n in names), (
- "应包含 ksadk_runtime_common 共享运行时代码"
- )
-
-
-def test_code_builder_excludes_real_dotenv_files_but_keeps_example(tmp_path):
- (tmp_path / "agent.py").write_text("print('ok')\n", encoding="utf-8")
- (tmp_path / ".env").write_text("OPENAI_API_KEY=secret\n", encoding="utf-8")
- (tmp_path / ".env.local").write_text("LOCAL_SECRET=secret\n", encoding="utf-8")
- (tmp_path / ".env.example").write_text("OPENAI_API_KEY=\n", encoding="utf-8")
-
- builder = CodeBuilder(tmp_path)
- builder.build_dir.mkdir(parents=True, exist_ok=True)
- builder.deps_dir.mkdir(parents=True, exist_ok=True)
-
- detection_result = SimpleNamespace(
- package_path=str(tmp_path),
- type=_FakeType(),
- name="demo_agent",
- entry_point="agent.py",
- agent_variable="root_agent",
- )
-
- zip_path = tmp_path / "demo.zip"
- builder._package_zip(zip_path, detection_result)
-
- with zipfile.ZipFile(zip_path) as zf:
- names = set(zf.namelist())
-
- assert ".env" not in names
- assert ".env.local" not in names
- assert ".env.example" in names
diff --git a/tests/test_compaction_pipeline.py b/tests/test_compaction_pipeline.py
deleted file mode 100644
index 90d4146b..00000000
--- a/tests/test_compaction_pipeline.py
+++ /dev/null
@@ -1,309 +0,0 @@
-from __future__ import annotations
-
-from ksadk.conversations.compaction_pipeline import (
- SnipResult,
- build_working_set_metadata,
- candidate_tokens,
- microcompact_cold_groups,
- run_pipeline,
- snip_redundant_groups,
-)
-from ksadk.sessions.base import SessionEvent
-
-
-def _tool_call_event(seq: int, name: str, arguments: str | dict, invocation_id: str = "inv1", run_id: str | None = None) -> SessionEvent:
- """构造真实 runtime 形态的 tool_call event。
-
- 真实 runtime 把 tool_name/tool_args 放在 metadata(不是 content),content.role="model",
- content.text 是工具名字符串。run_id 关联配对的 tool_result(同一 call/result 共享 run_id)。
- 这里严格对齐,确保 L2 在生产 event 上生效。
- """
- metadata = {
- "tool_name": name,
- "tool_args": arguments if isinstance(arguments, dict) else {"_raw": arguments},
- }
- if run_id is not None:
- metadata["run_id"] = run_id
- return SessionEvent(
- id=f"tc-{seq}",
- seq_id=seq,
- event_type="tool_call",
- author="runner",
- invocation_id=invocation_id,
- content={"role": "model", "text": name},
- metadata=metadata,
- )
-
-
-def _tool_result_event(seq: int, ok: bool = True, error_type: str = "", run_id: str | None = None) -> SessionEvent:
- """构造真实 runtime 形态的 tool_result event。
-
- 真实 runtime 的失败状态在 metadata.tool_receipt.status,不在 content.ok。
- run_id 关联配对的 tool_call(与 _tool_call_event 共享同一 run_id)。
- """
- content = {"role": "tool", "text": "result content"}
- metadata = {"tool_name": "some_tool"}
- if run_id is not None:
- metadata["run_id"] = run_id
- if not ok:
- metadata["tool_receipt"] = {"status": "failed"}
- if error_type:
- metadata["error_type"] = error_type
- return SessionEvent(
- id=f"tr-{seq}",
- seq_id=seq,
- event_type="tool_result",
- author="tool",
- content=content,
- metadata=metadata,
- )
-
-
-def _assistant_event(seq: int, text: str) -> SessionEvent:
- return SessionEvent(
- id=f"a-{seq}",
- seq_id=seq,
- event_type="assistant_message",
- author="assistant",
- content={"role": "assistant", "text": text},
- )
-
-
-class TestSnipDoesNotMutateTranscript:
- """Codex 纪律:L2 只作用于 candidate 投影,绝不改原 SessionEvent 实例和 transcript。"""
-
- def test_snip_returns_new_groups_not_original(self):
- original_call = _tool_call_event(1, "search", "query=a")
- original_result = _tool_result_event(2)
- groups = [[original_call, original_result]]
- original_call_id_before = id(original_call)
- original_result_content_before = dict(original_result.content)
-
- result = snip_redundant_groups(groups)
-
- # 原 SessionEvent 实例 id 不变(没被替换)。
- assert id(groups[0][0]) == original_call_id_before
- # 原 content 不变。
- assert groups[0][1].content == original_result_content_before
- # 返回的是新 list(浅拷贝),不是原 groups 对象。
- assert result.groups is not groups
-
- def test_snip_does_not_call_service_or_delete_events(self):
- # L2 是纯函数,不持有 service 引用,无法删 transcript。
- groups = [[_tool_call_event(1, "x", "1"), _tool_result_event(2)]]
- result = snip_redundant_groups(groups)
- # 原 groups 长度不变(transcript 没被删)。
- assert len(groups[0]) == 2
- # candidate 是新结构。
- assert isinstance(result, SnipResult)
- assert isinstance(result.stats.tokens_before, int)
-
-
-class TestSnipRedundancyRemoval:
- def test_removes_tool_result_covered_by_later_group(self):
- # 组1: search(query=a) [run_id=r1] → result [run_id=r1];组2: search(query=a) [run_id=r2] → result [run_id=r2](覆盖)
- group1 = [_tool_call_event(1, "search", "query=a", run_id="r1"), _tool_result_event(2, run_id="r1")]
- group2 = [_tool_call_event(3, "search", "query=a", run_id="r2"), _tool_result_event(4, run_id="r2")]
- groups = [group1, group2]
-
- result = snip_redundant_groups(groups)
-
- # 组1 的 tool_call/tool_result 按 run_id 精确配对移除,组2 保留。
- flat = [e for g in result.groups for e in g]
- seq_ids = [e.seq_id for e in flat]
- assert 1 not in seq_ids # 旧 tool_call 被移除
- assert 2 not in seq_ids # 配对 tool_result(run_id=r1)被精确移除,不留孤儿
- assert 3 in seq_ids
- assert 4 in seq_ids
- assert result.stats.removed_redundant_tool_results >= 2 # call+result 成对
-
- def test_parallel_tool_calls_no_orphan_result(self):
- """Codex Finding 1:parallel_tool_calls=True 时同一轮多 tool_call 交错,按 run_id 精确配对不留孤儿。
-
- 场景:同一组内两个 tool_call(search + web_fetch)交错,search 被后续组覆盖。
- 旧实现(邻接配对)会误删 web_fetch 的 result 或留 search 的孤儿 result;
- 新实现(按 run_id 配对)只删 search 的 call+result,web_fetch 完整保留。
- """
- # 组1: 两个并行 tool_call + 交错 result(search[r1] 被 web_fetch[r2] 隔开)
- group1 = [
- _tool_call_event(1, "search", "query=a", run_id="r1"),
- _tool_call_event(2, "web_fetch", "url=x", run_id="r2"),
- _tool_result_event(3, run_id="r1"), # search 的 result(被覆盖,应删)
- _tool_result_event(4, run_id="r2"), # web_fetch 的 result(保留)
- ]
- # 组2: search(query=a) 覆盖组1 的 search
- group2 = [_tool_call_event(5, "search", "query=a", run_id="r3"), _tool_result_event(6, run_id="r3")]
- groups = [group1, group2]
-
- result = snip_redundant_groups(groups)
-
- flat = [e for g in result.groups for e in g]
- seq_ids = [e.seq_id for e in flat]
- # search[r1] 的 call(1) 和 result(3) 都被移除(按 run_id 精确配对)。
- assert 1 not in seq_ids
- assert 3 not in seq_ids
- # web_fetch[r2] 的 call(2) 和 result(4) 完整保留(不因邻接被误删)。
- assert 2 in seq_ids
- assert 4 in seq_ids
- # 组2 的 search[r3] 保留。
- assert 5 in seq_ids
- assert 6 in seq_ids
-
- def test_missing_run_id_keeps_results_conservatively(self):
- """run_id 缺失时(退化到 invocation_id 共享)保守不误删 tool_result。
-
- 真实 LangGraph 路径 chunk 始终带 per-tool run_id,但若 chunk 无 run_id,
- tool_result 退化到 prepared.invocation_id(整轮共享)。此时 covered_run_ids
- 不收录(配对退化),tool_result 全部保留,避免误删未配对结果。
- """
- # 两个 tool_call 都无 run_id(模拟退化路径),即使 search 被覆盖,result 也保留。
- group1 = [_tool_call_event(1, "search", "query=a"), _tool_result_event(2)] # 无 run_id
- group2 = [_tool_call_event(3, "search", "query=a"), _tool_result_event(4)] # 无 run_id
- groups = [group1, group2]
-
- result = snip_redundant_groups(groups)
-
- flat = [e for g in result.groups for e in g]
- seq_ids = [e.seq_id for e in flat]
- # tool_call(1)被覆盖移除(signature 覆盖判定不依赖 run_id);但 tool_result(2)因无 run_id
- # 无法精确配对,保守保留(不误删)。这是有意的保守行为,避免 run_id 缺失时误删 result。
- assert 1 not in seq_ids # tool_call 覆盖移除(不依赖 run_id)
- assert 2 in seq_ids # tool_result 无 run_id,保守保留
- assert 3 in seq_ids and 4 in seq_ids
-
- def test_keeps_different_tool_results(self):
- group1 = [_tool_call_event(1, "search", "query=a"), _tool_result_event(2)]
- group2 = [_tool_call_event(3, "web_fetch", "url=x"), _tool_result_event(4)]
- groups = [group1, group2]
-
- result = snip_redundant_groups(groups)
-
- flat = [e for g in result.groups for e in g]
- assert len(flat) == 4 # 不同 tool 不覆盖,全保留
-
- def test_disabled_via_env_returns_unchanged(self, monkeypatch):
- monkeypatch.setenv("KSADK_COMPACT_SNIP_ENABLED", "false")
- group1 = [_tool_call_event(1, "search", "query=a"), _tool_result_event(2)]
- group2 = [_tool_call_event(3, "search", "query=a"), _tool_result_event(4)]
- groups = [group1, group2]
-
- result = snip_redundant_groups(groups)
-
- flat = [e for g in result.groups for e in g]
- assert len(flat) == 4 # 禁用后不移除
- assert result.stats.removed_redundant_tool_results == 0
-
-
-class TestMicrocompactProjection:
- """Codex 纪律:L3 合成摘要 event 只存在于 candidate,不写回 transcript。"""
-
- def test_microcompact_replaces_cold_groups_with_summary_event(self):
- # 5 组,默认 cold_rounds=3,前 2 组视为冷组。
- groups = [
- [_assistant_event(1, "old round 1")],
- [_assistant_event(2, "old round 2")],
- [_assistant_event(3, "tail 1")],
- [_assistant_event(4, "tail 2")],
- [_assistant_event(5, "tail 3")],
- ]
-
- result = microcompact_cold_groups(groups)
-
- # candidate: [合成摘要组] + 尾部 3 组。
- assert len(result.groups) == 4
- # 第一组是合成摘要 event。
- summary_event = result.groups[0][0]
- assert summary_event.event_type == "assistant_message"
- assert summary_event.content.get("role") == "assistant"
- assert "microcompact" in (summary_event.content.get("text") or "").lower()
- assert summary_event.metadata.get("compaction_projection") is True
- assert summary_event.metadata.get("compaction_stage") == "microcompact"
- # 尾部组保留原 event。
- assert result.groups[1][0].seq_id == 3
-
- def test_microcompact_does_not_mutate_original_groups(self):
- groups = [[_assistant_event(1, "x")], [_assistant_event(2, "y")], [_assistant_event(3, "z")], [_assistant_event(4, "w")]]
- original_len = len(groups)
-
- microcompact_cold_groups(groups)
-
- # 原 groups 列表不变。
- assert len(groups) == original_len
-
- def test_microcompact_preserved_receipts_collected(self):
- cold_event = _assistant_event(1, "x")
- cold_event.metadata = {"receipt_key": "deepresearch:web_search:v1"}
- groups = [cold_event, _assistant_event(2, "y")], [[_assistant_event(3, "z")]], [[_assistant_event(4, "w")]], [[_assistant_event(5, "v")]]
- # 构造 4+ 组让前几组变冷。
- flat_groups = [
- [cold_event, _assistant_event(2, "y")],
- [_assistant_event(3, "z")],
- [_assistant_event(4, "w")],
- [_assistant_event(5, "v")],
- ]
- result = microcompact_cold_groups(flat_groups)
- assert "deepresearch:web_search:v1" in result.stats.preserved_receipts
-
- def test_microcompact_disabled_via_env(self, monkeypatch):
- monkeypatch.setenv("KSADK_COMPACT_MICROCOMPACT_ENABLED", "false")
- groups = [[_assistant_event(1, "x")]] * 5
- result = microcompact_cold_groups(groups)
- assert result.stats.compacted_groups == 0
- assert len(result.groups) == 5 # 原样返回
-
-
-class TestPipelineProgressiveStop:
- def test_pipeline_stops_after_snip_if_under_threshold(self):
- # 单组小内容,snip 后 token 远低于阈值。
- groups = [[_assistant_event(1, "small")]]
- result = run_pipeline(groups, threshold_tokens=100000)
- assert "snip" in result["pipeline_stages"]
- assert "microcompact" not in result["pipeline_stages"] # 没超阈值,不进 L3
-
- def test_pipeline_runs_microcompact_if_over_threshold(self):
- # 5 组大内容,snip 后仍超低阈值,触发 L3。
- big = "x" * 5000
- groups = [[_assistant_event(i, big)] for i in range(1, 6)]
- result = run_pipeline(groups, threshold_tokens=100)
- assert "microcompact" in result["pipeline_stages"]
- # L3 后 candidate 应含合成摘要 + 尾部组。
- assert len(result["candidate_groups"]) <= 4
-
- def test_pipeline_returns_stats(self):
- groups = [[_assistant_event(1, "x")], [_assistant_event(2, "y")]]
- result = run_pipeline(groups, threshold_tokens=100000)
- assert "snip_stats" in result
- assert "tokens_before" in result
- assert "tokens_after" in result
- assert result["tokens_before"] >= result["tokens_after"]
-
-
-class TestCandidateTokens:
- def test_empty_groups_zero_tokens(self):
- assert candidate_tokens([]) == 0
-
- def test_counts_all_events(self):
- groups = [[_assistant_event(1, "hello world")]]
- tokens = candidate_tokens(groups)
- assert tokens > 0
-
-
-class TestWorkingSetMetadata:
- def test_conservative_metadata_no_file_content(self):
- # Codex 纪律:L5 第一版只记 metadata,不读内容。
- ws = build_working_set_metadata(
- pinned_state={"pending_approvals": ["appr1"], "current_user_goal": "deploy"},
- recent_files=[{"path": "/a/b.md", "mtime_ns": 1, "size_bytes": 100}],
- active_tools=["web_search", "web_fetch"],
- )
- assert ws["recent_files"] == [{"path": "/a/b.md", "mtime_ns": 1, "size_bytes": 100}]
- assert "content" not in ws["recent_files"][0] # 不含文件内容
- assert ws["active_tools"] == ["web_search", "web_fetch"]
- assert ws["pinned_approvals"] == ["appr1"]
- assert ws["current_user_goal"] == "deploy"
-
- def test_caps_recent_files(self, monkeypatch):
- monkeypatch.setenv("KSADK_WORKING_SET_MAX_FILES", "2")
- files = [{"path": f"/f{i}"} for i in range(5)]
- ws = build_working_set_metadata(pinned_state={}, recent_files=files)
- assert len(ws["recent_files"]) == 2 # 截断到 2
diff --git a/tests/test_config_root_visibility.py b/tests/test_config_root_visibility.py
deleted file mode 100644
index a56344ea..00000000
--- a/tests/test_config_root_visibility.py
+++ /dev/null
@@ -1,51 +0,0 @@
-from __future__ import annotations
-
-from click.testing import CliRunner
-
-from ksadk.cli import _register_commands, cli
-
-
-def test_root_help_shows_config_and_completion_but_not_model():
- _register_commands()
- runner = CliRunner()
-
- result = runner.invoke(cli, ["--help"])
-
- assert result.exit_code == 0, result.output
- assert "agentengine config" in result.output
- assert "agentengine completion" in result.output
- assert " model " not in result.output
-
-
-def test_config_without_subcommand_still_runs_wizard(monkeypatch):
- _register_commands()
- runner = CliRunner()
- captured: dict[str, object] = {}
-
- def _fake_run_config_wizard(*, config_file: str | None, set_items: tuple, is_global: bool):
- captured["config_file"] = config_file
- captured["set_items"] = set_items
- captured["is_global"] = is_global
-
- monkeypatch.setattr("ksadk.cli.cmd_config.run_config_wizard", _fake_run_config_wizard)
-
- result = runner.invoke(cli, ["config"])
-
- assert result.exit_code == 0, result.output
- assert captured == {
- "config_file": None,
- "set_items": (),
- "is_global": False,
- }
-
-
-def test_config_requires_interactive_tty_for_wizard_path():
- _register_commands()
- runner = CliRunner()
-
- result = runner.invoke(cli, ["config"])
-
- assert result.exit_code == 2, result.output
- assert "需要交互式终端" in result.output
- assert "config show" in result.output
- assert "config set" in result.output
diff --git a/tests/test_container_registry_credentials.py b/tests/test_container_registry_credentials.py
deleted file mode 100644
index ae76e6ef..00000000
--- a/tests/test_container_registry_credentials.py
+++ /dev/null
@@ -1,136 +0,0 @@
-from __future__ import annotations
-
-from ksadk.builders.container_builder import ContainerBuilder
-from ksadk.builders.mcp_builder import MCPContainerBuilder
-from ksadk.cli import cmd_mcp, cmd_openclaw
-from ksadk.detection.mcp_detector import MCPDetectionResult
-from ksadk.deployment.providers.serverless import ServerlessProvider
-
-
-def test_enterprise_registry_requires_explicit_kcr_username(monkeypatch, tmp_path, capsys):
- monkeypatch.delenv("KCR_USERNAME", raising=False)
- monkeypatch.setenv("KSYUN_ACCOUNT_ID", "2000003485")
- monkeypatch.setenv("KCR_PASSWORD", "secret")
- monkeypatch.setenv("KCR_REGISTRY", "agenthzzqy-vpc.ksyunkcr.com/testagent-pub")
-
- builder = ContainerBuilder(tmp_path)
-
- assert builder._auto_login_from_env("agenthzzqy-vpc.ksyunkcr.com") is False
-
- output = capsys.readouterr().out
- assert "企业版或第三方镜像仓库必须配置 KCR_USERNAME 和 KCR_PASSWORD" in output
- assert "KCR_USERNAME=<镜像仓库访问凭证用户名>" in output
- assert "KSYUN_ACCOUNT_ID 只会作为个人版 KCR 的用户名兜底" in output
-
-
-def test_personal_registry_can_fallback_to_ksyun_account_id(monkeypatch, tmp_path):
- calls = []
- monkeypatch.delenv("KCR_USERNAME", raising=False)
- monkeypatch.setenv("KSYUN_ACCOUNT_ID", "2000003485")
- monkeypatch.setenv("KCR_PASSWORD", "secret")
- monkeypatch.setenv("KCR_REGISTRY", "hub.kce.ksyun.com/agentengine")
-
- def fake_run(cmd, **kwargs):
- calls.append((cmd, kwargs))
-
- class Result:
- returncode = 0
- stderr = ""
-
- return Result()
-
- monkeypatch.setattr("ksadk.builders.container_builder.subprocess.run", fake_run)
-
- builder = ContainerBuilder(tmp_path)
-
- assert builder._auto_login_from_env("hub.kce.ksyun.com") is True
- assert calls[0][0] == [
- "docker",
- "login",
- "hub.kce.ksyun.com",
- "-u",
- "2000003485",
- "--password-stdin",
- ]
- assert calls[0][1]["input"] == "secret"
-
-
-def test_mcp_container_request_does_not_fallback_for_enterprise_registry(monkeypatch, capsys):
- monkeypatch.delenv("KCR_USERNAME", raising=False)
- monkeypatch.setenv("KSYUN_ACCOUNT_ID", "2000003485")
- monkeypatch.setenv("KCR_PASSWORD", "secret")
-
- class Detection:
- mcp_variable = "mcp"
- tools = []
-
- request = cmd_mcp._build_mcp_request_data(
- config={},
- mcp_name="demo-mcp",
- artifact_type="Container",
- artifact_reference="agenthzzqy-vpc.ksyunkcr.com/testagent-pub/demo:v1",
- region="cn-beijing-6",
- enable_auth=False,
- detection_result=Detection(),
- )
-
- assert "image_credential" not in request
- output = capsys.readouterr().out
- assert "未配置企业版 KCR 镜像凭证 (KCR_USERNAME/KCR_PASSWORD)" in output
-
-
-def test_openclaw_container_request_does_not_fallback_for_third_party_registry(monkeypatch, capsys):
- monkeypatch.delenv("KCR_USERNAME", raising=False)
- monkeypatch.setenv("KSYUN_ACCOUNT_ID", "2000003485")
- monkeypatch.setenv("KCR_PASSWORD", "secret")
- monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {})
-
- username, password, kind = cmd_openclaw.resolve_registry_credentials(
- "registry-1.docker.io/acme/openclaw:v1",
- environ=cmd_openclaw._openclaw_registry_env(),
- )
-
- assert (username, password, kind) == ("", "secret", "third_party")
-
-
-def test_serverless_container_request_does_not_fallback_for_enterprise_registry(monkeypatch, capsys):
- monkeypatch.delenv("KCR_USERNAME", raising=False)
- monkeypatch.setenv("KSYUN_ACCOUNT_ID", "2000003485")
- monkeypatch.setenv("KCR_PASSWORD", "secret")
-
- credential = ServerlessProvider._image_credential_from_env(
- "agenthzzqy-vpc.ksyunkcr.com/testagent-pub/demo:v1"
- )
-
- assert credential is None
- output = capsys.readouterr().out
- assert "缺少 KCR_USERNAME" in output
- assert "企业版 KCR" in output
-
-
-def test_mcp_container_builder_excludes_real_dotenv_files_but_keeps_example(tmp_path):
- (tmp_path / "server.py").write_text(
- "from fastmcp import FastMCP\nmcp = FastMCP('demo')\n",
- encoding="utf-8",
- )
- (tmp_path / ".env").write_text("OPENAI_API_KEY=secret\n", encoding="utf-8")
- (tmp_path / ".env.local").write_text("LOCAL_SECRET=secret\n", encoding="utf-8")
- (tmp_path / ".env.example").write_text("OPENAI_API_KEY=\n", encoding="utf-8")
-
- package = MCPContainerBuilder(tmp_path)._package_mcp_project(
- MCPDetectionResult(
- is_mcp=True,
- name="demo-mcp",
- entry_point="server.py",
- package_path=str(tmp_path),
- mcp_variable="mcp",
- tools=[],
- confidence=1.0,
- )
- )
-
- build_dir = tmp_path / ".agentengine" / "container_build"
- assert package.build_dir == str(build_dir)
- assert not (build_dir / ".env").exists()
- assert not (build_dir / ".env.local").exists()
- assert (build_dir / ".env.example").exists()
diff --git a/tests/test_conversation_runtime.py b/tests/test_conversation_runtime.py
deleted file mode 100644
index f78bd54c..00000000
--- a/tests/test_conversation_runtime.py
+++ /dev/null
@@ -1,4847 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-import base64
-import importlib
-import json
-import time
-from pathlib import Path
-
-import httpx
-import pytest
-from opentelemetry import trace
-from opentelemetry.sdk.trace import TracerProvider
-from opentelemetry.sdk.trace.export import SimpleSpanProcessor
-
-from ksadk.conversations.context import build_history_from_events
-from ksadk.conversations.context import canonical_event_type
-from ksadk.conversations.model_options import normalize_model_options
-from ksadk.conversations.model_context import estimate_text_tokens
-from ksadk.conversations.runtime import (
- PreparedConversationTurn,
- _build_runner_request_payload,
- _build_runner_ambient_contexts,
- append_context_checkpoint_event,
- append_run_checkpoint_event,
- append_run_resume_event,
- build_chat_completions_payload,
- build_compaction_sse_event,
- build_responses_payload,
- build_run_input,
- compact_conversation_history,
- extract_responses_resume_input,
- invoke_conversation_once,
- preview_auto_compaction,
- stream_conversation_turn,
- stream_responses_conversation_turn,
-)
-from ksadk.runtime_context import (
- PlatformInvocationContext,
- get_current_tool_execution_context_or_default,
- get_current_invocation_context_or_default,
- get_current_account_id,
- get_current_invocation_context,
- get_current_user_id,
- platform_invocation_scope,
- tool_execution_scope,
-)
-from ksadk.sessions.base import SessionEvent
-from ksadk.sessions.in_memory import InMemorySessionService
-from ksadk.tracing.exporters.inmemory_exporter import InMemoryExporter
-
-
-class _StubRunner:
- def __init__(self):
- self.detection_result = type("Detection", (), {"name": "demo-agent"})()
- self.calls: list[dict] = []
- self.prepared_models: list[str | None] = []
-
- def prepare_for_request(self, model):
- self.prepared_models.append(model)
-
- async def invoke(self, input_data: dict) -> dict:
- self.calls.append(input_data)
- return {"output": "assistant says hi"}
-
-
-class _TransientFallbackRunner(_StubRunner):
- def __init__(self):
- super().__init__()
- self.fail_once = True
-
- async def invoke(self, input_data: dict) -> dict:
- self.calls.append(input_data)
- if self.fail_once:
- self.fail_once = False
- raise RuntimeError("model unavailable")
- return {"output": "assistant says hi"}
-
- async def stream(self, input_data: dict):
- self.calls.append(input_data)
- if self.fail_once:
- self.fail_once = False
- raise RuntimeError("model unavailable")
- yield {"type": "text", "delta": "fallback answer"}
- yield {"type": "final", "output": "fallback answer"}
-
-
-class _CheckpointMetadataRunner(_StubRunner):
- async def invoke(self, input_data: dict) -> dict:
- self.calls.append(input_data)
- return {
- "output": "checkpointed",
- "metadata": {
- "agentengine": {
- "run_id": "run-1",
- "framework": "langgraph",
- "framework_ref": {
- "langgraph": {
- "thread_id": "tenant:agent:sess-1",
- "checkpoint_id": "ckpt-1",
- }
- },
- }
- },
- }
-
-
-class _UsageRunner(_StubRunner):
- async def invoke(self, input_data: dict) -> dict:
- self.calls.append(input_data)
- return {
- "output": "assistant says hi",
- "usage": {
- "input_tokens": 8,
- "output_tokens": 13,
- "total_tokens": 21,
- "output_token_details": {"reasoning": 5},
- },
- }
-
-
-class _CheckpointResumeAdvancedRunner(_StubRunner):
- async def invoke(self, input_data: dict) -> dict:
- self.calls.append(input_data)
- return {
- "output": "resumed",
- "metadata": {
- "agentengine": {
- "run_id": "run-1",
- "framework": "langgraph",
- "framework_ref": {
- "langgraph": {
- "thread_id": "tenant:agent:sess-1",
- "checkpoint_id": "ckpt-after-resume",
- }
- },
- }
- },
- }
-
-
-class _PromptTooLongRunner(_StubRunner):
- def __init__(self):
- super().__init__()
- self.invocation_count = 0
-
- async def invoke(self, input_data: dict) -> dict:
- self.calls.append(input_data)
- self.invocation_count += 1
- if self.invocation_count == 1:
- raise RuntimeError("prompt-too-long")
- return {"output": "compacted answer"}
-
-
-class _FailingRunner(_StubRunner):
- async def invoke(self, input_data: dict) -> dict:
- self.calls.append(input_data)
- raise RuntimeError("boom")
-
-
-class _StreamingRunner(_StubRunner):
- def __init__(self):
- super().__init__()
- self.stream_calls: list[dict] = []
-
- async def stream(self, input_data: dict):
- self.stream_calls.append(input_data)
- yield {"type": "text", "delta": "hello"}
- yield {"type": "final", "output": "hello"}
-
-
-class _PromptTooLongStreamingRunner(_StreamingRunner):
- async def stream(self, input_data: dict):
- self.stream_calls.append(input_data)
- raise RuntimeError("prompt-too-long")
-
-
-class _UsageStreamingRunner(_StreamingRunner):
- async def stream(self, input_data: dict):
- self.stream_calls.append(input_data)
- yield {"type": "text", "delta": "hello"}
- yield {
- "type": "final",
- "output": "hello",
- "usage": {
- "input_tokens": 8,
- "output_tokens": 13,
- "total_tokens": 21,
- "input_token_details": {"cached": 4},
- "output_token_details": {"reasoning": 5},
- },
- "metadata": {
- "last_usage": {
- "input_tokens": 8,
- "output_tokens": 13,
- "total_tokens": 21,
- "input_token_details": {"cached": 4},
- "output_token_details": {"reasoning": 5},
- },
- },
- }
-
-
-class _CheckpointMetadataStreamingRunner(_StreamingRunner):
- async def stream(self, input_data: dict):
- self.stream_calls.append(input_data)
- yield {"type": "text", "delta": "hello"}
- yield {
- "type": "checkpoint",
- "metadata": {
- "agentengine": {
- "run_id": "run-1",
- "framework": "langgraph",
- "framework_ref": {
- "langgraph": {
- "thread_id": "tenant:agent:sess-1",
- "checkpoint_id": "ckpt-stream",
- }
- },
- }
- },
- }
-
-
-class _CheckpointMetadataPhaseStreamingRunner(_StreamingRunner):
- async def stream(self, input_data: dict):
- self.stream_calls.append(input_data)
- yield {
- "type": "checkpoint",
- "metadata": {
- "agentengine": {
- "run_id": "run-1",
- "phase": "数据清洗完成,等待生成报告",
- "stage": "清洗聚合指标",
- "summary": "GMV、转化率和退款率已经聚合完成",
- "next_action": "恢复后继续生成复盘报告",
- "framework": "langgraph",
- "framework_ref": {
- "langgraph": {
- "thread_id": "tenant:agent:sess-1",
- "checkpoint_id": "ckpt-business-stage",
- }
- },
- }
- },
- }
-
-
-class _CheckpointMetadataWithoutRunIdStreamingRunner(_StreamingRunner):
- async def stream(self, input_data: dict):
- self.stream_calls.append(input_data)
- yield {
- "type": "checkpoint",
- "metadata": {
- "agentengine": {
- "framework": "langgraph",
- "framework_ref": {
- "langgraph": {
- "thread_id": "tenant:agent:sess-1",
- "checkpoint_id": "ckpt-stream-after-resume",
- }
- },
- }
- },
- }
-
-
-class _BlockingStreamingRunner(_StreamingRunner):
- async def stream(self, input_data: dict):
- self.stream_calls.append(input_data)
- yield {"type": "text", "delta": "hello"}
- await asyncio.Event().wait()
-
-
-class _ApprovalToolResultStreamingRunner(_StreamingRunner):
- async def stream(self, input_data: dict):
- self.stream_calls.append(input_data)
- yield {
- "type": "tool_call",
- "tool_name": "write_workspace_file",
- "tool_args": {"path": "notes.txt"},
- "run_id": "run-approval",
- }
- yield {
- "type": "tool_result",
- "tool_name": "write_workspace_file",
- "tool_args": {"path": "notes.txt"},
- "tool_output": {
- "ok": False,
- "type": "approval_required",
- "approval_request": {
- "id": "appr_write",
- "tool_name": "write_workspace_file",
- "risk_level": "medium",
- "side_effects": ["workspace_write"],
- },
- },
- "run_id": "run-approval",
- }
- yield {"type": "final", "output": "should not complete"}
-
-
-class _SuccessfulToolResultStreamingRunner(_StreamingRunner):
- async def stream(self, input_data: dict):
- self.stream_calls.append(input_data)
- yield {
- "type": "tool_call",
- "tool_name": "list_skills",
- "tool_args": {"include": ["focused"]},
- "run_id": "run-list-skills",
- }
- yield {
- "type": "tool_result",
- "tool_name": "list_skills",
- "tool_args": {"include": ["focused"]},
- "tool_output": {"ok": True, "skills": [{"name": "ppt-translator"}]},
- "run_id": "run-list-skills",
- }
- yield {"type": "final", "output": "done"}
-
-
-class _ToolSearchResultStreamingRunner(_StreamingRunner):
- async def stream(self, input_data: dict):
- self.stream_calls.append(input_data)
- yield {
- "type": "tool_result",
- "tool_name": "tool_search",
- "tool_args": {"query": "edit file"},
- "tool_output": {
- "ok": True,
- "deferred_tool_names": ["read_workspace_file", "edit_workspace_file"],
- },
- "run_id": "run-tool-search",
- }
- yield {"type": "final", "output": "ready"}
-
-
-class _ManyToolCallsStreamingRunner(_StreamingRunner):
- async def stream(self, input_data: dict):
- self.stream_calls.append(input_data)
- for index in range(3):
- yield {
- "type": "tool_call",
- "tool_name": "list_skills",
- "tool_args": {"index": index},
- "run_id": f"run-tool-{index}",
- }
- yield {"type": "final", "output": "should stop before final"}
-
-
-class _FailingToolResultsStreamingRunner(_StreamingRunner):
- async def stream(self, input_data: dict):
- self.stream_calls.append(input_data)
- for index in range(2):
- yield {
- "type": "tool_result",
- "tool_name": "run_command",
- "tool_args": {"command": "false"},
- "tool_output": {"ok": False, "error_type": "boom"},
- "run_id": f"run-failure-{index}",
- }
- yield {"type": "final", "output": "should stop before final"}
-
-
-class _StageActivityStreamingRunner(_StreamingRunner):
- async def stream(self, input_data: dict):
- self.stream_calls.append(input_data)
- yield {
- "type": "stage_tool_call",
- "tool_name": "deepresearch_query_agent",
- "tool_args": {"stage": "search_web", "query": "agent runtime"},
- "event_kind": "query",
- "display_title": "检索公开网页",
- "display_summary": "开始检索 agent runtime",
- "run_id": "run-stage-activity",
- }
- yield {
- "type": "stage_tool_result",
- "tool_name": "deepresearch_query_agent",
- "tool_args": {"stage": "search_web", "query": "agent runtime"},
- "tool_output": {"ok": True, "result_count": 3},
- "event_kind": "query",
- "display_title": "检索公开网页",
- "display_summary": "返回 3 条结果",
- "run_id": "run-stage-activity",
- }
- yield {"type": "final", "output": "done"}
-
-
-class _StageActivityNoFinalStreamingRunner(_StreamingRunner):
- async def stream(self, input_data: dict):
- self.stream_calls.append(input_data)
- yield {
- "type": "stage_tool_result",
- "tool_name": "deepresearch_query_agent",
- "tool_args": {"stage": "search_web", "query": "agent runtime"},
- "tool_output": {"ok": True, "result_count": 3},
- "event_kind": "query",
- "display_title": "检索公开网页",
- "display_summary": "返回 3 条结果",
- "run_id": "run-stage-activity",
- }
-
-
-class _ResumeStreamingRunner(_StreamingRunner):
- async def stream(self, input_data: dict):
- self.stream_calls.append(input_data)
- yield {"type": "final", "output": "resumed"}
-
-
-class _CompletedOutputStreamingRunner(_StreamingRunner):
- async def stream(self, input_data: dict):
- self.stream_calls.append(input_data)
- yield {"type": "text", "delta": "需要查询。"}
- yield {
- "type": "responses_output",
- "response_id": "resp_native",
- "output": [
- {
- "id": "fc_123",
- "type": "function_call",
- "call_id": "call_123",
- "name": "search",
- "arguments": '{"q":"openclaw"}',
- "status": "completed",
- },
- {
- "id": "rs_123",
- "type": "reasoning",
- "summary": [{"type": "summary_text", "text": "先查资料"}],
- },
- ],
- }
- yield {"type": "final", "output": "需要查询。"}
-
-
-class _CompletedOutputUsageStreamingRunner(_CompletedOutputStreamingRunner):
- async def stream(self, input_data: dict):
- self.stream_calls.append(input_data)
- yield {
- "type": "responses_output",
- "response_id": "resp_native_usage",
- "output": [
- {
- "id": "msg_123",
- "type": "message",
- "role": "assistant",
- "content": [{"type": "output_text", "text": "需要查询。"}],
- }
- ],
- "usage": {
- "input_tokens": 9,
- "output_tokens": 4,
- "total_tokens": 13,
- "output_token_details": {"reasoning": 2},
- },
- }
- yield {"type": "final", "output": "需要查询。"}
-
-
-class _ThinkingStreamingRunner(_StreamingRunner):
- async def stream(self, input_data: dict):
- self.stream_calls.append(input_data)
- yield {"type": "thinking", "delta": "先分析问题"}
- yield {"type": "text", "delta": "你好"}
- yield {"type": "final", "output": "你好"}
-
-
-class _ContextCapturingRunner(_StubRunner):
- def __init__(self):
- super().__init__()
- self.captured_runtime_context = None
- self.captured_tool_context = None
-
- async def invoke(self, input_data: dict) -> dict:
- self.calls.append(input_data)
- self.captured_runtime_context = get_current_invocation_context()
- self.captured_tool_context = get_current_tool_execution_context_or_default()
- return {"output": "captured"}
-
-
-class _ToolContextCapturingStreamingRunner(_StreamingRunner):
- def __init__(self):
- super().__init__()
- self.captured_tool_context = None
-
- async def stream(self, input_data: dict):
- self.stream_calls.append(input_data)
- self.captured_tool_context = get_current_tool_execution_context_or_default()
- yield {"type": "text", "delta": "hello"}
- yield {"type": "final", "output": "hello"}
-
-
-class _FakeLongTermMemoryService:
- instances: list["_FakeLongTermMemoryService"] = []
-
- def __init__(self):
- self.saved: list[dict] = []
- self.__class__.instances.append(self)
-
- def build_context(self, *, user_id: str, query: str, top_k=None) -> dict | None:
- return None
-
- def save_event_strings(self, *, user_id: str, event_strings: list[str], metadata=None) -> bool:
- self.saved.append(
- {
- "user_id": user_id,
- "event_strings": event_strings,
- "metadata": dict(metadata or {}),
- }
- )
- return True
-
-
-class _ExternalModelsAsyncClient:
- def __init__(self, *args, payload=None, error: Exception | None = None, **kwargs):
- self._payload = payload
- self._error = error
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return None
-
- async def get(self, url: str, headers: dict | None = None):
- if self._error is not None:
- raise self._error
- request = httpx.Request("GET", url, headers=headers)
- return httpx.Response(200, json=self._payload, request=request)
-
-
-def _extract_sse_payload(chunks: list[str], event_name: str) -> dict:
- current_event = ""
- for chunk in chunks:
- for line in chunk.splitlines():
- if line.startswith("event: "):
- current_event = line.removeprefix("event: ")
- elif line.startswith("data: ") and current_event == event_name:
- return json.loads(line.removeprefix("data: "))
- raise AssertionError(f"SSE event {event_name!r} not found")
-
-
-def test_runtime_context_helpers_return_defaults_outside_invocation_scope():
- assert get_current_invocation_context() is None
- context = get_current_invocation_context_or_default()
- assert context.user_id == ""
- assert context.account_id == ""
- assert context.session_id == ""
- assert context.history == []
- assert context.attachments == []
- assert get_current_user_id() == ""
- assert get_current_account_id() == ""
- assert get_current_user_id(default="anonymous") == "anonymous"
- assert get_current_account_id(default="tenantless") == "tenantless"
-
-
-def test_runtime_context_helpers_read_current_invocation_scope():
- context = PlatformInvocationContext(
- agent_id="demo-agent",
- user_id="user-1",
- account_id="acct-1",
- session_id="sess-1",
- history=[],
- input_content=[],
- input_messages=[],
- input_parts=[],
- attachments=[],
- attachment_results=[],
- current_attachments=[],
- current_attachment_results=[],
- has_current_files=False,
- runner_type="mock",
- )
-
- with platform_invocation_scope(context):
- assert get_current_user_id() == "user-1"
- assert get_current_account_id() == "acct-1"
-
-
-def test_tool_execution_context_helpers_return_defaults_and_scope_values():
- default_context = get_current_tool_execution_context_or_default()
-
- assert default_context.session_id == ""
- assert default_context.run_id == ""
- assert default_context.invocation_id == ""
-
- with tool_execution_scope(session_id="sess-1", run_id="run-1", invocation_id="inv-1"):
- context = get_current_tool_execution_context_or_default()
- assert context.session_id == "sess-1"
- assert context.run_id == "run-1"
- assert context.invocation_id == "inv-1"
-
- assert get_current_tool_execution_context_or_default().session_id == ""
-
-
-@pytest.fixture
-def in_memory_trace_exporter():
- provider = TracerProvider()
- exporter = InMemoryExporter()
- provider.add_span_processor(SimpleSpanProcessor(exporter))
- trace._TRACER_PROVIDER = None
- trace._TRACER_PROVIDER_SET_ONCE._done = False
- trace._set_tracer_provider(provider, log=False)
- yield exporter
- trace._TRACER_PROVIDER = None
- trace._TRACER_PROVIDER_SET_ONCE._done = False
-
-
-@pytest.fixture(autouse=True)
-def _disable_session_title_ai(monkeypatch):
- class _UnavailableTitleClient:
- @property
- def is_available(self):
- return False
-
- monkeypatch.setattr(
- "ksadk.conversations.runtime.resolve_session_title_client",
- lambda: _UnavailableTitleClient(),
- )
-
-
-def test_estimate_text_tokens_is_less_optimistic_for_cjk():
- assert estimate_text_tokens("") == 0
- assert estimate_text_tokens("hello world") == 3
- assert estimate_text_tokens("你好世界") == 4
- assert estimate_text_tokens("Agent平台设计") == 6
-
-
-def test_build_compaction_sse_event_returns_str_with_millisecond_timestamp():
- before_ms = int(time.time() * 1000)
- event = build_compaction_sse_event(
- phase="start",
- trigger="auto",
- compacted_until_seq_id=42,
- total_chars=1200,
- total_estimated_tokens=512,
- group_count=9,
- threshold_percentage=80,
- )
- after_ms = int(time.time() * 1000)
-
- assert isinstance(event, str)
- assert event.startswith("event: response.compaction.start\n")
- payload_line = event.splitlines()[1]
- assert payload_line.startswith("data: ")
- payload = json.loads(payload_line.removeprefix("data: "))
- assert payload["phase"] == "start"
- assert payload["trigger"] == "auto"
- assert payload["compacted_until_seq_id"] == 42
- assert payload["total_chars"] == 1200
- assert payload["total_estimated_tokens"] == 512
- assert payload["group_count"] == 9
- assert payload["threshold_percentage"] == 80
- assert isinstance(payload["timestamp"], int)
- assert before_ms <= payload["timestamp"] <= after_ms
-
-
-def test_extract_responses_resume_input_accepts_openai_mcp_approval_response():
- resume_input = extract_responses_resume_input(
- [
- {
- "type": "mcp_approval_response",
- "id": "mcprsp_123",
- "approval_request_id": "appr_123",
- "approve": True,
- "reason": "looks safe",
- }
- ]
- )
-
- assert resume_input == {
- "type": "mcp_approval_response",
- "id": "mcprsp_123",
- "approval_request_id": "appr_123",
- "approve": True,
- "reason": "looks safe",
- }
-
-
-def test_extract_responses_resume_input_accepts_ksadk_resume_extension():
- resume_input = extract_responses_resume_input(
- [
- {
- "type": "ksadk_resume",
- "interrupt_id": "intr_123",
- "value": {"answer": "继续", "approved": True},
- }
- ]
- )
-
- assert resume_input == {
- "type": "ksadk_resume",
- "interrupt_id": "intr_123",
- "value": {"answer": "继续", "approved": True},
- }
-
-
-def test_extract_responses_resume_input_accepts_openai_function_call_output():
- resume_input = extract_responses_resume_input(
- [
- {
- "type": "function_call_output",
- "call_id": "call_123",
- "output": {"ok": True},
- }
- ]
- )
-
- assert resume_input == {
- "type": "function_call_output",
- "call_id": "call_123",
- "output": {"ok": True},
- }
-
-
-def test_governance_records_approval_denials_and_trips_limit():
- runtime_module = importlib.import_module("ksadk.conversations.runtime")
- state = runtime_module.RuntimeGovernanceState(max_consecutive_approval_denials=2)
-
- runtime_module._governance_record_approval_response(state, {"approved": False})
- try:
- runtime_module._governance_record_approval_response(state, {"approved": False})
- except runtime_module.RuntimeCircuitOpen as exc:
- assert exc.reason == "consecutive_approval_denials"
- assert exc.metadata["consecutive_approval_denials"] == 2
- else:
- raise AssertionError("expected RuntimeCircuitOpen")
-
-
-def test_governance_records_compact_failures_and_trips_limit():
- runtime_module = importlib.import_module("ksadk.conversations.runtime")
- state = runtime_module.RuntimeGovernanceState(max_consecutive_compact_failures=1)
-
- try:
- runtime_module._governance_record_compact_failure(state)
- except runtime_module.RuntimeCircuitOpen as exc:
- assert exc.reason == "consecutive_compact_failures"
- assert exc.metadata["consecutive_compact_failures"] == 1
- else:
- raise AssertionError("expected RuntimeCircuitOpen")
-
-
-@pytest.mark.asyncio
-async def test_build_run_input_projects_history_from_append_only_events(monkeypatch):
- service = InMemorySessionService()
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-1")
- await service.append_event(
- "sess-1",
- SessionEvent(
- id="evt-1",
- author="user",
- event_type="user_message",
- content={"role": "user", "parts": [{"text": "hello"}]},
- ),
- )
- await service.append_event(
- "sess-1",
- SessionEvent(
- id="evt-2",
- author="demo-agent",
- event_type="assistant_message",
- content={"role": "model", "parts": [{"text": "hi"}]},
- ),
- )
-
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- prepared = await build_run_input(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-1",
- messages=[{"role": "user", "content": "follow up"}],
- )
-
- assert prepared.history == [
- {"role": "user", "content": "hello"},
- {"role": "model", "content": "hi"},
- {"role": "user", "content": "follow up"},
- ]
-
- events = await service.get_events("sess-1")
- assert [event.event_type for event in events] == [
- "user_message",
- "assistant_message",
- "user_message",
- ]
-
-
-@pytest.mark.asyncio
-async def test_build_run_input_preserves_responses_request_history_when_runtime_session_is_empty(
- monkeypatch,
-):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- prepared = await build_run_input(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-responses-history",
- messages=[
- {"role": "user", "content": "写一个python快排的示例"},
- {"role": "assistant", "content": "这是 Python 快速排序示例。"},
- {"role": "user", "content": "用go"},
- ],
- )
-
- assert prepared.user_input == "用go"
- assert prepared.history == [
- {"role": "user", "content": "写一个python快排的示例"},
- {"role": "model", "content": "这是 Python 快速排序示例。"},
- {"role": "user", "content": "用go"},
- ]
-
-
-@pytest.mark.asyncio
-async def test_build_run_input_deduplicates_responses_request_history_against_session_events(
- monkeypatch,
-):
- service = InMemorySessionService()
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-dup")
- await service.append_event(
- "sess-dup",
- SessionEvent(
- id="evt-1",
- author="user",
- event_type="user_message",
- content={"role": "user", "parts": [{"text": "写一个python快排的示例"}]},
- ),
- )
- await service.append_event(
- "sess-dup",
- SessionEvent(
- id="evt-2",
- author="demo-agent",
- event_type="assistant_message",
- content={"role": "model", "parts": [{"text": "这是 Python 快速排序示例。"}]},
- ),
- )
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- prepared = await build_run_input(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-dup",
- messages=[
- {"role": "user", "content": "写一个python快排的示例"},
- {"role": "assistant", "content": "这是 Python 快速排序示例。"},
- {"role": "user", "content": "用go"},
- ],
- )
-
- assert prepared.history == [
- {"role": "user", "content": "写一个python快排的示例"},
- {"role": "model", "content": "这是 Python 快速排序示例。"},
- {"role": "user", "content": "用go"},
- ]
-
-
-def test_set_conversation_span_attributes_sets_langfuse_and_standard_session_id():
- from ksadk.conversations.runtime import _set_conversation_span_attributes
-
- class _Span:
- def __init__(self):
- self.attributes = {}
-
- def set_attribute(self, key, value):
- self.attributes[key] = value
-
- span = _Span()
-
- _set_conversation_span_attributes(
- span,
- agent_id="agent-demo",
- user_id="user-demo",
- session_id="sess-demo",
- invocation_id="inv-demo",
- runner_name="demo-agent",
- model="glm-5.1",
- response_id="resp-demo",
- )
-
- assert span.attributes["langfuse.session.id"] == "sess-demo"
- assert span.attributes["session.id"] == "sess-demo"
- assert span.attributes["langfuse.user.id"] == "user-demo"
- assert span.attributes["user.id"] == "user-demo"
-
-
-@pytest.mark.asyncio
-async def test_build_run_input_persists_attachment_results_and_passes_them_to_runner(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- message = {
- "role": "user",
- "content": "[上传文件: resume.pdf]\n张三 8年经验",
- "display_content": "请分析附件\n\n## 附件\n- resume.pdf",
- "parts": [{"text": "请分析附件"}],
- "attachments": [
- {
- "display_name": "resume.pdf",
- "mime_type": "application/pdf",
- "transport": "reference",
- "file_uri": "ksadk-upload://resume",
- "size_bytes": 128,
- }
- ],
- "attachment_results": [
- {
- "display_name": "resume.pdf",
- "mime_type": "application/pdf",
- "transport": "reference",
- "file_uri": "ksadk-upload://resume",
- "size_bytes": 128,
- "kind": "document",
- "status": "ok",
- "warnings": [],
- "extraction_method": "pdf_native",
- "text_excerpt": "张三 8年经验",
- "text": "张三 8年经验",
- "document": {"format": "pdf"},
- }
- ],
- }
-
- prepared = await build_run_input(
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[message],
- )
-
- assert prepared.attachments == message["attachments"]
- assert prepared.attachment_results == message["attachment_results"]
-
- events = await service.get_events(prepared.session_id)
- assert events[0].metadata["attachment_results"] == [
- {
- "display_name": "resume.pdf",
- "mime_type": "application/pdf",
- "transport": "reference",
- "file_uri": "ksadk-upload://resume",
- "size_bytes": 128,
- "kind": "document",
- "status": "ok",
- "warnings": [],
- "extraction_method": "pdf_native",
- "text_excerpt": "张三 8年经验",
- "document": {"format": "pdf"},
- }
- ]
-
- runner = _StubRunner()
- session_id, result = await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id=prepared.session_id,
- messages=[message],
- model="gpt-4o",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- session_service_provider=lambda: service,
- )
-
- assert session_id == prepared.session_id
- assert result["output_text"] == "assistant says hi"
- assert runner.calls[-1]["attachment_results"] == message["attachment_results"]
-
-
-@pytest.mark.asyncio
-async def test_build_run_input_reuses_last_attachment_results_for_follow_up_turn(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- message = {
- "role": "user",
- "content": "[上传文件: resume.txt]\n张三 8年经验",
- "display_content": "请分析附件\n\n## 附件\n- resume.txt",
- "parts": [{"text": "请分析附件"}],
- "attachments": [
- {
- "display_name": "resume.txt",
- "mime_type": "text/plain",
- "transport": "reference",
- "file_uri": "ksadk-upload://resume",
- "size_bytes": 64,
- }
- ],
- "attachment_results": [
- {
- "display_name": "resume.txt",
- "mime_type": "text/plain",
- "transport": "reference",
- "file_uri": "ksadk-upload://resume",
- "size_bytes": 64,
- "kind": "text",
- "status": "ok",
- "warnings": [],
- "extraction_method": "text_decode",
- "text_excerpt": "张三 8年经验",
- "text": "张三 8年经验",
- }
- ],
- }
-
- first = await build_run_input(
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[message],
- )
- follow_up = await build_run_input(
- agent_id="demo-agent",
- user_id="user-1",
- session_id=first.session_id,
- messages=[{"role": "user", "content": "继续分析"}],
- )
-
- assert first.current_attachments == message["attachments"]
- assert first.current_attachment_results == message["attachment_results"]
- assert first.has_current_files is True
- assert follow_up.attachments == message["attachments"]
- assert follow_up.attachment_results == [
- {
- "display_name": "resume.txt",
- "mime_type": "text/plain",
- "transport": "reference",
- "file_uri": "ksadk-upload://resume",
- "size_bytes": 64,
- "kind": "text",
- "status": "ok",
- "warnings": [],
- "extraction_method": "text_decode",
- "text_excerpt": "张三 8年经验",
- }
- ]
- assert follow_up.current_attachments == []
- assert follow_up.current_attachment_results == []
- assert follow_up.has_current_files is False
-
- runner = _StubRunner()
- session_id, result = await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id=first.session_id,
- messages=[{"role": "user", "content": "继续分析"}],
- model="gpt-4o",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- session_service_provider=lambda: service,
- )
-
- assert session_id == first.session_id
- assert result["output_text"] == "assistant says hi"
- assert runner.calls[-1]["attachment_results"] == follow_up.attachment_results
- assert runner.calls[-1]["current_attachments"] == []
- assert runner.calls[-1]["current_attachment_results"] == []
- assert runner.calls[-1]["has_current_files"] is False
-
-
-@pytest.mark.asyncio
-async def test_build_run_input_stores_recent_attachment_context_without_inline_data(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- image_b64 = base64.b64encode(b"fake image bytes").decode("ascii")
- first = await build_run_input(
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[
- {
- "role": "user",
- "content": [
- {"type": "input_text", "text": "看图"},
- {
- "type": "input_image",
- "image_url": f"data:image/png;base64,{image_b64}",
- },
- ],
- }
- ],
- )
-
- session = await service.get_session(first.session_id)
- attachment_context = session.state["__ksadk_attachment_context__"]
- assert attachment_context["attachments"] == [
- {
- "display_name": "uploaded_image",
- "mime_type": "image/png",
- "transport": "inline",
- "size_bytes": len(b"fake image bytes"),
- "is_text": False,
- }
- ]
- assert "data" not in attachment_context["attachments"][0]
-
- follow_up = await build_run_input(
- agent_id="demo-agent",
- user_id="user-1",
- session_id=first.session_id,
- messages=[{"role": "user", "content": "继续"}],
- )
-
- assert follow_up.attachments == attachment_context["attachments"]
- assert follow_up.current_attachments == []
- assert follow_up.has_current_files is False
-
-
-@pytest.mark.asyncio
-async def test_build_run_input_sanitizes_legacy_recent_attachment_context(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- session = await service.create_session(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-legacy-inline-context",
- )
- await service.update_state(
- agent_id="demo-agent",
- user_id="user-1",
- session_id=session.id,
- scope="session",
- state_delta={
- "__ksadk_attachment_context__": {
- "attachments": [
- {
- "display_name": "legacy.png",
- "mime_type": "image/png",
- "transport": "inline",
- "data": base64.b64encode(b"legacy image").decode("ascii"),
- "size_bytes": 12,
- }
- ],
- "attachment_results": [
- {
- "display_name": "legacy.png",
- "mime_type": "image/png",
- "transport": "inline",
- "text": "图片摘要",
- "text_excerpt": "图片摘要",
- }
- ],
- }
- },
- )
-
- follow_up = await build_run_input(
- agent_id="demo-agent",
- user_id="user-1",
- session_id=session.id,
- messages=[{"role": "user", "content": "继续"}],
- )
-
- assert follow_up.attachments == [
- {
- "display_name": "legacy.png",
- "mime_type": "image/png",
- "transport": "inline",
- "size_bytes": 12,
- }
- ]
- assert "data" not in json.dumps(follow_up.attachments, ensure_ascii=False)
- assert follow_up.attachment_results == [
- {
- "display_name": "legacy.png",
- "mime_type": "image/png",
- "transport": "inline",
- "text_excerpt": "图片摘要",
- }
- ]
- assert "text" not in follow_up.attachment_results[0]
- assert follow_up.current_attachments == []
- assert follow_up.has_current_files is False
-
-
-@pytest.mark.asyncio
-async def test_build_run_input_detects_current_openai_input_image(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- image_b64 = "iVBORw0KGgo="
- prepared = await build_run_input(
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[
- {
- "role": "user",
- "content": [
- {"type": "input_text", "text": "请分析这张图"},
- {
- "type": "input_image",
- "image_url": f"data:image/png;base64,{image_b64}",
- },
- ],
- }
- ],
- model=None,
- )
-
- assert prepared.has_current_files is True
- assert prepared.current_attachments == [
- {
- "display_name": "uploaded_image",
- "mime_type": "image/png",
- "transport": "inline",
- "data": image_b64,
- "is_text": False,
- "size_bytes": 8,
- }
- ]
- assert prepared.attachments == prepared.current_attachments
- assert prepared.user_parts[1] == {
- "inlineData": {
- "data": image_b64,
- "mimeType": "image/png",
- "displayName": "uploaded_image",
- }
- }
- assert prepared.input_content == [
- {"type": "input_text", "text": "请分析这张图"},
- {"type": "input_image", "image_url": f"data:image/png;base64,{image_b64}"},
- ]
- assert prepared.input_messages == [
- {
- "role": "user",
- "content": [
- {"type": "input_text", "text": "请分析这张图"},
- {"type": "input_image", "image_url": f"data:image/png;base64,{image_b64}"},
- ],
- }
- ]
-
-
-@pytest.mark.asyncio
-async def test_build_run_input_detects_openai_input_image_object_url(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- image_b64 = "YWJj"
- prepared = await build_run_input(
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[
- {
- "role": "user",
- "content": [
- {
- "type": "input_image",
- "image_url": {"url": f"data:image/jpeg;base64,{image_b64}"},
- },
- ],
- }
- ],
- )
-
- assert prepared.has_current_files is True
- assert prepared.current_attachments[0]["display_name"] == "uploaded_image"
- assert prepared.current_attachments[0]["mime_type"] == "image/jpeg"
- assert prepared.current_attachments[0]["data"] == image_b64
-
-
-@pytest.mark.asyncio
-async def test_build_run_input_preserves_openai_input_image_remote_url(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- image_url = "https://example.com/diagram.png"
- prepared = await build_run_input(
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[
- {
- "role": "user",
- "content": [
- {
- "type": "input_image",
- "image_url": image_url,
- },
- ],
- }
- ],
- )
-
- assert prepared.has_current_files is True
- assert prepared.current_attachments == [
- {
- "display_name": "uploaded_image",
- "mime_type": "image/*",
- "transport": "reference",
- "file_uri": image_url,
- "is_text": False,
- "size_bytes": None,
- "storage_path": None,
- }
- ]
- assert prepared.attachment_results[0]["warnings"] == [
- "附件内容无法读取,请重新上传或检查文件句柄是否仍可访问。"
- ]
-
-
-@pytest.mark.asyncio
-async def test_build_run_input_detects_openai_input_file_data(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- file_text = "候选人简历内容"
- file_b64 = base64.b64encode(file_text.encode("utf-8")).decode("ascii")
- prepared = await build_run_input(
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[
- {
- "role": "user",
- "content": [
- {"type": "input_text", "text": "请总结附件"},
- {
- "type": "input_file",
- "filename": "resume.txt",
- "file_data": file_b64,
- },
- ],
- }
- ],
- )
-
- assert prepared.has_current_files is True
- assert prepared.current_attachments == [
- {
- "display_name": "resume.txt",
- "mime_type": "text/plain",
- "transport": "inline",
- "data": file_b64,
- "is_text": True,
- "size_bytes": len(file_text.encode("utf-8")),
- }
- ]
- assert prepared.attachment_results[0]["text"] == file_text
- assert prepared.input_content == [
- {"type": "input_text", "text": "请总结附件"},
- {
- "type": "input_file",
- "filename": "resume.txt",
- "file_data": file_b64,
- },
- ]
-
-
-@pytest.mark.asyncio
-async def test_build_run_input_preserves_openai_input_file_references(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- prepared = await build_run_input(
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[
- {
- "role": "user",
- "content": [
- {
- "type": "input_file",
- "filename": "report.pdf",
- "file_url": "https://example.com/report.pdf",
- },
- {
- "type": "input_file",
- "filename": "uploaded.pdf",
- "file_id": "file-abc123",
- },
- ],
- }
- ],
- )
-
- assert prepared.has_current_files is True
- assert prepared.current_attachments == [
- {
- "display_name": "report.pdf",
- "mime_type": "application/pdf",
- "transport": "reference",
- "file_uri": "https://example.com/report.pdf",
- "is_text": False,
- "size_bytes": None,
- "storage_path": None,
- },
- {
- "display_name": "uploaded.pdf",
- "mime_type": "application/pdf",
- "transport": "reference",
- "file_uri": "file-abc123",
- "is_text": False,
- "size_bytes": None,
- "storage_path": None,
- },
- ]
-
-
-@pytest.mark.asyncio
-async def test_invoke_conversation_once_persists_canonical_turn_events(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _StubRunner()
-
- session_id, result = await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[{"role": "user", "content": "hello"}],
- model="gpt-4o",
- prepare_runner=lambda runner, model: runner.prepare_for_request(model),
- )
-
- assert result["output_text"] == "assistant says hi"
- assert runner.prepared_models == ["gpt-4o"]
- assert runner.calls[-1]["history"] == [{"role": "user", "content": "hello"}]
- assert runner.calls[-1]["input_content"] == [{"type": "input_text", "text": "hello"}]
- assert runner.calls[-1]["input_messages"] == [
- {"role": "user", "content": [{"type": "input_text", "text": "hello"}]}
- ]
- assert runner.calls[-1]["input_parts"] == [{"text": "hello"}]
-
- events = await service.get_events(session_id)
- session = await service.get_session(session_id)
- assert [event.event_type for event in events] == [
- "user_message",
- "run_status",
- "assistant_message",
- "run_status",
- ]
- assert [event.author for event in events] == ["user", "demo-agent", "demo-agent", "demo-agent"]
- assert session is not None
- assert session.title == "hello"
- assert session.title_source == "fallback_first_prompt"
- assert session.first_prompt == "hello"
- assert session.last_prompt == "hello"
- assert session.summary == "assistant says hi"
-
-
-@pytest.mark.asyncio
-async def test_invoke_conversation_once_persists_responses_image_parts_for_replay(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _StubRunner()
- image_url = "data:image/png;base64,aW1hZ2U="
-
- session_id, _ = await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[
- {
- "role": "user",
- "content": [
- {"type": "input_text", "text": "请分析这张图"},
- {"type": "input_image", "image_url": image_url},
- ],
- }
- ],
- model=None,
- prepare_runner=lambda runner, model: None,
- )
-
- events = await service.get_events(session_id)
- assert events[0].event_type == "user_message"
- assert events[0].content == {
- "role": "user",
- "parts": [
- {"type": "input_text", "text": "请分析这张图"},
- {"type": "input_image", "image_url": image_url},
- ],
- }
-
-
-@pytest.mark.asyncio
-async def test_invoke_conversation_once_persists_response_id_on_assistant_event(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _StubRunner()
-
- session_id, result = await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[{"role": "user", "content": "hello"}],
- model="gpt-4o",
- response_id="resp_feedback_nonstream",
- prepare_runner=lambda runner, model: runner.prepare_for_request(model),
- )
-
- events = await service.get_events(session_id)
- assistant_event = next(event for event in events if event.event_type == "assistant_message")
- assert result["response_id"] == "resp_feedback_nonstream"
- assert assistant_event.metadata["response_id"] == "resp_feedback_nonstream"
-
-
-@pytest.mark.asyncio
-async def test_invoke_conversation_once_persists_trace_metadata_for_feedback(
- monkeypatch,
- in_memory_trace_exporter,
-):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _StubRunner()
-
- session_id, result = await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[{"role": "user", "content": "hello"}],
- model="gpt-4o",
- response_id="resp_trace_nonstream",
- prepare_runner=lambda runner, model: runner.prepare_for_request(model),
- )
-
- events = await service.get_events(session_id)
- assistant_event = next(event for event in events if event.event_type == "assistant_message")
- trace_id = assistant_event.metadata.get("trace_id")
- root_span_id = assistant_event.metadata.get("root_span_id")
-
- assert trace_id
- assert root_span_id
- assert result["metadata"]["trace_id"] == trace_id
- assert result["metadata"]["root_span_id"] == root_span_id
- exported_trace = in_memory_trace_exporter.get_trace(trace_id)
- assert exported_trace is not None
- assert any(span["span_id"] == root_span_id for span in exported_trace["spans"])
-
-
-@pytest.mark.asyncio
-async def test_invoke_conversation_once_sets_langfuse_trace_io_attributes(
- monkeypatch,
- in_memory_trace_exporter,
-):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _StubRunner()
-
- session_id, result = await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[{"role": "user", "content": "hello"}],
- model="gpt-4o",
- response_id="resp_trace_nonstream",
- prepare_runner=lambda runner, model: runner.prepare_for_request(model),
- )
-
- exported_trace = in_memory_trace_exporter.get_trace(result["metadata"]["trace_id"])
- assert exported_trace is not None
- root_span = next(
- span for span in exported_trace["spans"] if span["span_id"] == result["metadata"]["root_span_id"]
- )
- assert root_span["name"] == "demo-agent"
- assert root_span["status"]["code"] != "StatusCode.ERROR"
- assert root_span["attributes"]["langfuse.trace.name"] == "demo-agent"
- assert root_span["attributes"]["langfuse.user.id"] == "user-1"
- assert root_span["attributes"]["langfuse.session.id"] == session_id
- assert root_span["attributes"]["langfuse.trace.input"] == "hello"
- assert root_span["attributes"]["langfuse.trace.output"] == "assistant says hi"
- assert root_span["attributes"]["langfuse.observation.input"] == "hello"
- assert root_span["attributes"]["langfuse.observation.output"] == "assistant says hi"
- assert root_span["attributes"]["input.value"] == "hello"
- assert root_span["attributes"]["output.value"] == "assistant says hi"
-
-
-@pytest.mark.asyncio
-async def test_invoke_conversation_once_passes_session_id_to_runner(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _StubRunner()
-
- session_id, _ = await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[{"role": "user", "content": "hello"}],
- model="gpt-4o",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- )
-
- assert runner.calls[-1]["session_id"] == session_id
-
-
-@pytest.mark.asyncio
-async def test_invoke_conversation_once_passes_model_options_to_runner(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _StubRunner()
-
- await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[{"role": "user", "content": "hello"}],
- model="gpt-4o",
- model_options={"thinking": {"type": "disabled"}},
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- )
-
- assert runner.calls[-1]["model_options"] == {
- "thinking": {"type": "disabled"},
- "reasoning": {"effort": "none"},
- "max_reasoning_tokens": 0,
- }
-
-
-@pytest.mark.asyncio
-async def test_invoke_conversation_once_falls_back_on_transient_model_error(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _TransientFallbackRunner()
-
- await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[{"role": "user", "content": "hello"}],
- model="glm-5.2",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- )
-
- assert runner.prepared_models == ["glm-5.2", "deepseek-v4-pro"]
- assert runner.calls[-1]["model"] == "deepseek-v4-pro"
-
-
-@pytest.mark.asyncio
-async def test_stream_conversation_turn_falls_back_before_first_delta(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _TransientFallbackRunner()
-
- events = [
- event
- async for event in stream_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[{"role": "user", "content": "hello"}],
- model="glm-5.2",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- )
- ]
-
- assert runner.prepared_models == ["glm-5.2", "deepseek-v4-pro"]
- assert runner.calls[-1]["model"] == "deepseek-v4-pro"
- assert any("fallback answer" in event for event in events)
- assert any("response.completed" in event for event in events)
-
-
-@pytest.mark.asyncio
-async def test_invoke_conversation_once_auto_saves_turn_to_sdk_memory_by_default(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- monkeypatch.setenv("KSADK_LTM_BACKEND", "sdk")
- monkeypatch.setenv("KSADK_LTM_NAMESPACE", "mem-demo")
- monkeypatch.delenv("KSADK_LTM_AUTO_SAVE", raising=False)
- _FakeLongTermMemoryService.instances.clear()
- monkeypatch.setattr(
- "ksadk.conversations.runtime.LongTermMemoryService.from_env",
- lambda: _FakeLongTermMemoryService(),
- )
- runner = _StubRunner()
-
- session_id, _ = await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[
- {
- "role": "user",
- "content": [
- {"type": "input_text", "text": "请记住我偏好简洁回答"},
- {"type": "input_image", "image_url": "data:image/png;base64,aW1hZ2U="},
- ],
- }
- ],
- model="qwen3-vl-plus",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- )
-
- memory_service = _FakeLongTermMemoryService.instances[-1]
- assert len(memory_service.saved) == 1
- saved = memory_service.saved[0]
- assert saved["user_id"] == "user-1"
- assert saved["metadata"]["agent_id"] == "demo-agent"
- assert saved["metadata"]["session_id"] == session_id
- assert saved["metadata"]["model"] == "qwen3-vl-plus"
- assert saved["metadata"]["runner_type"]
- assert saved["metadata"]["invocation_id"]
-
- persisted_events = [json.loads(item) for item in saved["event_strings"]]
- assert [event["role"] for event in persisted_events] == ["user", "assistant"]
- assert persisted_events[0]["parts"] == [{"text": "请记住我偏好简洁回答"}]
- assert persisted_events[0]["metadata"]["attachments"] == [
- {"kind": "image", "display_name": "uploaded_image", "mime_type": "image/png"}
- ]
- assert persisted_events[1]["parts"] == [{"text": "assistant says hi"}]
- assert "base64" not in json.dumps(persisted_events, ensure_ascii=False)
-
-
-@pytest.mark.asyncio
-async def test_invoke_conversation_once_respects_ltm_auto_save_false(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- monkeypatch.setenv("KSADK_LTM_BACKEND", "sdk")
- monkeypatch.setenv("KSADK_LTM_NAMESPACE", "mem-demo")
- monkeypatch.setenv("KSADK_LTM_AUTO_SAVE", "false")
- _FakeLongTermMemoryService.instances.clear()
- monkeypatch.setattr(
- "ksadk.conversations.runtime.LongTermMemoryService.from_env",
- lambda: _FakeLongTermMemoryService(),
- )
-
- await invoke_conversation_once(
- runner=_StubRunner(),
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[{"role": "user", "content": "不要保存"}],
- model="qwen3-vl-plus",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- )
-
- assert _FakeLongTermMemoryService.instances == []
-
-
-@pytest.mark.asyncio
-async def test_stream_conversation_turn_auto_saves_completed_turn(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- monkeypatch.setenv("KSADK_LTM_BACKEND", "sdk")
- monkeypatch.setenv("KSADK_LTM_NAMESPACE", "mem-demo")
- monkeypatch.delenv("KSADK_LTM_AUTO_SAVE", raising=False)
- _FakeLongTermMemoryService.instances.clear()
- monkeypatch.setattr(
- "ksadk.conversations.runtime.LongTermMemoryService.from_env",
- lambda: _FakeLongTermMemoryService(),
- )
- runner = _StreamingRunner()
-
- events = [
- event
- async for event in stream_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[{"role": "user", "content": "流式保存测试"}],
- model="qwen3-vl-plus",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- session_service_provider=lambda: service,
- )
- ]
-
- assert any("response.completed" in chunk for chunk in events)
- memory_service = _FakeLongTermMemoryService.instances[-1]
- persisted_events = [json.loads(item) for item in memory_service.saved[0]["event_strings"]]
- assert [event["parts"][0]["text"] for event in persisted_events] == [
- "流式保存测试",
- "hello",
- ]
-
-
-def test_normalize_model_options_maps_legacy_thinking_disabled_to_reasoning_none():
- normalized = normalize_model_options({"thinking": {"type": "disabled"}})
-
- assert normalized["thinking"] == {"type": "disabled"}
- assert normalized["reasoning"] == {"effort": "none"}
- assert normalized["max_reasoning_tokens"] == 0
-
-
-def test_normalize_model_options_maps_enabled_thinking_to_default_reasoning_effort():
- normalized = normalize_model_options({"thinking": {"type": "enabled"}})
-
- assert normalized["thinking"] == {"type": "enabled"}
- assert normalized["reasoning"] == {"effort": "medium"}
- assert "max_reasoning_tokens" not in normalized
-
-
-@pytest.mark.asyncio
-async def test_invoke_conversation_once_maps_mcp_approval_response_to_runner_resume(monkeypatch):
- service = InMemorySessionService()
- await service.create_session(
- agent_id="demo-agent", user_id="user-1", session_id="sess-approval"
- )
- await service.append_event(
- "sess-approval",
- SessionEvent(
- id="evt-approval",
- author="demo-agent",
- event_type="approval_request",
- content={"role": "model", "parts": [{"text": "approval required"}]},
- metadata={
- "interrupt_info": {
- "approval_request_id": "appr_123",
- "tool_name": "deploy",
- "arguments": {"target": "preprod"},
- "run_id": "run_123",
- }
- },
- invocation_id="inv-approval",
- ),
- )
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _StubRunner()
-
- session_id, result = await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-approval",
- messages=[],
- model="gpt-4o",
- resume_input={
- "type": "mcp_approval_response",
- "approval_request_id": "appr_123",
- "approve": True,
- "reason": "looks safe",
- },
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- )
-
- assert session_id == "sess-approval"
- assert result["output_text"] == "assistant says hi"
- assert runner.calls[-1]["resume"] is True
- assert runner.calls[-1]["input"] == {
- "type": "mcp_approval_response",
- "approval_request_id": "appr_123",
- "approve": True,
- "reason": "looks safe",
- "tool_name": "deploy",
- "tool_args": {
- "target": "preprod",
- "approval": {
- "approved": True,
- "approval_request_id": "appr_123",
- "reason": "looks safe",
- },
- },
- "approval": {
- "approved": True,
- "approval_request_id": "appr_123",
- "reason": "looks safe",
- },
- "run_id": "run_123",
- }
- events = await service.get_events("sess-approval")
- assert [event.event_type for event in events] == [
- "approval_request",
- "approval_response",
- "run_status",
- "assistant_message",
- "run_status",
- ]
- assert events[1].metadata["resume_input"]["approval_request_id"] == "appr_123"
-
-
-@pytest.mark.asyncio
-async def test_invoke_conversation_once_executes_approved_builtin_tool_resume(
- monkeypatch,
- tmp_path: Path,
-):
- service = InMemorySessionService()
- workspace_ui = tmp_path / "ui"
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(workspace_ui))
- monkeypatch.setenv("KSADK_TOOL_APPROVAL_MODE", "strict")
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- await service.create_session(
- agent_id="demo-agent", user_id="user-1", session_id="sess-tool-approval"
- )
- await append_run_checkpoint_event(
- session_id="sess-tool-approval",
- author="demo-agent",
- run_id="call_write",
- checkpoint_id="ckpt-before-tool",
- framework="langgraph",
- framework_ref={
- "langgraph": {
- "thread_id": "tenant:agent:sess-tool-approval",
- "checkpoint_id": "ckpt-before-tool",
- }
- },
- invocation_id="inv-checkpoint",
- session_service_provider=lambda: service,
- )
- await service.append_event(
- "sess-tool-approval",
- SessionEvent(
- id="evt-approval",
- author="demo-agent",
- event_type="approval_request",
- content={"role": "model", "parts": [{"text": "approval required"}]},
- metadata={
- "interrupt_info": {
- "approval_request_id": "appr_write",
- "tool_name": "write_workspace_file",
- "arguments": {"path": "notes.txt", "content": "hello"},
- "run_id": "call_write",
- "server_label": "ksadk",
- }
- },
- invocation_id="inv-approval",
- ),
- )
- runner = _StubRunner()
-
- await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-tool-approval",
- messages=[],
- model="gpt-4o",
- resume_input={
- "type": "mcp_approval_response",
- "approval_request_id": "appr_write",
- "approve": True,
- },
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- )
-
- assert (workspace_ui / "workspace" / "notes.txt").read_text(encoding="utf-8") == "hello"
- assert runner.calls[-1]["resume"] is True
- assert runner.calls[-1]["input"]["type"] == "function_call_output"
- assert runner.calls[-1]["input"]["call_id"] == "call_write"
- assert runner.calls[-1]["input"]["output"]["ok"] is True
- events = await service.get_events("sess-tool-approval")
- tool_result = next(event for event in events if event.event_type == "tool_result")
- assert tool_result.metadata["tool_name"] == "write_workspace_file"
- assert tool_result.metadata["tool_output"]["ok"] is True
- receipt = tool_result.metadata["tool_receipt"]
- assert receipt["tool_name"] == "write_workspace_file"
- assert receipt["tool_call_id"] == "call_write"
- assert receipt["run_id"] == "call_write"
- assert receipt["checkpoint_id"] == "ckpt-before-tool"
- assert receipt["framework"] == "langgraph"
- assert receipt["framework_ref"]["langgraph"]["thread_id"] == "tenant:agent:sess-tool-approval"
- assert receipt["status"] == "completed"
- assert receipt["idempotency_key"].startswith("tool_receipt:")
-
-
-@pytest.mark.asyncio
-async def test_invoke_conversation_once_treats_accepted_memory_save_as_completed_receipt(
- monkeypatch,
-):
- service = InMemorySessionService()
- monkeypatch.setenv("KSADK_TOOL_APPROVAL_MODE", "strict")
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- monkeypatch.setattr(
- "ksadk.conversations.runtime._builtin_tool_callable",
- lambda name: (
- lambda **kwargs: {
- "ok": False,
- "status": "accepted_not_extracted",
- "message": "记忆保存请求已被后端受理,但尚未抽取成可检索记忆。",
- "session_state": 0,
- "session_id": "sess-memory-accepted",
- }
- )
- if name == "save_memory"
- else None,
- )
- await service.create_session(
- agent_id="demo-agent", user_id="user-1", session_id="sess-memory-accepted"
- )
- await service.append_event(
- "sess-memory-accepted",
- SessionEvent(
- id="evt-approval",
- author="demo-agent",
- event_type="approval_request",
- content={"role": "model", "parts": [{"text": "approval required"}]},
- metadata={
- "interrupt_info": {
- "approval_request_id": "appr_save_memory",
- "tool_name": "save_memory",
- "arguments": {"content": "favorite_breakfast: 武汉热干面"},
- "run_id": "call_save_memory",
- "server_label": "ksadk",
- }
- },
- invocation_id="inv-approval",
- ),
- )
- runner = _StubRunner()
-
- await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-memory-accepted",
- messages=[],
- model="gpt-4o",
- resume_input={
- "type": "mcp_approval_response",
- "approval_request_id": "appr_save_memory",
- "approve": True,
- },
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- )
-
- assert runner.calls[-1]["input"]["output"]["status"] == "accepted_not_extracted"
- events = await service.get_events("sess-memory-accepted")
- tool_result = next(event for event in events if event.event_type == "tool_result")
- receipt = tool_result.metadata["tool_receipt"]
- assert receipt["tool_name"] == "save_memory"
- assert receipt["status"] == "completed"
-
-
-@pytest.mark.asyncio
-async def test_invoke_conversation_once_replays_existing_tool_receipt_without_side_effect(
- monkeypatch,
- tmp_path: Path,
-):
- service = InMemorySessionService()
- workspace_ui = tmp_path / "ui"
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(workspace_ui))
- monkeypatch.setenv("KSADK_TOOL_APPROVAL_MODE", "strict")
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- await service.create_session(
- agent_id="demo-agent", user_id="user-1", session_id="sess-tool-replay"
- )
- await service.append_event(
- "sess-tool-replay",
- SessionEvent(
- id="evt-approval",
- author="demo-agent",
- event_type="approval_request",
- content={"role": "model", "parts": [{"text": "approval required"}]},
- metadata={
- "interrupt_info": {
- "approval_request_id": "appr_write",
- "tool_name": "write_workspace_file",
- "arguments": {"path": "notes.txt", "content": "hello"},
- "run_id": "call_write",
- "server_label": "ksadk",
- }
- },
- invocation_id="inv-approval",
- ),
- )
- runner = _StubRunner()
- resume_input = {
- "type": "mcp_approval_response",
- "approval_request_id": "appr_write",
- "approve": True,
- }
-
- await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-tool-replay",
- messages=[],
- model="gpt-4o",
- resume_input=resume_input,
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- )
- (workspace_ui / "workspace" / "notes.txt").write_text("changed-by-user", encoding="utf-8")
-
- await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-tool-replay",
- messages=[],
- model="gpt-4o",
- resume_input=resume_input,
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- )
-
- assert (workspace_ui / "workspace" / "notes.txt").read_text(encoding="utf-8") == "changed-by-user"
- assert runner.calls[-1]["input"]["type"] == "function_call_output"
- assert runner.calls[-1]["input"]["output"]["ok"] is True
- assert runner.calls[-1]["input"]["output"]["replayed"] is True
- events = await service.get_events("sess-tool-replay")
- tool_results = [event for event in events if event.event_type == "tool_result"]
- assert len(tool_results) == 2
- assert tool_results[-1].metadata["tool_receipt"]["replayed"] is True
- assert (
- tool_results[-1].metadata["tool_receipt"]["idempotency_key"]
- == tool_results[0].metadata["tool_receipt"]["idempotency_key"]
- )
-
-
-@pytest.mark.asyncio
-async def test_invoke_conversation_once_binds_platform_invocation_context_and_ambient_contexts(
- monkeypatch,
-):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- monkeypatch.setattr(
- "ksadk.conversations.runtime._build_runner_ambient_contexts",
- lambda **kwargs: {
- "kb_context": {"formatted_text": "KB facts"},
- "memory_context": {"formatted_text": "Memory facts"},
- },
- )
- runner = _ContextCapturingRunner()
-
- session_id, result = await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[{"role": "user", "content": "继续"}],
- model="gpt-4o",
- account_id="acct-1",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- session_service_provider=lambda: service,
- )
-
- assert result["output_text"] == "captured"
- assert session_id
- assert runner.calls[-1]["kb_context"] == {"formatted_text": "KB facts"}
- assert runner.calls[-1]["memory_context"] == {"formatted_text": "Memory facts"}
- assert runner.calls[-1]["platform_context"]["agent_id"] == "demo-agent"
- assert runner.calls[-1]["platform_context"]["user_id"] == "user-1"
- assert runner.calls[-1]["platform_context"]["account_id"] == "acct-1"
- assert runner.calls[-1]["platform_context"]["session_id"] == session_id
- assert runner.captured_runtime_context is not None
- assert runner.captured_runtime_context.agent_id == "demo-agent"
- assert runner.captured_runtime_context.user_id == "user-1"
- assert runner.captured_runtime_context.account_id == "acct-1"
- assert runner.captured_runtime_context.session_id == session_id
- assert runner.captured_runtime_context.kb_context == {"formatted_text": "KB facts"}
- assert runner.captured_runtime_context.memory_context == {"formatted_text": "Memory facts"}
- assert runner.captured_tool_context is not None
- assert runner.captured_tool_context.session_id == session_id
- assert runner.captured_tool_context.run_id
- assert runner.captured_tool_context.run_id == runner.captured_tool_context.invocation_id
- assert get_current_invocation_context() is None
-
-
-def test_build_runner_ambient_contexts_skips_memory_when_disabled(monkeypatch):
- monkeypatch.setenv("KSADK_LTM_AMBIENT_ENABLED", "false")
- monkeypatch.setattr(
- "ksadk.conversations.runtime.LongTermMemoryService.is_configured",
- staticmethod(lambda: True),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.LongTermMemoryService.from_env",
- staticmethod(
- lambda: (_ for _ in ()).throw(AssertionError("memory ambient should be skipped"))
- ),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.KnowledgeBaseService.is_configured",
- staticmethod(lambda: False),
- )
-
- contexts = _build_runner_ambient_contexts(
- runner=_StubRunner(),
- user_id="user-1",
- user_input="hello",
- )
-
- assert contexts == {"kb_context": None, "memory_context": None}
-
-
-def test_build_runner_ambient_contexts_skips_kb_when_disabled(monkeypatch):
- monkeypatch.setenv("KSADK_KB_AMBIENT_ENABLED", "0")
- monkeypatch.setattr(
- "ksadk.conversations.runtime.KnowledgeBaseService.is_configured",
- staticmethod(lambda: True),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.KnowledgeBaseService.from_env",
- staticmethod(lambda: (_ for _ in ()).throw(AssertionError("kb ambient should be skipped"))),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.LongTermMemoryService.is_configured",
- staticmethod(lambda: False),
- )
-
- contexts = _build_runner_ambient_contexts(
- runner=_StubRunner(),
- user_id="user-1",
- user_input="hello",
- )
-
- assert contexts == {"kb_context": None, "memory_context": None}
-
-
-def test_build_runner_ambient_contexts_default_on_demand_skips_chitchat(monkeypatch):
- monkeypatch.delenv("KSADK_KB_AMBIENT_POLICY", raising=False)
- monkeypatch.delenv("KSADK_LTM_AMBIENT_POLICY", raising=False)
- monkeypatch.setattr(
- "ksadk.conversations.runtime.KnowledgeBaseService.is_configured",
- staticmethod(lambda: True),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.LongTermMemoryService.is_configured",
- staticmethod(lambda: True),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.KnowledgeBaseService.from_env",
- staticmethod(
- lambda: (_ for _ in ()).throw(AssertionError("kb ambient should not run for chitchat"))
- ),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.LongTermMemoryService.from_env",
- staticmethod(
- lambda: (_ for _ in ()).throw(
- AssertionError("memory ambient should not run for chitchat")
- )
- ),
- )
-
- contexts = _build_runner_ambient_contexts(
- runner=_StubRunner(),
- user_id="user-1",
- user_input="你好,请介绍一下你自己",
- )
-
- assert contexts == {"kb_context": None, "memory_context": None}
-
-
-def test_build_runner_ambient_contexts_non_adk_runner_name_does_not_disable_ambient(monkeypatch):
- class _FakeKnowledgeBaseService:
- def build_context(self, query: str):
- return {"formatted_text": f"kb:{query}"}
-
- runner = _StubRunner()
- runner.detection_result = type(
- "Detection",
- (),
- {
- "name": "adk-migration-helper",
- "type": type("RunnerType", (), {"value": "langgraph"})(),
- },
- )()
-
- monkeypatch.delenv("KSADK_KB_AMBIENT_POLICY", raising=False)
- monkeypatch.setattr(
- "ksadk.conversations.runtime.KnowledgeBaseService.is_configured",
- staticmethod(lambda: True),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.KnowledgeBaseService.from_env",
- staticmethod(lambda: _FakeKnowledgeBaseService()),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.LongTermMemoryService.is_configured",
- staticmethod(lambda: False),
- )
-
- contexts = _build_runner_ambient_contexts(
- runner=runner,
- user_id="user-1",
- user_input="解释一下 KCE 和 KCF 的区别",
- )
-
- assert contexts["kb_context"] == {"formatted_text": "kb:解释一下 KCE 和 KCF 的区别"}
- assert contexts["memory_context"] is None
-
-
-def test_build_runner_ambient_contexts_default_on_demand_loads_memory_for_explicit_recall(
- monkeypatch,
-):
- class _FakeMemoryService:
- def build_context(self, *, user_id: str, query: str):
- return {"formatted_text": f"memory:{user_id}:{query}"}
-
- monkeypatch.delenv("KSADK_LTM_AMBIENT_POLICY", raising=False)
- monkeypatch.setattr(
- "ksadk.conversations.runtime.KnowledgeBaseService.is_configured",
- staticmethod(lambda: False),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.LongTermMemoryService.is_configured",
- staticmethod(lambda: True),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.LongTermMemoryService.from_env",
- staticmethod(lambda: _FakeMemoryService()),
- )
-
- contexts = _build_runner_ambient_contexts(
- runner=_StubRunner(),
- user_id="user-1",
- user_input="你还记得我上次说过的偏好吗?",
- )
-
- assert contexts["kb_context"] is None
- assert contexts["memory_context"] == {
- "formatted_text": "memory:user-1:你还记得我上次说过的偏好吗?"
- }
-
-
-def test_build_runner_ambient_contexts_default_on_demand_skips_memory_for_short_term_follow_up(
- monkeypatch,
-):
- monkeypatch.delenv("KSADK_LTM_AMBIENT_POLICY", raising=False)
- monkeypatch.setattr(
- "ksadk.conversations.runtime.KnowledgeBaseService.is_configured",
- staticmethod(lambda: False),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.LongTermMemoryService.is_configured",
- staticmethod(lambda: True),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.LongTermMemoryService.from_env",
- staticmethod(
- lambda: (_ for _ in ()).throw(
- AssertionError("memory ambient should not run for short-term follow-up")
- )
- ),
- )
-
- contexts = _build_runner_ambient_contexts(
- runner=_StubRunner(),
- user_id="user-1",
- user_input="把前面的回答翻译成英文",
- )
-
- assert contexts == {"kb_context": None, "memory_context": None}
-
-
-def test_build_runner_ambient_contexts_default_on_demand_skips_memory_for_mixed_short_term_prompt(
- monkeypatch,
-):
- monkeypatch.delenv("KSADK_LTM_AMBIENT_POLICY", raising=False)
- monkeypatch.setattr(
- "ksadk.conversations.runtime.KnowledgeBaseService.is_configured",
- staticmethod(lambda: False),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.LongTermMemoryService.is_configured",
- staticmethod(lambda: True),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.LongTermMemoryService.from_env",
- staticmethod(
- lambda: (_ for _ in ()).throw(
- AssertionError("memory ambient should not run for mixed short-term prompt")
- )
- ),
- )
-
- contexts = _build_runner_ambient_contexts(
- runner=_StubRunner(),
- user_id="user-1",
- user_input="你还记得刚才的回答吗",
- )
-
- assert contexts == {"kb_context": None, "memory_context": None}
-
-
-def test_build_runner_ambient_contexts_default_on_demand_loads_memory_for_profile_prompt(
- monkeypatch,
-):
- class _FakeMemoryService:
- def build_context(self, *, user_id: str, query: str):
- return {"formatted_text": f"memory:{user_id}:{query}"}
-
- monkeypatch.delenv("KSADK_LTM_AMBIENT_POLICY", raising=False)
- monkeypatch.setattr(
- "ksadk.conversations.runtime.KnowledgeBaseService.is_configured",
- staticmethod(lambda: False),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.LongTermMemoryService.is_configured",
- staticmethod(lambda: True),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.LongTermMemoryService.from_env",
- staticmethod(lambda: _FakeMemoryService()),
- )
-
- contexts = _build_runner_ambient_contexts(
- runner=_StubRunner(),
- user_id="user-1",
- user_input="按照我的风格来写",
- )
-
- assert contexts["kb_context"] is None
- assert contexts["memory_context"] == {"formatted_text": "memory:user-1:按照我的风格来写"}
-
-
-def test_build_runner_ambient_contexts_default_on_demand_loads_kb_for_information_query(
- monkeypatch,
-):
- class _FakeKnowledgeBaseService:
- def build_context(self, query: str):
- return {"formatted_text": f"kb:{query}"}
-
- monkeypatch.delenv("KSADK_KB_AMBIENT_POLICY", raising=False)
- monkeypatch.setattr(
- "ksadk.conversations.runtime.KnowledgeBaseService.is_configured",
- staticmethod(lambda: True),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.KnowledgeBaseService.from_env",
- staticmethod(lambda: _FakeKnowledgeBaseService()),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.LongTermMemoryService.is_configured",
- staticmethod(lambda: False),
- )
-
- contexts = _build_runner_ambient_contexts(
- runner=_StubRunner(),
- user_id="user-1",
- user_input="查一下云主机现在有哪些机型",
- )
-
- assert contexts["kb_context"] == {"formatted_text": "kb:查一下云主机现在有哪些机型"}
- assert contexts["memory_context"] is None
-
-
-def test_build_runner_ambient_contexts_default_on_demand_loads_kb_for_explanatory_query(
- monkeypatch,
-):
- class _FakeKnowledgeBaseService:
- def build_context(self, query: str):
- return {"formatted_text": f"kb:{query}"}
-
- monkeypatch.delenv("KSADK_KB_AMBIENT_POLICY", raising=False)
- monkeypatch.setattr(
- "ksadk.conversations.runtime.KnowledgeBaseService.is_configured",
- staticmethod(lambda: True),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.KnowledgeBaseService.from_env",
- staticmethod(lambda: _FakeKnowledgeBaseService()),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.LongTermMemoryService.is_configured",
- staticmethod(lambda: False),
- )
-
- contexts = _build_runner_ambient_contexts(
- runner=_StubRunner(),
- user_id="user-1",
- user_input="帮我总结一下 AgentEngine 部署步骤",
- )
-
- assert contexts["kb_context"] == {"formatted_text": "kb:帮我总结一下 AgentEngine 部署步骤"}
- assert contexts["memory_context"] is None
-
-
-def test_build_runner_ambient_contexts_drops_kb_error_text_returned_by_service(monkeypatch):
- class _BrokenKnowledgeBaseService:
- def build_context(self, query: str):
- return {"formatted_text": "知识库检索失败: timeout", "query": query}
-
- monkeypatch.delenv("KSADK_KB_AMBIENT_POLICY", raising=False)
- monkeypatch.setattr(
- "ksadk.conversations.runtime.KnowledgeBaseService.is_configured",
- staticmethod(lambda: True),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.KnowledgeBaseService.from_env",
- staticmethod(lambda: _BrokenKnowledgeBaseService()),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.LongTermMemoryService.is_configured",
- staticmethod(lambda: False),
- )
-
- contexts = _build_runner_ambient_contexts(
- runner=_StubRunner(),
- user_id="user-1",
- user_input="帮我总结一下 AgentEngine 部署步骤",
- )
-
- assert contexts == {"kb_context": None, "memory_context": None}
-
-
-def test_build_runner_ambient_contexts_drops_memory_error_text_returned_by_service(monkeypatch):
- class _BrokenMemoryService:
- def build_context(self, *, user_id: str, query: str):
- return {"formatted_text": "长期记忆检索失败: timeout", "query": query}
-
- monkeypatch.delenv("KSADK_LTM_AMBIENT_POLICY", raising=False)
- monkeypatch.setattr(
- "ksadk.conversations.runtime.KnowledgeBaseService.is_configured",
- staticmethod(lambda: False),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.LongTermMemoryService.is_configured",
- staticmethod(lambda: True),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.LongTermMemoryService.from_env",
- staticmethod(lambda: _BrokenMemoryService()),
- )
-
- contexts = _build_runner_ambient_contexts(
- runner=_StubRunner(),
- user_id="user-1",
- user_input="按照我的风格来写",
- )
-
- assert contexts == {"kb_context": None, "memory_context": None}
-
-
-def test_build_runner_ambient_contexts_ambient_failures_degrade_quietly(monkeypatch):
- class _BrokenKnowledgeBaseService:
- def build_context(self, query: str):
- raise RuntimeError(f"kb boom: {query}")
-
- class _BrokenMemoryService:
- def build_context(self, *, user_id: str, query: str):
- raise RuntimeError(f"memory boom: {user_id}:{query}")
-
- monkeypatch.delenv("KSADK_KB_AMBIENT_POLICY", raising=False)
- monkeypatch.delenv("KSADK_LTM_AMBIENT_POLICY", raising=False)
- monkeypatch.setattr(
- "ksadk.conversations.runtime.KnowledgeBaseService.is_configured",
- staticmethod(lambda: True),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.KnowledgeBaseService.from_env",
- staticmethod(lambda: _BrokenKnowledgeBaseService()),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.LongTermMemoryService.is_configured",
- staticmethod(lambda: True),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.LongTermMemoryService.from_env",
- staticmethod(lambda: _BrokenMemoryService()),
- )
-
- contexts = _build_runner_ambient_contexts(
- runner=_StubRunner(),
- user_id="user-1",
- user_input="你还记得我上次说过的偏好吗?",
- )
-
- assert contexts == {"kb_context": None, "memory_context": None}
-
-
-def test_build_runner_ambient_contexts_always_policy_preserves_legacy_behavior(monkeypatch):
- class _FakeMemoryService:
- def build_context(self, *, user_id: str, query: str):
- return {"formatted_text": f"memory:{query}"}
-
- monkeypatch.setenv("KSADK_LTM_AMBIENT_POLICY", "always")
- monkeypatch.setattr(
- "ksadk.conversations.runtime.KnowledgeBaseService.is_configured",
- staticmethod(lambda: False),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.LongTermMemoryService.is_configured",
- staticmethod(lambda: True),
- )
- monkeypatch.setattr(
- "ksadk.conversations.runtime.LongTermMemoryService.from_env",
- staticmethod(lambda: _FakeMemoryService()),
- )
-
- contexts = _build_runner_ambient_contexts(
- runner=_StubRunner(),
- user_id="user-1",
- user_input="你好",
- )
-
- assert contexts["memory_context"] == {"formatted_text": "memory:你好"}
-
-
-@pytest.mark.asyncio
-async def test_stream_conversation_turn_passes_session_id_to_runner(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _StreamingRunner()
-
- session = await service.create_session(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-stream",
- )
-
- events = []
- async for event in stream_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id=session.id,
- messages=[{"role": "user", "content": "继续"}],
- model="gpt-4o",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- session_service_provider=lambda: service,
- ):
- events.append(event)
-
- assert events
- assert runner.stream_calls[-1]["session_id"] == session.id
-
-
-@pytest.mark.asyncio
-async def test_stream_conversation_turn_binds_tool_execution_context(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _ToolContextCapturingStreamingRunner()
- session = await service.create_session(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-stream-tool-context",
- )
-
- events = [
- event
- async for event in stream_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id=session.id,
- messages=[{"role": "user", "content": "继续"}],
- model="gpt-4o",
- invocation_id="inv-stream-1",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- session_service_provider=lambda: service,
- )
- ]
-
- assert events
- assert runner.captured_tool_context is not None
- assert runner.captured_tool_context.session_id == session.id
- assert runner.captured_tool_context.run_id == "inv-stream-1"
- assert runner.captured_tool_context.invocation_id == "inv-stream-1"
-
-
-@pytest.mark.asyncio
-async def test_stream_conversation_turn_emits_final_text_after_tool_events(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _SuccessfulToolResultStreamingRunner()
-
- chunks = [
- chunk
- async for chunk in stream_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[{"role": "user", "content": "记住这个"}],
- model="gpt-4o",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- session_service_provider=lambda: service,
- )
- ]
-
- assert any("response.completed" in chunk and '"output_text": "done"' in chunk for chunk in chunks)
- completed_payload = _extract_sse_payload(chunks, "response.completed")
- session_id = completed_payload["session_id"]
- events = await service.get_events(session_id)
- assistant_messages = [event for event in events if event.event_type == "assistant_message"]
- assert assistant_messages[-1].content["parts"][0]["text"] == "done"
-
-
-@pytest.mark.asyncio
-async def test_stream_responses_conversation_turn_maps_ksadk_resume_to_runner_resume(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _ResumeStreamingRunner()
- await service.create_session(
- agent_id="demo-agent", user_id="user-1", session_id="sess-resume-stream"
- )
- await service.append_event(
- "sess-resume-stream",
- SessionEvent(
- id="evt-background-status",
- author="demo-agent",
- event_type="run_status",
- content={"status": "interrupted"},
- metadata={
- "status": "interrupted",
- "run_mode": "background",
- "run_trigger": "new_run",
- },
- invocation_id="inv-approval",
- ),
- )
- await service.append_event(
- "sess-resume-stream",
- SessionEvent(
- id="evt-approval",
- author="demo-agent",
- event_type="approval_request",
- content={"role": "model", "parts": [{"text": "need human input"}]},
- metadata={"interrupt_info": {"id": "intr_123"}},
- invocation_id="inv-approval",
- ),
- )
-
- chunks = [
- chunk
- async for chunk in stream_responses_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-resume-stream",
- messages=[],
- model="gpt-4o",
- resume_input={
- "type": "ksadk_resume",
- "interrupt_id": "intr_123",
- "value": {"answer": "继续", "approved": True},
- },
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- session_service_provider=lambda: service,
- )
- ]
-
- assert runner.stream_calls[-1]["resume"] is True
- assert runner.stream_calls[-1]["input"] == {
- "type": "ksadk_resume",
- "interrupt_id": "intr_123",
- "value": {"answer": "继续", "approved": True},
- }
- assert any(chunk.startswith("event: response.completed\n") for chunk in chunks)
- events = await service.get_events("sess-resume-stream")
- assert "approval_response" in [event.event_type for event in events]
- run_status_events = [event for event in events if event.event_type == "run_status"]
- assert run_status_events[-1].metadata["run_mode"] == "background"
- assert run_status_events[-1].metadata["run_trigger"] == "approval_resume"
-
-
-@pytest.mark.asyncio
-async def test_stream_responses_conversation_turn_emits_cancelled_terminal(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _BlockingStreamingRunner()
- chunks: list[str] = []
-
- async def consume():
- async for chunk in stream_responses_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-cancel-stream",
- messages=[{"role": "user", "content": "cancel me"}],
- model="gpt-4o",
- invocation_id="inv-cancel-stream",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- session_service_provider=lambda: service,
- ):
- chunks.append(chunk)
-
- task = asyncio.create_task(consume())
- for _ in range(20):
- if any("response.output_text.delta" in chunk for chunk in chunks):
- break
- await asyncio.sleep(0.01)
- task.cancel()
- await task
-
- events = await service.get_events("sess-cancel-stream")
- statuses = [
- event.content.get("status")
- for event in events
- if event.event_type == "run_status"
- ]
- assert statuses == ["in_progress", "cancelled"]
- assert any(chunk.startswith("event: response.cancelled\n") for chunk in chunks)
-
-
-@pytest.mark.asyncio
-async def test_stream_responses_conversation_turn_promotes_gateway_approval_result_to_interrupt(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _ApprovalToolResultStreamingRunner()
-
- chunks = [
- chunk
- async for chunk in stream_responses_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-gateway-approval",
- messages=[{"role": "user", "content": "写文件"}],
- model="gpt-4o",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- session_service_provider=lambda: service,
- )
- ]
-
- event_names = [
- line.removeprefix("event: ")
- for chunk in chunks
- for line in chunk.splitlines()
- if line.startswith("event: ")
- ]
- assert "response.output_item.done" in event_names
- assert "response.incomplete" in event_names
- assert "response.completed" not in event_names
-
- incomplete = _extract_sse_payload(chunks, "response.incomplete")
- interrupt = incomplete["incomplete_details"]["ksadk_interrupt"]
- assert interrupt["approval_request_id"] == "appr_write"
- assert interrupt["tool_name"] == "write_workspace_file"
- assert interrupt["arguments"] == {"path": "notes.txt"}
-
- events = await service.get_events("sess-gateway-approval")
- assert [event.event_type for event in events] == [
- "user_message",
- "run_status",
- "tool_call",
- "approval_request",
- "run_status",
- ]
-
-
-@pytest.mark.asyncio
-async def test_stream_responses_conversation_turn_adds_tool_receipt_to_tool_result(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _SuccessfulToolResultStreamingRunner()
- await service.create_session(
- agent_id="demo-agent", user_id="user-1", session_id="sess-tool-receipt"
- )
- await append_run_checkpoint_event(
- session_id="sess-tool-receipt",
- author="demo-agent",
- run_id="run-list-skills",
- checkpoint_id="ckpt-list-skills",
- framework="langgraph",
- framework_ref={
- "langgraph": {
- "thread_id": "tenant:agent:sess-tool-receipt",
- "checkpoint_id": "ckpt-list-skills",
- }
- },
- invocation_id="inv-checkpoint",
- session_service_provider=lambda: service,
- )
-
- chunks = [
- chunk
- async for chunk in stream_responses_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-tool-receipt",
- messages=[{"role": "user", "content": "列出 skills"}],
- model="gpt-4o",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- session_service_provider=lambda: service,
- )
- ]
-
- assert any(chunk.startswith("event: response.completed\n") for chunk in chunks)
- events = await service.get_events("sess-tool-receipt")
- tool_result = next(event for event in events if event.event_type == "tool_result")
- receipt = tool_result.metadata["tool_receipt"]
- assert receipt["tool_name"] == "list_skills"
- assert receipt["tool_call_id"] == "run-list-skills"
- assert receipt["run_id"] == "run-list-skills"
- assert receipt["checkpoint_id"] == "ckpt-list-skills"
- assert receipt["framework"] == "langgraph"
- assert receipt["status"] == "completed"
- assert receipt["idempotency_key"].startswith("tool_receipt:")
-
-
-@pytest.mark.asyncio
-async def test_stream_responses_conversation_turn_records_deferred_tools_from_tool_search(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _ToolSearchResultStreamingRunner()
-
- chunks = [
- chunk
- async for chunk in stream_responses_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-deferred-tools",
- messages=[{"role": "user", "content": "find edit tools"}],
- model="gpt-4o",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- session_service_provider=lambda: service,
- run_mode="background",
- )
- ]
-
- assert any(chunk.startswith("event: response.completed\n") for chunk in chunks)
- events = await service.get_events("sess-deferred-tools")
- status = [
- event
- for event in events
- if event.event_type == "run_status"
- and (event.metadata or {}).get("detail") == "deferred_tools_selected"
- ][-1]
- assert status.metadata["deferred_tool_names"] == ["read_workspace_file", "edit_workspace_file"]
- assert status.metadata["run_mode"] == "background"
- assert status.metadata["run_trigger"] == "new_run"
- assert status.state_delta["active_run"]["run_mode"] == "background"
- assert status.state_delta["active_run"]["run_trigger"] == "new_run"
-
-
-@pytest.mark.asyncio
-async def test_stream_responses_conversation_turn_stops_at_max_tool_calls(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setenv("KSADK_MAX_TOOL_CALLS", "2")
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _ManyToolCallsStreamingRunner()
-
- chunks = [
- chunk
- async for chunk in stream_responses_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-max-tools",
- messages=[{"role": "user", "content": "call tools"}],
- model="gpt-4o",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- session_service_provider=lambda: service,
- )
- ]
-
- assert any("response.failed" in chunk for chunk in chunks)
- assert not any("response.completed" in chunk for chunk in chunks)
- events = await service.get_events("sess-max-tools")
- statuses = [event for event in events if event.event_type == "run_status"]
- assert statuses[-1].content["status"] == "failed"
- assert statuses[-1].metadata["governance"]["reason"] == "max_tool_calls_exceeded"
-
-
-@pytest.mark.asyncio
-async def test_stream_responses_conversation_turn_stops_on_consecutive_tool_failures(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setenv("KSADK_MAX_CONSECUTIVE_TOOL_FAILURES", "2")
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _FailingToolResultsStreamingRunner()
-
- chunks = [
- chunk
- async for chunk in stream_responses_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-tool-failures",
- messages=[{"role": "user", "content": "run failing tools"}],
- model="gpt-4o",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- session_service_provider=lambda: service,
- )
- ]
-
- assert any("response.failed" in chunk for chunk in chunks)
- assert not any("response.completed" in chunk for chunk in chunks)
- events = await service.get_events("sess-tool-failures")
- failed_status = [event for event in events if event.event_type == "run_status"][-1]
- assert failed_status.metadata["governance"]["reason"] == "consecutive_tool_failures"
- tool_results = [event for event in events if event.event_type == "tool_result"]
- assert len(tool_results) == 2
- assert tool_results[-1].metadata["observability"]["tool_name"] == "run_command"
- assert tool_results[-1].metadata["observability"]["error_type"] == "boom"
-
-
-@pytest.mark.asyncio
-async def test_stream_responses_resume_denial_trips_governance_on_main_path(monkeypatch):
- service = InMemorySessionService()
- await service.create_session(
- agent_id="demo-agent", user_id="user-1", session_id="sess-denial-governance"
- )
- await service.append_event(
- "sess-denial-governance",
- SessionEvent(
- id="evt-approval",
- author="demo-agent",
- event_type="approval_request",
- content={"role": "model", "parts": [{"text": "approval required"}]},
- metadata={
- "interrupt_info": {
- "approval_request_id": "appr_deny",
- "tool_name": "write_workspace_file",
- "arguments": {"path": "notes.txt"},
- "run_id": "run_write",
- }
- },
- invocation_id="inv-approval",
- ),
- )
- monkeypatch.setenv("KSADK_MAX_CONSECUTIVE_APPROVAL_DENIALS", "1")
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _StreamingRunner()
-
- chunks = [
- chunk
- async for chunk in stream_responses_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-denial-governance",
- messages=[],
- model="gpt-4o",
- resume_input={
- "type": "mcp_approval_response",
- "approval_request_id": "appr_deny",
- "approve": False,
- "reason": "not safe",
- },
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- session_service_provider=lambda: service,
- )
- ]
-
- assert any("response.failed" in chunk for chunk in chunks)
- assert runner.stream_calls == []
- events = await service.get_events("sess-denial-governance")
- assert [event.event_type for event in events][:2] == ["approval_request", "approval_response"]
- failed_status = [event for event in events if event.event_type == "run_status"][-1]
- assert failed_status.metadata["governance"]["reason"] == "consecutive_approval_denials"
-
-
-@pytest.mark.asyncio
-async def test_stream_responses_prompt_too_long_compaction_failure_trips_governance(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setenv("KSADK_MAX_CONSECUTIVE_COMPACT_FAILURES", "1")
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- async def _broken_compaction(**_kwargs):
- raise RuntimeError("compact backend down")
-
- monkeypatch.setattr("ksadk.conversations.runtime.compact_conversation_history", _broken_compaction)
- runner = _PromptTooLongStreamingRunner()
-
- chunks = [
- chunk
- async for chunk in stream_responses_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-compact-governance",
- messages=[{"role": "user", "content": "large history"}],
- model="gpt-4o",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- session_service_provider=lambda: service,
- )
- ]
-
- assert any("response.failed" in chunk for chunk in chunks)
- events = await service.get_events("sess-compact-governance")
- failed_status = [event for event in events if event.event_type == "run_status"][-1]
- assert failed_status.metadata["governance"]["reason"] == "consecutive_compact_failures"
-
-
-@pytest.mark.asyncio
-async def test_stream_responses_conversation_turn_preserves_tool_call_display_metadata(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- class DisplayToolCallRunner(_StubRunner):
- async def stream(self, input_data):
- yield {
- "type": "tool_call",
- "tool_name": "workspace.write",
- "tool_args": {"stage": "write_report"},
- "run_id": "run-display-tool-call",
- "stage": "write_report",
- "event_kind": "artifact",
- "display_title": "正在生成研究报告",
- "display_summary": "7/7 生成研究报告:正在综合证据并写入工作区。",
- }
- yield {"type": "final", "output": "done"}
-
- chunks = [
- chunk
- async for chunk in stream_responses_conversation_turn(
- runner=DisplayToolCallRunner(),
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-tool-call-display",
- messages=[{"role": "user", "content": "研究 agent runtime"}],
- model="gpt-4o",
- invocation_id="inv-tool-call-display",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- session_service_provider=lambda: service,
- )
- ]
-
- assert any("正在生成研究报告" in chunk for chunk in chunks)
- events = await service.get_events("sess-tool-call-display")
- tool_call = next(event for event in events if event.event_type == "tool_call")
- assert tool_call.metadata["stage"] == "write_report"
- assert tool_call.metadata["event_kind"] == "artifact"
- assert tool_call.metadata["display_title"] == "正在生成研究报告"
- assert tool_call.metadata["display_summary"].startswith("7/7 生成研究报告")
-
-
-@pytest.mark.asyncio
-async def test_stream_responses_conversation_turn_persists_stage_activity_without_receipt(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _StageActivityStreamingRunner()
-
- chunks = [
- chunk
- async for chunk in stream_responses_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-stage-activity",
- messages=[{"role": "user", "content": "研究 agent runtime"}],
- model="gpt-4o",
- invocation_id="inv-stage-activity",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- session_service_provider=lambda: service,
- )
- ]
-
- assert any("response.ksadk.stage_tool_call" in chunk for chunk in chunks)
- assert any("response.ksadk.stage_tool_result" in chunk for chunk in chunks)
- events = await service.get_events("sess-stage-activity")
- activity_events = [
- event for event in events if event.event_type in {"stage_tool_call", "stage_tool_result"}
- ]
- assert [event.event_type for event in activity_events] == [
- "stage_tool_call",
- "stage_tool_result",
- ]
- assert activity_events[0].metadata["tool_name"] == "deepresearch_query_agent"
- assert activity_events[0].metadata["tool_args"]["query"] == "agent runtime"
- assert activity_events[1].metadata["tool_output"]["result_count"] == 3
- assert "tool_receipt" not in activity_events[1].metadata
-
-
-@pytest.mark.asyncio
-async def test_stream_responses_conversation_turn_marks_non_final_stage_stream_failed(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _StageActivityNoFinalStreamingRunner()
-
- chunks = [
- chunk
- async for chunk in stream_responses_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-stage-no-final",
- messages=[{"role": "user", "content": "研究 agent runtime"}],
- model="gpt-4o",
- invocation_id="inv-stage-no-final",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- session_service_provider=lambda: service,
- )
- ]
-
- assert any(chunk.startswith("event: response.failed\n") for chunk in chunks)
- assert not any(chunk.startswith("event: response.completed\n") for chunk in chunks)
- events = await service.get_events("sess-stage-no-final")
- run_statuses = [
- event.content.get("status")
- for event in events
- if event.event_type == "run_status" and event.invocation_id == "inv-stage-no-final"
- ]
- assert run_statuses == ["in_progress", "failed"]
- failed_event = [event for event in events if event.event_type == "run_status"][-1]
- assert failed_event.metadata["detail"] == "runner_stream_ended_without_final_output"
-
-
-@pytest.mark.asyncio
-async def test_stream_responses_conversation_turn_replays_completed_output_items(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _CompletedOutputStreamingRunner()
-
- chunks = [
- chunk
- async for chunk in stream_responses_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-native-output",
- messages=[{"role": "user", "content": "查一下"}],
- model="gpt-4o",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- session_service_provider=lambda: service,
- )
- ]
-
- event_names = [
- line.removeprefix("event: ")
- for chunk in chunks
- for line in chunk.splitlines()
- if line.startswith("event: ")
- ]
- assert "response.function_call_arguments.done" in event_names
- assert "response.reasoning.delta" in event_names
-
- completed_payload = None
- current_event = ""
- for chunk in chunks:
- for line in chunk.splitlines():
- if line.startswith("event: "):
- current_event = line.removeprefix("event: ")
- elif line.startswith("data: ") and current_event == "response.completed":
- completed_payload = json.loads(line.removeprefix("data: "))
- assert completed_payload is not None
- assert completed_payload["id"] == "resp_native"
- assert any(item.get("type") == "function_call" for item in completed_payload["output"])
-
-
-@pytest.mark.asyncio
-async def test_stream_responses_conversation_turn_persists_outer_response_id_on_assistant_event(
- monkeypatch,
-):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _StreamingRunner()
-
- chunks = [
- chunk
- async for chunk in stream_responses_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-stream-feedback",
- messages=[{"role": "user", "content": "hello"}],
- model="gpt-4o",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- session_service_provider=lambda: service,
- )
- ]
-
- created_payload = _extract_sse_payload(chunks, "response.created")
- completed_payload = _extract_sse_payload(chunks, "response.completed")
- events = await service.get_events("sess-stream-feedback")
- assistant_event = next(event for event in events if event.event_type == "assistant_message")
- assert completed_payload["id"] == created_payload["id"]
- assert assistant_event.metadata["response_id"] == created_payload["id"]
-
-
-@pytest.mark.asyncio
-async def test_stream_responses_conversation_turn_persists_trace_metadata_for_feedback(
- monkeypatch,
- in_memory_trace_exporter,
-):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _StreamingRunner()
-
- chunks = [
- chunk
- async for chunk in stream_responses_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-stream-trace-feedback",
- messages=[{"role": "user", "content": "hello"}],
- model="gpt-4o",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- session_service_provider=lambda: service,
- )
- ]
-
- completed_payload = _extract_sse_payload(chunks, "response.completed")
- events = await service.get_events("sess-stream-trace-feedback")
- assistant_event = next(event for event in events if event.event_type == "assistant_message")
- trace_id = assistant_event.metadata.get("trace_id")
- root_span_id = assistant_event.metadata.get("root_span_id")
-
- assert trace_id
- assert root_span_id
- assert completed_payload["metadata"]["trace_id"] == trace_id
- assert completed_payload["metadata"]["root_span_id"] == root_span_id
- exported_trace = in_memory_trace_exporter.get_trace(trace_id)
- assert exported_trace is not None
- assert any(span["span_id"] == root_span_id for span in exported_trace["spans"])
-
-
-@pytest.mark.asyncio
-async def test_stream_responses_conversation_turn_emits_trace_metadata_from_created_event(
- monkeypatch,
- in_memory_trace_exporter,
-):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _StreamingRunner()
-
- chunks = [
- chunk
- async for chunk in stream_responses_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-stream-created-trace",
- messages=[{"role": "user", "content": "hello"}],
- model="gpt-4o",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- session_service_provider=lambda: service,
- )
- ]
-
- created_payload = _extract_sse_payload(chunks, "response.created")
- completed_payload = _extract_sse_payload(chunks, "response.completed")
- assert created_payload["metadata"]["trace_id"]
- assert created_payload["metadata"]["root_span_id"]
- assert created_payload["metadata"]["trace_id"] == completed_payload["metadata"]["trace_id"]
- assert (
- created_payload["metadata"]["root_span_id"]
- == completed_payload["metadata"]["root_span_id"]
- )
-
- exported_trace = in_memory_trace_exporter.get_trace(created_payload["metadata"]["trace_id"])
- root_span = next(
- span
- for span in exported_trace["spans"]
- if span["span_id"] == created_payload["metadata"]["root_span_id"]
- )
- assert root_span["name"] == "demo-agent"
- assert root_span["attributes"]["langfuse.trace.input"] == "hello"
- assert root_span["attributes"]["langfuse.trace.output"] == "hello"
-
-
-@pytest.mark.asyncio
-async def test_stream_responses_conversation_turn_persists_reasoning_events(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _ThinkingStreamingRunner()
-
- chunks = [
- chunk
- async for chunk in stream_responses_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-reasoning",
- messages=[{"role": "user", "content": "你好"}],
- model="gpt-4o",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- session_service_provider=lambda: service,
- )
- ]
-
- assert any(chunk.startswith("event: response.reasoning.delta\n") for chunk in chunks)
- events = await service.get_events("sess-reasoning")
- assert [event.event_type for event in events] == [
- "user_message",
- "run_status",
- "reasoning",
- "assistant_message",
- "run_status",
- ]
- assert events[2].content["parts"][0]["text"] == "先分析问题"
-
-
-@pytest.mark.asyncio
-async def test_stream_responses_conversation_turn_suppresses_reasoning_when_disabled(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _CompletedOutputStreamingRunner()
-
- chunks = [
- chunk
- async for chunk in stream_responses_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-no-reasoning",
- messages=[{"role": "user", "content": "查一下"}],
- model="gpt-4o",
- model_options={"thinking": {"type": "disabled"}},
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- session_service_provider=lambda: service,
- )
- ]
-
- event_names = [
- line.removeprefix("event: ")
- for chunk in chunks
- for line in chunk.splitlines()
- if line.startswith("event: ")
- ]
- assert "response.reasoning.delta" not in event_names
- completed_payload = _extract_sse_payload(chunks, "response.completed")
- assert not any(item.get("type") == "reasoning" for item in completed_payload["output"])
- events = await service.get_events("sess-no-reasoning")
- assert "reasoning" not in [event.event_type for event in events]
-
-
-@pytest.mark.asyncio
-async def test_stream_responses_turn_maps_function_call_output_without_pending_approval(
- monkeypatch,
-):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _ResumeStreamingRunner()
- await service.create_session(
- agent_id="demo-agent", user_id="user-1", session_id="sess-tool-output"
- )
-
- chunks = [
- chunk
- async for chunk in stream_responses_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-tool-output",
- messages=[],
- model="gpt-4o",
- resume_input={
- "type": "function_call_output",
- "call_id": "call_123",
- "output": {"ok": True},
- },
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- request_metadata={"previous_response_id": "resp_123"},
- session_service_provider=lambda: service,
- )
- ]
-
- assert runner.stream_calls[-1]["resume"] is True
- assert runner.stream_calls[-1]["input"] == {
- "type": "function_call_output",
- "call_id": "call_123",
- "output": {"ok": True},
- }
- assert runner.stream_calls[-1]["previous_response_id"] == "resp_123"
- assert any(chunk.startswith("event: response.completed\n") for chunk in chunks)
- events = await service.get_events("sess-tool-output")
- assert "tool_result" in [event.event_type for event in events]
- assert "approval_response" not in [event.event_type for event in events]
- run_status_events = [event for event in events if event.event_type == "run_status"]
- assert run_status_events[-1].metadata["run_mode"] == "foreground"
- assert run_status_events[-1].metadata["run_trigger"] == "approval_resume"
-
-
-@pytest.mark.asyncio
-async def test_invoke_conversation_once_refines_session_title_after_first_turn(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- class _FakeTitleClient:
- @property
- def is_available(self):
- return True
-
- async def generate_title(self, *, model, messages, timeout_ms):
- assert model == "glm-5.1"
- assert messages[0]["role"] == "system"
- assert "你好,请介绍一下你自己" in messages[-1]["content"]
- return "自我介绍", {"total_tokens": 12}
-
- monkeypatch.setattr(
- "ksadk.conversations.runtime.resolve_session_title_client",
- lambda: _FakeTitleClient(),
- )
-
- runner = _StubRunner()
- session_id, _ = await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[{"role": "user", "content": "你好,请介绍一下你自己"}],
- model="glm-5.1",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- )
-
- session = await service.get_session(session_id)
- assert session is not None
- assert session.first_prompt == "你好,请介绍一下你自己"
- for _ in range(20):
- if session.title == "自我介绍":
- break
- await asyncio.sleep(0.01)
- session = await service.get_session(session_id)
- assert session is not None
- assert session.title == "自我介绍"
- assert session.title_source == "ai"
-
-
-@pytest.mark.asyncio
-async def test_invoke_conversation_once_does_not_wait_for_ai_session_title(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- title_started = asyncio.Event()
- release_title = asyncio.Event()
-
- class _BlockingTitleClient:
- @property
- def is_available(self):
- return True
-
- async def generate_title(self, *, model, messages, timeout_ms):
- title_started.set()
- await release_title.wait()
- return "自我介绍", {"total_tokens": 12}
-
- monkeypatch.setattr(
- "ksadk.conversations.runtime.resolve_session_title_client",
- lambda: _BlockingTitleClient(),
- )
-
- runner = _StubRunner()
- invoke_task = asyncio.create_task(
- invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[{"role": "user", "content": "你好,请介绍一下你自己"}],
- model="glm-5.1",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- )
- )
- await asyncio.wait_for(title_started.wait(), timeout=1)
-
- session_id, _ = await asyncio.wait_for(invoke_task, timeout=0.2)
- session = await service.get_session(session_id)
- assert session is not None
- assert session.title == "Agent能力介绍"
- assert session.title_source == "heuristic"
-
- release_title.set()
- for _ in range(20):
- session = await service.get_session(session_id)
- assert session is not None
- if session.title == "自我介绍":
- break
- await asyncio.sleep(0.01)
- assert session.title == "自我介绍"
- assert session.title_source == "ai"
-
-
-@pytest.mark.asyncio
-async def test_invoke_conversation_once_uses_heuristic_title_for_agent_intro(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- class _IntroRunner(_StubRunner):
- async def invoke(self, input_data: dict) -> dict:
- self.calls.append(input_data)
- return {
- "output": (
- "你好!我是企业高端招聘全流程助手,可以协助你完成职位分析、"
- "候选人筛选和面试建议生成。"
- )
- }
-
- runner = _IntroRunner()
- session_id, _ = await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[{"role": "user", "content": "你好,请介绍一下你自己"}],
- model="glm-5.1",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- )
-
- session = await service.get_session(session_id)
- assert session is not None
- assert session.title == "招聘助手能力"
- assert session.title_source == "heuristic"
-
-
-@pytest.mark.asyncio
-async def test_invoke_conversation_once_strips_inline_think_markup_from_output(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- class _ThinkingTagRunner(_StubRunner):
- async def invoke(self, input_data: dict) -> dict:
- self.calls.append(input_data)
- return {"output": "先判断问题。我是招聘助手。"}
-
- runner = _ThinkingTagRunner()
- session_id, result = await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[{"role": "user", "content": "你好,请介绍一下你自己"}],
- model="glm-5.1",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- )
-
- events = await service.get_events(session_id)
- assistant_event = next(event for event in events if event.event_type == "assistant_message")
- session = await service.get_session(session_id)
-
- assert result["output_text"] == "我是招聘助手。"
- assert assistant_event.content["parts"][0]["text"] == "我是招聘助手。"
- assert session is not None
- assert session.summary == "我是招聘助手。"
- assert session.title == "招聘助手能力"
-
-
-@pytest.mark.asyncio
-async def test_invoke_conversation_once_uses_heuristic_title_for_architecture_attachment(
- monkeypatch,
-):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- class _ArchitectureRunner(_StubRunner):
- async def invoke(self, input_data: dict) -> dict:
- self.calls.append(input_data)
- return {
- "output": (
- "这张图展示了典型的微服务分层架构,"
- "包含网关、业务服务、数据库和异步消息链路。"
- )
- }
-
- runner = _ArchitectureRunner()
- session_id, _ = await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[
- {
- "role": "user",
- "content": [
- {
- "type": "input_text",
- "text": "看看这个上传文件,直接开始分析吧,这里还有他画的架构图",
- },
- {
- "type": "input_file",
- "fileData": {
- "fileUri": "ksadk-upload://arch.png",
- "displayName": "架构.png",
- "mimeType": "image/png",
- },
- },
- ],
- }
- ],
- model="glm-5.1",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- )
-
- session = await service.get_session(session_id)
- assert session is not None
- assert session.title == "架构图分析"
- assert session.title_source == "heuristic"
-
-
-@pytest.mark.asyncio
-async def test_append_context_checkpoint_event_records_compaction_boundary(monkeypatch):
- service = InMemorySessionService()
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-1")
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- event = await append_context_checkpoint_event(
- session_id="sess-1",
- author="demo-agent",
- compacted_until_seq_id=8,
- metadata={"reason": "auto-compact"},
- )
-
- assert event.event_type == "context_checkpoint"
- assert event.metadata["compacted_until_seq_id"] == 8
- assert event.metadata["reason"] == "auto-compact"
-
-
-def test_session_event_infers_canonical_message_types():
- user_event = SessionEvent.from_dict(
- {
- "author": "user",
- "content": {"role": "user", "parts": [{"text": "hello"}]},
- }
- )
- assistant_event = SessionEvent.from_dict(
- {
- "author": "demo-agent",
- "content": {"role": "model", "parts": [{"text": "hi"}]},
- }
- )
-
- assert user_event.event_type == "user_message"
- assert assistant_event.event_type == "assistant_message"
-
-
-def test_runtime_checkpoint_events_are_canonical_but_not_projected_to_history():
- events = [
- SessionEvent(
- id="evt-1",
- author="demo-agent",
- event_type="run_checkpoint",
- content={"text": "checkpoint saved"},
- metadata={"run_id": "run-1", "checkpoint_id": "ckpt-1"},
- seq_id=1,
- ),
- SessionEvent(
- id="evt-2",
- author="demo-agent",
- event_type="run_resume",
- content={"text": "resume requested"},
- metadata={"run_id": "run-1", "resume_attempt_id": "resume-1"},
- seq_id=2,
- ),
- SessionEvent(
- id="evt-3",
- author="user",
- event_type="user_message",
- content={"role": "user", "parts": [{"text": "继续"}]},
- seq_id=3,
- ),
- ]
-
- assert canonical_event_type("run_checkpoint") == "run_checkpoint"
- assert canonical_event_type("run_resume") == "run_resume"
- assert build_history_from_events(events) == [{"role": "user", "content": "继续"}]
-
-
-@pytest.mark.asyncio
-async def test_append_run_checkpoint_and_resume_events(monkeypatch):
- service = InMemorySessionService()
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-1")
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- checkpoint = await append_run_checkpoint_event(
- session_id="sess-1",
- author="demo-agent",
- run_id="run-1",
- checkpoint_id="ckpt-1",
- framework="langgraph",
- framework_ref={
- "langgraph": {
- "thread_id": "tenant:agent:sess-1",
- "checkpoint_id": "ckpt-1",
- }
- },
- phase="tool_result",
- invocation_id="inv-1",
- )
- resume = await append_run_resume_event(
- session_id="sess-1",
- author="demo-agent",
- run_id="run-1",
- checkpoint_id="ckpt-1",
- resume_attempt_id="resume-1",
- framework="langgraph",
- framework_ref={
- "langgraph": {
- "thread_id": "tenant:agent:sess-1",
- "checkpoint_id": "ckpt-1",
- }
- },
- invocation_id="inv-2",
- )
-
- assert checkpoint.event_type == "run_checkpoint"
- assert checkpoint.metadata["run_id"] == "run-1"
- assert checkpoint.metadata["checkpoint_id"] == "ckpt-1"
- assert checkpoint.metadata["framework_ref"]["langgraph"]["thread_id"] == "tenant:agent:sess-1"
- assert checkpoint.metadata["is_terminal"] is False
- assert checkpoint.metadata["is_resumable"] is None
- assert checkpoint.metadata["resume_status"] == "unknown"
- assert checkpoint.metadata["resume_disabled_reason"] == ""
- assert checkpoint.metadata["backend"] == "unknown"
- assert checkpoint.metadata["scope"] == "unknown"
- assert checkpoint.metadata["durable"] is False
- assert checkpoint.content["is_terminal"] is False
- assert checkpoint.content["resume_status"] == "unknown"
- assert resume.event_type == "run_resume"
- assert resume.metadata["resume_attempt_id"] == "resume-1"
-
-
-def test_extract_responses_resume_input_accepts_checkpoint_resume_action():
- resume_input = extract_responses_resume_input(
- [
- {
- "type": "agentengine.resume_checkpoint",
- "run_id": "run-1",
- "checkpoint_id": "ckpt-1",
- "resume_attempt_id": "resume-1",
- "framework": "langgraph",
- "framework_ref": {
- "langgraph": {
- "thread_id": "tenant:agent:sess-1",
- "checkpoint_id": "ckpt-1",
- }
- },
- }
- ]
- )
-
- assert resume_input == {
- "type": "agentengine.resume_checkpoint",
- "run_id": "run-1",
- "checkpoint_id": "ckpt-1",
- "resume_attempt_id": "resume-1",
- "framework": "langgraph",
- "framework_ref": {
- "langgraph": {
- "thread_id": "tenant:agent:sess-1",
- "checkpoint_id": "ckpt-1",
- }
- },
- }
-
-
-def test_build_runner_request_payload_exposes_invocation_id():
- prepared = PreparedConversationTurn(
- session_id="sess-1",
- invocation_id="inv-runtime-cancel",
- user_input="hello",
- user_display_input="hello",
- history=[],
- input_content=[],
- input_messages=[],
- user_parts=[],
- attachments=[],
- attachment_results=[],
- current_attachments=[],
- current_attachment_results=[],
- has_current_files=False,
- )
- runtime_context = PlatformInvocationContext(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-1",
- history=[],
- input_content=[],
- input_messages=[],
- input_parts=[],
- attachments=[],
- attachment_results=[],
- current_attachments=[],
- current_attachment_results=[],
- has_current_files=False,
- runner_type="langgraph",
- )
-
- payload = _build_runner_request_payload(
- prepared=prepared,
- model="demo-model",
- runtime_context=runtime_context,
- )
-
- assert payload["invocation_id"] == "inv-runtime-cancel"
-
-
-@pytest.mark.asyncio
-async def test_invoke_conversation_once_checkpoint_resume_writes_runtime_event(monkeypatch):
- service = InMemorySessionService()
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-1")
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- runner = _StubRunner()
- session_id, result = await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-1",
- messages=[],
- model="demo-model",
- prepare_runner=lambda active_runner, model: active_runner.prepare_for_request(model),
- resume_input={
- "type": "agentengine.resume_checkpoint",
- "run_id": "run-1",
- "checkpoint_id": "ckpt-1",
- "resume_attempt_id": "resume-1",
- "framework": "langgraph",
- "framework_ref": {
- "langgraph": {
- "thread_id": "tenant:agent:sess-1",
- "checkpoint_id": "ckpt-1",
- }
- },
- },
- )
-
- events = await service.get_events(session_id)
- resume_events = [event for event in events if event.event_type == "run_resume"]
- assert len(resume_events) == 1
- assert resume_events[0].metadata["run_id"] == "run-1"
- assert resume_events[0].metadata["checkpoint_id"] == "ckpt-1"
- assert resume_events[0].metadata["resume_attempt_id"] == "resume-1"
- assert build_history_from_events(events) == [{"role": "model", "content": "assistant says hi"}]
- assert runner.calls[0]["checkpoint_resume"] is True
- assert runner.calls[0]["run_id"] == "run-1"
- assert runner.calls[0]["framework_ref"]["langgraph"]["checkpoint_id"] == "ckpt-1"
- assert result["metadata"]["agentengine"]["run_id"] == "run-1"
- assert result["metadata"]["agentengine"]["resume_attempt_id"] == "resume-1"
-
-
-@pytest.mark.asyncio
-async def test_invoke_conversation_once_failure_does_not_write_completed_or_assistant(monkeypatch):
- service = InMemorySessionService()
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-fail")
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- runner = _FailingRunner()
- with pytest.raises(RuntimeError, match="boom"):
- await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-fail",
- messages=[{"role": "user", "content": "hello"}],
- model="demo-model",
- prepare_runner=lambda active_runner, model: active_runner.prepare_for_request(model),
- )
-
- events = await service.get_events("sess-fail")
- assert [event.event_type for event in events] == ["user_message", "run_status", "run_status"]
- assert [event.content.get("status") for event in events if event.event_type == "run_status"] == [
- "in_progress",
- "failed",
- ]
-
-
-@pytest.mark.asyncio
-async def test_checkpoint_resume_response_metadata_prefers_new_checkpoint(monkeypatch):
- service = InMemorySessionService()
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-1")
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- runner = _CheckpointResumeAdvancedRunner()
- _, result = await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-1",
- messages=[],
- model="demo-model",
- prepare_runner=lambda active_runner, model: active_runner.prepare_for_request(model),
- resume_input={
- "type": "agentengine.resume_checkpoint",
- "run_id": "run-1",
- "checkpoint_id": "ckpt-before-resume",
- "resume_attempt_id": "resume-1",
- "framework": "langgraph",
- "framework_ref": {
- "langgraph": {
- "thread_id": "tenant:agent:sess-1",
- "checkpoint_id": "ckpt-before-resume",
- }
- },
- },
- )
-
- assert (
- result["metadata"]["agentengine"]["framework_ref"]["langgraph"]["checkpoint_id"]
- == "ckpt-after-resume"
- )
- events = await service.get_events("sess-1")
- assert [event.event_type for event in events if event.event_type.startswith("run_")] == [
- "run_resume",
- "run_status",
- "run_status",
- "run_checkpoint",
- "run_status",
- ]
- # resume 现在先写 run_status(resuming) 再写 run_status(in_progress)
- run_statuses = [e.content["status"] for e in events if e.event_type == "run_status"]
- assert run_statuses == ["resuming", "in_progress", "completed"]
-
-
-@pytest.mark.asyncio
-async def test_invoke_conversation_once_records_runner_checkpoint_metadata(monkeypatch):
- service = InMemorySessionService()
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-1")
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- runner = _CheckpointMetadataRunner()
- session_id, result = await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-1",
- messages=[{"role": "user", "content": "hello"}],
- model="demo-model",
- prepare_runner=lambda active_runner, model: active_runner.prepare_for_request(model),
- )
-
- events = await service.get_events(session_id)
- checkpoint_events = [event for event in events if event.event_type == "run_checkpoint"]
- assert len(checkpoint_events) == 1
- assert checkpoint_events[0].metadata["run_id"] == "run-1"
- assert checkpoint_events[0].metadata["checkpoint_id"] == "ckpt-1"
- assert checkpoint_events[0].metadata["framework_ref"]["langgraph"]["thread_id"] == "tenant:agent:sess-1"
- assert result["metadata"]["agentengine"]["framework_ref"]["langgraph"]["checkpoint_id"] == "ckpt-1"
-
-
-@pytest.mark.asyncio
-async def test_invoke_conversation_once_preserves_runner_usage(monkeypatch):
- service = InMemorySessionService()
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-usage")
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- runner = _UsageRunner()
- _, result = await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-usage",
- messages=[{"role": "user", "content": "hello"}],
- model="demo-model",
- prepare_runner=lambda active_runner, model: active_runner.prepare_for_request(model),
- )
-
- assert result["usage"] == {
- "input_tokens": 8,
- "output_tokens": 13,
- "total_tokens": 21,
- "output_token_details": {"reasoning": 5},
- }
- assert result["metadata"]["usage"] == result["usage"]
-
-
-def test_build_chat_completions_payload_uses_real_usage_from_metadata():
- payload = build_chat_completions_payload(
- output_text="assistant says hi",
- model="demo-model",
- session_id="sess-usage",
- metadata={
- "usage": {
- "input_tokens": 8,
- "output_tokens": 13,
- "total_tokens": 21,
- "output_token_details": {"reasoning": 5},
- }
- },
- )
-
- assert payload["usage"] == {
- "prompt_tokens": 8,
- "completion_tokens": 13,
- "total_tokens": 21,
- "completion_tokens_details": {"reasoning_tokens": 5},
- }
-
-
-def test_build_chat_completions_payload_maps_cached_prompt_details():
- payload = build_chat_completions_payload(
- output_text="assistant says hi",
- model="demo-model",
- session_id="sess-usage",
- metadata={
- "usage": {
- "input_tokens": 8,
- "output_tokens": 13,
- "total_tokens": 21,
- "input_token_details": {"cached": 4},
- "output_token_details": {"reasoning": 5},
- }
- },
- )
-
- assert payload["usage"] == {
- "prompt_tokens": 8,
- "completion_tokens": 13,
- "total_tokens": 21,
- "prompt_tokens_details": {"cached_tokens": 4},
- "completion_tokens_details": {"reasoning_tokens": 5},
- }
-
-
-def test_build_chat_completions_payload_preserves_official_usage_details():
- payload = build_chat_completions_payload(
- output_text="assistant says hi",
- model="demo-model",
- session_id="sess-usage",
- metadata={
- "usage": {
- "prompt_tokens": 8,
- "completion_tokens": 13,
- "total_tokens": 21,
- "prompt_tokens_details": {
- "cached_tokens": 4,
- "audio_tokens": 2,
- },
- "completion_tokens_details": {
- "reasoning_tokens": 5,
- "audio_tokens": 1,
- "accepted_prediction_tokens": 3,
- "rejected_prediction_tokens": 6,
- },
- }
- },
- )
-
- assert payload["usage"] == {
- "prompt_tokens": 8,
- "completion_tokens": 13,
- "total_tokens": 21,
- "prompt_tokens_details": {
- "cached_tokens": 4,
- "audio_tokens": 2,
- },
- "completion_tokens_details": {
- "reasoning_tokens": 5,
- "audio_tokens": 1,
- "accepted_prediction_tokens": 3,
- "rejected_prediction_tokens": 6,
- },
- }
-
-
-def test_build_responses_payload_uses_real_usage_from_metadata():
- payload = build_responses_payload(
- output_text="assistant says hi",
- model="demo-model",
- session_id="sess-usage",
- metadata={
- "usage": {
- "input_tokens": 8,
- "output_tokens": 13,
- "total_tokens": 21,
- "output_token_details": {"reasoning": 5},
- },
- "last_usage": {
- "input_tokens": 8,
- "output_tokens": 13,
- "total_tokens": 21,
- "input_token_details": {"cached": 4},
- },
- },
- )
-
- assert payload["usage"] == {
- "input_tokens": 8,
- "output_tokens": 13,
- "total_tokens": 21,
- "output_token_details": {"reasoning": 5},
- }
- # last_usage 透传到 metadata(供 server 取窗口占用)
- assert payload["metadata"]["last_usage"]["input_tokens"] == 8
- assert payload["metadata"]["last_usage"]["input_token_details"]["cached"] == 4
-
-
-@pytest.mark.asyncio
-async def test_stream_conversation_turn_preserves_final_chunk_usage(monkeypatch):
- service = InMemorySessionService()
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-stream-usage")
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- runner = _UsageStreamingRunner()
- chunks = [
- chunk
- async for chunk in stream_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-stream-usage",
- messages=[{"role": "user", "content": "hello"}],
- model="demo-model",
- prepare_runner=lambda active_runner, model: active_runner.prepare_for_request(model),
- )
- ]
-
- completed_payload = _extract_sse_payload(chunks, "response.completed")
- assert completed_payload["usage"] == {
- "input_tokens": 8,
- "output_tokens": 13,
- "total_tokens": 21,
- "input_token_details": {"cached": 4},
- "output_token_details": {"reasoning": 5},
- }
- # last_usage 透传到 response.completed 的 metadata(供 server 取窗口占用)
- assert completed_payload["metadata"]["last_usage"]["input_tokens"] == 8
- assert completed_payload["metadata"]["last_usage"]["input_token_details"]["cached"] == 4
- events = await service.get_events("sess-stream-usage")
- assistant_event = next(event for event in events if event.event_type == "assistant_message")
- assert assistant_event.metadata["usage"] == completed_payload["usage"]
- assert assistant_event.metadata["last_usage"]["input_tokens"] == 8
-
-
-@pytest.mark.asyncio
-async def test_stream_responses_conversation_turn_preserves_responses_output_usage(monkeypatch):
- service = InMemorySessionService()
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- runner = _CompletedOutputUsageStreamingRunner()
-
- chunks = [
- chunk
- async for chunk in stream_responses_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-native-output-usage",
- messages=[{"role": "user", "content": "查一下"}],
- model="gpt-4o",
- prepare_runner=lambda current_runner, model: current_runner.prepare_for_request(model),
- session_service_provider=lambda: service,
- )
- ]
-
- completed_payload = _extract_sse_payload(chunks, "response.completed")
- assert completed_payload["usage"] == {
- "input_tokens": 9,
- "output_tokens": 4,
- "total_tokens": 13,
- "output_token_details": {"reasoning": 2},
- }
- events = await service.get_events("sess-native-output-usage")
- assistant_event = next(event for event in events if event.event_type == "assistant_message")
- assert assistant_event.metadata["usage"] == completed_payload["usage"]
-
-
-@pytest.mark.asyncio
-async def test_stream_conversation_turn_records_checkpoint_chunk(monkeypatch):
- service = InMemorySessionService()
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-1")
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- runner = _CheckpointMetadataStreamingRunner()
- chunks = [
- chunk
- async for chunk in stream_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-1",
- messages=[{"role": "user", "content": "hello"}],
- model="demo-model",
- prepare_runner=lambda active_runner, model: active_runner.prepare_for_request(model),
- )
- ]
-
- events = await service.get_events("sess-1")
- checkpoint_events = [event for event in events if event.event_type == "run_checkpoint"]
- assert len(checkpoint_events) == 1
- assert checkpoint_events[0].metadata["run_id"] == "run-1"
- assert checkpoint_events[0].metadata["checkpoint_id"] == "ckpt-stream"
- completed = [chunk for chunk in chunks if "response.completed" in chunk][0]
- assert "ckpt-stream" in completed
-
-
-@pytest.mark.asyncio
-async def test_stream_conversation_turn_preserves_checkpoint_phase(monkeypatch):
- service = InMemorySessionService()
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-1")
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- runner = _CheckpointMetadataPhaseStreamingRunner()
- chunks = [
- chunk
- async for chunk in stream_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-1",
- messages=[{"role": "user", "content": "hello"}],
- model="demo-model",
- prepare_runner=lambda active_runner, model: active_runner.prepare_for_request(model),
- )
- ]
-
- events = await service.get_events("sess-1")
- checkpoint_events = [event for event in events if event.event_type == "run_checkpoint"]
- assert len(checkpoint_events) == 1
- assert checkpoint_events[0].metadata["checkpoint_id"] == "ckpt-business-stage"
- assert checkpoint_events[0].metadata["phase"] == "数据清洗完成,等待生成报告"
- assert checkpoint_events[0].metadata["stage"] == "清洗聚合指标"
- assert checkpoint_events[0].metadata["summary"] == "GMV、转化率和退款率已经聚合完成"
- assert checkpoint_events[0].metadata["next_action"] == "恢复后继续生成复盘报告"
- assert any("response.error" in chunk for chunk in chunks)
- assert not any("response.completed" in chunk for chunk in chunks)
- run_statuses = [
- event.content.get("status")
- for event in events
- if event.event_type == "run_status"
- ]
- assert run_statuses == ["in_progress", "failed"]
-
-
-@pytest.mark.asyncio
-async def test_stream_checkpoint_resume_falls_back_to_original_run_id(monkeypatch):
- service = InMemorySessionService()
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-1")
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- runner = _CheckpointMetadataWithoutRunIdStreamingRunner()
- chunks = [
- chunk
- async for chunk in stream_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-1",
- messages=[],
- model="demo-model",
- prepare_runner=lambda active_runner, model: active_runner.prepare_for_request(model),
- invocation_id="resume-attempt-1",
- resume_input={
- "type": "agentengine.resume_checkpoint",
- "run_id": "run-original",
- "checkpoint_id": "ckpt-before",
- "resume_attempt_id": "resume-attempt-1",
- "framework": "langgraph",
- "framework_ref": {
- "langgraph": {
- "thread_id": "tenant:agent:sess-1",
- "checkpoint_id": "ckpt-before",
- }
- },
- },
- )
- ]
-
- events = await service.get_events("sess-1")
- checkpoint_events = [event for event in events if event.event_type == "run_checkpoint"]
- assert len(checkpoint_events) == 1
- assert checkpoint_events[0].metadata["run_id"] == "run-original"
- assert checkpoint_events[0].metadata["checkpoint_id"] == "ckpt-stream-after-resume"
- assert any("response.error" in chunk for chunk in chunks)
- assert not any("response.completed" in chunk for chunk in chunks)
- run_statuses = [
- event.content.get("status")
- for event in events
- if event.event_type == "run_status"
- ]
- # checkpoint resume 失败现在写 resume_failed(独立终态),而非 failed。
- # 状态序列:resuming(build_run_input 补写)→ in_progress → resume_failed(失败改写)。
- assert run_statuses == ["resuming", "in_progress", "resume_failed"]
-
-
-def test_build_history_from_events_prefers_latest_checkpoint_and_tail():
- events = [
- SessionEvent(
- id="evt-1",
- author="user",
- event_type="user_message",
- content={"role": "user", "parts": [{"text": "hello"}]},
- seq_id=1,
- ),
- SessionEvent(
- id="evt-2",
- author="demo-agent",
- event_type="assistant_message",
- content={"role": "model", "parts": [{"text": "hi"}]},
- seq_id=2,
- ),
- SessionEvent(
- id="evt-3",
- author="demo-agent",
- event_type="context_checkpoint",
- content={
- "role": "model",
- "parts": [{"text": "Earlier conversation summary:\nuser: hello | assistant: hi"}],
- },
- seq_id=3,
- metadata={"compacted_until_seq_id": 2},
- ),
- SessionEvent(
- id="evt-4",
- author="user",
- event_type="user_message",
- content={"role": "user", "parts": [{"text": "follow up"}]},
- seq_id=4,
- ),
- ]
-
- assert build_history_from_events(events) == [
- {"role": "model", "content": "Earlier conversation summary:\nuser: hello | assistant: hi"},
- {"role": "user", "content": "follow up"},
- ]
-
-
-@pytest.mark.asyncio
-async def test_build_run_input_auto_compacts_old_rounds_into_checkpoint(monkeypatch):
- model_context_module = importlib.import_module("ksadk.conversations.model_context")
- service = InMemorySessionService()
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-compact")
- for turn in range(5):
- await service.append_event(
- "sess-compact",
- SessionEvent(
- id=f"u-{turn}",
- author="user",
- event_type="user_message",
- content={"role": "user", "parts": [{"text": f"user-{turn} " + ("x" * 80)}]},
- invocation_id=f"inv-{turn}",
- ),
- )
- await service.append_event(
- "sess-compact",
- SessionEvent(
- id=f"a-{turn}",
- author="demo-agent",
- event_type="assistant_message",
- content={"role": "model", "parts": [{"text": f"assistant-{turn} " + ("y" * 80)}]},
- invocation_id=f"inv-{turn}",
- ),
- )
-
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- monkeypatch.setattr(model_context_module, "AUTOCOMPACT_SUMMARY_RESERVE_TOKENS", 0)
- monkeypatch.setattr(model_context_module, "AUTOCOMPACT_BUFFER_TOKENS", 20)
- compact_model_metadata = {
- "id": "glm-5.1",
- "context_window_tokens": 120,
- "max_output_tokens": 1,
- }
-
- preview = await preview_auto_compaction(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-compact",
- messages=[{"role": "user", "content": "follow up"}],
- model="glm-5.1",
- model_metadata=compact_model_metadata,
- session_service_provider=lambda: service,
- )
- prepared = await build_run_input(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-compact",
- messages=[{"role": "user", "content": "follow up"}],
- model="glm-5.1",
- model_metadata=compact_model_metadata,
- )
-
- events = await service.get_events("sess-compact")
- assert preview.should_compact is True
- assert preview.total_estimated_tokens > 0
- assert "compaction_boundary" in [event.event_type for event in events]
- assert "context_checkpoint" in [event.event_type for event in events]
- assert prepared.compaction_triggered is True
- assert prepared.compaction_trigger == "auto"
- assert prepared.compacted_until_seq_id is not None
- assert prepared.history[0]["role"] == "model"
- assert "Earlier conversation summary:" in prepared.history[0]["content"]
- assert prepared.history[-1] == {"role": "user", "content": "follow up"}
-
-
-@pytest.mark.asyncio
-async def test_auto_compaction_ignores_inline_image_base64_for_context_estimation(monkeypatch):
- model_context_module = importlib.import_module("ksadk.conversations.model_context")
- service = InMemorySessionService()
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-image-compact")
- large_image_data = "A" * 260_000
-
- for turn in range(3):
- await service.append_event(
- "sess-image-compact",
- SessionEvent(
- id=f"img-{turn}",
- author="user",
- event_type="user_message",
- content={"role": "user", "parts": [{"text": "分析这张图片"}]},
- metadata={
- "agent_input": json.dumps(
- [
- {
- "role": "user",
- "content": [
- {"type": "input_text", "text": "分析这张图片"},
- {
- "type": "input_image",
- "image_url": f"data:image/png;base64,{large_image_data}",
- },
- ],
- }
- ]
- )
- },
- invocation_id=f"img-inv-{turn}",
- ),
- )
-
- monkeypatch.setattr(model_context_module, "AUTOCOMPACT_SUMMARY_RESERVE_TOKENS", 0)
- monkeypatch.setattr(model_context_module, "AUTOCOMPACT_BUFFER_TOKENS", 20)
- preview = await preview_auto_compaction(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-image-compact",
- messages=[{"role": "user", "content": "继续分析"}],
- model="qwen3-vl-plus",
- model_metadata={
- "id": "qwen3-vl-plus",
- "context_window_tokens": 200_000,
- "max_output_tokens": 32_000,
- },
- session_service_provider=lambda: service,
- )
-
- assert preview.should_compact is False
- assert preview.total_estimated_tokens < 1_000
-
-
-@pytest.mark.asyncio
-async def test_build_run_input_respects_explicit_model_metadata_for_auto_compaction(monkeypatch):
- service = InMemorySessionService()
- await service.create_session(
- agent_id="demo-agent", user_id="user-1", session_id="sess-model-metadata"
- )
- for turn in range(6):
- await service.append_event(
- "sess-model-metadata",
- SessionEvent(
- id=f"u-{turn}",
- author="user",
- event_type="user_message",
- content={"role": "user", "parts": [{"text": f"user-{turn} " + ("x" * 30_000)}]},
- invocation_id=f"inv-{turn}",
- ),
- )
- await service.append_event(
- "sess-model-metadata",
- SessionEvent(
- id=f"a-{turn}",
- author="demo-agent",
- event_type="assistant_message",
- content={
- "role": "model",
- "parts": [{"text": f"assistant-{turn} " + ("y" * 30_000)}],
- },
- invocation_id=f"inv-{turn}",
- ),
- )
-
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
-
- preview = await preview_auto_compaction(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-model-metadata",
- messages=[{"role": "user", "content": "follow up"}],
- model="glm-5.1",
- model_metadata={
- "id": "glm-5.1",
- "context_length": "64k",
- "max_completion_tokens": "8k",
- },
- session_service_provider=lambda: service,
- )
- prepared = await build_run_input(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-model-metadata",
- messages=[{"role": "user", "content": "follow up"}],
- model="glm-5.1",
- model_metadata={
- "id": "glm-5.1",
- "context_length": "64k",
- "max_completion_tokens": "8k",
- },
- session_service_provider=lambda: service,
- )
-
- assert preview.should_compact is True
- assert preview.auto_compact_threshold_tokens == 43000
- assert prepared.compaction_triggered is True
- assert prepared.history[0]["role"] == "model"
- assert "Earlier conversation summary:" in prepared.history[0]["content"]
-
-
-@pytest.mark.asyncio
-async def test_invoke_conversation_once_fetches_model_metadata_from_remote_catalog(monkeypatch):
- service = InMemorySessionService()
- runner = _StubRunner()
-
- monkeypatch.setenv("OPENAI_BASE_URL", "https://kspmas.ksyun.com/v1")
- monkeypatch.setenv("OPENAI_API_KEY", "secret-key")
- monkeypatch.setattr(
- "httpx.AsyncClient",
- lambda *args, **kwargs: _ExternalModelsAsyncClient(
- *args,
- payload={
- "data": [
- {
- "id": "kimi-k2.6",
- "architecture": {
- "input_modalities": ["文字", "图片", "视频"],
- "output_modalities": ["文字"],
- },
- }
- ]
- },
- **kwargs,
- ),
- )
-
- session_id, _ = await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id=None,
- messages=[{"role": "user", "content": "请分析图片"}],
- model="kimi-k2.6",
- prepare_runner=lambda _runner, _model: None,
- session_service_provider=lambda: service,
- )
-
- assert session_id
- assert runner.calls[0]["model_metadata"]["id"] == "kimi-k2.6"
- assert runner.calls[0]["model_metadata"]["architecture"]["input_modalities"] == [
- "文字",
- "图片",
- "视频",
- ]
- assert runner.calls[0]["model_metadata"]["capabilities"]["multimodal_input_image"] is True
-
-
-@pytest.mark.asyncio
-async def test_invoke_conversation_once_compacts_and_retries_on_prompt_too_long(monkeypatch):
- model_context_module = importlib.import_module("ksadk.conversations.model_context")
- service = InMemorySessionService()
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-ptl")
- for turn in range(4):
- await service.append_event(
- "sess-ptl",
- SessionEvent(
- id=f"u-{turn}",
- author="user",
- event_type="user_message",
- content={"role": "user", "parts": [{"text": f"user-{turn} " + ("x" * 80)}]},
- invocation_id=f"inv-{turn}",
- ),
- )
- await service.append_event(
- "sess-ptl",
- SessionEvent(
- id=f"a-{turn}",
- author="demo-agent",
- event_type="assistant_message",
- content={"role": "model", "parts": [{"text": f"assistant-{turn} " + ("y" * 80)}]},
- invocation_id=f"inv-{turn}",
- ),
- )
-
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- monkeypatch.setattr(model_context_module, "DEFAULT_CONTEXT_WINDOW_TOKENS", 120)
- monkeypatch.setattr(model_context_module, "DEFAULT_MAX_OUTPUT_TOKENS", 0)
- monkeypatch.setattr(model_context_module, "AUTOCOMPACT_SUMMARY_RESERVE_TOKENS", 0)
- monkeypatch.setattr(model_context_module, "AUTOCOMPACT_BUFFER_TOKENS", 20)
- runner = _PromptTooLongRunner()
-
- session_id, result = await invoke_conversation_once(
- runner=runner,
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-ptl",
- messages=[{"role": "user", "content": "new follow up"}],
- model="gpt-4o",
- prepare_runner=lambda current, model: current.prepare_for_request(model),
- )
-
- assert session_id == "sess-ptl"
- assert result["output_text"] == "compacted answer"
- assert len(runner.calls) == 2
- assert len(runner.calls[1]["history"]) < len(runner.calls[0]["history"])
- assert runner.calls[1]["history"][0]["role"] == "model"
- assert "Earlier conversation summary:" in runner.calls[1]["history"][0]["content"]
-
- events = await service.get_events("sess-ptl")
- assert "compaction_boundary" in [event.event_type for event in events]
- assert "context_checkpoint" in [event.event_type for event in events]
-
-
-@pytest.mark.asyncio
-async def test_compact_conversation_history_prefers_semantic_summary_and_records_metadata(
- monkeypatch,
-):
- service = InMemorySessionService()
- await service.create_session(
- agent_id="demo-agent", user_id="user-1", session_id="sess-semantic"
- )
-
- for turn in range(3):
- await service.append_event(
- "sess-semantic",
- SessionEvent(
- id=f"u-sem-{turn}",
- author="user",
- event_type="user_message",
- content={"role": "user", "parts": [{"text": f"用户问题 {turn} " + ("甲" * 40)}]},
- invocation_id=f"sem-{turn}",
- ),
- )
- await service.append_event(
- "sess-semantic",
- SessionEvent(
- id=f"a-sem-{turn}",
- author="demo-agent",
- event_type="assistant_message",
- content={"role": "model", "parts": [{"text": f"助手回复 {turn} " + ("乙" * 40)}]},
- invocation_id=f"sem-{turn}",
- ),
- )
-
- class _FakeSummaryClient:
- is_available = True
-
- async def summarize(self, *, model, messages, timeout_ms):
- assert model == "glm-5.1"
- assert timeout_ms > 0
- assert any("当前用户目标" in item["content"] for item in messages)
- return (
- "draft"
- "当前用户目标\n- 修复语义压缩\n\n"
- "关键约束与偏好\n- 质量优先\n\n"
- "已完成进展\n- 已生成 checkpoint\n\n"
- "重要决策/代码上下文\n- 保持 append-only 事件契约\n\n"
- "未完成事项\n- 补更多回归测试\n\n"
- "下一步工作位置\n- ksadk.conversations.runtime.compact_conversation_history"
- "",
- {"prompt_tokens": 120, "completion_tokens": 48, "total_tokens": 168},
- )
-
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- monkeypatch.setattr(
- "ksadk.conversations.semantic_summary.resolve_summary_model_client",
- lambda: _FakeSummaryClient(),
- )
-
- checkpoint = await compact_conversation_history(
- session_id="sess-semantic",
- author="demo-agent",
- invocation_id="inv-semantic",
- model="glm-5.1",
- force=True,
- keep_tail_groups=1,
- session_service_provider=lambda: service,
- )
-
- assert checkpoint is not None
- assert checkpoint.event_type == "context_checkpoint"
- assert "" not in checkpoint.content["parts"][0]["text"]
- assert "当前用户目标" in checkpoint.content["parts"][0]["text"]
- assert checkpoint.metadata["summary_strategy"] == "semantic"
- assert checkpoint.metadata["summary_version"] == "v1"
- assert checkpoint.metadata["summary_model"] == "glm-5.1"
- assert checkpoint.metadata["summary_usage"]["total_tokens"] == 168
-
-
-@pytest.mark.asyncio
-async def test_compact_conversation_history_falls_back_to_extractive_when_semantic_summary_fails(
- monkeypatch,
-):
- service = InMemorySessionService()
- await service.create_session(
- agent_id="demo-agent", user_id="user-1", session_id="sess-fallback"
- )
-
- for turn in range(3):
- await service.append_event(
- "sess-fallback",
- SessionEvent(
- id=f"u-fb-{turn}",
- author="user",
- event_type="user_message",
- content={"role": "user", "parts": [{"text": f"user-{turn} " + ("x" * 60)}]},
- invocation_id=f"fb-{turn}",
- ),
- )
- await service.append_event(
- "sess-fallback",
- SessionEvent(
- id=f"a-fb-{turn}",
- author="demo-agent",
- event_type="assistant_message",
- content={"role": "model", "parts": [{"text": f"assistant-{turn} " + ("y" * 60)}]},
- invocation_id=f"fb-{turn}",
- ),
- )
-
- class _BrokenSummaryClient:
- is_available = True
-
- async def summarize(self, *, model, messages, timeout_ms):
- raise RuntimeError("summary backend down")
-
- monkeypatch.setattr("ksadk.conversations.runtime.resolve_session_service", lambda: service)
- monkeypatch.setattr(
- "ksadk.conversations.semantic_summary.resolve_summary_model_client",
- lambda: _BrokenSummaryClient(),
- )
-
- checkpoint = await compact_conversation_history(
- session_id="sess-fallback",
- author="demo-agent",
- invocation_id="inv-fallback",
- model="glm-5.1",
- force=True,
- keep_tail_groups=1,
- session_service_provider=lambda: service,
- )
-
- assert checkpoint is not None
- assert checkpoint.metadata["summary_strategy"] == "extractive"
- assert checkpoint.metadata["summary_version"] == "v1"
- assert "summary backend down" in checkpoint.metadata["fallback_reason"]
- assert "Earlier conversation summary:" in checkpoint.content["parts"][0]["text"]
-
-
-def test_plan_compaction_keeps_pending_approval_group_out_of_checkpoint():
- runtime_module = importlib.import_module("ksadk.conversations.runtime")
- events = [
- SessionEvent(
- id="evt-1",
- author="user",
- event_type="user_message",
- content={"role": "user", "parts": [{"text": "先看第一轮"}]},
- invocation_id="inv-1",
- seq_id=1,
- ),
- SessionEvent(
- id="evt-2",
- author="demo-agent",
- event_type="assistant_message",
- content={"role": "model", "parts": [{"text": "第一轮回复"}]},
- invocation_id="inv-1",
- seq_id=2,
- ),
- SessionEvent(
- id="evt-3",
- author="demo-agent",
- event_type="approval_request",
- content={"role": "model", "parts": [{"text": "请确认是否继续执行部署"}]},
- invocation_id="inv-2",
- seq_id=3,
- ),
- SessionEvent(
- id="evt-4",
- author="user",
- event_type="user_message",
- content={"role": "user", "parts": [{"text": "顺便记录这个当前任务"}]},
- invocation_id="inv-3",
- seq_id=4,
- ),
- ]
-
- plan = runtime_module._plan_compaction(
- events,
- force=True,
- keep_tail_groups=1,
- )
-
- assert plan.should_compact is True
- assert [[item.seq_id for item in group] for group in plan.groups_to_compact] == [[1, 2]]
- assert plan.pinned_state["pending_approvals"]
- assert "当前任务" in plan.pinned_state["current_user_goal"]
diff --git a/tests/test_deepagents_integration.py b/tests/test_deepagents_integration.py
deleted file mode 100644
index a36b6947..00000000
--- a/tests/test_deepagents_integration.py
+++ /dev/null
@@ -1,249 +0,0 @@
-"""DeepAgents framework integration tests."""
-
-from pathlib import Path
-
-import pytest
-import yaml
-
-from ksadk.detection import FrameworkDetector, FrameworkType, DetectionResult
-from ksadk.runners.factory import create_runner
-from ksadk.runners.utils.loader import load_agent_module
-
-
-def _write_deepagents_project(project_dir: Path) -> None:
- package_name = "deepagents_demo"
- package_dir = project_dir / package_name
- package_dir.mkdir(parents=True)
-
- (package_dir / "__init__.py").write_text(
- 'from .agent import root_agent\n__all__ = ["root_agent"]\n',
- encoding="utf-8",
- )
-
- (package_dir / "agent.py").write_text(
- '''from collections.abc import Callable, Sequence
-from typing import Any
-
-from deepagents import create_deep_agent
-from langchain_core.language_models import LanguageModelInput
-from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
-from langchain_core.messages import AIMessage
-from langchain_core.runnables import Runnable
-from langchain_core.tools import BaseTool
-
-
-class FixedGenericFakeChatModel(GenericFakeChatModel):
- def bind_tools(
- self,
- tools: Sequence[dict[str, Any] | type | Callable | BaseTool],
- *,
- tool_choice: str | None = None,
- **kwargs: Any,
- ) -> Runnable[LanguageModelInput, AIMessage]:
- return self
-
-
-fake_model = FixedGenericFakeChatModel(
- messages=iter(
- [
- AIMessage(
- content="",
- tool_calls=[
- {
- "name": "write_todos",
- "args": {"todos": []},
- "id": "call_1",
- "type": "tool_call",
- }
- ],
- ),
- AIMessage(content="DeepAgents invoke ok"),
- ]
- )
-)
-
-root_agent = create_deep_agent(model=fake_model)
-''',
- encoding="utf-8",
- )
-
- (project_dir / "agentengine.yaml").write_text(
- yaml.dump(
- {
- "name": "deepagents-demo",
- "framework": "deepagents",
- "entry_point": f"{package_name}/agent.py",
- "package": package_name,
- "agent_variable": "root_agent",
- }
- ),
- encoding="utf-8",
- )
-
-
-def _write_deepagents_script_entry(project_dir: Path, entry_file: str) -> None:
- project_dir.mkdir(parents=True, exist_ok=True)
- (project_dir / entry_file).write_text(
- """from deepagents import create_deep_agent
-
-root_agent = create_deep_agent(model=None)
-""",
- encoding="utf-8",
- )
-
-
-def test_detector_supports_deepagents_from_config(tmp_path: Path):
- _write_deepagents_project(tmp_path)
- detector = FrameworkDetector(str(tmp_path))
- result = detector.detect()
- assert result.type == FrameworkType.DEEPAGENTS
- assert result.entry_point.endswith("deepagents_demo/agent.py")
-
-
-def test_detector_reads_custom_runner_class_from_config(tmp_path: Path):
- package_dir = tmp_path / "demo_agent"
- package_dir.mkdir()
- (package_dir / "__init__.py").write_text("", encoding="utf-8")
- (package_dir / "agent.py").write_text("root_agent = object()\n", encoding="utf-8")
- (tmp_path / "agentengine.yaml").write_text(
- yaml.dump(
- {
- "name": "demo-agent",
- "framework": "langgraph",
- "entry_point": "demo_agent/agent.py",
- "package": "demo_agent",
- "agent_variable": "root_agent",
- "runner_class": "demo_agent.agent.CustomRunner",
- }
- ),
- encoding="utf-8",
- )
-
- result = FrameworkDetector(str(tmp_path)).detect()
-
- assert result.type == FrameworkType.LANGGRAPH
- assert result.runner_class == "demo_agent.agent.CustomRunner"
-
-
-def test_detector_ignores_config_when_agent_variable_missing_and_finds_src_agent(tmp_path: Path):
- package_dir = tmp_path / "src" / "demo_agent"
- package_dir.mkdir(parents=True)
- (package_dir / "main.py").write_text(
- "from fastapi import FastAPI\n"
- "app = FastAPI()\n",
- encoding="utf-8",
- )
- (package_dir / "agent.py").write_text(
- "from langchain_openai import ChatOpenAI\n"
- "from langchain_core.output_parsers import StrOutputParser\n"
- "root_agent = ChatOpenAI() | StrOutputParser()\n",
- encoding="utf-8",
- )
- (tmp_path / "agentengine.yaml").write_text(
- "name: demo-agent\nframework: langchain\nentry_point: src/demo_agent/main.py\nagent_variable: root_agent\n",
- encoding="utf-8",
- )
-
- result = FrameworkDetector(str(tmp_path)).detect()
-
- assert result.type == FrameworkType.LANGCHAIN
- assert result.entry_point == "src/demo_agent/agent.py"
- assert Path(result.package_path) == package_dir
-
-
-def test_detector_reads_valid_langgraph_json_when_config_is_stale(tmp_path: Path):
- package_dir = tmp_path / "src" / "demo_agent"
- package_dir.mkdir(parents=True)
- (package_dir / "graph.py").write_text(
- "from langgraph.graph import StateGraph\n"
- "graph = StateGraph(dict).compile()\n",
- encoding="utf-8",
- )
- (tmp_path / "agentengine.yaml").write_text(
- "name: demo-agent\nframework: langgraph\nentry_point: src/demo_agent/main.py\nagent_variable: root_agent\n",
- encoding="utf-8",
- )
- (tmp_path / "langgraph.json").write_text(
- '{"graphs": {"agent": "./src/demo_agent/graph.py:graph"}}\n',
- encoding="utf-8",
- )
-
- result = FrameworkDetector(str(tmp_path)).detect()
-
- assert result.type == FrameworkType.LANGGRAPH
- assert result.entry_point == "src/demo_agent/graph.py"
- assert result.agent_variable == "graph"
-
-
-@pytest.mark.parametrize("entry_file", ["agent.py", "main.py", "app.py"])
-def test_detector_supports_script_project_without_package_init(tmp_path: Path, entry_file: str):
- nested_project = tmp_path / "deep" / "deep"
- _write_deepagents_script_entry(nested_project, entry_file)
-
- detector = FrameworkDetector(str(nested_project))
- result = detector.detect()
-
- assert result.type == FrameworkType.DEEPAGENTS
- assert result.entry_point == entry_file
- assert Path(result.package_path) == nested_project
-
-
-def test_detector_supports_bom_encoded_agent_file(tmp_path: Path):
- nested_project = tmp_path / "deep" / "deep"
- nested_project.mkdir(parents=True, exist_ok=True)
- (nested_project / "agent.py").write_text(
- "\ufefffrom deepagents import create_deep_agent\nroot_agent = create_deep_agent(model=None)\n",
- encoding="utf-8",
- )
-
- detector = FrameworkDetector(str(nested_project))
- result = detector.detect()
-
- assert result.type == FrameworkType.DEEPAGENTS
- assert result.entry_point == "agent.py"
-
-
-def test_loader_supports_src_layout_imports(tmp_path: Path):
- package_dir = tmp_path / "src" / "src_demo"
- package_dir.mkdir(parents=True)
- (package_dir / "__init__.py").write_text("", encoding="utf-8")
- (package_dir / "helper.py").write_text("VALUE = 'src import ok'\n", encoding="utf-8")
- (package_dir / "agent.py").write_text(
- "from src_demo.helper import VALUE\n"
- "root_agent = VALUE\n",
- encoding="utf-8",
- )
-
- agent, module = load_agent_module(str(tmp_path), "src/src_demo/agent.py", "root_agent")
-
- assert agent == "src import ok"
- assert module.__name__ == "src.src_demo.agent"
-
-
-def test_factory_creates_deepagents_runner(tmp_path: Path):
- detection = DetectionResult(
- type=FrameworkType.DEEPAGENTS,
- name="deepagents-demo",
- entry_point="deepagents_demo/agent.py",
- package_path=str(tmp_path / "deepagents_demo"),
- agent_variable="root_agent",
- )
- runner = create_runner(detection, str(tmp_path))
- assert runner.__class__.__name__ == "DeepAgentsRunner"
-
-
-@pytest.mark.asyncio
-async def test_create_runner_invoke_deepagents_e2e(tmp_path: Path):
- pytest.importorskip("deepagents")
-
- _write_deepagents_project(tmp_path)
- detector = FrameworkDetector(str(tmp_path))
- result = detector.detect()
- assert result.type == FrameworkType.DEEPAGENTS
-
- runner = create_runner(result, str(tmp_path))
- runner.load_agent()
-
- response = await runner.invoke({"input": "hello deepagents"})
- assert "output" in response
- assert "DeepAgents invoke ok" in response["output"]
diff --git a/tests/test_deepagents_runner_skill_runtime.py b/tests/test_deepagents_runner_skill_runtime.py
deleted file mode 100644
index 5c193f85..00000000
--- a/tests/test_deepagents_runner_skill_runtime.py
+++ /dev/null
@@ -1,23 +0,0 @@
-from __future__ import annotations
-
-
-def _tool_names(tools):
- return [getattr(tool, "name", None) or getattr(tool, "__name__", "") for tool in tools]
-
-
-def test_deepagents_projects_can_use_agentengine_toolsets_before_compile(monkeypatch):
- from ksadk.runners.deepagents_runner import DeepAgentsRunner
- from ksadk.runners.langgraph_runner import LangGraphRunner
- from ksadk.toolsets import get_agentengine_tools
-
- monkeypatch.setenv("KSADK_SKILL_RUNTIME_BACKEND", "disabled")
- monkeypatch.delenv("KSADK_SANDBOX_TEMPLATE_ID", raising=False)
-
- tools = get_agentengine_tools(include=["skill", "workspace", "platform", "sandbox"])
- names = _tool_names(tools)
-
- assert issubclass(DeepAgentsRunner, LangGraphRunner)
- assert "execute_skills" in names
- assert "workspace_status" in names
- assert "component_status" in names
- assert "sandbox_status" in names
diff --git a/tests/test_deploy_integration.py b/tests/test_deploy_integration.py
deleted file mode 100644
index d722bc6e..00000000
--- a/tests/test_deploy_integration.py
+++ /dev/null
@@ -1,1082 +0,0 @@
-"""
-CLI 部署集成测试
-
-测试 Agent 部署的本地状态文件机制
-"""
-
-import os
-import json
-import pytest
-import tempfile
-import yaml
-from pathlib import Path
-from unittest.mock import AsyncMock, patch, MagicMock
-
-from ksadk.deployment.providers.serverless import ServerlessProvider
-from ksadk.deployment.base import PackageInfo, DeployTarget, DeployStatus
-from ksadk.builders.base import BuildResult
-
-
-# ============================================================================
-# Fixtures
-# ============================================================================
-
-@pytest.fixture
-def temp_project_dir():
- """创建临时项目目录"""
- with tempfile.TemporaryDirectory() as tmpdir:
- # 创建基本项目结构
- project_dir = Path(tmpdir)
- (project_dir / "agent.py").write_text("# Agent code")
- (project_dir / "agentengine.yaml").write_text(yaml.dump({
- "name": "test-agent",
- "framework": "langgraph"
- }))
- yield project_dir
-
-
-@pytest.fixture
-def sample_package_info(temp_project_dir):
- """示例打包信息"""
- return PackageInfo(
- name="test-agent",
- framework="langgraph",
- build_dir=str(temp_project_dir / ".agentengine" / "build"),
- project_dir=str(temp_project_dir),
- metadata={
- "ks3_path": "ks3://test-bucket/agents/test-agent/code.zip"
- }
- )
-
-
-@pytest.fixture
-def sample_deploy_target():
- """示例部署目标"""
- return DeployTarget(
- provider="serverless",
- region="cn-beijing-6",
- extra={
- "artifact_type": "Code",
- "enable_observability": True
- }
- )
-
-
-# ============================================================================
-# Local State File Tests
-# ============================================================================
-
-class TestLocalStateFile:
- """本地状态文件测试"""
-
- def test_load_state_empty(self, temp_project_dir):
- """测试加载空状态文件"""
- provider = ServerlessProvider()
- state_file = temp_project_dir / ".agentengine.state"
-
- state = provider._load_state(state_file)
-
- assert state == {}
-
- def test_load_state_existing(self, temp_project_dir):
- """测试加载已存在的状态文件"""
- provider = ServerlessProvider()
- state_file = temp_project_dir / ".agentengine.state"
-
- # 创建状态文件
- state_file.write_text(yaml.dump({
- "agent_id": "ar-20260119-abcdef",
- "name": "test-agent",
- "endpoint": "https://test.kspmas.ksyun.com"
- }))
-
- state = provider._load_state(state_file)
-
- assert state["agent_id"] == "ar-20260119-abcdef"
- assert state["name"] == "test-agent"
-
- def test_save_state(self, temp_project_dir):
- """测试保存状态文件"""
- provider = ServerlessProvider()
- state_file = temp_project_dir / ".agentengine.state"
-
- provider._save_state(state_file, {
- "agent_id": "ar-20260119-newid",
- "name": "new-agent",
- "endpoint": "https://new.kspmas.ksyun.com"
- })
-
- assert state_file.exists()
-
- loaded = yaml.safe_load(state_file.read_text())
- assert loaded["agent_id"] == "ar-20260119-newid"
-
-
-# ============================================================================
-# Deploy Logic Tests
-# ============================================================================
-
-class TestDeployLogic:
- """部署逻辑测试"""
-
- @pytest.mark.asyncio
- async def test_deploy_create_new_agent(
- self,
- temp_project_dir,
- sample_package_info,
- sample_deploy_target
- ):
- """测试首次部署 - 创建新 Agent"""
- provider = ServerlessProvider()
-
- # 模拟 AgentEngineClient
- mock_client = AsyncMock()
- mock_client.create_agent = AsyncMock(return_value={
- "agent_id": "ar-20260119-newagent",
- "name": "test-agent",
- "endpoint": "https://test.kspmas.ksyun.com",
- "api_key": "ak-test-key"
- })
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock()
-
- with patch.dict(os.environ, {"AGENTENGINE_SERVER_URL": "http://localhost:8080"}), \
- patch('ksadk.deployment.providers.serverless.AgentEngineClient', return_value=mock_client), \
- patch('ksadk.common.auth.AWSV4Auth') as MockAuth:
-
- MockAuth.return_value.access_key = "test-ak"
- MockAuth.return_value.secret_key = "test-sk"
-
- result = await provider.deploy(sample_package_info, sample_deploy_target)
-
- assert result.status == DeployStatus.DEPLOYING
- assert result.agent_name == "test-agent"
- assert "首次部署" in result.message
-
- # 验证状态文件已创建
- state_file = temp_project_dir / ".agentengine.state"
- assert state_file.exists()
-
- state = yaml.safe_load(state_file.read_text())
- assert state["agent_id"] == "ar-20260119-newagent"
-
- @pytest.mark.asyncio
- async def test_deploy_create_new_agent_refreshes_quick_access_when_agent_id_is_immediate(
- self,
- temp_project_dir,
- sample_package_info,
- sample_deploy_target,
- ):
- """测试首次部署即使立即拿到 agent_id,也会回查并持久化 quick access。"""
- provider = ServerlessProvider()
-
- mock_client = AsyncMock()
- mock_client.create_agent = AsyncMock(
- return_value={
- "agent_id": "ar-20260119-newagent",
- "name": "test-agent",
- "endpoint": "http://stale.example.com",
- "api_key": None,
- "order_id": "ord-123",
- }
- )
- mock_client.get_agent = AsyncMock(
- return_value={
- "basic": {
- "agent_id": "ar-20260119-newagent",
- "name": "test-agent",
- },
- "quick_access": {
- "public_endpoint": "https://fresh.example.com",
- "api_key": "ak-fresh-key",
- },
- }
- )
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock()
-
- with patch.dict(os.environ, {"AGENTENGINE_SERVER_URL": "http://localhost:8080"}), \
- patch("ksadk.deployment.providers.serverless.AgentEngineClient", return_value=mock_client), \
- patch("ksadk.common.auth.AWSV4Auth") as MockAuth:
-
- MockAuth.return_value.access_key = "test-ak"
- MockAuth.return_value.secret_key = "test-sk"
-
- await provider.deploy(sample_package_info, sample_deploy_target)
-
- state_file = temp_project_dir / ".agentengine.state"
- state = yaml.safe_load(state_file.read_text())
- assert state["endpoint"] == "https://fresh.example.com"
- assert state["api_key"] == "ak-fresh-key"
-
- @pytest.mark.asyncio
- async def test_deploy_create_new_agent_retries_quick_access_when_agent_not_yet_visible(
- self,
- temp_project_dir,
- sample_package_info,
- sample_deploy_target,
- ):
- """测试首次部署后 GetAgent 短暂 404 时,会短退避重试而不是立即打印警告。"""
- provider = ServerlessProvider()
-
- mock_client = AsyncMock()
- mock_client.create_agent = AsyncMock(
- return_value={
- "agent_id": "ar-20260119-newagent",
- "name": "test-agent",
- "endpoint": "http://stale.example.com",
- "api_key": None,
- "order_id": "ord-123",
- }
- )
- mock_client.get_agent = AsyncMock(
- side_effect=[
- Exception(
- 'HTTP 404 POST http://aicp.inner.api.ksyun.com/?Action=GetAgent&Version=2024-06-12: '
- '{"Code":404,"Message":"未找到对应的 Agent","RequestId":"req-1","Data":null}'
- ),
- {
- "basic": {
- "agent_id": "ar-20260119-newagent",
- "name": "test-agent",
- },
- "quick_access": {
- "public_endpoint": "https://fresh.example.com",
- "api_key": "ak-fresh-key",
- },
- },
- ]
- )
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock()
-
- with patch.dict(os.environ, {"AGENTENGINE_SERVER_URL": "http://localhost:8080"}), \
- patch("ksadk.deployment.providers.serverless.AgentEngineClient", return_value=mock_client), \
- patch("ksadk.deployment.agent_access.asyncio.sleep", new=AsyncMock()) as mock_sleep, \
- patch("ksadk.deployment.providers.serverless.logger.warning") as mock_warning, \
- patch("ksadk.common.auth.AWSV4Auth") as MockAuth:
-
- MockAuth.return_value.access_key = "test-ak"
- MockAuth.return_value.secret_key = "test-sk"
-
- await provider.deploy(sample_package_info, sample_deploy_target)
-
- state_file = temp_project_dir / ".agentengine.state"
- state = yaml.safe_load(state_file.read_text())
- assert state["endpoint"] == "https://fresh.example.com"
- assert state["api_key"] == "ak-fresh-key"
- assert mock_client.get_agent.await_count == 2
- mock_sleep.assert_awaited_once_with(0.3)
- mock_warning.assert_not_called()
-
- @pytest.mark.asyncio
- async def test_deploy_update_existing_agent(
- self,
- temp_project_dir,
- sample_package_info,
- sample_deploy_target
- ):
- """测试二次部署 - 更新已有 Agent"""
- provider = ServerlessProvider()
-
- # 预先创建状态文件
- state_file = temp_project_dir / ".agentengine.state"
- state_file.write_text(yaml.dump({
- "agent_id": "ar-20260119-existing",
- "name": "test-agent",
- "endpoint": "https://existing.kspmas.ksyun.com"
- }))
-
- # 模拟 AgentEngineClient
- mock_client = AsyncMock()
- mock_client.update_agent = AsyncMock(return_value={
- "agent_id": "ar-20260119-existing",
- "name": "test-agent",
- "endpoint": "https://existing.kspmas.ksyun.com"
- })
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock()
-
- with patch.dict(os.environ, {"AGENTENGINE_SERVER_URL": "http://localhost:8080"}), \
- patch('ksadk.deployment.providers.serverless.AgentEngineClient', return_value=mock_client), \
- patch('ksadk.common.auth.AWSV4Auth') as MockAuth:
-
- MockAuth.return_value.access_key = "test-ak"
- MockAuth.return_value.secret_key = "test-sk"
-
- result = await provider.deploy(sample_package_info, sample_deploy_target)
-
- assert result.status == DeployStatus.DEPLOYING
- assert "已更新" in result.message
-
- # 验证调用了 update_agent 而不是 create_agent
- mock_client.update_agent.assert_called_once()
- mock_client.create_agent.assert_not_called()
-
- @pytest.mark.asyncio
- async def test_deploy_uses_project_yaml_ui_bundle_path(
- self,
- temp_project_dir,
- sample_package_info,
- sample_deploy_target,
- ):
- provider = ServerlessProvider()
-
- (temp_project_dir / "agentengine.yaml").write_text(
- yaml.dump(
- {
- "name": "test-agent",
- "framework": "langgraph",
- "ui_profile": "custom",
- "ui_path": "/luoluo",
- "ui_bundle_path": "frontend/dist",
- }
- )
- )
- (temp_project_dir / ".agentengine.state").write_text(
- yaml.dump(
- {
- "agent_id": "ar-20260119-existing",
- "name": "test-agent",
- "endpoint": "https://existing.kspmas.ksyun.com",
- }
- )
- )
-
- captured = {}
- mock_client = AsyncMock()
- mock_client.get_agent = AsyncMock(
- return_value={"basic": {"agent_id": "ar-20260119-existing", "name": "test-agent"}}
- )
-
- async def _fake_update_agent(agent_id, payload):
- captured["payload"] = payload
- return {
- "agent_id": agent_id,
- "name": "test-agent",
- "endpoint": "https://existing.kspmas.ksyun.com",
- }
-
- mock_client.update_agent = AsyncMock(side_effect=_fake_update_agent)
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock()
-
- with patch.dict(os.environ, {"AGENTENGINE_SERVER_URL": "http://localhost:8080"}), \
- patch('ksadk.deployment.providers.serverless.AgentEngineClient', return_value=mock_client), \
- patch('ksadk.common.auth.AWSV4Auth') as MockAuth:
-
- MockAuth.return_value.access_key = "test-ak"
- MockAuth.return_value.secret_key = "test-sk"
-
- await provider.deploy(sample_package_info, sample_deploy_target)
-
- env_vars = captured["payload"]["env_vars"]
- assert env_vars["KSADK_UI_PROFILE"] == "custom"
- assert env_vars["KSADK_UI_PATH"] == "/luoluo"
- assert env_vars["KSADK_UI_BUNDLE_PATH"] == "frontend/dist"
-
- @pytest.mark.asyncio
- async def test_deploy_update_existing_agent_refreshes_quick_access_in_state(
- self,
- temp_project_dir,
- sample_package_info,
- sample_deploy_target,
- ):
- """测试热更新后会把最新 quick access endpoint/api_key 回填到本地状态。"""
- provider = ServerlessProvider()
-
- state_file = temp_project_dir / ".agentengine.state"
- state_file.write_text(
- yaml.dump(
- {
- "agent_id": "ar-20260119-existing",
- "name": "test-agent",
- "endpoint": "http://stale.example.com",
- "api_key": None,
- }
- )
- )
-
- mock_client = AsyncMock()
- mock_client.get_agent = AsyncMock(
- side_effect=[
- {
- "basic": {
- "agent_id": "ar-20260119-existing",
- "name": "test-agent",
- }
- },
- {
- "basic": {
- "agent_id": "ar-20260119-existing",
- "name": "test-agent",
- },
- "quick_access": {
- "public_endpoint": "https://fresh.example.com",
- "api_key": "ak-fresh-key",
- },
- },
- ]
- )
- mock_client.update_agent = AsyncMock(
- return_value={
- "agent_id": "ar-20260119-existing",
- "name": "test-agent",
- "endpoint": "http://stale.example.com",
- }
- )
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock()
-
- with patch.dict(os.environ, {"AGENTENGINE_SERVER_URL": "http://localhost:8080"}), \
- patch("ksadk.deployment.providers.serverless.AgentEngineClient", return_value=mock_client), \
- patch("ksadk.common.auth.AWSV4Auth") as MockAuth:
-
- MockAuth.return_value.access_key = "test-ak"
- MockAuth.return_value.secret_key = "test-sk"
-
- await provider.deploy(sample_package_info, sample_deploy_target)
-
- state = yaml.safe_load(state_file.read_text())
- assert state["endpoint"] == "https://fresh.example.com"
- assert state["api_key"] == "ak-fresh-key"
-
- @pytest.mark.asyncio
- async def test_deploy_rejects_ks3_path_without_object_key(
- self,
- temp_project_dir,
- sample_deploy_target,
- ):
- """测试当 ks3_path 只有 bucket 没有 object key 时,本地直接报错。"""
- provider = ServerlessProvider()
- bad_package_info = PackageInfo(
- name="test-agent",
- framework="langgraph",
- build_dir=str(temp_project_dir / ".agentengine" / "build"),
- project_dir=str(temp_project_dir),
- metadata={
- "ks3_path": "ks3://test-bucket"
- },
- )
-
- with pytest.raises(ValueError, match="ks3_path 格式无效"):
- await provider.deploy(bad_package_info, sample_deploy_target)
-
- @pytest.mark.asyncio
- async def test_deploy_persists_ui_config_to_state(
- self,
- temp_project_dir,
- sample_package_info,
- sample_deploy_target,
- ):
- """测试部署后会持久化 UI 配置,供 dashboard 无参打开使用。"""
- provider = ServerlessProvider()
- sample_deploy_target.extra.update(
- {
- "ui_profile": "langchain",
- "ui_path": "/",
- "ui_url": None,
- }
- )
-
- captured = {}
- mock_client = AsyncMock()
- async def _fake_create_agent(payload):
- captured["payload"] = payload
- return {
- "agent_id": "ar-20260119-newagent-ui",
- "name": "test-agent",
- "endpoint": "https://test.kspmas.ksyun.com",
- "api_key": "ak-test-key",
- }
-
- mock_client.create_agent = AsyncMock(side_effect=_fake_create_agent)
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock()
-
- with patch.dict(os.environ, {"AGENTENGINE_SERVER_URL": "http://localhost:8080"}), patch(
- "ksadk.deployment.providers.serverless.AgentEngineClient", return_value=mock_client
- ), patch("ksadk.common.auth.AWSV4Auth") as MockAuth:
- MockAuth.return_value.access_key = "test-ak"
- MockAuth.return_value.secret_key = "test-sk"
-
- await provider.deploy(sample_package_info, sample_deploy_target)
-
- state_file = temp_project_dir / ".agentengine.state"
- state = yaml.safe_load(state_file.read_text())
- assert state["ui_profile"] == "langchain"
- assert state["ui_path"] == "/"
- assert captured["payload"]["ui_config"] == {
- "profile": "langchain",
- "path": "/",
- "url": None,
- }
-
- @pytest.mark.asyncio
- async def test_deploy_update_forwards_ui_config_to_control_plane(
- self,
- temp_project_dir,
- sample_package_info,
- sample_deploy_target,
- ):
- provider = ServerlessProvider()
- sample_deploy_target.extra.update(
- {
- "ui_profile": "custom",
- "ui_path": "/chat",
- "ui_url": "https://ui.example.com/custom-ui/",
- }
- )
-
- state_file = temp_project_dir / ".agentengine.state"
- state_file.write_text(
- yaml.dump(
- {
- "agent_id": "ar-20260119-existing",
- "name": "test-agent",
- "endpoint": "https://existing.kspmas.ksyun.com",
- }
- )
- )
-
- captured = {}
- mock_client = AsyncMock()
- mock_client.get_agent = AsyncMock(
- side_effect=[
- {"basic": {"agent_id": "ar-20260119-existing", "name": "test-agent"}},
- {"basic": {"agent_id": "ar-20260119-existing", "name": "test-agent"}},
- ]
- )
-
- async def _fake_update_agent(agent_id, payload):
- captured["agent_id"] = agent_id
- captured["payload"] = payload
- return {
- "agent_id": agent_id,
- "name": "test-agent",
- "endpoint": "https://existing.kspmas.ksyun.com",
- }
-
- mock_client.update_agent = AsyncMock(side_effect=_fake_update_agent)
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock()
-
- with patch.dict(os.environ, {"AGENTENGINE_SERVER_URL": "http://localhost:8080"}), \
- patch("ksadk.deployment.providers.serverless.AgentEngineClient", return_value=mock_client), \
- patch("ksadk.common.auth.AWSV4Auth") as MockAuth:
-
- MockAuth.return_value.access_key = "test-ak"
- MockAuth.return_value.secret_key = "test-sk"
-
- await provider.deploy(sample_package_info, sample_deploy_target)
-
- assert captured["agent_id"] == "ar-20260119-existing"
- assert captured["payload"]["ui_config"] == {
- "profile": "custom",
- "path": "/",
- "url": "https://ui.example.com/custom-ui/",
- }
-
- @pytest.mark.asyncio
- async def test_deploy_update_injects_custom_ui_runtime_env(
- self,
- temp_project_dir,
- sample_package_info,
- sample_deploy_target,
- ):
- provider = ServerlessProvider()
- (temp_project_dir / ".agentengine.state").write_text(
- yaml.dump(
- {
- "agent_id": "ar-20260119-existing",
- "name": "test-agent",
- "endpoint": "https://existing.kspmas.ksyun.com",
- "ui_profile": "custom",
- "ui_path": "/",
- "ui_bundle_path": "research-ui/dist",
- }
- )
- )
-
- captured = {}
- mock_client = AsyncMock()
- mock_client.get_agent = AsyncMock(
- side_effect=[
- {"basic": {"agent_id": "ar-20260119-existing", "name": "test-agent"}},
- {"basic": {"agent_id": "ar-20260119-existing", "name": "test-agent"}},
- ]
- )
-
- async def _fake_update_agent(agent_id, payload):
- captured["payload"] = payload
- return {
- "agent_id": agent_id,
- "name": "test-agent",
- "endpoint": "https://existing.kspmas.ksyun.com",
- }
-
- mock_client.update_agent = AsyncMock(side_effect=_fake_update_agent)
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock()
-
- with patch.dict(os.environ, {"AGENTENGINE_SERVER_URL": "http://localhost:8080"}), \
- patch("ksadk.deployment.providers.serverless.AgentEngineClient", return_value=mock_client), \
- patch("ksadk.common.auth.AWSV4Auth") as MockAuth:
-
- MockAuth.return_value.access_key_id = "test-ak"
- MockAuth.return_value.secret_access_key = "test-sk"
-
- await provider.deploy(sample_package_info, sample_deploy_target)
-
- env_vars = captured["payload"]["env_vars"]
- assert env_vars["KSADK_UI_PROFILE"] == "custom"
- assert env_vars["KSADK_UI_PATH"] == "/"
- assert env_vars["KSADK_UI_BUNDLE_PATH"] == "research-ui/dist"
-
- @pytest.mark.asyncio
- async def test_deploy_strips_bom_from_env_keys(
- self,
- temp_project_dir,
- sample_package_info,
- sample_deploy_target,
- ):
- """测试 .env 带 BOM 时,环境变量 key 会被规范化。"""
- provider = ServerlessProvider()
- env_file = temp_project_dir / ".env"
- env_file.write_text(
- "OPENAI_API_KEY=test-key\nOPENAI_MODEL_NAME=test-model\n",
- encoding="utf-8-sig",
- )
-
- captured = {}
- mock_client = AsyncMock()
-
- async def _fake_create_agent(payload):
- captured["payload"] = payload
- return {
- "agent_id": "ar-20260119-bom",
- "name": "test-agent",
- "endpoint": "https://test.kspmas.ksyun.com",
- "api_key": "ak-test-key",
- }
-
- mock_client.create_agent = AsyncMock(side_effect=_fake_create_agent)
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock()
-
- with patch.dict(os.environ, {"AGENTENGINE_SERVER_URL": "http://localhost:8080"}), \
- patch("ksadk.deployment.providers.serverless.AgentEngineClient", return_value=mock_client), \
- patch("ksadk.common.auth.AWSV4Auth") as MockAuth:
-
- MockAuth.return_value.access_key_id = "test-ak"
- MockAuth.return_value.secret_access_key = "test-sk"
-
- await provider.deploy(sample_package_info, sample_deploy_target)
-
- env_vars = captured["payload"]["env_vars"]
- assert "OPENAI_API_KEY" in env_vars
- assert "\ufeffOPENAI_API_KEY" not in env_vars
- assert env_vars["OPENAI_MODEL_NAME"] == "test-model"
-
- @pytest.mark.asyncio
- async def test_deploy_merges_global_env_with_project_env(
- self,
- temp_project_dir,
- sample_package_info,
- sample_deploy_target,
- ):
- """部署环境变量使用全局配置 + 项目 .env,且项目 .env 优先。"""
- provider = ServerlessProvider()
- (temp_project_dir / ".env").write_text(
- "OPENAI_API_KEY=project-key\nPROJECT_ONLY=project-value\n",
- encoding="utf-8",
- )
-
- captured = {}
- mock_client = AsyncMock()
-
- async def _fake_create_agent(payload):
- captured["payload"] = payload
- return {
- "agent_id": "ar-20260119-env",
- "name": "test-agent",
- "endpoint": "https://test.kspmas.ksyun.com",
- "api_key": "ak-test-key",
- }
-
- mock_client.create_agent = AsyncMock(side_effect=_fake_create_agent)
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock()
-
- with patch.dict(os.environ, {"AGENTENGINE_SERVER_URL": "http://localhost:8080"}), \
- patch(
- "ksadk.deployment.providers.serverless.get_env_from_global_config",
- return_value={
- "OPENAI_API_KEY": "global-key",
- "OPENAI_BASE_URL": "https://model.example.com/v1",
- },
- ), \
- patch("ksadk.deployment.providers.serverless.AgentEngineClient", return_value=mock_client), \
- patch("ksadk.common.auth.AWSV4Auth") as MockAuth:
-
- MockAuth.return_value.access_key_id = "test-ak"
- MockAuth.return_value.secret_access_key = "test-sk"
-
- await provider.deploy(sample_package_info, sample_deploy_target)
-
- env_vars = captured["payload"]["env_vars"]
- assert env_vars["OPENAI_API_KEY"] == "project-key"
- assert env_vars["OPENAI_BASE_URL"] == "https://model.example.com/v1"
- assert env_vars["PROJECT_ONLY"] == "project-value"
-
- def test_deploy_env_vars_precedence_and_process_env_allowlist(
- self,
- temp_project_dir,
- ):
- """环境变量优先级: 全局配置 < allowlist shell env < 项目 .env < 显式 env。"""
- provider = ServerlessProvider()
- (temp_project_dir / ".env").write_text(
- "OPENAI_API_KEY=project-key\nPROJECT_ONLY=project-value\n",
- encoding="utf-8",
- )
-
- with patch.dict(
- os.environ,
- {
- "A": "B",
- "OPENAI_API_KEY": "shell-key",
- "KSADK_BUILD_ENABLE_MCP": "true",
- "KSADK_CUSTOM_RUNTIME_FLAG": "from-shell",
- "KSADK_SANDBOX_TEMPLATE_ID": "tmpl-shell",
- },
- clear=True,
- ), patch(
- "ksadk.deployment.providers.serverless.get_env_from_global_config",
- return_value={
- "OPENAI_API_KEY": "global-key",
- "OPENAI_BASE_URL": "https://model.example.com/v1",
- },
- ):
- env_vars, _, _ = provider._load_deploy_env_vars(
- temp_project_dir,
- {
- "OPENAI_API_KEY": "explicit-key",
- "CUSTOM_RUNTIME_FLAG": "enabled",
- },
- )
-
- assert env_vars["OPENAI_API_KEY"] == "explicit-key"
- assert env_vars["OPENAI_BASE_URL"] == "https://model.example.com/v1"
- assert env_vars["PROJECT_ONLY"] == "project-value"
- assert env_vars["KSADK_CUSTOM_RUNTIME_FLAG"] == "from-shell"
- assert env_vars["KSADK_SANDBOX_TEMPLATE_ID"] == "tmpl-shell"
- assert env_vars["CUSTOM_RUNTIME_FLAG"] == "enabled"
- assert "A" not in env_vars
- assert "KSADK_BUILD_ENABLE_MCP" not in env_vars
-
- def test_deploy_env_vars_default_timezone_to_shanghai(
- self,
- temp_project_dir,
- ):
- provider = ServerlessProvider()
- (temp_project_dir / ".env").write_text("OPENAI_API_KEY=project-key\n", encoding="utf-8")
-
- with patch.dict(os.environ, {}, clear=True), patch(
- "ksadk.deployment.providers.serverless.get_env_from_global_config",
- return_value={},
- ):
- env_vars, _, _ = provider._load_deploy_env_vars(temp_project_dir)
-
- assert env_vars["TZ"] == "Asia/Shanghai"
-
- def test_deploy_env_vars_preserve_explicit_timezone(
- self,
- temp_project_dir,
- ):
- provider = ServerlessProvider()
- (temp_project_dir / ".env").write_text("TZ=UTC\n", encoding="utf-8")
-
- with patch.dict(os.environ, {"TZ": "Asia/Shanghai"}, clear=True), patch(
- "ksadk.deployment.providers.serverless.get_env_from_global_config",
- return_value={},
- ):
- env_vars, _, _ = provider._load_deploy_env_vars(
- temp_project_dir,
- {"CUSTOM_RUNTIME_FLAG": "enabled"},
- )
-
- assert env_vars["TZ"] == "UTC"
-
- def test_deploy_project_env_overrides_process_env_allowlist(
- self,
- temp_project_dir,
- ):
- provider = ServerlessProvider()
- (temp_project_dir / ".env").write_text(
- "OPENAI_API_KEY=project-key\nOPENAI_MODEL_NAME=project-model\n",
- encoding="utf-8",
- )
-
- with patch.dict(
- os.environ,
- {
- "OPENAI_API_KEY": "shell-key",
- "OPENAI_MODEL_NAME": "shell-model",
- },
- clear=True,
- ), patch(
- "ksadk.deployment.providers.serverless.get_env_from_global_config",
- return_value={"OPENAI_API_KEY": "global-key"},
- ):
- env_vars, _, _ = provider._load_deploy_env_vars(temp_project_dir)
-
- assert env_vars["OPENAI_API_KEY"] == "project-key"
- assert env_vars["OPENAI_MODEL_NAME"] == "project-model"
-
- @pytest.mark.asyncio
- async def test_deploy_forwards_network_configuration_to_create_agent(
- self,
- temp_project_dir,
- sample_package_info,
- sample_deploy_target,
- ):
- """测试 serverless deploy 会把网络配置透传给 CreateAgent。"""
- provider = ServerlessProvider()
- sample_deploy_target.network.enable_public_access = False
- sample_deploy_target.network.enable_vpc_access = True
- sample_deploy_target.network.vpc_id = "vpc-demo"
- sample_deploy_target.network.subnet_id = "subnet-demo"
- sample_deploy_target.network.security_group_id = "sg-demo"
- sample_deploy_target.network.availability_zone = "cn-beijing-6a"
-
- captured = {}
- mock_client = AsyncMock()
-
- async def _fake_create_agent(payload):
- captured["payload"] = payload
- return {
- "agent_id": "ar-20260119-network",
- "name": "test-agent",
- "endpoint": "https://test.kspmas.ksyun.com",
- "api_key": "ak-test-key",
- }
-
- mock_client.create_agent = AsyncMock(side_effect=_fake_create_agent)
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock()
-
- with patch.dict(os.environ, {"AGENTENGINE_SERVER_URL": "http://localhost:8080"}), \
- patch("ksadk.deployment.providers.serverless.AgentEngineClient", return_value=mock_client), \
- patch("ksadk.common.auth.AWSV4Auth") as MockAuth:
-
- MockAuth.return_value.access_key_id = "test-ak"
- MockAuth.return_value.secret_access_key = "test-sk"
-
- await provider.deploy(sample_package_info, sample_deploy_target)
-
- assert captured["payload"]["network"] == {
- "enable_public_access": False,
- "enable_vpc_access": True,
- "vpc_id": "vpc-demo",
- "subnet_id": "subnet-demo",
- "security_group_id": "sg-demo",
- "availability_zone": "cn-beijing-6a",
- }
-
- @pytest.mark.asyncio
- async def test_deploy_forwards_storage_configuration_to_create_agent(
- self,
- temp_project_dir,
- sample_package_info,
- sample_deploy_target,
- ):
- """测试 serverless deploy 会把存储配置透传给 CreateAgent。"""
- provider = ServerlessProvider()
- sample_deploy_target.storage.mount_path = "/home/node/.agentengine"
- sample_deploy_target.storage.size_gi = 64
-
- captured = {}
- mock_client = AsyncMock()
-
- async def _fake_create_agent(payload):
- captured["payload"] = payload
- return {
- "agent_id": "ar-20260119-storage",
- "name": "test-agent",
- "endpoint": "https://test.kspmas.ksyun.com",
- "api_key": "ak-test-key",
- }
-
- mock_client.create_agent = AsyncMock(side_effect=_fake_create_agent)
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock()
-
- with patch.dict(os.environ, {"AGENTENGINE_SERVER_URL": "http://localhost:8080"}), \
- patch("ksadk.deployment.providers.serverless.AgentEngineClient", return_value=mock_client), \
- patch("ksadk.common.auth.AWSV4Auth") as MockAuth:
-
- MockAuth.return_value.access_key_id = "test-ak"
- MockAuth.return_value.secret_access_key = "test-sk"
-
- await provider.deploy(sample_package_info, sample_deploy_target)
-
- assert captured["payload"]["storage"] == {
- "mount_path": "/home/node/.agentengine",
- "size_gi": 64,
- }
-
- @pytest.mark.asyncio
- async def test_build_persists_ks3_path_metadata_for_followup_cache(
- self,
- temp_project_dir,
- ):
- """测试 provider.build 后会持久化 ks3_path,供后续 deploy/launch 命中缓存。"""
- provider = ServerlessProvider()
- package_info = PackageInfo(
- name="test-agent",
- framework="langgraph",
- build_dir=str(temp_project_dir / ".agentengine" / "build"),
- project_dir=str(temp_project_dir),
- metadata={},
- )
- target = DeployTarget(
- provider="serverless",
- region="cn-beijing-6",
- extra={"artifact_type": "Code", "no_cache": False},
- )
-
- fake_build_result = BuildResult(
- success=True,
- artifact_path=temp_project_dir / ".agentengine" / "code_build" / "test-agent.zip",
- artifact_size=1234,
- metadata={"agent_name": "test-agent", "framework": "langgraph"},
- )
- mock_builder = MagicMock()
- mock_builder.build.return_value = fake_build_result
-
- mock_uploader = AsyncMock()
- mock_uploader.upload = AsyncMock(return_value="ks3://test-bucket/agents/test-agent/code_20260320180000.zip")
-
- with patch("ksadk.deployment.providers.serverless.CodeBuilder", return_value=mock_builder), \
- patch("ksadk.deployment.providers.serverless.KS3Uploader", return_value=mock_uploader):
- result = await provider.build(package_info, target)
-
- metadata_file = temp_project_dir / ".agentengine" / "build-metadata.json"
- assert metadata_file.exists()
- metadata = json.loads(metadata_file.read_text(encoding="utf-8"))
- assert metadata["metadata"]["ks3_path"] == result.metadata["ks3_path"]
-
- class _PackageDetectionType:
- value = "langgraph"
-
- class _PackageDetectionResult:
- name = "test-agent"
- type = _PackageDetectionType()
- entry_point = "agent.py"
-
- packaged_again = await provider.package(
- str(temp_project_dir),
- _PackageDetectionResult(),
- {},
- )
- assert packaged_again.metadata["ks3_path"] == result.metadata["ks3_path"]
-
- @pytest.mark.asyncio
- async def test_deploy_converts_ks3_path_to_internal_url_for_serverless_runtime_pull(
- self,
- temp_project_dir,
- monkeypatch,
- ):
- provider = ServerlessProvider()
- package_info = PackageInfo(
- name="test-agent",
- framework="langgraph",
- build_dir=str(temp_project_dir / ".agentengine" / "build"),
- project_dir=str(temp_project_dir),
- metadata={"ks3_path": "ks3://test-bucket/agents/test-agent/code.zip"},
- )
- target = DeployTarget(
- provider="serverless",
- region="cn-beijing-6",
- extra={"artifact_type": "Code"},
- )
- captured = {}
-
- mock_client = AsyncMock()
-
- async def _fake_create_agent(data):
- captured.update(data)
- return {
- "agent_id": "ar-test",
- "name": "test-agent",
- "endpoint": "https://test.kspmas.ksyun.com",
- "api_key": "ak-test-key",
- }
-
- mock_client.create_agent = AsyncMock(side_effect=_fake_create_agent)
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock()
- monkeypatch.setenv("KS3_ENDPOINT_MODE", "public")
-
- with patch.dict(os.environ, {"AGENTENGINE_SERVER_URL": "http://localhost:8080"}), \
- patch("ksadk.deployment.providers.serverless.AgentEngineClient", return_value=mock_client), \
- patch("ksadk.common.auth.AWSV4Auth") as MockAuth:
-
- MockAuth.return_value.access_key_id = "test-ak"
- MockAuth.return_value.secret_access_key = "test-sk"
-
- await provider.deploy(package_info, target)
-
- assert captured["artifact_path"] == (
- "http://test-bucket.ks3-cn-beijing-internal.ksyuncs.com/agents/test-agent/code.zip"
- )
-
- @pytest.mark.asyncio
- async def test_container_build_uses_cached_image_without_rebuild(
- self,
- temp_project_dir,
- ):
- """测试 container 模式存在 cached image 时,不会重复 build。"""
- provider = ServerlessProvider()
- package_info = PackageInfo(
- name="test-agent",
- framework="langgraph",
- build_dir=str(temp_project_dir / ".agentengine" / "build"),
- project_dir=str(temp_project_dir),
- metadata={"image": "hub.kce.ksyun.com/agentengine/test-agent:cached"},
- )
- target = DeployTarget(
- provider="serverless",
- region="cn-beijing-6",
- extra={"artifact_type": "Container", "no_cache": False},
- )
-
- with patch("ksadk.deployment.providers.serverless.ContainerBuilder") as MockBuilder:
- result = await provider.build(package_info, target)
-
- assert result.image == "hub.kce.ksyun.com/agentengine/test-agent:cached"
- MockBuilder.assert_not_called()
-
-
-# ============================================================================
-# State File Not Uploaded Tests
-# ============================================================================
-
-class TestStateFileNotUploaded:
- """验证状态文件不会被上传"""
-
- def test_state_file_excluded_from_package(self, temp_project_dir):
- """测试状态文件在打包时被排除"""
- # 创建状态文件
- state_file = temp_project_dir / ".agentengine.state"
- state_file.write_text("agent_id: test")
-
- # 模拟打包逻辑 (检查 code_builder.py 中的排除规则)
- excluded_items = []
-
- for item in temp_project_dir.iterdir():
- if item.name.startswith('.'):
- if item.name != '.env':
- excluded_items.append(item.name)
-
- assert ".agentengine.state" in excluded_items
diff --git a/tests/test_error_utils_hints.py b/tests/test_error_utils_hints.py
deleted file mode 100644
index c0d1ce33..00000000
--- a/tests/test_error_utils_hints.py
+++ /dev/null
@@ -1,143 +0,0 @@
-from pathlib import Path
-
-from ksadk.api.client import AgentEngineAPIError
-from ksadk.cli.error_utils import explain_exception
-
-
-SNAPSHOT_FILE = Path(__file__).parent / "snapshots" / "error_hint_snapshots.txt"
-
-
-def load_section_snapshots(path: Path) -> dict[str, str]:
- sections: dict[str, str] = {}
- current_name: str | None = None
- current_lines: list[str] = []
-
- for line in path.read_text(encoding="utf-8").splitlines():
- if line.startswith("=== ") and line.endswith(" ==="):
- if current_name is not None:
- sections[current_name] = "\n".join(current_lines).rstrip() + "\n"
- current_name = line[4:-4]
- current_lines = []
- continue
- current_lines.append(line)
-
- if current_name is not None:
- sections[current_name] = "\n".join(current_lines).rstrip() + "\n"
-
- return sections
-
-
-def test_dashboard_not_found_hint_points_to_canonical_open():
- err = Exception("Server API Error (Code: 404): Agent not found")
-
- _, hints = explain_exception(err, argv=["dashboard"])
-
- assert any("agentengine agent list" in hint for hint in hints)
- assert any("agentengine dashboard open --agent" in hint for hint in hints)
-
-
-def test_dashboard_list_hint_points_to_share_list():
- err = Exception("Server API Error (Code: 404): Agent not found")
-
- _, hints = explain_exception(err, argv=["dashboard", "list"])
-
- assert any("dashboard list" in hint for hint in hints)
- assert any("dashboard share list" in hint for hint in hints)
-
-
-def test_error_hint_snapshots_match_canonical_hints():
- snapshots = load_section_snapshots(SNAPSHOT_FILE)
- cases = {
- "dashboard_not_found": (
- Exception("Server API Error (Code: 404): Agent not found"),
- ["dashboard"],
- ),
- "dashboard_list_not_found": (
- Exception("Server API Error (Code: 404): Agent not found"),
- ["dashboard", "list"],
- ),
- "dashboard_share_not_found": (
- Exception("Server API Error (Code: 404): Agent not found"),
- ["dashboard", "share", "list"],
- ),
- "mcp_not_found": (
- Exception("Server API Error (Code: 404): MCP not found"),
- ["mcp", "status"],
- ),
- "openclaw_not_found": (
- Exception("Server API Error (Code: 404): OpenClaw not found"),
- ["openclaw", "status"],
- ),
- "version_not_found": (
- Exception("Server API Error (Code: 404): Version not found"),
- ["version", "list"],
- ),
- "auth_failed": (
- Exception("Server API Error (Code: 401): unauthorized"),
- ["mcp", "status"],
- ),
- "missing_aksk": (
- AgentEngineAPIError(
- 400,
- "Access Key is Missing",
- details={
- "http_status": 400,
- "remote_error_code": "MissingAccesskey",
- "remote_error_message": "Access Key is Missing",
- "request_id": "req-missing-ak",
- },
- ),
- ["hermes", "status"],
- ),
- "invalid_aksk": (
- AgentEngineAPIError(
- 403,
- "The Access Key Id you provided does not exist",
- details={
- "http_status": 403,
- "remote_error_code": "InvalidAccessKey",
- "remote_error_message": "The Access Key Id you provided does not exist",
- "request_id": "req-invalid-ak",
- },
- ),
- ["agent", "status"],
- ),
- "missing_runtime_permission": (
- AgentEngineAPIError(
- 403,
- "当前账号没有 KsyunAgentEngineDefaultRole 权限",
- details={
- "http_status": 403,
- "remote_error_code": "AccessDenied",
- "remote_error_message": "当前账号没有 KsyunAgentEngineDefaultRole 权限",
- "request_id": "req-no-role",
- },
- ),
- ["openclaw", "status"],
- ),
- }
-
- for name, (err, argv) in cases.items():
- summary, hints = explain_exception(err, argv=argv)
- actual = "\n".join([summary, *[f"- {hint}" for hint in hints]]).rstrip() + "\n"
- assert actual == snapshots[name]
-
-
-def test_missing_aksk_hint_points_to_credential_and_permission_docs():
- err = AgentEngineAPIError(
- 400,
- "Access Key is Missing",
- details={
- "http_status": 400,
- "remote_error_code": "MissingAccesskey",
- "remote_error_message": "Access Key is Missing",
- },
- )
-
- summary, hints = explain_exception(err, argv=["hermes", "status"])
-
- assert "AK/SK" in summary
- assert any("KSYUN_ACCESS_KEY" in hint for hint in hints)
- assert any("agentEngineRuntime" in hint for hint in hints)
- assert any("/permission/authorize" in hint for hint in hints)
- assert any("/pro/iam/" in hint for hint in hints)
diff --git a/tests/test_help_snapshots.py b/tests/test_help_snapshots.py
deleted file mode 100644
index e1601bc6..00000000
--- a/tests/test_help_snapshots.py
+++ /dev/null
@@ -1,144 +0,0 @@
-from __future__ import annotations
-
-import re
-from pathlib import Path
-
-from click.testing import CliRunner
-from click.utils import strip_ansi
-from rich.cells import cell_len
-
-import ksadk.cli as cli_module
-from ksadk.cli import ROOT_HELP_COMMANDS, SHORT_HELP_MAP, _register_commands, cli
-
-SNAPSHOT_FILE = Path(__file__).parent / "snapshots" / "help_snapshots.txt"
-COLORED_ROOT_HELP_ROWS = {
- "agentengine init": "初始化项目",
- "agentengine run": "运行 API Server",
- "agentengine web": "本地调试 Agent Invoke UI",
- "agentengine build": "构建部署制品",
- "agentengine deploy": "部署到云端",
- "agentengine launch": "一键构建+部署",
- "agentengine agent": "Agent 资源管理",
- "agentengine dashboard": "打开云端 Agent Dashboard",
- "agentengine hermes": "Hermes Agent 资源管理",
- "agentengine openclaw": "OpenClaw 资源管理",
- "agentengine config": "项目配置向导与模型配置",
- "--output": "输出格式(pretty/json)",
- "--no-color": "禁用颜色输出",
- "--version": "显示版本号",
- "-h, --help": "显示帮助信息",
-}
-
-
-def load_section_snapshots(path: Path) -> dict[str, str]:
- sections: dict[str, str] = {}
- current_name: str | None = None
- current_lines: list[str] = []
-
- for line in path.read_text(encoding="utf-8").splitlines():
- if line.startswith("=== ") and line.endswith(" ==="):
- if current_name is not None:
- sections[current_name] = "\n".join(current_lines).rstrip() + "\n"
- current_name = line[4:-4]
- current_lines = []
- continue
- current_lines.append(line)
-
- if current_name is not None:
- sections[current_name] = "\n".join(current_lines).rstrip() + "\n"
-
- return sections
-
-
-def _normalize_help(text: str) -> str:
- text = re.sub(r"v\d+\.\d+\.\d+(?:[-+][^\s]+)?", "vX.Y.Z", text)
- return text.rstrip() + "\n"
-
-
-def test_help_snapshots_match_canonical_cli_surface():
- _register_commands()
- runner = CliRunner()
- snapshots = load_section_snapshots(SNAPSHOT_FILE)
-
- commands = {
- "root_help": ["--help"],
- "a2a_help": ["a2a", "--help"],
- "a2a_serve_help": ["a2a", "serve", "--help"],
- "a2a_card_help": ["a2a", "card", "--help"],
- "agent_help": ["agent", "--help"],
- "dashboard_help": ["dashboard", "--help"],
- "dashboard_open_help": ["dashboard", "open", "--help"],
- "hermes_help": ["hermes", "--help"],
- "mcp_help": ["mcp", "--help"],
- "mcp_build_help": ["mcp", "build", "--help"],
- "openclaw_help": ["openclaw", "--help"],
- "version_help": ["version", "--help"],
- "config_help": ["config", "--help"],
- "config_wizard_help": ["config", "wizard", "--help"],
- "config_show_help": ["config", "show", "--help"],
- "config_set_help": ["config", "set", "--help"],
- "config_model_help": ["config", "model", "--help"],
- "completion_help": ["completion", "--help"],
- "model_alias_help": ["model", "--help"],
- "status_alias_help": ["status", "--help"],
- }
-
- for name, argv in commands.items():
- result = runner.invoke(cli, argv)
- assert result.exit_code == 0, result.output
- assert _normalize_help(result.output) == snapshots[name]
-
-
-def test_colored_root_help_command_columns_align_with_unicode_icons(monkeypatch):
- _register_commands()
- monkeypatch.setattr(cli_module, "should_render_banner", lambda: True)
-
- result = CliRunner().invoke(cli, ["--help"], color=True)
-
- assert result.exit_code == 0, result.output
-
- command_lines: list[str] = []
- in_commands = False
- for line in strip_ansi(result.output).splitlines():
- if "可用命令:" in line:
- in_commands = True
- continue
- if in_commands and line.startswith(" ") and line.strip():
- command_lines.append(line)
-
- assert len(command_lines) == len(ROOT_HELP_COMMANDS)
-
- command_offsets: set[int] = set()
- description_offsets: set[int] = set()
- for line in command_lines:
- command_name = next(
- name for name in ROOT_HELP_COMMANDS if re.search(rf"\b{re.escape(name)}\b", line)
- )
- description = SHORT_HELP_MAP[command_name]
-
- command_offsets.add(cell_len(line[: line.index(command_name)]))
- description_offsets.add(cell_len(line[: line.index(description)]))
-
- assert command_offsets == {10}
- assert description_offsets == {34}
-
-
-def test_colored_root_help_overview_rows_share_description_column(monkeypatch):
- _register_commands()
- monkeypatch.setattr(cli_module, "should_render_banner", lambda: True)
-
- result = CliRunner().invoke(cli, ["--help"], color=True)
-
- assert result.exit_code == 0, result.output
-
- lines = strip_ansi(result.output).splitlines()
- description_offsets: dict[str, int] = {}
- for label, description in COLORED_ROOT_HELP_ROWS.items():
- line = next(
- line
- for line in lines
- if line.startswith(" ") and label in line and description in line
- )
- description_offsets[label] = cell_len(line[: line.index(description)])
-
- assert set(description_offsets.values()) == {34}
diff --git a/tests/test_hermes_container_builder.py b/tests/test_hermes_container_builder.py
deleted file mode 100644
index d4a74591..00000000
--- a/tests/test_hermes_container_builder.py
+++ /dev/null
@@ -1,133 +0,0 @@
-import os
-import subprocess
-import sys
-from pathlib import Path
-
-from ksadk.builders.container_builder import ContainerBuilder
-from ksadk.detection import DetectionResult, FrameworkDetector, FrameworkType
-
-
-def test_container_builder_preserves_hermes_template_dockerfile(tmp_path: Path):
- project = tmp_path / "demo-hermes"
- project.mkdir()
- (project / "runtime").mkdir()
- (project / "runtime" / "app.py").write_text("app = object()\n", encoding="utf-8")
- (project / "entrypoint.sh").write_text("#!/usr/bin/env bash\nexec true\n", encoding="utf-8")
- (project / "Dockerfile").write_text("FROM python:3.12-slim\nCMD [\"/app/entrypoint.sh\"]\n", encoding="utf-8")
- (project / "agentengine.yaml").write_text(
- "name: demo_hermes\nframework: hermes\nartifact_type: Container\n",
- encoding="utf-8",
- )
-
- detection = FrameworkDetector(str(project)).detect()
- assert detection.type.value == "hermes"
- assert detection.entry_point == "runtime/app.py"
-
- package = ContainerBuilder(project)._package(detection)
-
- build_dir = Path(package.build_dir)
- assert (build_dir / "Dockerfile").read_text(encoding="utf-8") == "FROM python:3.12-slim\nCMD [\"/app/entrypoint.sh\"]\n"
- assert (build_dir / "entrypoint.sh").exists()
- assert not (build_dir / "entrypoint.py").exists()
-
-
-def test_container_builder_bundles_runtime_common_for_image_mode(tmp_path: Path):
- project = tmp_path / "demo-langgraph"
- project.mkdir()
- package_dir = project / "demo_langgraph"
- package_dir.mkdir()
- (package_dir / "__init__.py").write_text("", encoding="utf-8")
- (package_dir / "agent.py").write_text("root_agent = object()\n", encoding="utf-8")
-
- detection = DetectionResult(
- type=FrameworkType.LANGGRAPH,
- name="demo-langgraph",
- entry_point="demo_langgraph/agent.py",
- package_path=str(package_dir),
- agent_variable="root_agent",
- confidence=1.0,
- )
-
- package = ContainerBuilder(project)._package(detection)
-
- build_dir = Path(package.build_dir)
- assert (build_dir / "ksadk" / "server" / "app.py").exists()
- assert (build_dir / "ksadk_runtime_common" / "workspace_files" / "__init__.py").exists()
-
- env = os.environ.copy()
- env["PYTHONPATH"] = str(build_dir)
- result = subprocess.run(
- [
- sys.executable,
- "-c",
- "import ksadk.server.app; import ksadk_runtime_common; print('ok')",
- ],
- cwd=tmp_path,
- env=env,
- text=True,
- capture_output=True,
- timeout=30,
- )
-
- assert result.returncode == 0, result.stderr
- assert result.stdout.strip() == "ok"
-
-
-def test_container_builder_packages_project_custom_ui_dist_without_node_modules(tmp_path: Path):
- project = tmp_path / "demo-langgraph"
- project.mkdir()
- package_dir = project / "demo_langgraph"
- package_dir.mkdir()
- (package_dir / "__init__.py").write_text("", encoding="utf-8")
- (package_dir / "agent.py").write_text("root_agent = object()\n", encoding="utf-8")
- custom_dist = project / "research-ui" / "dist" / "assets"
- custom_dist.mkdir(parents=True)
- (project / "research-ui" / "dist" / "index.html").write_text("Custom UI", encoding="utf-8")
- (custom_dist / "index.js").write_text("console.log('custom ui')\n", encoding="utf-8")
- (project / "research-ui" / "node_modules").mkdir(parents=True)
- (project / "research-ui" / "node_modules" / "ignored.js").write_text("ignored\n", encoding="utf-8")
-
- detection = DetectionResult(
- type=FrameworkType.LANGGRAPH,
- name="demo-langgraph",
- entry_point="demo_langgraph/agent.py",
- package_path=str(package_dir),
- agent_variable="root_agent",
- confidence=1.0,
- )
-
- package = ContainerBuilder(project)._package(detection)
- build_dir = Path(package.build_dir)
-
- assert (build_dir / "research-ui" / "dist" / "index.html").exists()
- assert (build_dir / "research-ui" / "dist" / "assets" / "index.js").exists()
- assert not (build_dir / "research-ui" / "node_modules" / "ignored.js").exists()
- assert 'from ksadk.server import app, set_runner' in (build_dir / "entrypoint.py").read_text(encoding="utf-8")
-
-
-def test_container_builder_excludes_real_dotenv_files_but_keeps_example(tmp_path: Path):
- project = tmp_path / "demo-langgraph"
- project.mkdir()
- package_dir = project / "demo_langgraph"
- package_dir.mkdir()
- (package_dir / "__init__.py").write_text("", encoding="utf-8")
- (package_dir / "agent.py").write_text("root_agent = object()\n", encoding="utf-8")
- (project / ".env").write_text("OPENAI_API_KEY=secret\n", encoding="utf-8")
- (project / ".env.local").write_text("LOCAL_SECRET=secret\n", encoding="utf-8")
- (project / ".env.example").write_text("OPENAI_API_KEY=\n", encoding="utf-8")
-
- detection = DetectionResult(
- type=FrameworkType.LANGGRAPH,
- name="demo-langgraph",
- entry_point="demo_langgraph/agent.py",
- package_path=str(package_dir),
- agent_variable="root_agent",
- confidence=1.0,
- )
-
- package = ContainerBuilder(project)._package(detection)
-
- build_dir = Path(package.build_dir)
- assert not (build_dir / ".env").exists()
- assert not (build_dir / ".env.local").exists()
- assert (build_dir / ".env.example").exists()
diff --git a/tests/test_hermes_terminal.py b/tests/test_hermes_terminal.py
deleted file mode 100644
index fe848dd8..00000000
--- a/tests/test_hermes_terminal.py
+++ /dev/null
@@ -1,510 +0,0 @@
-import asyncio
-import contextlib
-import io
-import json
-import os
-import sys
-from types import SimpleNamespace
-
-import pytest
-
-import ksadk.hermes_terminal as hermes_terminal
-from ksadk.hermes_terminal import (
- TERMINAL_SUBPROTOCOL,
- _recv_loop,
- _send_control,
- _stdin_loop,
- build_start_frame,
- build_terminal_ws_url,
- run_hermes_terminal_session,
- validate_hermes_exec_argv,
- validate_hermes_pairing_argv,
-)
-from ksadk.terminal_client import run_terminal_session
-from ksadk.terminal_exec_policy import (
- OPENCLAW_TERMINAL_EXEC_POLICY,
-)
-from ksadk.terminal_exec_policy import (
- validate_terminal_exec_argv as validate_exec_argv_with_policy,
-)
-
-
-def test_build_terminal_ws_url_uses_terminal_path_and_ws_scheme():
- assert (
- build_terminal_ws_url("https://agent.example.com/runtime/")
- == "wss://agent.example.com/runtime/_ksadk/terminal/ws"
- )
- assert build_terminal_ws_url("http://agent.example.com") == "ws://agent.example.com/_ksadk/terminal/ws"
-
-
-def test_build_start_frame_encodes_protocol_contract():
- payload = json.loads(build_start_frame(mode="exec", argv=["status"], cols=120, rows=40))
-
- assert payload == {
- "type": "start",
- "mode": "exec",
- "argv": ["status"],
- "cols": 120,
- "rows": 40,
- }
-
-
-def test_build_start_frame_supports_pairing_mode():
- payload = json.loads(build_start_frame(mode="pairing", argv=["list"], cols=120, rows=40))
-
- assert payload["mode"] == "pairing"
- assert payload["argv"] == ["list"]
-
-
-def test_build_start_frame_supports_connect_mode():
- payload = json.loads(build_start_frame(mode="connect", argv=[], cols=120, rows=40))
-
- assert payload["mode"] == "connect"
- assert payload["argv"] == []
-
-
-def test_build_start_frame_supports_workspace_cwd():
- payload = json.loads(build_start_frame(mode="tui", argv=[], cols=120, rows=40, cwd="demo-workspace"))
-
- assert payload["mode"] == "tui"
- assert payload["cwd"] == "demo-workspace"
-
-
-def test_build_start_frame_supports_whitelisted_terminal_options():
- payload = json.loads(
- build_start_frame(
- mode="tui",
- argv=[],
- cols=120,
- rows=40,
- options={
- "message": "你好",
- "thinking": "medium",
- "history_limit": 50,
- "timeout_ms": 30000,
- "deliver": True,
- },
- )
- )
-
- assert payload["mode"] == "tui"
- assert payload["options"] == {
- "message": "你好",
- "thinking": "medium",
- "history_limit": 50,
- "timeout_ms": 30000,
- "deliver": True,
- }
-
-
-@pytest.mark.parametrize(
- "argv",
- [
- ["status"],
- ["doctor"],
- ["version"],
- ["sessions", "list"],
- ["sessions", "show", "session-1"],
- ["sessions", "export", "session-1"],
- ["config", "show"],
- ["config", "check"],
- ["skills", "list"],
- ["skills", "audit"],
- ["tools", "list"],
- ["insights"],
- ["cron", "list"],
- ["cron", "status"],
- ["gateway", "status"],
- ],
-)
-def test_validate_hermes_exec_argv_accepts_read_only_subcommands(argv):
- assert validate_hermes_exec_argv(argv) == argv
-
-
-@pytest.mark.parametrize(
- "argv",
- [
- [],
- ["setup"],
- ["auth"],
- ["update"],
- ["install"],
- ["uninstall"],
- ["gateway", "start"],
- ["gateway", "restart"],
- ["cron", "add"],
- ["cron", "remove"],
- ["pairing"],
- ["skills", "install"],
- ["doctor", "--fix"],
- ["config", "query"],
- ["config", "query", "model.context_length"],
- ["status;rm", "-rf"],
- ["sessions", "list", "|", "cat"],
- ],
-)
-def test_validate_hermes_exec_argv_rejects_mutating_or_shell_like_commands(argv):
- with pytest.raises(ValueError):
- validate_hermes_exec_argv(argv)
-
-
-def test_validate_hermes_exec_argv_accepts_env_allowlisted_prefix(monkeypatch):
- monkeypatch.setenv("KSADK_TERMINAL_EXEC_SUBCOMMAND_ALLOWLIST", "config")
-
- assert validate_hermes_exec_argv(["config", "set", "memory.provider", "hindsight"]) == [
- "config",
- "set",
- "memory.provider",
- "hindsight",
- ]
-
-
-def test_validate_hermes_exec_argv_env_allowlist_still_rejects_shell_metacharacters(monkeypatch):
- monkeypatch.setenv("KSADK_TERMINAL_EXEC_SUBCOMMAND_ALLOWLIST", "config set")
-
- with pytest.raises(ValueError):
- validate_hermes_exec_argv(["config", "set", "memory.provider", "hindsight;rm"])
-
-
-def test_validate_terminal_exec_argv_defaults_to_common_commands(monkeypatch):
- monkeypatch.delenv("KSADK_TERMINAL_EXEC_SUBCOMMAND_ALLOWLIST", raising=False)
-
- assert hermes_terminal.validate_terminal_exec_argv(["ls", "-la"]) == ["ls", "-la"]
- assert hermes_terminal.validate_terminal_exec_argv(["git", "status", "--short"]) == [
- "git",
- "status",
- "--short",
- ]
- with pytest.raises(ValueError):
- hermes_terminal.validate_terminal_exec_argv(["openclaw", "config", "set", "memory.provider", "hindsight"])
-
-
-def test_validate_terminal_exec_argv_rejection_mentions_allowlist_env(monkeypatch):
- monkeypatch.delenv("KSADK_TERMINAL_EXEC_SUBCOMMAND_ALLOWLIST", raising=False)
-
- with pytest.raises(ValueError) as exc_info:
- hermes_terminal.validate_terminal_exec_argv(["openclaw", "config", "set"])
-
- message = str(exc_info.value)
- assert "KSADK_TERMINAL_EXEC_SUBCOMMAND_ALLOWLIST='openclaw'" in message
- assert "KSADK_TERMINAL_EXEC_SUBCOMMAND_ALLOWLIST='*'" in message
-
-
-def test_validate_terminal_exec_argv_accepts_common_env_allowlisted_prefix(monkeypatch):
- monkeypatch.setenv("KSADK_TERMINAL_EXEC_SUBCOMMAND_ALLOWLIST", "openclaw config")
-
- assert hermes_terminal.validate_terminal_exec_argv(
- ["openclaw", "config", "set", "memory.provider", "hindsight"]
- ) == ["openclaw", "config", "set", "memory.provider", "hindsight"]
-
-
-def test_validate_terminal_exec_argv_accepts_wildcard_env_allowlist(monkeypatch):
- monkeypatch.setenv("KSADK_TERMINAL_EXEC_SUBCOMMAND_ALLOWLIST", "*")
-
- assert hermes_terminal.validate_terminal_exec_argv(["python", "-c", "print('ok')"]) == [
- "python",
- "-c",
- "print('ok')",
- ]
-
-
-def test_openclaw_exec_policy_allows_remote_cli_fallback_by_default(monkeypatch):
- monkeypatch.delenv("KSADK_TERMINAL_EXEC_SUBCOMMAND_ALLOWLIST", raising=False)
-
- assert validate_exec_argv_with_policy(
- ["openclaw", "channels", "login", "--channel", "openclaw-weixin"],
- policy=OPENCLAW_TERMINAL_EXEC_POLICY,
- ) == ["openclaw", "channels", "login", "--channel", "openclaw-weixin"]
-
-
-@pytest.mark.parametrize(
- "argv",
- [
- ["list"],
- ["approve", "feishu", "ABC123"],
- ["approve", "weixin", "XYZ789"],
- ["approve", "wpsxiezuo", "WPS123"],
- ["revoke", "feishu", "user-1"],
- ["revoke", "wpsxiezuo", "user-1"],
- ["clear-pending"],
- ],
-)
-def test_validate_hermes_pairing_argv_accepts_safe_pairing_commands(argv):
- assert validate_hermes_pairing_argv(argv) == argv
-
-
-@pytest.mark.parametrize(
- "argv",
- [
- [],
- ["approve"],
- ["approve", "unknown-platform", "ABC123"],
- ["approve", "feishu", "ABC123", "extra"],
- ["revoke", "unknown", "user-1"],
- ["clear-pending", "now"],
- ["list", "--json"],
- ["approve", "feishu", "A;B"],
- ["pairing", "list"],
- ],
-)
-def test_validate_hermes_pairing_argv_rejects_unsafe_or_unsupported_commands(argv):
- with pytest.raises(ValueError):
- validate_hermes_pairing_argv(argv)
-
-
-def test_terminal_session_helpers_are_importable_without_real_tty():
- assert SimpleNamespace is not None
-
-
-class _FakeReceiveWebSocket:
- def __init__(self, messages):
- self._messages = list(messages)
-
- def __aiter__(self):
- return self
-
- async def __anext__(self):
- if not self._messages:
- raise StopAsyncIteration
- return self._messages.pop(0)
-
-
-class _FakeSendWebSocket:
- def __init__(self):
- self.sent = []
-
- async def send(self, payload):
- self.sent.append(payload)
-
-
-class _FakeTerminalConnection:
- def __init__(self, ws):
- self.ws = ws
-
- async def __aenter__(self):
- return self.ws
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
-
-class _FakeTerminalWebSocket(_FakeSendWebSocket):
- subprotocol = TERMINAL_SUBPROTOCOL
-
- def __init__(self):
- super().__init__()
- self._messages = [
- json.dumps({"type": "ready"}),
- json.dumps({"type": "exit", "code": 0}),
- ]
-
- def __aiter__(self):
- return self
-
- async def __anext__(self):
- if not self._messages:
- raise StopAsyncIteration
- return self._messages.pop(0)
-
-
-class _NonTtyDefaultStdin:
- def isatty(self):
- return False
-
- def fileno(self): # pragma: no cover - should not be reached
- raise AssertionError("default non-tty stdin should not be read for exec/pairing")
-
-
-class _FakeKernel32:
- def __init__(self, mode: int):
- self.mode = mode
- self.handles = []
- self.set_modes = []
-
- def GetConsoleMode(self, handle, mode_ptr):
- self.handles.append(handle)
- mode_ptr._obj.value = self.mode
- return 1
-
- def SetConsoleMode(self, handle, mode):
- self.set_modes.append((handle, mode))
- return 1
-
-
-class _FakeWindowsStdin:
- def __init__(self, fd: int = 11):
- self._fd = fd
-
- def isatty(self):
- return True
-
- def fileno(self):
- return self._fd
-
-
-@pytest.mark.asyncio
-async def test_recv_loop_writes_binary_output_and_returns_exit_code():
- ws = _FakeReceiveWebSocket([b"hello", json.dumps({"type": "ready"}), json.dumps({"type": "exit", "code": 7})])
- stdout = io.BytesIO()
-
- exit_code = await _recv_loop(ws, stdout)
-
- assert exit_code == 7
- assert stdout.getvalue() == b"hello"
-
-
-@pytest.mark.asyncio
-async def test_send_control_encodes_text_control_frame():
- ws = _FakeSendWebSocket()
-
- await _send_control(ws, {"type": "resize", "cols": 100, "rows": 30})
- await _send_control(ws, {"type": "signal", "signal": "SIGINT"})
-
- assert [json.loads(item) for item in ws.sent] == [
- {"type": "resize", "cols": 100, "rows": 30},
- {"type": "signal", "signal": "SIGINT"},
- ]
-
-
-@pytest.mark.asyncio
-async def test_stdin_loop_sends_binary_stdin_and_eof_control_frame():
- read_fd, write_fd = os.pipe()
- os.write(write_fd, b"abc")
- os.close(write_fd)
- reader = os.fdopen(read_fd, "rb", closefd=True)
- ws = _FakeSendWebSocket()
-
- try:
- await _stdin_loop(ws, reader)
- finally:
- reader.close()
-
- assert ws.sent[0] == b"abc"
- assert json.loads(ws.sent[1]) == {"type": "stdin_eof"}
-
-
-@pytest.mark.asyncio
-async def test_terminal_session_cancels_blocked_stdin_after_remote_exit(monkeypatch):
- read_fd, write_fd = os.pipe()
- reader = os.fdopen(read_fd, "rb", closefd=True)
- fake_ws = _FakeTerminalWebSocket()
-
- async def _fake_connect(*_args, **_kwargs):
- return _FakeTerminalConnection(fake_ws)
-
- monkeypatch.setattr("ksadk.hermes_terminal._connect_websocket", _fake_connect)
-
- try:
- exit_code = await asyncio.wait_for(
- run_hermes_terminal_session(
- endpoint="https://agent.example.com",
- mode="tui",
- stdin=reader,
- stdout=io.BytesIO(),
- ),
- timeout=1,
- )
- finally:
- os.close(write_fd)
- reader.close()
-
- assert exit_code == 0
- assert json.loads(fake_ws.sent[0])["mode"] == "tui"
-
-
-@pytest.mark.asyncio
-async def test_exec_session_does_not_read_default_non_tty_stdin(monkeypatch):
- fake_ws = _FakeTerminalWebSocket()
-
- async def _fake_connect(*_args, **_kwargs):
- return _FakeTerminalConnection(fake_ws)
-
- monkeypatch.setattr("ksadk.hermes_terminal._connect_websocket", _fake_connect)
- monkeypatch.setattr(sys, "stdin", _NonTtyDefaultStdin())
-
- exit_code = await run_hermes_terminal_session(
- endpoint="https://agent.example.com",
- mode="exec",
- argv=["status"],
- stdout=io.BytesIO(),
- )
-
- assert exit_code == 0
- assert json.loads(fake_ws.sent[0])["mode"] == "exec"
- assert json.loads(fake_ws.sent[1]) == {"type": "stdin_eof"}
-
-
-def test_windows_raw_terminal_enables_console_raw_mode_and_restores(monkeypatch):
- stdin = _FakeWindowsStdin()
- fake_kernel32 = _FakeKernel32(mode=0x00FF)
- fake_msvcrt = SimpleNamespace(get_osfhandle=lambda fd: fd + 1000)
-
- with hermes_terminal._windows_raw_terminal(
- stdin,
- kernel32=fake_kernel32,
- msvcrt_module=fake_msvcrt,
- ):
- pass
-
- assert fake_kernel32.handles == [1011]
- assert fake_kernel32.set_modes[0] == (1011, 0x02B8)
- assert fake_kernel32.set_modes[1] == (1011, 0x00FF)
-
-
-@pytest.mark.asyncio
-async def test_hermes_terminal_session_uses_windows_raw_terminal_on_windows(monkeypatch):
- fake_ws = _FakeTerminalWebSocket()
- fake_stdin = _FakeWindowsStdin()
- entered = []
-
- @contextlib.contextmanager
- def _fake_windows_raw_terminal(stdin, **_kwargs):
- entered.append(stdin)
- yield
-
- async def _fake_connect(*_args, **_kwargs):
- return _FakeTerminalConnection(fake_ws)
-
- monkeypatch.setattr("ksadk.hermes_terminal._connect_websocket", _fake_connect)
- monkeypatch.setattr(hermes_terminal.sys, "platform", "win32")
- monkeypatch.setattr(hermes_terminal, "_windows_raw_terminal", _fake_windows_raw_terminal)
- monkeypatch.setattr(hermes_terminal, "_read_stdin_chunk", lambda _fd: asyncio.sleep(0, result=b""))
-
- exit_code = await run_hermes_terminal_session(
- endpoint="https://agent.example.com",
- mode="exec",
- argv=["status"],
- stdin=fake_stdin,
- stdout=io.BytesIO(),
- )
-
- assert exit_code == 0
- assert entered == [fake_stdin]
-
-
-@pytest.mark.asyncio
-async def test_terminal_exec_with_openclaw_policy_allows_openclaw_cli_argv(monkeypatch):
- fake_ws = _FakeTerminalWebSocket()
-
- async def _fake_connect(*_args, **_kwargs):
- return _FakeTerminalConnection(fake_ws)
-
- monkeypatch.setattr("ksadk.hermes_terminal._connect_websocket", _fake_connect)
- monkeypatch.setattr(sys, "stdin", _NonTtyDefaultStdin())
-
- exit_code = await run_terminal_session(
- endpoint="https://agent.example.com",
- mode="exec",
- argv=["openclaw", "channels", "login", "--channel", "openclaw-weixin"],
- exec_policy=OPENCLAW_TERMINAL_EXEC_POLICY,
- stdout=io.BytesIO(),
- )
-
- assert exit_code == 0
- assert json.loads(fake_ws.sent[0]) == {
- "type": "start",
- "mode": "exec",
- "argv": ["openclaw", "channels", "login", "--channel", "openclaw-weixin"],
- "cols": 80,
- "rows": 24,
- }
diff --git a/tests/test_hermes_terminal_e2e.py b/tests/test_hermes_terminal_e2e.py
deleted file mode 100644
index 833263c1..00000000
--- a/tests/test_hermes_terminal_e2e.py
+++ /dev/null
@@ -1,157 +0,0 @@
-import io
-import json
-import os
-
-import pytest
-import websockets
-
-from ksadk.hermes_terminal import TERMINAL_SUBPROTOCOL, run_hermes_terminal_session
-
-
-@pytest.mark.asyncio
-async def test_terminal_session_real_websocket_exec_round_trip():
- observed = {}
-
- async def _handler(ws):
- observed["subprotocol"] = ws.subprotocol
- observed["start"] = json.loads(await ws.recv())
- await ws.send(json.dumps({"type": "ready"}))
- observed["stdin"] = await ws.recv()
- observed["eof"] = json.loads(await ws.recv())
- await ws.send(b"status ok\n")
- await ws.send(json.dumps({"type": "exit", "code": 0}))
-
- server = await websockets.serve(
- _handler,
- "127.0.0.1",
- 0,
- subprotocols=[TERMINAL_SUBPROTOCOL],
- )
- port = server.sockets[0].getsockname()[1]
- read_fd, write_fd = os.pipe()
- os.write(write_fd, b"abc")
- os.close(write_fd)
- stdin = os.fdopen(read_fd, "rb", closefd=True)
- stdout = io.BytesIO()
-
- try:
- exit_code = await run_hermes_terminal_session(
- endpoint=f"http://127.0.0.1:{port}",
- mode="exec",
- argv=["status"],
- stdin=stdin,
- stdout=stdout,
- )
- finally:
- stdin.close()
- server.close()
- await server.wait_closed()
-
- assert exit_code == 0
- assert observed["subprotocol"] == TERMINAL_SUBPROTOCOL
- assert observed["start"]["mode"] == "exec"
- assert observed["start"]["argv"] == ["status"]
- assert observed["stdin"] == b"abc"
- assert observed["eof"] == {"type": "stdin_eof"}
- assert stdout.getvalue() == b"status ok\n"
-
-
-@pytest.mark.asyncio
-async def test_terminal_session_real_websocket_pairing_start_frame():
- observed = {}
-
- async def _handler(ws):
- observed["start"] = json.loads(await ws.recv())
- await ws.send(json.dumps({"type": "ready"}))
- await ws.send(json.dumps({"type": "exit", "code": 0}))
-
- server = await websockets.serve(
- _handler,
- "127.0.0.1",
- 0,
- subprotocols=[TERMINAL_SUBPROTOCOL],
- )
- port = server.sockets[0].getsockname()[1]
-
- try:
- exit_code = await run_hermes_terminal_session(
- endpoint=f"http://127.0.0.1:{port}",
- mode="pairing",
- argv=["list"],
- stdin=io.BytesIO(),
- stdout=io.BytesIO(),
- )
- finally:
- server.close()
- await server.wait_closed()
-
- assert exit_code == 0
- assert observed["start"]["mode"] == "pairing"
- assert observed["start"]["argv"] == ["list"]
-
-
-@pytest.mark.asyncio
-async def test_terminal_session_real_websocket_connect_start_frame():
- observed = {}
-
- async def _handler(ws):
- observed["start"] = json.loads(await ws.recv())
- await ws.send(json.dumps({"type": "ready"}))
- await ws.send(json.dumps({"type": "exit", "code": 0}))
-
- server = await websockets.serve(
- _handler,
- "127.0.0.1",
- 0,
- subprotocols=[TERMINAL_SUBPROTOCOL],
- )
- port = server.sockets[0].getsockname()[1]
-
- try:
- exit_code = await run_hermes_terminal_session(
- endpoint=f"http://127.0.0.1:{port}",
- mode="connect",
- stdin=io.BytesIO(),
- stdout=io.BytesIO(),
- )
- finally:
- server.close()
- await server.wait_closed()
-
- assert exit_code == 0
- assert observed["start"]["mode"] == "connect"
- assert observed["start"]["argv"] == []
-
-
-@pytest.mark.asyncio
-async def test_terminal_session_real_websocket_tui_start_frame_carries_cwd():
- observed = {}
-
- async def _handler(ws):
- observed["start"] = json.loads(await ws.recv())
- await ws.send(json.dumps({"type": "ready"}))
- await ws.send(json.dumps({"type": "exit", "code": 0}))
-
- server = await websockets.serve(
- _handler,
- "127.0.0.1",
- 0,
- subprotocols=[TERMINAL_SUBPROTOCOL],
- )
- port = server.sockets[0].getsockname()[1]
-
- try:
- exit_code = await run_hermes_terminal_session(
- endpoint=f"http://127.0.0.1:{port}",
- mode="tui",
- cwd="demo-workspace",
- stdin=io.BytesIO(),
- stdout=io.BytesIO(),
- )
- finally:
- server.close()
- await server.wait_closed()
-
- assert exit_code == 0
- assert observed["start"]["mode"] == "tui"
- assert observed["start"]["cwd"] == "demo-workspace"
diff --git a/tests/test_identity_resolver.py b/tests/test_identity_resolver.py
deleted file mode 100644
index a67c39f8..00000000
--- a/tests/test_identity_resolver.py
+++ /dev/null
@@ -1,295 +0,0 @@
-"""identity resolver 单测:AK/SK 反查 + 缓存 + 内网 fallback。"""
-
-from __future__ import annotations
-
-import json
-import os
-from pathlib import Path
-from unittest.mock import MagicMock, patch
-
-import pytest
-
-from ksadk.identity.resolver import (
- ResolvedIdentity,
- _ak_fingerprint,
- _extract_main_account_id_from_krn,
- _find_username_by_ak,
- _resolve_iam_endpoint,
- _should_retry_intranet,
- get_cached_identity,
- get_cached_user_uuid,
- invalidate_cache,
- resolve_identity,
-)
-
-
-# ---------------------------------------------------------------------------
-# 纯函数测试
-# ---------------------------------------------------------------------------
-
-
-def test_ak_fingerprint_stable_and_unique():
- fp1 = _ak_fingerprint("AKLTtest123")
- fp2 = _ak_fingerprint("AKLTtest123")
- fp3 = _ak_fingerprint("AKLTtest456")
- assert fp1 == fp2 # 稳定
- assert fp1 != fp3 # 不同 AK 不同指纹
- assert len(fp1) == 16
-
-
-@pytest.mark.parametrize(
- "krn,expected",
- [
- ("krn:ksc:iam::2000003485:user/xiayu", "2000003485"),
- ("krn:ksc:iam::73398439:user/w_test", "73398439"),
- ("not a krn", None),
- ("", None),
- (None, None),
- ("krn:ksc:iam:::user/x", None), # 空主账号 ID
- ],
-)
-def test_extract_main_account_id_from_krn(krn, expected):
- assert _extract_main_account_id_from_krn(krn) == expected
-
-
-def test_find_username_by_ak():
- keys = [
- {"AccessKey": "AKLTaaa", "UserName": "user1"},
- {"AccessKey": "AKLTbbb", "UserName": "user2"},
- ]
- assert _find_username_by_ak(keys, "AKLTbbb") == "user2"
- assert _find_username_by_ak(keys, "AKLTccc") is None # 不在列表(主账号 AK)
- assert _find_username_by_ak([], "AKLTaaa") is None
-
-
-def test_resolve_iam_endpoint_default():
- old = (os.environ.get("KSYUN_IAM_URL"), os.environ.get("IAM_URL"))
- os.environ.pop("KSYUN_IAM_URL", None)
- os.environ.pop("IAM_URL", None)
- try:
- assert _resolve_iam_endpoint() == ("iam.api.ksyun.com", "https")
- finally:
- for k, v in zip(("KSYUN_IAM_URL", "IAM_URL"), old):
- if v is not None:
- os.environ[k] = v
-
-
-def test_resolve_iam_endpoint_from_env():
- old = os.environ.get("KSYUN_IAM_URL")
- os.environ["KSYUN_IAM_URL"] = "http://iam.inner.api.ksyun.com"
- try:
- assert _resolve_iam_endpoint() == ("iam.inner.api.ksyun.com", "http")
- finally:
- if old is None:
- os.environ.pop("KSYUN_IAM_URL", None)
- else:
- os.environ["KSYUN_IAM_URL"] = old
-
-
-def test_should_retry_intranet():
- exc = Exception('{"Error":{"Code":"InnerAccountCanOnlyAccessThroughIntranet"}}')
- assert _should_retry_intranet(exc) is True
- assert _should_retry_intranet(Exception("other error")) is False
- assert _should_retry_intranet(None) is False
-
-
-# ---------------------------------------------------------------------------
-# 缓存测试(monkeypatch 缓存路径到 tmp_path)
-# ---------------------------------------------------------------------------
-
-
-@pytest.fixture
-def isolated_cache(monkeypatch, tmp_path):
- """把缓存读写重定向到 tmp_path,避免污染真实 settings.json。"""
- cache_file = tmp_path / "settings.json"
-
- def fake_load():
- if not cache_file.exists():
- return {}
- return json.loads(cache_file.read_text(encoding="utf-8"))
-
- def fake_save(config):
- cache_file.write_text(json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8")
-
- monkeypatch.setattr("ksadk.identity.resolver._load_identity_cache", lambda: fake_load().__getitem__("cloud").get("IDENTITY_CACHE", {}) if fake_load() else {})
- # 直接 patch _load/_save 更简单
- _cache = {}
-
- def load():
- return dict(_cache)
-
- def save(cache):
- _cache.clear()
- _cache.update(cache)
-
- monkeypatch.setattr("ksadk.identity.resolver._load_identity_cache", load)
- monkeypatch.setattr("ksadk.identity.resolver._save_identity_cache", save)
- return _cache
-
-
-def test_get_cached_identity_miss(isolated_cache):
- assert get_cached_identity("AKLTnone") is None
- assert get_cached_user_uuid("AKLTnone") is None
-
-
-def test_invalidate_cache(isolated_cache):
- # 写入一个条目
- fp = _ak_fingerprint("AKLTtest")
- isolated_cache[fp] = {"ak_fingerprint": fp, "user_uuid": "uuid-x"}
- # 清空
- invalidate_cache("AKLTtest")
- assert get_cached_user_uuid("AKLTtest") is None
- # 全清
- isolated_cache[fp] = {"ak_fingerprint": fp, "user_uuid": "uuid-x"}
- invalidate_cache(None)
- assert get_cached_user_uuid("AKLTtest") is None
-
-
-# ---------------------------------------------------------------------------
-# resolve_identity 集成测试(mock IAM SDK)
-# ---------------------------------------------------------------------------
-
-
-def _mock_sdk_parts():
- """构造 mock 的 sdk_parts 元组。"""
- return tuple(MagicMock() for _ in range(6))
-
-
-def test_resolve_identity_cache_hit_no_iam_call(isolated_cache, monkeypatch):
- """缓存命中时不调 IAM。"""
- fp = _ak_fingerprint("AKLTtest")
- isolated_cache[fp] = {
- "ak_fingerprint": fp,
- "user_uuid": "uuid-cached",
- "main_account_id": "2000003485",
- "user_name": "cached-user",
- "krn": "krn:ksc:iam::2000003485:user/cached-user",
- }
- called = MagicMock()
- monkeypatch.setattr("ksadk.identity.resolver._import_iam_sdk", lambda: called())
- r = resolve_identity(access_key="AKLTtest", secret_key="SKtest")
- assert called.call_count == 0 # 缓存命中,未调 IAM
- assert r is not None
- assert r.user_uuid == "uuid-cached"
- assert r.main_account_id == "2000003485"
-
-
-def test_resolve_identity_cache_miss_invokes_iam(isolated_cache, monkeypatch):
- """缓存 miss 时调 IAM 两步链路并写缓存。"""
- sdk_parts = _mock_sdk_parts()
- IamClient = sdk_parts[0]
- ListReq = sdk_parts[1]
- GetReq = sdk_parts[2]
-
- # mock client 实例
- client = MagicMock()
- IamClient.return_value = client
- client.ListAllUserAccessKeys.return_value = json.dumps(
- {"AccessKeyList": [{"AccessKey": "AKLTtest", "UserName": "xiayu"}]}
- )
- client.GetUser.return_value = json.dumps(
- {"GetUserResult": {"User": {"UserId": "uuid-new", "Krn": "krn:ksc:iam::2000003485:user/xiayu"}}}
- )
-
- monkeypatch.setattr("ksadk.identity.resolver._import_iam_sdk", lambda: sdk_parts)
-
- r = resolve_identity(access_key="AKLTtest", secret_key="SKtest")
- assert r is not None
- assert r.user_uuid == "uuid-new"
- assert r.main_account_id == "2000003485"
- assert r.user_name == "xiayu"
- # 验证写缓存
- fp = _ak_fingerprint("AKLTtest")
- assert fp in isolated_cache
- assert isolated_cache[fp]["user_uuid"] == "uuid-new"
-
-
-def test_resolve_identity_ak_not_in_list_returns_none(isolated_cache, monkeypatch):
- """AK 不在子用户列表(主账号 AK)返回 None。"""
- sdk_parts = _mock_sdk_parts()
- IamClient = sdk_parts[0]
- client = MagicMock()
- IamClient.return_value = client
- client.ListAllUserAccessKeys.return_value = json.dumps(
- {"AccessKeyList": [{"AccessKey": "OTHER_AK", "UserName": "other"}]}
- )
- monkeypatch.setattr("ksadk.identity.resolver._import_iam_sdk", lambda: sdk_parts)
-
- r = resolve_identity(access_key="AKLTtest", secret_key="SKtest")
- assert r is None
- client.GetUser.assert_not_called() # 没找到 AK 就不调 GetUser
-
-
-def test_resolve_identity_network_failure_returns_none(isolated_cache, monkeypatch):
- """IAM 调用异常返回 None 不抛。"""
- sdk_parts = _mock_sdk_parts()
- IamClient = sdk_parts[0]
- IamClient.side_effect = Exception("network error")
- monkeypatch.setattr("ksadk.identity.resolver._import_iam_sdk", lambda: sdk_parts)
-
- r = resolve_identity(access_key="AKLTtest", secret_key="SKtest")
- assert r is None
-
-
-def test_resolve_identity_intranet_fallback(isolated_cache, monkeypatch):
- """公网失败(InnerAccountCanOnlyAccessThroughIntranet)时 fallback 内网。"""
- sdk_parts = _mock_sdk_parts()
- IamClient = sdk_parts[0]
- client = MagicMock()
- IamClient.return_value = client
- # 第一次(公网)抛内网错误,第二次(内网)成功
- client.ListAllUserAccessKeys.side_effect = [
- Exception('{"Error":{"Code":"InnerAccountCanOnlyAccessThroughIntranet"}}'),
- json.dumps({"AccessKeyList": [{"AccessKey": "AKLTtest", "UserName": "inner-user"}]}),
- ]
- client.GetUser.return_value = json.dumps(
- {"GetUserResult": {"User": {"UserId": "uuid-inner", "Krn": "krn:ksc:iam::2000003485:user/inner-user"}}}
- )
- monkeypatch.setattr("ksadk.identity.resolver._import_iam_sdk", lambda: sdk_parts)
-
- r = resolve_identity(access_key="AKLTtest", secret_key="SKtest")
- assert r is not None
- assert r.user_uuid == "uuid-inner"
- # 验证调了两次(公网 + 内网)
- assert client.ListAllUserAccessKeys.call_count == 2
-
-
-def test_resolve_identity_no_credentials_returns_none(isolated_cache):
- assert resolve_identity(access_key="", secret_key="SK") is None
- assert resolve_identity(access_key="AK", secret_key="") is None
-
-
-def test_resolve_identity_force_refresh_bypasses_cache(isolated_cache, monkeypatch):
- """force_refresh=True 绕过缓存重新反查。"""
- fp = _ak_fingerprint("AKLTtest")
- isolated_cache[fp] = {"ak_fingerprint": fp, "user_uuid": "old-uuid"}
- sdk_parts = _mock_sdk_parts()
- IamClient = sdk_parts[0]
- client = MagicMock()
- IamClient.return_value = client
- client.ListAllUserAccessKeys.return_value = json.dumps(
- {"AccessKeyList": [{"AccessKey": "AKLTtest", "UserName": "u"}]}
- )
- client.GetUser.return_value = json.dumps(
- {"GetUserResult": {"User": {"UserId": "new-uuid", "Krn": "krn:ksc:iam::2000003485:user/u"}}}
- )
- monkeypatch.setattr("ksadk.identity.resolver._import_iam_sdk", lambda: sdk_parts)
-
- r = resolve_identity(access_key="AKLTtest", secret_key="SK", force_refresh=True)
- assert r is not None
- assert r.user_uuid == "new-uuid" # 用新值,不是缓存的 old-uuid
-
-
-def test_get_cached_identity_returns_full_identity(isolated_cache):
- fp = _ak_fingerprint("AKLTtest")
- isolated_cache[fp] = {
- "ak_fingerprint": fp,
- "user_uuid": "uuid-x",
- "main_account_id": "2000003485",
- "user_name": "u",
- "krn": "krn:ksc:iam::2000003485:user/u",
- }
- r = get_cached_identity("AKLTtest")
- assert r is not None
- assert r.user_uuid == "uuid-x"
- assert r.main_account_id == "2000003485"
diff --git a/tests/test_json_contracts.py b/tests/test_json_contracts.py
deleted file mode 100644
index b276816b..00000000
--- a/tests/test_json_contracts.py
+++ /dev/null
@@ -1,729 +0,0 @@
-from __future__ import annotations
-
-import json
-from pathlib import Path
-
-import yaml
-from click.testing import CliRunner
-
-from ksadk.api.client import DryRunExit
-from ksadk.cli import _register_commands, cli
-from ksadk.cli import cmd_dashboard, cmd_deploy, cmd_launch, cmd_mcp
-from ksadk.cli.cmd_build import build
-from ksadk.cli.cmd_mcp import mcp
-from ksadk.deployment.base import DeployResult, DeployStatus, PackageInfo
-from ksadk.builders.base import BuildResult
-
-
-def _parse_json(output: str) -> dict:
- return json.loads(output.strip())
-
-
-def test_config_show_json_envelope(tmp_path: Path, monkeypatch):
- _register_commands()
- runner = CliRunner()
- monkeypatch.chdir(tmp_path)
-
- (tmp_path / "agentengine.yaml").write_text(
- yaml.safe_dump({"name": "demo-agent", "framework": "langgraph", "region": "cn-beijing-6"}),
- encoding="utf-8",
- )
- (tmp_path / ".env").write_text("OPENAI_MODEL_NAME=demo-model\n", encoding="utf-8")
- monkeypatch.setattr(
- "ksadk.configs.global_config.load_global_config",
- lambda: {"cloud": {"KSYUN_REGION": "cn-guangzhou-1"}},
- )
- monkeypatch.setattr(
- "ksadk.configs.global_config.get_env_from_global_config",
- lambda: {"KSYUN_REGION": "cn-guangzhou-1"},
- )
- monkeypatch.setattr(
- "ksadk.configs.global_config.get_global_config_path",
- lambda: tmp_path / ".agentengine" / "settings.json",
- )
-
- result = runner.invoke(cli, ["--output", "json", "config", "show"])
-
- assert result.exit_code == 0, result.output
- payload = _parse_json(result.output)
- assert payload["ok"] is True
- assert payload["kind"] == "status"
- assert payload["resource"] == "config"
- assert payload["item"]["project_config"]["name"] == "demo-agent"
- assert payload["item"]["effective_env"]["OPENAI_MODEL_NAME"] == "demo-model"
- assert payload["item"]["effective_env"]["KSYUN_REGION"] == "cn-guangzhou-1"
-
-
-def test_config_set_json_envelope_and_file_updates(tmp_path: Path, monkeypatch):
- _register_commands()
- runner = CliRunner()
- monkeypatch.chdir(tmp_path)
-
- result = runner.invoke(
- cli,
- [
- "--output",
- "json",
- "config",
- "set",
- "region=cn-beijing-6",
- "OPENAI_MODEL_NAME=glm-5.1",
- ],
- )
-
- assert result.exit_code == 0, result.output
- payload = _parse_json(result.output)
- assert payload["ok"] is True
- assert payload["kind"] == "result"
- assert payload["resource"] == "config"
- assert payload["action"] == "set"
- assert sorted(payload["result"]["updated_project_keys"]) == ["region"]
- assert sorted(payload["result"]["updated_env_keys"]) == ["KSYUN_REGION", "OPENAI_MODEL_NAME"]
-
- project_config = yaml.safe_load((tmp_path / "agentengine.yaml").read_text(encoding="utf-8-sig"))
- env_text = (tmp_path / ".env").read_text(encoding="utf-8-sig")
- assert project_config["region"] == "cn-beijing-6"
- assert "OPENAI_MODEL_NAME=glm-5.1" in env_text
- assert "KSYUN_REGION=cn-beijing-6" in env_text
-
-
-def test_config_set_uppercase_env_var_updates_project_env(tmp_path: Path, monkeypatch):
- _register_commands()
- runner = CliRunner()
- monkeypatch.chdir(tmp_path)
-
- result = runner.invoke(
- cli,
- [
- "--output",
- "json",
- "config",
- "set",
- "AGENTENGINE_SERVER_URL=http://aicp.inner.api.ksyun.com",
- ],
- )
-
- assert result.exit_code == 0, result.output
- payload = _parse_json(result.output)
- assert payload["result"]["updated_project_keys"] == []
- assert payload["result"]["updated_env_keys"] == ["AGENTENGINE_SERVER_URL"]
-
- env_text = (tmp_path / ".env").read_text(encoding="utf-8-sig")
- assert "AGENTENGINE_SERVER_URL=http://aicp.inner.api.ksyun.com" in env_text
- assert not (tmp_path / "agentengine.yaml").exists()
-
-
-def test_dashboard_open_json_does_not_open_browser(monkeypatch):
- runner = CliRunner()
- opened_urls: list[str] = []
-
- async def _fake_resolve_agent_detail(*_args, **_kwargs):
- return (
- {
- "agent_id": "ar-demo",
- "name": "demo-agent",
- "framework": "langgraph",
- "endpoint": "https://agent.example.com",
- },
- type("Ref", (), {"source": "cli", "source_text": "CLI", "value": "ar-demo"})(),
- False,
- )
-
- async def _fake_create_dashboard_access_link(**_kwargs):
- return {
- "link_id": "lnk-demo",
- "expires_at": None,
- "access_url": "https://dashboard.example.com/share/lnk-demo",
- }
-
- monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve_agent_detail)
- monkeypatch.setattr(cmd_dashboard, "_create_dashboard_access_link", _fake_create_dashboard_access_link)
- monkeypatch.setattr(cmd_dashboard, "load_state", lambda _cwd: {})
- monkeypatch.setattr(cmd_dashboard.webbrowser, "open", lambda url: opened_urls.append(url))
-
- result = runner.invoke(cmd_dashboard.dashboard, ["open", "ar-demo", "--output", "json"])
-
- assert result.exit_code == 0, result.output
- payload = _parse_json(result.output)
- assert payload["ok"] is True
- assert payload["kind"] == "result"
- assert payload["resource"] == "dashboard_share"
- assert payload["action"] == "open"
- assert payload["result"]["url"] == "https://dashboard.example.com/share/lnk-demo"
- assert opened_urls == []
-
-
-def test_dashboard_share_revoke_json_requires_yes(monkeypatch):
- runner = CliRunner()
-
- async def _should_not_run(**_kwargs):
- raise AssertionError("delete should not be called without --yes")
-
- monkeypatch.setattr(cmd_dashboard, "_delete_dashboard_access_link", _should_not_run)
-
- result = runner.invoke(cmd_dashboard.dashboard, ["share", "revoke", "lnk-demo", "--output", "json"])
-
- assert result.exit_code == 2, result.output
- payload = _parse_json(result.output)
- assert payload["ok"] is False
- assert payload["error"]["code"] == "usage_error"
- assert "--yes" in payload["error"]["message"] or "--yes" in "".join(payload["hints"])
-
-
-class _FakeMCPDetectionResult:
- is_valid = True
- entry_point = "mcp_server.py"
- mcp_variable = "mcp"
- tools = ["search", "fetch"]
-
-
-class _FakeMCPDetector:
- def __init__(self, *_args, **_kwargs):
- pass
-
- def detect(self):
- return _FakeMCPDetectionResult()
-
-
-class _FakeMCPBuildResult:
- success = True
- artifact_path = Path("/tmp/demo-mcp.zip")
- error_message = ""
- metadata = {}
-
-
-async def _fake_build_code_artifact(*_args, **_kwargs):
- build_result = BuildResult(
- success=True,
- artifact_path=Path("/tmp/demo-mcp.zip"),
- artifact_size=1234,
- metadata={"framework": "mcp"},
- )
- return build_result, "ks3://demo-bucket/mcps/demo-mcp/code_20260322120000.zip"
-
-
-async def _fake_build_mcp_async(*_args, **_kwargs):
- return {
- "framework": "mcp",
- "artifact_type": "code",
- "artifact_reference": "ks3://demo-bucket/mcps/demo-mcp/code_fake.zip",
- "artifact_built": True,
- "artifact_source": "built",
- "artifact_reused": False,
- "push": True,
- }
-
-
-class _FakeMCPClient:
- def __init__(self, *args, **kwargs):
- self.kwargs = kwargs
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
- async def create_mcp(self, _request):
- return {
- "mcp_id": "mcp-demo",
- "endpoint": "https://mcp.example.com",
- "api_key": "secret",
- }
-
- async def update_mcp(self, _mcp_id, _request):
- raise AssertionError("update path should not be used in this test")
-
-
-class _FakeMCPDryRunClient:
- def __init__(self, *args, **kwargs):
- self.kwargs = kwargs
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
- async def create_mcp(self, request):
- raise DryRunExit("dry-run", payload={"body": request})
-
- async def close(self):
- return None
-
-
-def test_mcp_deploy_json_envelope(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr("ksadk.detection.mcp_detector.MCPDetector", _FakeMCPDetector)
- monkeypatch.setattr(cmd_mcp, "_build_code_artifact", _fake_build_code_artifact)
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeMCPClient)
-
- result = runner.invoke(mcp, ["deploy", str(tmp_path), "--output", "json"])
-
- assert result.exit_code == 0, result.output
- payload = _parse_json(result.output)
- assert payload["ok"] is True
- assert payload["kind"] == "result"
- assert payload["resource"] == "workflow"
- assert payload["action"] == "deploy"
- assert payload["result"]["artifact_type"] == "code"
- assert payload["result"]["artifact_reference"] == "ks3://demo-bucket/mcps/demo-mcp/code_20260322120000.zip"
- assert payload["result"]["mcp_id"] == "mcp-demo"
- assert payload["result"]["mcp_url"] == "https://mcp.example.com/mcp"
-
-
-def test_mcp_deploy_dry_run_json_envelope(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr("ksadk.detection.mcp_detector.MCPDetector", _FakeMCPDetector)
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeMCPDryRunClient)
-
- def _should_not_build(*_args, **_kwargs):
- raise AssertionError("Dry run should not build artifacts")
-
- monkeypatch.setattr(cmd_mcp, "_build_code_artifact", _should_not_build)
-
- result = runner.invoke(
- mcp,
- ["deploy", str(tmp_path), "--dry-run", "--output", "json", "--ks3-bucket", "demo-bucket"],
- )
-
- assert result.exit_code == 0, result.output
- payload = _parse_json(result.output)
- assert payload["ok"] is True
- assert payload["kind"] == "dry_run"
- assert payload["resource"] == "workflow"
- assert payload["action"] == "deploy"
- assert payload["request"]["body"]["artifact_type"] == "Code"
- assert payload["plan"]["artifact"]["reference"].startswith("ks3://demo-bucket/")
-
-
-async def _fake_build_mcp_async(**_kwargs):
- return {
- "framework": "mcp",
- "artifact_type": "code",
- "artifact_source": "built",
- "artifact_reused": False,
- "artifact_built": True,
- "artifact_reference": "ks3://demo-bucket/mcps/demo-mcp/code_fake.zip",
- "push": True,
- "region": "cn-beijing-6",
- "mcp_name": "demo-mcp",
- "tools": ["ping", "add"],
- }
-
-
-def test_mcp_build_json_envelope(tmp_path: Path, monkeypatch):
- runner = CliRunner()
- monkeypatch.chdir(tmp_path)
- monkeypatch.setattr(cmd_mcp, "_build_mcp_async", _fake_build_mcp_async)
-
- result = runner.invoke(mcp, ["build", str(tmp_path), "--artifact-type", "Code", "--output", "json"])
-
- assert result.exit_code == 0, result.output
- payload = _parse_json(result.output)
- assert payload["ok"] is True
- assert payload["kind"] == "result"
- assert payload["resource"] == "workflow"
- assert payload["action"] == "build"
- assert payload["result"]["artifact_type"] == "code"
- assert payload["result"]["artifact_reference"] == "ks3://demo-bucket/mcps/demo-mcp/code_fake.zip"
-
-
-class _FakeBuildResult:
- def __init__(self):
- self.success = True
- self.error_message = ""
- self.metadata = {"framework": "langgraph", "agent_name": "demo-agent", "reused": False}
- self.artifact_path = Path("/tmp/demo-agent.zip")
- self.artifact_size_mb = 12.5
-
-
-class _FakeCodeBuilder:
- def __init__(self, *args, **kwargs):
- self.kwargs = kwargs
-
- def build(self):
- return _FakeBuildResult()
-
-
-class _FakeContainerBuildResult:
- def __init__(self):
- self.success = True
- self.error_message = ""
- self.metadata = {"framework": "langgraph", "image": "hub.kce.ksyun.com/demo/demo-agent:latest", "reused": False}
- self.artifact_path = Path("/tmp/demo-image.tar")
- self.artifact_size_mb = 25.0
-
-
-class _FakeContainerBuilderPushFailure:
- def __init__(self, *args, **kwargs):
- self.kwargs = kwargs
-
- def build(self):
- return _FakeContainerBuildResult()
-
- def push(self, _image):
- return False
-
-
-def test_build_json_envelope(monkeypatch, tmp_path: Path):
- runner = CliRunner()
- monkeypatch.setattr("ksadk.builders.CodeBuilder", _FakeCodeBuilder)
-
- result = runner.invoke(build, [str(tmp_path), "--output", "json"])
-
- assert result.exit_code == 0, result.output
- payload = _parse_json(result.output)
- assert payload["ok"] is True
- assert payload["kind"] == "result"
- assert payload["resource"] == "workflow"
- assert payload["action"] == "build"
- assert payload["result"]["artifact_type"] == "code"
- assert payload["result"]["artifact_built"] is True
-
-
-def test_build_push_failure_returns_structured_json_error(monkeypatch, tmp_path: Path):
- _register_commands()
- runner = CliRunner()
- monkeypatch.setattr("ksadk.builders.ContainerBuilder", _FakeContainerBuilderPushFailure)
-
- result = runner.invoke(
- cli,
- ["--output", "json", "build", str(tmp_path), "--mode", "container", "--push"],
- )
-
- assert result.exit_code == 6, result.output
- payload = _parse_json(result.output)
- assert payload["ok"] is False
- assert payload["error"]["code"] == "remote_error"
- assert payload["error"]["details"]["image"] == "hub.kce.ksyun.com/demo/demo-agent:latest"
-
-
-class _FakeDetectionType:
- value = "langgraph"
-
-
-class _FakeDetectionResult:
- type = _FakeDetectionType()
- name = "langgraph"
- entry_point = "agent.py"
-
-
-class _FakeProvider:
- def __init__(self):
- self.calls = []
-
- async def validate_config(self, _target):
- self.calls.append("validate")
- return True, ""
-
- async def package(self, project_dir, _detection_result, _config):
- self.calls.append("package")
- return PackageInfo(
- name="demo-agent",
- framework="langgraph",
- build_dir=str(Path(project_dir) / ".agentengine" / "build"),
- project_dir=str(project_dir),
- metadata={},
- )
-
- async def build(self, package_info, _target):
- self.calls.append("build")
- package_info.metadata["ks3_path"] = "ks3://bucket/agents/demo-agent/code_20260320170000.zip"
- return package_info
-
- async def deploy(self, _package_info, _target):
- self.calls.append("deploy")
- return DeployResult(
- status=DeployStatus.DEPLOYING,
- agent_id="ar-demo",
- agent_name="demo-agent",
- endpoint="http://demo-endpoint",
- message="ok",
- )
-
-
-class _FakeWorkflowDryRunProvider(_FakeProvider):
- async def deploy(self, package_info, target):
- self.calls.append("deploy")
- artifact_reference = (
- package_info.metadata.get("ks3_path")
- or package_info.image
- or target.extra.get("ks3_path")
- or target.extra.get("image")
- or ""
- )
- return DeployResult(
- status=DeployStatus.SKIPPED,
- message="dry run",
- metadata={
- "dry_run_request": {
- "method": "POST",
- "url": "https://agentengine.example.com/agentengine/api/v1/CreateAgent",
- "headers": {"Content-Type": "application/json"},
- "body": {"ArtifactPath": artifact_reference, "Name": package_info.name},
- "curl": "curl -X POST https://agentengine.example.com/agentengine/api/v1/CreateAgent",
- }
- },
- )
-
-
-class _NoBuildDuringDryRunProvider(_FakeWorkflowDryRunProvider):
- async def build(self, package_info, _target):
- raise AssertionError("build should not run during dry-run")
-
-
-def test_deploy_json_envelope(tmp_path: Path, monkeypatch):
- provider = _FakeProvider()
- runner = CliRunner()
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})())
- monkeypatch.setattr("ksadk.cli.cmd_deploy._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"})
- monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- result = runner.invoke(
- cmd_deploy.deploy,
- [str(tmp_path), "--account-id", "2000003485", "--output", "json"],
- )
-
- assert result.exit_code == 0, result.output
- payload = _parse_json(result.output)
- assert payload["ok"] is True
- assert payload["kind"] == "result"
- assert payload["resource"] == "workflow"
- assert payload["action"] == "deploy"
- assert payload["result"]["agent_id"] == "ar-demo"
- assert payload["result"]["endpoint"] == "http://demo-endpoint"
-
-
-def test_launch_json_envelope(tmp_path: Path, monkeypatch):
- provider = _FakeProvider()
- runner = CliRunner()
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})())
- monkeypatch.setattr("ksadk.cli.cmd_launch._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"})
- monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- result = runner.invoke(
- cmd_launch.launch,
- [str(tmp_path), "--account-id", "2000003485", "--output", "json"],
- )
-
- assert result.exit_code == 0, result.output
- payload = _parse_json(result.output)
- assert payload["ok"] is True
- assert payload["kind"] == "result"
- assert payload["resource"] == "workflow"
- assert payload["action"] == "launch"
- assert payload["result"]["agent_id"] == "ar-demo"
- assert payload["result"]["endpoint"] == "http://demo-endpoint"
-
-
-def test_deploy_dry_run_json_envelope_includes_local_plan_and_remote_curl(tmp_path: Path, monkeypatch):
- provider = _NoBuildDuringDryRunProvider()
- runner = CliRunner()
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})())
- monkeypatch.setattr("ksadk.cli.cmd_deploy._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"})
- monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- result = runner.invoke(
- cmd_deploy.deploy,
- [str(tmp_path), "--account-id", "2000003485", "--dry-run", "--output", "json"],
- )
-
- assert result.exit_code == 0, result.output
- payload = _parse_json(result.output)
- assert payload["ok"] is True
- assert payload["kind"] == "dry_run"
- assert payload["resource"] == "workflow"
- assert payload["action"] == "deploy"
- assert payload["plan"]["artifact"]["should_build"] is True
- assert payload["plan"]["artifact"]["will_build"] is False
- assert payload["plan"]["artifact"]["should_local_build"] is True
- assert payload["plan"]["artifact"]["will_local_build"] is False
- assert payload["plan"]["artifact"]["should_publish"] is True
- assert payload["plan"]["artifact"]["will_publish"] is False
- assert payload["plan"]["artifact"]["source"] == "planned_build"
- assert payload["plan"]["artifact"]["reference_is_predicted"] is True
- assert [step["name"] for step in payload["plan"]["steps"]] == [
- "validate_config",
- "package",
- "local_build",
- "artifact_publish",
- "deploy_request",
- ]
- assert payload["plan"]["steps"][-1]["name"] == "deploy_request"
- assert payload["plan"]["steps"][2]["will_run"] is False
- assert payload["plan"]["steps"][2]["planned"] is True
- assert payload["plan"]["steps"][2]["reason"] == "dry_run_prediction"
- assert payload["plan"]["steps"][3]["kind"] == "remote"
- assert payload["plan"]["steps"][3]["will_run"] is False
- assert payload["plan"]["steps"][3]["planned"] is True
- assert payload["plan"]["steps"][3]["reason"] == "dry_run_prediction"
- assert "CreateAgent" in payload["request"]["curl"]
- assert payload["request"]["body"]["ArtifactPath"].startswith("ks3://agentengine-2000003485-cn-beijing-6/")
-
-
-def test_deploy_dry_run_pretty_output_groups_summary_plan_and_request(tmp_path: Path, monkeypatch):
- provider = _NoBuildDuringDryRunProvider()
- runner = CliRunner()
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})())
- monkeypatch.setattr("ksadk.cli.cmd_deploy._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"})
- monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- result = runner.invoke(
- cmd_deploy.deploy,
- [str(tmp_path), "--account-id", "2000003485", "--dry-run"],
- )
-
- assert result.exit_code == 0, result.output
- assert "执行摘要" in result.output
- assert "本次执行" in result.output
- assert "仅计划" in result.output
- assert "local_build" in result.output
- assert "artifact_publish" in result.output
- assert "远端请求" in result.output
- assert "请求方法" in result.output
- assert "请求地址" in result.output
- assert "请求字段" in result.output
- assert "Curl:" in result.output
-
-
-def test_launch_dry_run_json_envelope_tracks_external_artifact_plan(tmp_path: Path, monkeypatch):
- provider = _FakeWorkflowDryRunProvider()
- runner = CliRunner()
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})())
- monkeypatch.setattr("ksadk.cli.cmd_launch._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"})
- monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- result = runner.invoke(
- cmd_launch.launch,
- [
- str(tmp_path),
- "--account-id",
- "2000003485",
- "--artifact-type",
- "Container",
- "--image",
- "hub.kce.ksyun.com/demo/demo-agent:latest",
- "--dry-run",
- "--output",
- "json",
- ],
- )
-
- assert result.exit_code == 0, result.output
- payload = _parse_json(result.output)
- assert payload["ok"] is True
- assert payload["kind"] == "dry_run"
- assert payload["action"] == "launch"
- assert payload["plan"]["artifact"]["should_build"] is False
- assert payload["plan"]["artifact"]["should_local_build"] is False
- assert payload["plan"]["artifact"]["should_publish"] is False
- assert payload["plan"]["artifact"]["explicit_ref_option"] == "--image"
- assert payload["plan"]["artifact"]["source"] == "external"
- assert payload["plan"]["steps"][2]["name"] == "local_build"
- assert payload["plan"]["steps"][2]["will_run"] is False
- assert payload["plan"]["steps"][2]["reason"] == "explicit_reference"
- assert payload["plan"]["steps"][3]["name"] == "artifact_publish"
- assert payload["plan"]["steps"][3]["will_run"] is False
- assert payload["plan"]["steps"][3]["reason"] == "explicit_reference"
- assert payload["request"]["body"]["ArtifactPath"] == "hub.kce.ksyun.com/demo/demo-agent:latest"
-
-
-def test_launch_dry_run_json_skips_real_build_and_predicts_container_reference(tmp_path: Path, monkeypatch):
- provider = _NoBuildDuringDryRunProvider()
- runner = CliRunner()
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})())
- monkeypatch.setattr("ksadk.cli.cmd_launch._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"})
- monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- result = runner.invoke(
- cmd_launch.launch,
- [
- str(tmp_path),
- "--account-id",
- "2000003485",
- "--artifact-type",
- "Container",
- "--registry",
- "hub.kce.ksyun.com/demo",
- "--dry-run",
- "--output",
- "json",
- ],
- )
-
- assert result.exit_code == 0, result.output
- payload = _parse_json(result.output)
- assert payload["kind"] == "dry_run"
- assert payload["plan"]["artifact"]["should_build"] is True
- assert payload["plan"]["artifact"]["will_build"] is False
- assert payload["plan"]["artifact"]["should_publish"] is True
- assert payload["plan"]["artifact"]["will_publish"] is False
- assert payload["plan"]["artifact"]["source"] == "planned_build"
- assert payload["plan"]["steps"][2]["name"] == "local_build"
- assert payload["plan"]["steps"][2]["reason"] == "dry_run_prediction"
- assert payload["plan"]["steps"][3]["name"] == "artifact_publish"
- assert payload["plan"]["steps"][3]["reason"] == "dry_run_prediction"
- assert payload["request"]["body"]["ArtifactPath"] == "hub.kce.ksyun.com/demo/demo-agent:dry-run"
-
-
-def test_deploy_reuses_cached_artifact_without_rebuild(tmp_path: Path, monkeypatch):
- provider = _NoBuildDuringDryRunProvider()
- runner = CliRunner()
- metadata_dir = tmp_path / ".agentengine"
- metadata_dir.mkdir(parents=True, exist_ok=True)
- (metadata_dir / "build-metadata.json").write_text(
- json.dumps({"metadata": {"ks3_path": "ks3://bucket/agents/demo-agent/cached.zip"}}),
- encoding="utf-8",
- )
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})())
- monkeypatch.setattr("ksadk.cli.cmd_deploy._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"})
- monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- result = runner.invoke(
- cmd_deploy.deploy,
- [str(tmp_path), "--account-id", "2000003485", "--output", "json"],
- )
-
- assert result.exit_code == 0, result.output
- payload = _parse_json(result.output)
- assert payload["result"]["artifact_source"] == "cached"
- assert payload["result"]["artifact_reused"] is True
- assert payload["result"]["artifact_built"] is False
- assert payload["result"]["artifact_reference"] == "ks3://bucket/agents/demo-agent/cached.zip"
-
-
-def test_launch_reuses_cached_container_artifact_without_rebuild(tmp_path: Path, monkeypatch):
- provider = _NoBuildDuringDryRunProvider()
- runner = CliRunner()
- metadata_dir = tmp_path / ".agentengine"
- metadata_dir.mkdir(parents=True, exist_ok=True)
- (metadata_dir / "build-metadata.json").write_text(
- json.dumps({"image": "hub.kce.ksyun.com/demo/demo-agent:cached", "metadata": {"image": "hub.kce.ksyun.com/demo/demo-agent:cached"}}),
- encoding="utf-8",
- )
-
- monkeypatch.setattr("ksadk.detection.FrameworkDetector", lambda *_args, **_kwargs: type("D", (), {"detect": lambda self: _FakeDetectionResult()})())
- monkeypatch.setattr("ksadk.cli.cmd_launch._load_config", lambda *_args, **_kwargs: {"name": "demo-agent"})
- monkeypatch.setattr("ksadk.deployment.DeploymentManager.get_provider", lambda *_args, **_kwargs: provider)
-
- result = runner.invoke(
- cmd_launch.launch,
- [str(tmp_path), "--account-id", "2000003485", "--artifact-type", "Container", "--output", "json"],
- )
-
- assert result.exit_code == 0, result.output
- payload = _parse_json(result.output)
- assert payload["result"]["artifact_source"] == "cached"
- assert payload["result"]["artifact_reused"] is True
- assert payload["result"]["artifact_built"] is False
- assert payload["result"]["artifact_reference"] == "hub.kce.ksyun.com/demo/demo-agent:cached"
diff --git a/tests/test_ks3_uploader_urls.py b/tests/test_ks3_uploader_urls.py
deleted file mode 100644
index 31c7ce9f..00000000
--- a/tests/test_ks3_uploader_urls.py
+++ /dev/null
@@ -1,261 +0,0 @@
-import asyncio
-import pickle
-from concurrent.futures import ThreadPoolExecutor
-from types import SimpleNamespace
-
-from ksadk.builders.ks3_uploader import KS3Uploader
-
-
-def test_public_and_internal_url_by_key_include_full_object_key():
- uploader = KS3Uploader(region="cn-beijing-6", bucket="agentengine-test-cn-beijing-6")
- object_key = "agents/hr_projects_wrap_test/code_20260308154645.zip"
-
- public_url = uploader.get_public_url_by_key(object_key)
- internal_url = uploader.get_internal_url_by_key(object_key)
-
- assert public_url.endswith(f"/{object_key}")
- assert internal_url.endswith(f"/{object_key}")
- assert "code_20260308154645.zip" in public_url
- assert "code_20260308154645.zip" in internal_url
-
-
-def test_url_by_key_normalizes_leading_slash():
- uploader = KS3Uploader(region="cn-beijing-6", bucket="agentengine-test-cn-beijing-6")
- object_key = "/agents/demo/code_20260308154645.zip"
-
- public_url = uploader.get_public_url_by_key(object_key)
- internal_url = uploader.get_internal_url_by_key(object_key)
-
- assert "//agents/" not in public_url
- assert "//agents/" not in internal_url
- assert public_url.endswith("/agents/demo/code_20260308154645.zip")
- assert internal_url.endswith("/agents/demo/code_20260308154645.zip")
-
-
-def test_rank_upload_endpoints_prefers_fastest_reachable_host(monkeypatch):
- uploader = KS3Uploader(region="cn-beijing-6", bucket="agentengine-test-cn-beijing-6")
-
- monkeypatch.setattr(
- "ksadk.builders.ks3_uploader.get_ks3_endpoints",
- lambda _region: ("ks3-public.example.com", "ks3-internal.example.com"),
- )
- monkeypatch.setattr(
- uploader,
- "_probe_endpoint_latency",
- lambda host: {
- "ks3-public.example.com": 0.32,
- "ks3-internal.example.com": 0.08,
- }[host],
- raising=False,
- )
-
- targets, summary = uploader._rank_upload_endpoints()
-
- assert [item["host"] for item in targets] == [
- "ks3-internal.example.com",
- "ks3-public.example.com",
- ]
- assert "测速优先" in summary
-
-
-def test_auto_rank_upload_endpoints_skips_unreachable_fallback(monkeypatch):
- uploader = KS3Uploader(region="cn-beijing-6", bucket="agentengine-test-cn-beijing-6")
-
- monkeypatch.setattr(
- "ksadk.builders.ks3_uploader.get_ks3_endpoints",
- lambda _region: ("ks3-public.example.com", "ks3-internal.example.com"),
- )
- monkeypatch.setattr(
- uploader,
- "_probe_endpoint_latency",
- lambda host: {
- "ks3-public.example.com": 0.04,
- "ks3-internal.example.com": None,
- }[host],
- raising=False,
- )
-
- targets, summary = uploader._rank_upload_endpoints()
-
- assert [item["host"] for item in targets] == ["ks3-public.example.com"]
- assert "跳过不可达端点" in summary
-
-
-def test_large_upload_timeout_default_allows_slow_customer_networks(tmp_path):
- uploader = KS3Uploader(region="cn-beijing-6", bucket="agentengine-test-cn-beijing-6")
- artifact = tmp_path / "large.zip"
- with artifact.open("wb") as fp:
- fp.truncate(380 * 1024 * 1024)
-
- assert uploader._upload_timeout_seconds(artifact) >= 1800
-
-
-def test_upload_timeout_env_override_still_wins(tmp_path, monkeypatch):
- uploader = KS3Uploader(region="cn-beijing-6", bucket="agentengine-test-cn-beijing-6")
- artifact = tmp_path / "large.zip"
- with artifact.open("wb") as fp:
- fp.truncate(380 * 1024 * 1024)
- monkeypatch.setenv("KS3_UPLOAD_TIMEOUT_SECONDS", "2400")
-
- assert uploader._upload_timeout_seconds(artifact) == 2400
-
-
-def test_large_upload_uses_resumable_multipart_task(tmp_path, monkeypatch):
- uploader = KS3Uploader(region="cn-beijing-6", bucket="agentengine-test-cn-beijing-6")
- artifact = tmp_path / "large.zip"
- with artifact.open("wb") as fp:
- fp.truncate(120 * 1024 * 1024)
-
- captured = {}
-
- class _FakeConnection:
- def __init__(self, *args, **kwargs):
- captured["connection_kwargs"] = kwargs
-
- class _FakeKey:
- def __init__(self):
- self.name = "agents/demo/code.zip"
-
- class _FakeBucket:
- def new_key(self, object_key):
- captured["object_key"] = object_key
- return _FakeKey()
-
- class _FakeExecutor:
- def __init__(self, max_workers):
- captured["max_workers"] = max_workers
-
- class _FakeUploadTask:
- def __init__(self, key, bucket, src_file, executor, **kwargs):
- captured["task"] = {
- "key": key,
- "bucket": bucket,
- "src_file": src_file,
- "executor": executor,
- **kwargs,
- }
-
- def upload(self, headers=None):
- captured["headers"] = headers
- return SimpleNamespace(response_metadata=SimpleNamespace(status=200))
-
- monkeypatch.setenv("KSYUN_ACCESS_KEY", "ak")
- monkeypatch.setenv("KSYUN_SECRET_KEY", "sk")
- monkeypatch.setattr("ks3.connection.Connection", _FakeConnection)
- monkeypatch.setattr("ksadk.builders.ks3_uploader.ThreadPoolExecutor", _FakeExecutor)
- monkeypatch.setattr(uploader, "_ensure_bucket", lambda _conn: _FakeBucket(), raising=False)
- monkeypatch.setattr("ksadk.builders.ks3_uploader.UploadTask", _FakeUploadTask)
-
- assert uploader._upload_via_host(artifact, "agents/demo/code.zip", "ks3.example.com") is True
- assert captured["task"]["src_file"] == str(artifact)
- assert captured["task"]["resumable"] is True
- assert captured["task"]["resumable_filename"].endswith(".agentengine/ks3_resume/agents_demo_code.zip.ks3resume")
-
-
-def test_ks3_resumable_upload_skips_already_uploaded_parts(tmp_path, monkeypatch):
- import ks3.upload as ks3_upload
- from ks3.multipart import MultiPartUpload, Part
- from ks3.upload import UploadRecord, UploadTask
-
- artifact = tmp_path / "large.zip"
- artifact.write_bytes(b"x" * (2 * 1024 * 1024))
- object_key = "agents/demo/code.zip"
- resumable_file = tmp_path / ".agentengine" / "ks3_resume" / "agents_demo_code.zip.ks3resume"
- resumable_file.parent.mkdir(parents=True, exist_ok=True)
- part_info_cls = getattr(ks3_upload, "PartInfo", None)
- if part_info_cls is not None:
- uploaded_part = part_info_cls(size=1024 * 1024, part_crc="crc1")
- else:
- uploaded_part = Part()
- uploaded_part.size = 1024 * 1024
- uploaded_part.part_crc = "crc1"
-
- record = UploadRecord(
- "upload-id",
- artifact.stat().st_size,
- artifact.stat().st_mtime,
- "bucket",
- object_key,
- 1024 * 1024,
- {1: uploaded_part},
- )
- with resumable_file.open("wb") as fp:
- pickle.dump(record, fp)
-
- uploaded_parts = []
-
- class _FakeBucket:
- name = "bucket"
- connection = SimpleNamespace(
- enable_crc=True,
- provider=SimpleNamespace(checksum_crc64ecma_header="x-kss-checksum-crc64ecma"),
- )
-
- def _fake_upload_part_from_file(self, fp, part_num, headers=None):
- uploaded_parts.append(part_num)
- return SimpleNamespace(getheader=lambda _name: f"crc-{part_num}")
-
- def _fake_complete_upload(self, headers=None):
- return SimpleNamespace(
- response_metadata=SimpleNamespace(
- status=200,
- headers={"ETag": "etag", "x-kss-checksum-crc64ecma": "crc"},
- request_id="req",
- ),
- etag="etag",
- )
-
- monkeypatch.setattr(MultiPartUpload, "upload_part_from_file", _fake_upload_part_from_file, raising=False)
- monkeypatch.setattr(MultiPartUpload, "complete_upload", _fake_complete_upload, raising=False)
-
- task = UploadTask(
- key=SimpleNamespace(name=object_key),
- bucket=_FakeBucket(),
- src_file=str(artifact),
- executor=ThreadPoolExecutor(max_workers=2),
- part_size=1024 * 1024,
- resumable=True,
- resumable_filename=str(resumable_file),
- )
-
- result = task.upload(headers={})
-
- assert uploaded_parts == [2]
- assert result.response_metadata.status == 200
- assert not resumable_file.exists()
-
-
-def test_upload_retries_next_endpoint_after_transport_failure(tmp_path, monkeypatch):
- uploader = KS3Uploader(region="cn-beijing-6", bucket="agentengine-test-cn-beijing-6")
- artifact = tmp_path / "demo.zip"
- artifact.write_bytes(b"zip")
-
- monkeypatch.setenv("KSYUN_ACCESS_KEY", "ak")
- monkeypatch.setenv("KSYUN_SECRET_KEY", "sk")
- monkeypatch.setattr(
- uploader,
- "_rank_upload_endpoints",
- lambda: (
- [
- {"host": "ks3-internal.example.com", "label": "内网"},
- {"host": "ks3-public.example.com", "label": "公网"},
- ],
- "测速优先 内网",
- ),
- raising=False,
- )
-
- calls = []
-
- def fake_upload_via_host(_file_path, _object_key, host):
- calls.append(host)
- if host == "ks3-internal.example.com":
- raise TimeoutError("internal timeout")
- return True
-
- monkeypatch.setattr(uploader, "_upload_via_host", fake_upload_via_host, raising=False)
-
- result = asyncio.run(uploader.upload(artifact, "agents/demo/code.zip"))
-
- assert result == "ks3://agentengine-test-cn-beijing-6/agents/demo/code.zip"
- assert calls == ["ks3-internal.example.com", "ks3-public.example.com"]
diff --git a/tests/test_langchain_runner_session_continuity.py b/tests/test_langchain_runner_session_continuity.py
deleted file mode 100644
index b6ac5e95..00000000
--- a/tests/test_langchain_runner_session_continuity.py
+++ /dev/null
@@ -1,342 +0,0 @@
-from __future__ import annotations
-
-from types import SimpleNamespace
-
-import pytest
-from langchain_core.chat_history import InMemoryChatMessageHistory
-from langchain_core.runnables import RunnableLambda
-from langchain_core.runnables.history import RunnableWithMessageHistory
-
-from ksadk.runners.langchain_runner import LangChainRunner
-
-
-class _RecordingAgent:
- def __init__(self):
- self.calls: list[tuple[dict, dict | None]] = []
-
- async def ainvoke(self, payload, config=None):
- self.calls.append((payload, config))
- return {"output": "ok"}
-
-
-class _UsageMessage:
- def __init__(self):
- self.content = "ok"
- self.usage_metadata = {
- "input_tokens": 11,
- "output_tokens": 7,
- "total_tokens": 18,
- "input_token_details": {},
- "output_token_details": {"reasoning": 3},
- }
-
-
-class _UsageAgent:
- async def ainvoke(self, payload, config=None):
- del payload, config
- return {"messages": [_UsageMessage()]}
-
-
-class _UsageStreamingAgent:
- async def astream(self, payload, config=None):
- del payload, config
- yield _UsageMessage()
-
-
-def _make_runner(agent, module=None) -> LangChainRunner:
- detection = SimpleNamespace(entry_point="src/agent.py", agent_variable="root_agent")
- runner = LangChainRunner(detection, ".")
- runner._agent = agent
- runner._module = module or SimpleNamespace()
- return runner
-
-
-@pytest.mark.asyncio
-async def test_langchain_runner_uses_standard_prepare_input_hook():
- agent = _RecordingAgent()
- captured: list[tuple[dict, dict]] = []
-
- def ksadk_prepare_input(payload: dict, session_context: dict) -> dict:
- captured.append((payload, session_context))
- return {
- "question": payload["input"],
- "history_len": len(session_context["history"]),
- "session_id": session_context["session_id"],
- }
-
- runner = _make_runner(agent, module=SimpleNamespace(ksadk_prepare_input=ksadk_prepare_input))
-
- result = await runner.invoke(
- {
- "session_id": "sess-1",
- "input": "现在进展到哪了",
- "history": [
- {"role": "user", "content": "我叫张三"},
- {"role": "model", "content": "记住了"},
- ],
- }
- )
-
- assert result["output"] == "ok"
- assert captured == [
- (
- {"input": "现在进展到哪了"},
- {
- "session_id": "sess-1",
- "history": [
- {"role": "user", "content": "我叫张三"},
- {"role": "model", "content": "记住了"},
- ],
- "input_parts": [],
- "attachments": [],
- "attachment_results": [],
- "instructions": None,
- "platform_context": None,
- "kb_context": None,
- "memory_context": None,
- },
- )
- ]
- assert agent.calls[0][0] == {
- "question": "现在进展到哪了",
- "history_len": 2,
- "session_id": "sess-1",
- }
-
-
-@pytest.mark.asyncio
-async def test_langchain_runner_standard_hook_receives_ksadk_builtin_tool_descriptors(monkeypatch):
- agent = _RecordingAgent()
- captured: list[dict] = []
-
- def ksadk_prepare_input(payload: dict, session_context: dict) -> dict:
- captured.append(session_context)
- return payload
-
- monkeypatch.setenv("KSADK_BUILTIN_TOOLS_MODE", "dispatcher")
- runner = _make_runner(agent, module=SimpleNamespace(ksadk_prepare_input=ksadk_prepare_input))
-
- await runner.invoke({"session_id": "sess-tools", "input": "hello"})
-
- tool_names = [tool["name"] for tool in captured[0]["ksadk_tools"]]
- assert tool_names == ["tool_dispatcher"]
- assert captured[0]["ksadk_builtin_tools_mode"] == "dispatcher"
-
-
-@pytest.mark.asyncio
-async def test_langchain_runner_uses_runnable_with_message_history_session_config():
- store: dict[str, InMemoryChatMessageHistory] = {}
-
- def get_history(session_id: str) -> InMemoryChatMessageHistory:
- return store.setdefault(session_id, InMemoryChatMessageHistory())
-
- def sync_chain(payload: dict) -> dict:
- messages = payload["input"]
- return {"output": f"history={len(messages)}"}
-
- runnable = RunnableWithMessageHistory(RunnableLambda(sync_chain), get_history)
- runner = _make_runner(runnable)
-
- result = await runner.invoke({"session_id": "sess-history", "input": "hello"})
-
- assert result["output"] == "history=1"
- assert [message.content for message in store["sess-history"].messages] == ["hello", "history=1"]
-
-
-@pytest.mark.asyncio
-async def test_langchain_runner_falls_back_to_transcript_replay_prompt():
- agent = _RecordingAgent()
- runner = _make_runner(agent)
-
- await runner.invoke(
- {
- "session_id": "sess-replay",
- "input": "那我叫什么",
- "history": [
- {"role": "user", "content": "我叫张三"},
- {"role": "model", "content": "我记住了"},
- {"role": "user", "content": "那我叫什么"},
- ],
- }
- )
-
- payload, _config = agent.calls[0]
- assert payload["input"].startswith("Conversation history:")
- assert "user: 我叫张三" in payload["input"]
- assert "assistant: 我记住了" in payload["input"]
- assert payload["input"].rstrip().endswith("user: 那我叫什么")
-
-
-@pytest.mark.asyncio
-async def test_langchain_runner_standard_hook_receives_platform_kb_and_memory_context():
- agent = _RecordingAgent()
- captured: list[dict] = []
-
- def ksadk_prepare_input(payload: dict, session_context: dict) -> dict:
- captured.append(session_context)
- return payload
-
- runner = _make_runner(agent, module=SimpleNamespace(ksadk_prepare_input=ksadk_prepare_input))
-
- await runner.invoke(
- {
- "session_id": "sess-2",
- "input": "查一下最新支持库",
- "platform_context": {"agent_id": "demo-agent", "user_id": "user-1"},
- "kb_context": {"formatted_text": "KB facts"},
- "memory_context": {"formatted_text": "Memory facts"},
- }
- )
-
- assert captured == [
- {
- "session_id": "sess-2",
- "history": [],
- "input_parts": [],
- "attachments": [],
- "attachment_results": [],
- "instructions": None,
- "platform_context": {"agent_id": "demo-agent", "user_id": "user-1"},
- "kb_context": {"formatted_text": "KB facts"},
- "memory_context": {"formatted_text": "Memory facts"},
- }
- ]
-
-
-@pytest.mark.asyncio
-async def test_langchain_runner_replay_prompt_includes_ambient_kb_and_memory_context():
- agent = _RecordingAgent()
- runner = _make_runner(agent)
-
- await runner.invoke(
- {
- "session_id": "sess-3",
- "input": "继续",
- "kb_context": {"formatted_text": "知识库: 当前支持标准型和计算型"},
- "memory_context": {"formatted_text": "记忆: 用户上次查过主机机型"},
- }
- )
-
- payload, _config = agent.calls[0]
- assert "Knowledge base context:" in payload["input"]
- assert "知识库: 当前支持标准型和计算型" in payload["input"]
- assert "Long-term memory context:" in payload["input"]
- assert "记忆: 用户上次查过主机机型" in payload["input"]
-
-
-@pytest.mark.asyncio
-async def test_langchain_runner_replay_prompt_includes_instructions():
- agent = _RecordingAgent()
- runner = _make_runner(agent)
-
- await runner.invoke(
- {
- "session_id": "sess-instructions",
- "input": "hello",
- "instructions": "只用中文回答",
- }
- )
-
- payload, _config = agent.calls[0]
- assert payload["input"].startswith("只用中文回答")
- assert payload["input"].rstrip().endswith("user: hello")
-
-
-@pytest.mark.asyncio
-async def test_langchain_runner_message_history_includes_instructions_without_ambient_context():
- store: dict[str, InMemoryChatMessageHistory] = {}
- seen_messages = []
-
- def get_history(session_id: str) -> InMemoryChatMessageHistory:
- return store.setdefault(session_id, InMemoryChatMessageHistory())
-
- def sync_chain(payload: dict) -> dict:
- seen_messages.append(payload["input"])
- return {"output": "ok"}
-
- runnable = RunnableWithMessageHistory(RunnableLambda(sync_chain), get_history)
- runner = _make_runner(runnable)
-
- result = await runner.invoke(
- {
- "session_id": "sess-history-instructions",
- "input": "hello",
- "instructions": "只用中文回答",
- }
- )
-
- assert result["output"] == "ok"
- assert seen_messages
- assert seen_messages[0][0].__class__.__name__ == "SystemMessage"
- assert "只用中文回答" in seen_messages[0][0].content
- assert seen_messages[0][1].content == "hello"
-
-
-@pytest.mark.asyncio
-async def test_langchain_runner_invoke_extracts_usage_from_message_metadata():
- runner = _make_runner(_UsageAgent())
-
- result = await runner.invoke({"session_id": "sess-usage", "input": "hello"})
-
- assert result["usage"] == {
- "input_tokens": 11,
- "output_tokens": 7,
- "total_tokens": 18,
- "input_token_details": {},
- "output_token_details": {"reasoning": 3},
- }
-
-
-@pytest.mark.asyncio
-async def test_langchain_runner_stream_emits_final_usage_from_last_chunk():
- runner = _make_runner(_UsageStreamingAgent())
-
- chunks = [chunk async for chunk in runner.stream({"session_id": "sess-usage", "input": "hello"})]
-
- assert chunks == [
- {"delta": "ok", "type": "text"},
- {
- "output": "ok",
- "type": "final",
- "usage": {
- "input_tokens": 11,
- "output_tokens": 7,
- "total_tokens": 18,
- "input_token_details": {},
- "output_token_details": {"reasoning": 3},
- },
- "metadata": {
- "last_usage": {
- "input_tokens": 11,
- "output_tokens": 7,
- "total_tokens": 18,
- "input_token_details": {},
- "output_token_details": {"reasoning": 3},
- },
- },
- },
- ]
-
-
-def test_langchain_runner_extracts_wrapped_history_runnable():
- store: dict[str, InMemoryChatMessageHistory] = {}
-
- def get_history(session_id: str) -> InMemoryChatMessageHistory:
- return store.setdefault(session_id, InMemoryChatMessageHistory())
-
- runnable = RunnableLambda(lambda payload: {"output": payload["input"]})
- wrapped = RunnableWithMessageHistory(runnable, get_history)
- runner = _make_runner(wrapped)
-
- extracted = runner._extract_wrapped_history_runnable()
-
- assert extracted is not None
- assert hasattr(extracted, "invoke")
-
-
-def test_langchain_runner_logs_unknown_wrapped_history_shape(caplog):
- caplog.set_level("DEBUG", logger="ksadk.runners.langchain_runner")
- runner = _make_runner(SimpleNamespace(bound=object()))
-
- assert runner._extract_wrapped_history_runnable() is None
- assert "Unable to inspect RunnableWithMessageHistory wrapper" in caplog.text
diff --git a/tests/test_langfuse_exporter.py b/tests/test_langfuse_exporter.py
deleted file mode 100644
index 9e18d05a..00000000
--- a/tests/test_langfuse_exporter.py
+++ /dev/null
@@ -1,115 +0,0 @@
-from __future__ import annotations
-
-from types import SimpleNamespace
-
-from ksadk.tracing.exporters.langfuse_exporter import LangfuseExporterConfig, _LangfuseSpanExporter
-
-
-class _FakeLangfuse:
- def __init__(self):
- self.traces: list[dict] = []
- self.scores: list[dict] = []
-
- def trace(self, **kwargs):
- self.traces.append(kwargs)
- return SimpleNamespace(generation=lambda **_kwargs: None, span=lambda **_kwargs: None)
-
- def score(self, **kwargs):
- self.scores.append(kwargs)
- return None
-
- def flush(self):
- return None
-
-
-class _FakeSpan:
- def __init__(self, attributes: dict[str, object], events: list[object] | None = None):
- self.name = "demo-agent"
- self.attributes = attributes
- self.events = events or []
- self.parent = None
- self.context = SimpleNamespace(trace_id=1, span_id=2)
-
-
-def _exporter_with_fake_client() -> tuple[_LangfuseSpanExporter, _FakeLangfuse]:
- fake = _FakeLangfuse()
- exporter = _LangfuseSpanExporter(
- LangfuseExporterConfig(public_key="pk-test", secret_key="sk-test")
- )
- exporter._langfuse = fake
- exporter._agent_config = None
- return exporter, fake
-
-
-def test_langfuse_exporter_reads_openinference_style_user_and_session_keys():
- exporter, fake = _exporter_with_fake_client()
-
- exporter._export_trace(
- "trace-1",
- [
- _FakeSpan(
- {
- "langfuse.session.id": "conv-a",
- "session.id": "conv-a",
- "langfuse.user.id": "user-a",
- "user.id": "user-a",
- "user.input": "hello",
- "agent.output": "hi",
- }
- )
- ],
- )
-
- assert fake.traces[-1]["session_id"] == "conv-a"
- assert fake.traces[-1]["user_id"] == "user-a"
-
-
-def test_langfuse_exporter_still_reads_legacy_user_and_session_keys():
- exporter, fake = _exporter_with_fake_client()
-
- exporter._export_trace(
- "trace-1",
- [
- _FakeSpan(
- {
- "langfuse.session_id": "legacy-session",
- "langfuse.user_id": "legacy-user",
- }
- )
- ],
- )
-
- assert fake.traces[-1]["session_id"] == "legacy-session"
- assert fake.traces[-1]["user_id"] == "legacy-user"
-
-
-def test_langfuse_exporter_converts_score_span_events_to_langfuse_scores():
- exporter, fake = _exporter_with_fake_client()
- event = SimpleNamespace(
- name="langfuse.score",
- attributes={
- "score.id": "feedback:agent-demo:sess-1:resp_123",
- "score.name": "hosted_ui_feedback",
- "score.value": "down",
- "score.data_type": "CATEGORICAL",
- "score.comment": "不准确",
- "langfuse.trace_id": "trace-from-event",
- "langfuse.observation_id": "span-1",
- "source": "hosted-ui",
- },
- )
-
- exporter._export_trace("trace-1", [_FakeSpan({}, events=[event])])
-
- assert fake.scores == [
- {
- "id": "feedback:agent-demo:sess-1:resp_123",
- "trace_id": "trace-from-event",
- "observation_id": "span-1",
- "name": "hosted_ui_feedback",
- "value": "down",
- "data_type": "CATEGORICAL",
- "comment": "不准确",
- "metadata": {"source": "hosted-ui"},
- }
- ]
diff --git a/tests/test_langfuse_runner_utils.py b/tests/test_langfuse_runner_utils.py
deleted file mode 100644
index 36b2a86f..00000000
--- a/tests/test_langfuse_runner_utils.py
+++ /dev/null
@@ -1,49 +0,0 @@
-from __future__ import annotations
-
-import importlib
-import sys
-import types
-
-
-class _FakeCallbackHandler:
- instances = 0
-
- def __init__(self):
- self.__class__.instances += 1
-
-
-def _reload_langfuse_utils(monkeypatch):
- module = importlib.import_module("ksadk.runners.utils.langfuse")
- module = importlib.reload(module)
- monkeypatch.setattr(module, "_langfuse_callback", None)
- _FakeCallbackHandler.instances = 0
- monkeypatch.setitem(
- sys.modules,
- "langfuse.langchain",
- types.SimpleNamespace(CallbackHandler=_FakeCallbackHandler),
- )
- return module
-
-
-def test_langfuse_callback_disabled_by_default_when_otlp_direct_is_available(monkeypatch):
- monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-test")
- monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-test")
- monkeypatch.setenv("LANGFUSE_BASE_URL", "https://trace-pre.example.com")
- monkeypatch.delenv("LANGFUSE_USE_CALLBACK", raising=False)
-
- module = _reload_langfuse_utils(monkeypatch)
-
- assert module.get_langfuse_callback() is None
- assert _FakeCallbackHandler.instances == 0
-
-
-def test_langfuse_callback_can_be_enabled_explicitly(monkeypatch):
- monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-test")
- monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-test")
- monkeypatch.setenv("LANGFUSE_BASE_URL", "https://trace-pre.example.com")
- monkeypatch.setenv("LANGFUSE_USE_CALLBACK", "true")
-
- module = _reload_langfuse_utils(monkeypatch)
-
- assert isinstance(module.get_langfuse_callback(), _FakeCallbackHandler)
- assert _FakeCallbackHandler.instances == 1
diff --git a/tests/test_langgraph_runner_resume.py b/tests/test_langgraph_runner_resume.py
deleted file mode 100644
index 600980bd..00000000
--- a/tests/test_langgraph_runner_resume.py
+++ /dev/null
@@ -1,1172 +0,0 @@
-from types import SimpleNamespace
-
-import pytest
-from langgraph.types import Command
-import base64
-
-from ksadk.runners.langgraph_runner import LangGraphRunner
-
-
-class _DummyAgent:
- def __init__(self):
- self.last_ainvoke_state = None
- self.last_astream_state = None
- self.last_ainvoke_context = None
- self.last_ainvoke_config = None
- self.last_astream_config = None
- self.state_config = None
-
- async def ainvoke(self, state, config=None, context=None):
- self.last_ainvoke_state = state
- self.last_ainvoke_context = context
- self.last_ainvoke_config = config
- return {"messages": [{"content": "ok"}]}
-
- def get_state(self, config):
- del config
- return SimpleNamespace(config=self.state_config)
-
- async def astream_events(self, state, version="v2", config=None):
- self.last_astream_state = state
- self.last_astream_config = config
- if False:
- yield {}
-
-
-class _AsyncStateAgent(_DummyAgent):
- async def aget_state(self, config):
- del config
- return SimpleNamespace(config=self.state_config)
-
- get_state = None
-
-
-class _Chunk:
- def __init__(self, content="", reasoning_content=None):
- self.content = content
- self.additional_kwargs = {}
- if reasoning_content is not None:
- self.additional_kwargs["reasoning_content"] = reasoning_content
-
-
-class _StreamingAgent(_DummyAgent):
- async def astream_events(self, state, version="v2", config=None):
- self.last_astream_state = state
- yield {
- "event": "on_chat_model_stream",
- "data": {"chunk": _Chunk(reasoning_content="先分析需求。")},
- }
- yield {
- "event": "on_chat_model_stream",
- "data": {"chunk": _Chunk(content="这是最终回复。")},
- }
-
-
-class _DuplicatedReasoningStreamingAgent(_DummyAgent):
- async def astream_events(self, state, version="v2", config=None):
- self.last_astream_state = state
- yield {
- "event": "on_chat_model_stream",
- "data": {
- "chunk": _Chunk(
- content="先分析需求。",
- reasoning_content="先分析需求。",
- )
- },
- }
- yield {
- "event": "on_chat_model_stream",
- "data": {"chunk": _Chunk(content="这是最终回复。")},
- }
-
-
-class _ToolDictOutputStreamingAgent(_DummyAgent):
- async def astream_events(self, state, version="v2", config=None):
- self.last_astream_state = state
- yield {
- "event": "on_tool_end",
- "name": "write_workspace_file",
- "run_id": "run-approval",
- "data": {
- "output": {
- "ok": False,
- "type": "approval_required",
- "approval_request": {
- "id": "appr_write",
- "tool_name": "write_workspace_file",
- },
- }
- },
- }
-
-
-class _ToolThenAnswerStreamingAgent(_DummyAgent):
- async def astream_events(self, state, version="v2", config=None):
- self.last_astream_state = state
- yield {
- "event": "on_tool_start",
- "name": "list_skills",
- "run_id": "run-list-skills",
- "data": {"input": {}},
- }
- yield {
- "event": "on_tool_end",
- "name": "list_skills",
- "run_id": "run-list-skills",
- "data": {"output": {"ok": True, "skills": [{"name": "ppt-translator"}]}},
- }
- yield {
- "event": "on_chain_end",
- "name": "LangGraph",
- "data": {
- "output": {
- "answer": "已真实调用 `list_skills`。\n当前返回的 Skill:\n- ppt-translator",
- "messages": [{"content": ""}],
- }
- },
- }
-
-
-class _InlineThinkTagStreamingAgent(_DummyAgent):
- async def astream_events(self, state, version="v2", config=None):
- self.last_astream_state = state
- yield {
- "event": "on_chat_model_stream",
- "data": {"chunk": _Chunk(content="先分析需求。这是最终回复。")},
- }
-
-
-class _SplitInlineThinkTagStreamingAgent(_DummyAgent):
- async def astream_events(self, state, version="v2", config=None):
- self.last_astream_state = state
- yield {
- "event": "on_chat_model_stream",
- "data": {"chunk": _Chunk(content="先")},
- }
- yield {
- "event": "on_chat_model_stream",
- "data": {"chunk": _Chunk(content="分析需求。这是")},
- }
- yield {
- "event": "on_chat_model_stream",
- "data": {"chunk": _Chunk(content="最终回复。")},
- }
-
-
-class _UsageMessage:
- def __init__(self):
- self.content = "ok"
- self.usage_metadata = {
- "input_tokens": 8,
- "output_tokens": 13,
- "total_tokens": 21,
- "input_token_details": {},
- "output_token_details": {"reasoning": 5},
- }
-
-
-class _UsageAgent(_DummyAgent):
- async def ainvoke(self, state, config=None, context=None):
- self.last_ainvoke_state = state
- self.last_ainvoke_context = context
- self.last_ainvoke_config = config
- return {"messages": [_UsageMessage()]}
-
-
-class _UsageStateStreamingAgent(_StreamingAgent):
- def get_state(self, config):
- del config
- return SimpleNamespace(values={"messages": [_UsageMessage()]}, config=self.state_config)
-
-
-class _FinalOutputUsageStreamingAgent(_DummyAgent):
- async def astream_events(self, state, version="v2", config=None):
- self.last_astream_state = state
- self.last_astream_config = config
- yield {
- "event": "on_chain_end",
- "name": "LangGraph",
- "data": {"output": {"answer": "final only", "messages": [_UsageMessage()]}},
- }
-
-
-class _CheckpointResumeUpdatesAgent(_DummyAgent):
- def __init__(self):
- super().__init__()
- self.last_astream_stream_mode = None
- self.astream_events_called = False
-
- async def astream(self, state, config=None, stream_mode=None):
- self.last_astream_state = state
- self.last_astream_config = config
- self.last_astream_stream_mode = stream_mode
- yield {"search": {"answer": "resumed via updates"}}
-
- async def astream_events(self, state, version="v2", config=None):
- self.astream_events_called = True
- if False:
- yield {}
-
- def get_state(self, config):
- del config
- return SimpleNamespace(
- values={"answer": "resumed via updates"},
- config={
- "configurable": {
- "thread_id": "sess-1",
- "checkpoint_id": "ckpt-after",
- }
- },
- next=("report",),
- )
-
-
-def _make_runner(module=None) -> LangGraphRunner:
- detection = SimpleNamespace(entry_point="src/agent.py", agent_variable="root_agent")
- runner = LangGraphRunner(detection, ".")
- runner._agent = _DummyAgent()
- if module is not None:
- runner._module = module
- return runner
-
-
-def _make_streaming_runner() -> LangGraphRunner:
- runner = _make_runner()
- runner._agent = _StreamingAgent()
- return runner
-
-
-def _make_duplicated_reasoning_streaming_runner() -> LangGraphRunner:
- runner = _make_runner()
- runner._agent = _DuplicatedReasoningStreamingAgent()
- return runner
-
-
-def _make_tool_dict_output_streaming_runner() -> LangGraphRunner:
- runner = _make_runner()
- runner._agent = _ToolDictOutputStreamingAgent()
- return runner
-
-
-def _make_tool_then_answer_streaming_runner() -> LangGraphRunner:
- runner = _make_runner()
- runner._agent = _ToolThenAnswerStreamingAgent()
- return runner
-
-
-def _make_inline_think_tag_streaming_runner() -> LangGraphRunner:
- runner = _make_runner()
- runner._agent = _InlineThinkTagStreamingAgent()
- return runner
-
-
-def _make_split_inline_think_tag_streaming_runner() -> LangGraphRunner:
- runner = _make_runner()
- runner._agent = _SplitInlineThinkTagStreamingAgent()
- return runner
-
-
-def _make_usage_runner() -> LangGraphRunner:
- runner = _make_runner()
- runner._agent = _UsageAgent()
- return runner
-
-
-def _make_usage_state_streaming_runner() -> LangGraphRunner:
- runner = _make_runner()
- runner._agent = _UsageStateStreamingAgent()
- return runner
-
-
-def _make_final_output_usage_streaming_runner() -> LangGraphRunner:
- runner = _make_runner()
- runner._agent = _FinalOutputUsageStreamingAgent()
- return runner
-
-
-def _make_checkpoint_resume_updates_runner() -> LangGraphRunner:
- runner = _make_runner()
- runner._agent = _CheckpointResumeUpdatesAgent()
- return runner
-
-
-@pytest.mark.asyncio
-async def test_invoke_simplified_input_preserves_extra_state():
- runner = _make_runner()
-
- await runner.invoke(
- {
- "session_id": "s1",
- "input": "hello",
- "history": [{"role": "user", "content": "prev"}],
- "files": [{"name": "resume.txt"}],
- }
- )
-
- state = runner._agent.last_ainvoke_state
- assert "messages" in state
- assert "files" in state
- assert state["files"] == [{"name": "resume.txt"}]
- assert len(state["messages"]) == 2
-
-
-@pytest.mark.asyncio
-async def test_invoke_simplified_input_does_not_duplicate_current_user_message_when_history_contains_it():
- runner = _make_runner()
-
- await runner.invoke(
- {
- "session_id": "s1",
- "input": "hello",
- "history": [{"role": "user", "content": "hello"}],
- }
- )
-
- messages = runner._agent.last_ainvoke_state["messages"]
- user_messages = [
- message
- for message in messages
- if message.__class__.__name__ == "HumanMessage" and message.content == "hello"
- ]
- assert len(user_messages) == 1
-
-
-@pytest.mark.asyncio
-async def test_invoke_simplified_input_preserves_attachment_contract_fields():
- runner = _make_runner()
-
- await runner.invoke(
- {
- "session_id": "s1",
- "input": "请分析附件",
- "history": [{"role": "user", "content": "上一轮"}],
- "input_parts": [{"text": "请分析附件"}],
- "attachments": [{"display_name": "resume.pdf"}],
- "attachment_results": [{"display_name": "resume.pdf", "kind": "document"}],
- }
- )
-
- state = runner._agent.last_ainvoke_state
- assert state["input_parts"] == [{"text": "请分析附件"}]
- assert state["attachments"] == [{"display_name": "resume.pdf"}]
- assert state["attachment_results"] == [{"display_name": "resume.pdf", "kind": "document"}]
- assert len(state["messages"]) == 2
-
-
-@pytest.mark.asyncio
-async def test_stream_resume_uses_command():
- runner = _make_runner()
-
- chunks = [
- chunk
- async for chunk in runner.stream(
- {
- "session_id": "s1",
- "resume": True,
- "input": {"approved": True},
- }
- )
- ]
-
- assert isinstance(runner._agent.last_astream_state, Command)
- assert runner._agent.last_astream_state.resume == {"approved": True}
- assert isinstance(runner._agent.last_ainvoke_state, Command)
- assert runner._agent.last_ainvoke_state.resume == {"approved": True}
- assert chunks and chunks[-1]["type"] == "final"
-
-
-@pytest.mark.asyncio
-async def test_invoke_checkpoint_resume_uses_checkpoint_id_and_none_input():
- runner = _make_runner()
-
- result = await runner.invoke(
- {
- "session_id": "sess-1",
- "checkpoint_resume": True,
- "framework_ref": {
- "langgraph": {
- "thread_id": "tenant-a:agent-b:sess-1",
- "checkpoint_id": "ckpt-123",
- }
- },
- }
- )
-
- assert result["output"] == "ok"
- assert runner._agent.last_ainvoke_state is None
- assert runner._agent.last_ainvoke_config["configurable"] == {
- "thread_id": "tenant-a:agent-b:sess-1",
- "checkpoint_ns": "",
- "checkpoint_id": "ckpt-123",
- }
-
-
-@pytest.mark.asyncio
-async def test_invoke_checkpoint_resume_preserves_checkpoint_namespace_when_present():
- runner = _make_runner()
-
- await runner.invoke(
- {
- "session_id": "sess-1",
- "checkpoint_resume": True,
- "framework_ref": {
- "langgraph": {
- "thread_id": "tenant-a:agent-b:sess-1",
- "checkpoint_ns": "subgraph-ns",
- "checkpoint_id": "ckpt-123",
- }
- },
- }
- )
-
- assert runner._agent.last_ainvoke_config["configurable"] == {
- "thread_id": "tenant-a:agent-b:sess-1",
- "checkpoint_ns": "subgraph-ns",
- "checkpoint_id": "ckpt-123",
- }
-
-
-@pytest.mark.asyncio
-async def test_invoke_reports_latest_langgraph_checkpoint_ref_from_state_config():
- runner = _make_runner()
- runner._agent.state_config = {
- "configurable": {
- "thread_id": "tenant-a:agent-b:sess-1",
- "checkpoint_id": "ckpt-after",
- }
- }
-
- result = await runner.invoke({"session_id": "tenant-a:agent-b:sess-1", "input": "hello"})
-
- agentengine_metadata = result["metadata"]["agentengine"]
- assert agentengine_metadata["framework"] == "langgraph"
- assert agentengine_metadata["framework_ref"]["langgraph"] == {
- "thread_id": "tenant-a:agent-b:sess-1",
- "checkpoint_id": "ckpt-after",
- }
- assert agentengine_metadata["is_terminal"] is True
- assert agentengine_metadata["is_resumable"] is False
-
-
-@pytest.mark.asyncio
-async def test_invoke_reports_checkpoint_namespace_from_state_config_when_present():
- runner = _make_runner()
- runner._agent.state_config = {
- "configurable": {
- "thread_id": "tenant-a:agent-b:sess-1",
- "checkpoint_ns": "subgraph-ns",
- "checkpoint_id": "ckpt-after",
- }
- }
-
- result = await runner.invoke({"session_id": "tenant-a:agent-b:sess-1", "input": "hello"})
-
- assert result["metadata"]["agentengine"]["framework_ref"]["langgraph"] == {
- "thread_id": "tenant-a:agent-b:sess-1",
- "checkpoint_ns": "subgraph-ns",
- "checkpoint_id": "ckpt-after",
- }
-
-
-@pytest.mark.asyncio
-async def test_invoke_reports_latest_langgraph_checkpoint_ref_from_async_state_config():
- runner = _make_runner()
- runner._agent = _AsyncStateAgent()
- runner._agent.state_config = {
- "configurable": {
- "thread_id": "tenant-a:agent-b:sess-async",
- "checkpoint_id": "ckpt-async",
- }
- }
-
- result = await runner.invoke({"session_id": "tenant-a:agent-b:sess-async", "input": "hello"})
-
- agentengine_metadata = result["metadata"]["agentengine"]
- assert agentengine_metadata["framework"] == "langgraph"
- assert agentengine_metadata["framework_ref"]["langgraph"] == {
- "thread_id": "tenant-a:agent-b:sess-async",
- "checkpoint_id": "ckpt-async",
- }
- assert agentengine_metadata["is_terminal"] is True
- assert agentengine_metadata["is_resumable"] is False
-
-
-@pytest.mark.asyncio
-async def test_invoke_extracts_usage_from_langchain_message_metadata():
- runner = _make_usage_runner()
-
- result = await runner.invoke({"session_id": "sess-usage", "input": "hello"})
-
- assert result["usage"] == {
- "input_tokens": 8,
- "output_tokens": 13,
- "total_tokens": 21,
- "input_token_details": {},
- "output_token_details": {"reasoning": 5},
- }
-
-
-@pytest.mark.asyncio
-async def test_stream_emits_final_usage_from_graph_state_after_text_stream():
- runner = _make_usage_state_streaming_runner()
-
- chunks = [
- chunk
- async for chunk in runner.stream(
- {
- "session_id": "sess-usage-stream",
- "input": "hello",
- }
- )
- ]
-
- assert chunks[-1] == {
- "output": "这是最终回复。",
- "type": "final",
- "usage": {
- "input_tokens": 8,
- "output_tokens": 13,
- "total_tokens": 21,
- "input_token_details": {},
- "output_token_details": {"reasoning": 5},
- },
- "metadata": {
- "last_usage": {
- "input_tokens": 8,
- "output_tokens": 13,
- "total_tokens": 21,
- "input_token_details": {},
- "output_token_details": {"reasoning": 5},
- },
- },
- }
-
-
-@pytest.mark.asyncio
-async def test_stream_final_output_chunk_includes_usage_from_chain_end_output():
- runner = _make_final_output_usage_streaming_runner()
-
- chunks = [
- chunk
- async for chunk in runner.stream(
- {
- "session_id": "sess-final-usage",
- "input": "hello",
- }
- )
- ]
-
- assert chunks[-1] == {
- "output": "final only",
- "type": "final",
- "usage": {
- "input_tokens": 8,
- "output_tokens": 13,
- "total_tokens": 21,
- "input_token_details": {},
- "output_token_details": {"reasoning": 5},
- },
- "metadata": {
- "last_usage": {
- "input_tokens": 8,
- "output_tokens": 13,
- "total_tokens": 21,
- "input_token_details": {},
- "output_token_details": {"reasoning": 5},
- },
- },
- }
-
-
-@pytest.mark.asyncio
-async def test_stream_checkpoint_resume_uses_checkpoint_id_and_none_input():
- runner = _make_runner()
-
- chunks = [
- chunk
- async for chunk in runner.stream(
- {
- "session_id": "sess-1",
- "checkpoint_resume": True,
- "framework_ref": {
- "langgraph": {
- "checkpoint_id": "ckpt-456",
- }
- },
- }
- )
- ]
-
- assert chunks and chunks[-1]["type"] == "final"
- assert runner._agent.last_astream_state is None
- assert runner._agent.last_astream_config["configurable"] == {
- "thread_id": "sess-1",
- "checkpoint_ns": "",
- "checkpoint_id": "ckpt-456",
- }
-
-
-@pytest.mark.asyncio
-async def test_stream_checkpoint_resume_prefers_astream_updates_over_events():
- runner = _make_checkpoint_resume_updates_runner()
-
- chunks = [
- chunk
- async for chunk in runner.stream(
- {
- "session_id": "sess-1",
- "checkpoint_resume": True,
- "framework_ref": {
- "langgraph": {
- "checkpoint_id": "ckpt-before",
- }
- },
- }
- )
- ]
-
- assert runner._agent.last_astream_state is None
- assert runner._agent.last_astream_stream_mode == "updates"
- assert runner._agent.astream_events_called is False
- assert {"type": "final", "output": "resumed via updates"} in chunks
- checkpoint = chunks[-1]
- assert checkpoint["type"] == "checkpoint"
- assert checkpoint["metadata"]["agentengine"]["framework_ref"]["langgraph"]["checkpoint_id"] == "ckpt-after"
- assert checkpoint["metadata"]["agentengine"]["next_node"] == "report"
-
-
-@pytest.mark.asyncio
-async def test_stream_reports_latest_langgraph_checkpoint_ref_from_state_config():
- runner = _make_streaming_runner()
- runner._agent.state_config = {
- "configurable": {
- "thread_id": "tenant-a:agent-b:sess-1",
- "checkpoint_id": "ckpt-stream",
- }
- }
-
- chunks = [
- chunk
- async for chunk in runner.stream(
- {
- "session_id": "tenant-a:agent-b:sess-1",
- "input": "hello",
- }
- )
- ]
-
- checkpoint = chunks[-1]
- assert checkpoint["type"] == "checkpoint"
- agentengine_metadata = checkpoint["metadata"]["agentengine"]
- assert agentengine_metadata["framework"] == "langgraph"
- assert agentengine_metadata["framework_ref"]["langgraph"] == {
- "thread_id": "tenant-a:agent-b:sess-1",
- "checkpoint_id": "ckpt-stream",
- }
- assert agentengine_metadata["is_terminal"] is True
- assert agentengine_metadata["is_resumable"] is False
-
-
-@pytest.mark.asyncio
-async def test_stream_does_not_mix_reasoning_into_final_text():
- runner = _make_streaming_runner()
-
- chunks = [
- chunk
- async for chunk in runner.stream(
- {
- "session_id": "s1",
- "input": "写一个python快排的示例",
- }
- )
- ]
-
- assert chunks[:-1] == [
- {"delta": "先分析需求。", "type": "thinking"},
- {"delta": "这是最终回复。", "type": "text"},
- ]
- assert chunks[-1] == {"output": "这是最终回复。", "type": "final"}
- assert all("先分析需求。" not in chunk.get("delta", "") for chunk in chunks if chunk["type"] == "text")
-
-
-@pytest.mark.asyncio
-async def test_stream_ignores_content_when_chunk_duplicates_reasoning():
- runner = _make_duplicated_reasoning_streaming_runner()
-
- chunks = [
- chunk
- async for chunk in runner.stream(
- {
- "session_id": "s1",
- "input": "写一个python快排的示例",
- }
- )
- ]
-
- assert chunks[:-1] == [
- {"delta": "先分析需求。", "type": "thinking"},
- {"delta": "这是最终回复。", "type": "text"},
- ]
- assert chunks[-1] == {"output": "这是最终回复。", "type": "final"}
-
-
-@pytest.mark.asyncio
-async def test_stream_extracts_inline_think_tags_from_content():
- runner = _make_inline_think_tag_streaming_runner()
-
- chunks = [
- chunk
- async for chunk in runner.stream(
- {
- "session_id": "s1",
- "input": "写一个python快排的示例",
- }
- )
- ]
-
- assert chunks[:-1] == [
- {"delta": "先分析需求。", "type": "thinking"},
- {"delta": "这是最终回复。", "type": "text"},
- ]
- assert chunks[-1] == {"output": "这是最终回复。", "type": "final"}
-
-
-@pytest.mark.asyncio
-async def test_stream_extracts_split_inline_think_tags_from_content():
- runner = _make_split_inline_think_tag_streaming_runner()
-
- chunks = [
- chunk
- async for chunk in runner.stream(
- {
- "session_id": "s1",
- "input": "写一个python快排的示例",
- }
- )
- ]
-
- thinking_deltas = [chunk["delta"] for chunk in chunks if chunk["type"] == "thinking"]
- text_deltas = [chunk["delta"] for chunk in chunks if chunk["type"] == "text"]
-
- assert thinking_deltas == ["先分析需求。"]
- assert "".join(text_deltas) == "这是最终回复。"
- assert all(" str:
- assert captured["file"] == str(venv_python)
- args = captured["args"]
- assert isinstance(args, list)
- assert args[:2] == [str(venv_python), "-c"]
- assert "from ksadk.cli import main; main()" in args[2]
- assert args[3:] == command_args
- return args[2]
-
-
-def _write_project_venv(project_dir: Path) -> Path:
- venv_bin = project_dir / ".venv" / "bin"
- venv_bin.mkdir(parents=True)
- venv_python = venv_bin / "python"
- venv_python.write_text("#!/bin/sh\n", encoding="utf-8")
- return venv_python
-
-
-def _capture_reexec(monkeypatch):
- import ksadk.cli.local_runtime as local_runtime
-
- captured: dict[str, object] = {}
-
- def _fake_execvpe(file: str, args: list[str], env: dict[str, str]) -> None:
- captured["file"] = file
- captured["args"] = args
- captured["env"] = env
- raise SystemExit(23)
-
- monkeypatch.delenv("AGENTENGINE_LOCAL_RUNTIME_VENV_REEXEC", raising=False)
- monkeypatch.setattr(local_runtime.sys, "executable", sys.executable, raising=False)
- monkeypatch.setattr(local_runtime.os, "execvpe", _fake_execvpe, raising=False)
- return local_runtime, captured
-
-
-def test_run_reexecs_with_project_venv_python(monkeypatch, tmp_path: Path):
- project_dir = tmp_path / "demo-agent"
- project_dir.mkdir()
- venv_python = _write_project_venv(project_dir)
- local_runtime, captured = _capture_reexec(monkeypatch)
- _register_commands()
-
- result = CliRunner().invoke(
- cli,
- [
- "run",
- str(project_dir),
- "--port",
- "8899",
- "--interactive",
- "--no-trace",
- "--model",
- "demo-model",
- "--show-thinking",
- "--no-stream",
- ],
- )
-
- assert result.exit_code == 23
- bootstrap_code = _assert_bootstrap_args(
- captured,
- venv_python,
- [
- "run",
- str(project_dir.resolve()),
- "--port",
- "8899",
- "--interactive",
- "--no-trace",
- "--model",
- "demo-model",
- "--show-thinking",
- "--no-stream",
- ],
- )
- assert str(Path(local_runtime.__file__).resolve().parents[2]) in bootstrap_code
- env = captured["env"]
- assert isinstance(env, dict)
- assert env["AGENTENGINE_LOCAL_RUNTIME_VENV_REEXEC"] == "1"
-
-
-def test_run_reexec_bootstrap_includes_current_site_packages(monkeypatch, tmp_path: Path):
- project_dir = tmp_path / "demo-agent"
- project_dir.mkdir()
- venv_python = _write_project_venv(project_dir)
- local_runtime, captured = _capture_reexec(monkeypatch)
- fake_site_packages = str(tmp_path / "current-site-packages")
- Path(fake_site_packages).mkdir()
- monkeypatch.setattr(local_runtime, "_current_site_package_paths", lambda: [fake_site_packages])
- _register_commands()
-
- result = CliRunner().invoke(cli, ["run", str(project_dir), "--no-trace"])
-
- assert result.exit_code == 23
- bootstrap_code = _assert_bootstrap_args(
- captured,
- venv_python,
- [
- "run",
- str(project_dir.resolve()),
- "--port",
- "8080",
- "--no-trace",
- ],
- )
- assert fake_site_packages in bootstrap_code
-
-
-def test_run_reexecs_when_venv_python_symlinks_to_current_python(monkeypatch, tmp_path: Path):
- project_dir = tmp_path / "demo-agent"
- project_dir.mkdir()
- venv_bin = project_dir / ".venv" / "bin"
- venv_bin.mkdir(parents=True)
- venv_python = venv_bin / "python"
- try:
- venv_python.symlink_to(sys.executable)
- except OSError:
- venv_python.write_text("#!/bin/sh\n", encoding="utf-8")
- _local_runtime, captured = _capture_reexec(monkeypatch)
- _register_commands()
-
- result = CliRunner().invoke(cli, ["run", str(project_dir), "--no-trace"])
-
- assert result.exit_code == 23
- _assert_bootstrap_args(
- captured,
- venv_python,
- [
- "run",
- str(project_dir.resolve()),
- "--port",
- "8080",
- "--no-trace",
- ],
- )
-
-
-def test_a2a_serve_reexecs_with_project_venv_python(monkeypatch, tmp_path: Path):
- project_dir = tmp_path / "demo-agent"
- project_dir.mkdir()
- venv_python = _write_project_venv(project_dir)
- _local_runtime, captured = _capture_reexec(monkeypatch)
- _register_commands()
-
- result = CliRunner().invoke(
- cli,
- [
- "a2a",
- "serve",
- str(project_dir),
- "--host",
- "127.0.0.1",
- "--port",
- "9091",
- "--url",
- "http://example.test/a2a",
- "--name",
- "demo",
- "--description",
- "local a2a",
- "--skill",
- "echo",
- "--no-trace",
- ],
- )
-
- assert result.exit_code == 23
- _assert_bootstrap_args(
- captured,
- venv_python,
- [
- "a2a",
- "serve",
- str(project_dir.resolve()),
- "--host",
- "127.0.0.1",
- "--port",
- "9091",
- "--url",
- "http://example.test/a2a",
- "--name",
- "demo",
- "--description",
- "local a2a",
- "--skill",
- "echo",
- "--no-trace",
- ],
- )
diff --git a/tests/test_long_task_pilot_validation.py b/tests/test_long_task_pilot_validation.py
deleted file mode 100644
index 31373eda..00000000
--- a/tests/test_long_task_pilot_validation.py
+++ /dev/null
@@ -1,240 +0,0 @@
-import json
-
-import pytest
-
-from scripts import validate_long_task_pilot
-
-
-@pytest.mark.asyncio
-async def test_build_pilot_report_includes_resume_cancel_and_acceptance_metrics(monkeypatch):
- async def fake_run_validation(*, dsn: str, keep_session: bool):
- assert dsn == "postgresql://example"
- assert keep_session is False
- return {
- "session_id": "sess_resume",
- "run_id": "run_resume",
- "checkpoint_id": "ckpt_resume",
- "output_text": "a,b,c",
- "checkpoint_count": 1,
- "run_checkpoint_event_count": 2,
- "run_resume_event_count": 1,
- "checkpoint_log_before_resume": ["a", "b"],
- "node_counts_after_resume": {"a": 1, "b": 1, "c": 1},
- "resume_did_not_rerun_prior_nodes": True,
- }
-
- async def fake_run_cancel_validation(*, dsn: str, keep_session: bool):
- assert dsn == "postgresql://example"
- assert keep_session is False
- return {
- "session_id": "sess_cancel",
- "invocation_id": "run_cancel",
- "cancel_found": True,
- "cancel_status": "cancelling",
- "cancelled_event_count": 1,
- "post_cancel_extra_event_count": 0,
- }
-
- async def fake_run_cancel_then_resume_validation(*, dsn: str, keep_session: bool):
- assert dsn == "postgresql://example"
- assert keep_session is False
- return {
- "session_id": "sess_closed_loop",
- "run_id": "run_closed_loop",
- "invocation_id": "run_closed_loop",
- "checkpoint_id": "ckpt_closed_loop",
- "cancel_found": True,
- "cancel_status": "cancelling",
- "cancelled_event_count": 1,
- "post_cancel_extra_event_count": 0,
- "output_text_after_resume": "a,b,c",
- "checkpoint_log_before_cancel": ["a", "b"],
- "node_counts_after_resume": {"a": 1, "b": 1, "c": 1},
- "resume_after_cancel_did_not_rerun_prior_nodes": True,
- }
-
- monkeypatch.setattr(validate_long_task_pilot, "run_validation", fake_run_validation)
- monkeypatch.setattr(validate_long_task_pilot, "run_cancel_validation", fake_run_cancel_validation)
- monkeypatch.setattr(
- validate_long_task_pilot,
- "run_cancel_then_resume_validation",
- fake_run_cancel_then_resume_validation,
- )
-
- report = await validate_long_task_pilot.build_pilot_report(
- dsn="postgresql://example",
- keep_session=False,
- include_cancel=True,
- )
-
- assert report["overall_status"] == "pass"
- assert report["metrics"]["checkpoint_resume_success_rate"] == 1.0
- assert report["metrics"]["runtime_cancel_success_rate"] == 1.0
- assert report["metrics"]["cancel_then_resume_success_rate"] == 1.0
- assert report["cases"]["checkpoint_resume"]["status"] == "pass"
- assert report["cases"]["runtime_cancel"]["status"] == "pass"
- assert report["cases"]["cancel_then_resume"]["status"] == "pass"
- assert report["acceptance"]["same_run_id_resume"] == "pass"
- assert report["acceptance"]["resume_does_not_restart"] == "pass"
- assert report["cases"]["checkpoint_resume"]["resume_did_not_rerun_prior_nodes"] is True
- assert report["metrics"]["node_counts_after_resume"] == {"a": 1, "b": 1, "c": 1}
- assert report["acceptance"]["no_events_after_cancel"] == "pass"
- assert report["acceptance"]["cancel_then_resume_after_cancelled"] == "pass"
- assert report["acceptance"]["resume_after_cancel_does_not_restart"] == "pass"
- assert report["cases"]["cancel_then_resume"]["output_text_after_resume"] == "a,b,c"
- json.dumps(report, ensure_ascii=False)
-
-
-@pytest.mark.asyncio
-async def test_build_pilot_report_marks_failed_cancel_boundary(monkeypatch):
- async def fake_run_validation(*, dsn: str, keep_session: bool):
- return {
- "output_text": "a,b,c",
- "checkpoint_count": 1,
- "run_checkpoint_event_count": 2,
- "run_resume_event_count": 1,
- "checkpoint_log_before_resume": ["a", "b"],
- "node_counts_after_resume": {"a": 1, "b": 1, "c": 1},
- "resume_did_not_rerun_prior_nodes": True,
- }
-
- async def fake_run_cancel_validation(*, dsn: str, keep_session: bool):
- return {
- "cancel_found": True,
- "cancel_status": "cancelling",
- "cancelled_event_count": 1,
- "post_cancel_extra_event_count": 2,
- }
-
- async def fake_run_cancel_then_resume_validation(*, dsn: str, keep_session: bool):
- return {
- "cancel_found": True,
- "cancel_status": "cancelling",
- "cancelled_event_count": 1,
- "post_cancel_extra_event_count": 0,
- "output_text_after_resume": "a,b,c",
- "node_counts_after_resume": {"a": 1, "b": 1, "c": 1},
- "resume_after_cancel_did_not_rerun_prior_nodes": True,
- }
-
- monkeypatch.setattr(validate_long_task_pilot, "run_validation", fake_run_validation)
- monkeypatch.setattr(validate_long_task_pilot, "run_cancel_validation", fake_run_cancel_validation)
- monkeypatch.setattr(
- validate_long_task_pilot,
- "run_cancel_then_resume_validation",
- fake_run_cancel_then_resume_validation,
- )
-
- report = await validate_long_task_pilot.build_pilot_report(
- dsn="postgresql://example",
- keep_session=False,
- include_cancel=True,
- )
-
- assert report["overall_status"] == "fail"
- assert report["metrics"]["runtime_cancel_success_rate"] == 0.0
- assert report["acceptance"]["no_events_after_cancel"] == "fail"
-
-
-@pytest.mark.asyncio
-async def test_build_pilot_report_aggregates_multiple_iterations(monkeypatch):
- checkpoint_calls = 0
- cancel_calls = 0
-
- async def fake_run_validation(*, dsn: str, keep_session: bool):
- nonlocal checkpoint_calls
- checkpoint_calls += 1
- result = {
- "session_id": f"sess_resume_{checkpoint_calls}",
- "run_id": f"run_resume_{checkpoint_calls}",
- "checkpoint_id": f"ckpt_resume_{checkpoint_calls}",
- "output_text": "a,b,c",
- "checkpoint_count": 1,
- "run_checkpoint_event_count": 2,
- "run_resume_event_count": 1,
- "checkpoint_log_before_resume": ["a", "b"],
- "node_counts_after_resume": {"a": 1, "b": 1, "c": 1},
- "resume_did_not_rerun_prior_nodes": True,
- }
- if checkpoint_calls == 2:
- result["resume_did_not_rerun_prior_nodes"] = False
- result["node_counts_after_resume"] = {"a": 2, "b": 2, "c": 1}
- return result
-
- async def fake_run_cancel_validation(*, dsn: str, keep_session: bool):
- nonlocal cancel_calls
- cancel_calls += 1
- return {
- "session_id": f"sess_cancel_{cancel_calls}",
- "invocation_id": f"run_cancel_{cancel_calls}",
- "cancel_found": True,
- "cancel_status": "cancelling",
- "cancelled_event_count": 1,
- "post_cancel_extra_event_count": 0,
- }
-
- async def fake_run_cancel_then_resume_validation(*, dsn: str, keep_session: bool):
- return {
- "session_id": "sess_closed_loop",
- "run_id": "run_closed_loop",
- "invocation_id": "run_closed_loop",
- "checkpoint_id": "ckpt_closed_loop",
- "cancel_found": True,
- "cancel_status": "cancelling",
- "cancelled_event_count": 1,
- "post_cancel_extra_event_count": 0,
- "output_text_after_resume": "a,b,c",
- "checkpoint_log_before_cancel": ["a", "b"],
- "node_counts_after_resume": {"a": 1, "b": 1, "c": 1},
- "resume_after_cancel_did_not_rerun_prior_nodes": True,
- }
-
- monkeypatch.setattr(validate_long_task_pilot, "run_validation", fake_run_validation)
- monkeypatch.setattr(validate_long_task_pilot, "run_cancel_validation", fake_run_cancel_validation)
- monkeypatch.setattr(
- validate_long_task_pilot,
- "run_cancel_then_resume_validation",
- fake_run_cancel_then_resume_validation,
- )
-
- report = await validate_long_task_pilot.build_pilot_report(
- dsn="postgresql://example",
- keep_session=False,
- include_cancel=True,
- iterations=3,
- )
-
- assert report["overall_status"] == "fail"
- assert report["metrics"]["total_iterations"] == 3
- assert report["metrics"]["checkpoint_resume_passed"] == 2
- assert report["metrics"]["runtime_cancel_passed"] == 3
- assert report["metrics"]["cancel_then_resume_passed"] == 3
- assert report["metrics"]["checkpoint_resume_success_rate"] == pytest.approx(2 / 3)
- assert report["metrics"]["runtime_cancel_success_rate"] == 1.0
- assert report["metrics"]["cancel_then_resume_success_rate"] == 1.0
- assert len(report["iterations"]) == 3
- assert report["iterations"][1]["cases"]["checkpoint_resume"]["status"] == "fail"
- assert report["cases"]["checkpoint_resume"]["status"] == "fail"
- assert report["acceptance"]["resume_does_not_restart"] == "fail"
-
-
-@pytest.mark.asyncio
-async def test_build_pilot_report_returns_json_failure_when_validation_raises(monkeypatch):
- async def fake_run_validation(*, dsn: str, keep_session: bool):
- raise RuntimeError("database unavailable")
-
- monkeypatch.setattr(validate_long_task_pilot, "run_validation", fake_run_validation)
-
- report = await validate_long_task_pilot.build_pilot_report(
- dsn="postgresql://example",
- keep_session=False,
- include_cancel=True,
- )
-
- assert report["overall_status"] == "fail"
- assert report["cases"]["checkpoint_resume"]["status"] == "fail"
- assert report["cases"]["checkpoint_resume"]["error_type"] == "RuntimeError"
- assert "database unavailable" in report["cases"]["checkpoint_resume"]["error"]
- assert report["cases"]["runtime_cancel"]["status"] == "skipped"
- assert report["cases"]["cancel_then_resume"]["status"] == "skipped"
- json.dumps(report, ensure_ascii=False)
diff --git a/tests/test_mcp_runtime.py b/tests/test_mcp_runtime.py
deleted file mode 100644
index 05788b3e..00000000
--- a/tests/test_mcp_runtime.py
+++ /dev/null
@@ -1,436 +0,0 @@
-from __future__ import annotations
-
-import json
-import socket
-import textwrap
-import threading
-import time
-from contextlib import contextmanager
-from types import SimpleNamespace
-from uuid import uuid4
-
-import httpx
-import pytest
-import uvicorn
-from fastmcp import FastMCP
-from sse_starlette.sse import AppStatus
-
-from ksadk.detection import DetectionResult, FrameworkType
-
-
-def _write_adk_project(tmp_path, source: str) -> DetectionResult:
- package_name = f"demo_agent_{uuid4().hex[:8]}"
- package_dir = tmp_path / package_name
- package_dir.mkdir()
- (package_dir / "__init__.py").write_text("", encoding="utf-8")
- (package_dir / "agent.py").write_text(textwrap.dedent(source), encoding="utf-8")
- return DetectionResult(
- type=FrameworkType.ADK,
- name="demo-agent",
- entry_point=f"{package_name}/agent.py",
- package_path=str(package_dir),
- agent_variable="root_agent",
- confidence=1.0,
- )
-
-
-@contextmanager
-def _run_fastmcp_http_server(app):
- AppStatus.should_exit = False
- AppStatus.should_exit_event = None
-
- sock = socket.socket()
- sock.bind(("127.0.0.1", 0))
- host, port = sock.getsockname()
- sock.close()
-
- config = uvicorn.Config(app, host=host, port=port, log_level="warning")
- server = uvicorn.Server(config)
- thread = threading.Thread(target=server.run, daemon=True)
- thread.start()
-
- deadline = time.time() + 5
- while not server.started and time.time() < deadline:
- time.sleep(0.05)
-
- if not server.started:
- raise RuntimeError("FastMCP test server failed to start")
-
- try:
- yield f"http://{host}:{port}"
- finally:
- server.should_exit = True
- thread.join(timeout=5)
- AppStatus.should_exit = False
- AppStatus.should_exit_event = None
-
-
-@pytest.fixture
-def weather_mcp_server():
- server = FastMCP("weather")
-
- @server.tool
- def forecast(city: str) -> str:
- return f"forecast:{city}"
-
- app = server.http_app(path="/mcp", transport="streamable-http")
- with _run_fastmcp_http_server(app) as base_url:
- yield base_url
-
-
-def test_load_mcp_server_configs_validates_shape():
- from ksadk.mcp_runtime import load_mcp_server_configs
-
- configs = load_mcp_server_configs(
- json.dumps(
- [
- {
- "name": "weather",
- "url": "https://example.com/mcp",
- "api_key": "ak-123",
- "tool_filter": ["forecast", "alerts"],
- "tool_name_prefix": "weather",
- }
- ]
- )
- )
-
- assert len(configs) == 1
- assert configs[0].name == "weather"
- assert configs[0].url == "https://example.com/mcp"
- assert configs[0].api_key == "ak-123"
- assert configs[0].tool_filter == ("forecast", "alerts")
- assert configs[0].tool_name_prefix == "weather"
-
-
-def test_load_mcp_server_configs_rejects_invalid_payloads():
- from ksadk.mcp_runtime import load_mcp_server_configs
-
- with pytest.raises(ValueError, match="JSON array"):
- load_mcp_server_configs("{}")
-
- with pytest.raises(ValueError, match="/mcp"):
- load_mcp_server_configs(
- json.dumps([{"name": "weather", "url": "https://example.com/api"}])
- )
-
-
-def test_build_connection_params_includes_bearer_auth_header():
- from ksadk.mcp_runtime import MCPServerConfig, build_connection_params
-
- descriptor = MCPServerConfig(
- name="weather",
- url="https://example.com/mcp",
- api_key="ak-123",
- tool_filter=("forecast",),
- tool_name_prefix="weather",
- )
-
- params = build_connection_params(descriptor)
-
- assert params.url == "https://example.com/mcp"
- assert params.headers == {"Authorization": "Bearer ak-123"}
-
-
-def test_build_connection_params_disables_proxy_for_loopback_urls(monkeypatch):
- from ksadk.mcp_runtime import MCPServerConfig, build_connection_params
-
- monkeypatch.setenv("HTTP_PROXY", "http://127.0.0.1:7890")
- descriptor = MCPServerConfig(name="local", url="http://127.0.0.1:8899/mcp")
-
- params = build_connection_params(descriptor)
- client = params.httpx_client_factory()
-
- try:
- assert client._trust_env is False
- finally:
- import anyio
-
- anyio.run(client.aclose)
-
-
-@pytest.mark.asyncio
-async def test_build_mcp_toolset_roundtrip_lists_and_calls_remote_tools(weather_mcp_server):
- from ksadk.mcp_runtime import MCPServerConfig, build_mcp_toolset
-
- headers_seen: list[dict[str, str]] = []
-
- def httpx_client_factory(headers=None, timeout=None, auth=None):
- headers_seen.append(dict(headers or {}))
- return httpx.AsyncClient(
- headers=headers,
- timeout=timeout,
- auth=auth,
- follow_redirects=True,
- trust_env=False,
- )
-
- descriptor = MCPServerConfig(
- name="weather",
- url=f"{weather_mcp_server}/mcp",
- api_key="secret-token",
- tool_filter=("forecast",),
- tool_name_prefix="weather",
- )
- toolset = build_mcp_toolset(
- descriptor,
- httpx_client_factory=httpx_client_factory,
- )
-
- tools = await toolset.get_tools_with_prefix()
- result = await tools[0]._run_async_impl(
- args={"city": "beijing"},
- tool_context=SimpleNamespace(_invocation_context=None),
- credential=None,
- )
-
- assert [tool.name for tool in tools] == ["weather_forecast"]
- assert result["content"][0]["text"] == "forecast:beijing"
- assert headers_seen[0]["Authorization"] == "Bearer secret-token"
-
- await toolset.close()
-
-
-def test_adk_runner_load_agent_injects_mcp_toolsets_and_deduplicates(monkeypatch, tmp_path):
- import google.adk.runners as adk_runners
-
- from ksadk.runners.adk_runner import ADKRunner
-
- detection = _write_adk_project(
- tmp_path,
- """
- class DemoAgent:
- def __init__(self):
- self.name = "demo-agent"
- self.tools = []
- self.instruction = "Be helpful."
-
- root_agent = DemoAgent()
- """,
- )
-
- class FakeRunner:
- def __init__(self, **kwargs):
- self.kwargs = kwargs
-
- class FakeToolset:
- def __init__(self, key: str):
- self._ksadk_mcp_toolset_key = key
-
- monkeypatch.delenv("KSADK_ENABLE_MCP_TOOLS", raising=False)
- monkeypatch.setattr(ADKRunner, "_apply_json_patch", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_short_term_memory", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_long_term_memory", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_knowledge_base", lambda self: None)
- monkeypatch.setattr(adk_runners, "Runner", FakeRunner)
- monkeypatch.setattr(
- "ksadk.mcp_runtime.load_mcp_toolsets_from_env",
- lambda: [
- FakeToolset("https://example.com/mcp|weather"),
- FakeToolset("https://example.com/mcp|weather"),
- ],
- )
-
- runner = ADKRunner(detection, str(tmp_path))
- runner.load_agent()
-
- keys = [
- getattr(tool, "_ksadk_mcp_toolset_key", None)
- for tool in runner._agent.tools
- if getattr(tool, "_ksadk_mcp_toolset_key", None)
- ]
- assert keys == ["https://example.com/mcp|weather"]
-
-
-def test_adk_runner_registers_mcp_tool_descriptors_for_tool_search(monkeypatch, tmp_path):
- import google.adk.runners as adk_runners
-
- from ksadk.runners.adk_runner import ADKRunner
- from ksadk.toolsets import clear_external_tools, tool_search
-
- detection = _write_adk_project(
- tmp_path,
- """
- class DemoAgent:
- def __init__(self):
- self.name = "demo-agent"
- self.tools = []
- self.instruction = "Be helpful."
-
- root_agent = DemoAgent()
- """,
- )
-
- class FakeRunner:
- def __init__(self, **kwargs):
- self.kwargs = kwargs
-
- class FakeMCPTool:
- name = "weather_forecast"
- description = "Get weather forecast from the weather MCP server."
- args = {"city": {"type": "string"}}
-
- class FakeToolset:
- _ksadk_mcp_toolset_key = "https://example.com/mcp|weather"
- _ksadk_mcp_server_name = "weather"
-
- def get_tools_with_prefix(self):
- return [FakeMCPTool()]
-
- clear_external_tools()
- monkeypatch.delenv("KSADK_ENABLE_MCP_TOOLS", raising=False)
- monkeypatch.setattr(ADKRunner, "_apply_json_patch", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_short_term_memory", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_long_term_memory", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_knowledge_base", lambda self: None)
- monkeypatch.setattr(adk_runners, "Runner", FakeRunner)
- monkeypatch.setattr("ksadk.mcp_runtime.load_mcp_toolsets_from_env", lambda: [FakeToolset()])
-
- try:
- runner = ADKRunner(detection, str(tmp_path))
- runner.load_agent()
- result = tool_search("weather forecast", profile="coding", max_results=5)
- finally:
- clear_external_tools()
-
- forecast = next(item for item in result["results"] if item["name"] == "weather_forecast")
- assert forecast["group"] == "mcp:weather"
- assert forecast["boundary"] == "ksadk_managed_mcp_tool"
- assert forecast["execution"] == "external"
-
-
-def test_adk_runner_load_agent_skips_mcp_toolsets_when_disabled(monkeypatch, tmp_path):
- import google.adk.runners as adk_runners
-
- from ksadk.runners.adk_runner import ADKRunner
-
- detection = _write_adk_project(
- tmp_path,
- """
- class DemoAgent:
- def __init__(self):
- self.name = "demo-agent"
- self.tools = []
- self.instruction = "Be helpful."
-
- root_agent = DemoAgent()
- """,
- )
-
- class FakeRunner:
- def __init__(self, **kwargs):
- self.kwargs = kwargs
-
- class FakeToolset:
- def __init__(self, key: str):
- self._ksadk_mcp_toolset_key = key
-
- monkeypatch.setenv("KSADK_ENABLE_MCP_TOOLS", "0")
- monkeypatch.setattr(ADKRunner, "_apply_json_patch", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_short_term_memory", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_long_term_memory", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_knowledge_base", lambda self: None)
- monkeypatch.setattr(adk_runners, "Runner", FakeRunner)
- monkeypatch.setattr(
- "ksadk.mcp_runtime.load_mcp_toolsets_from_env",
- lambda: [FakeToolset("https://example.com/mcp|weather")],
- )
-
- runner = ADKRunner(detection, str(tmp_path))
- runner.load_agent()
-
- assert all(
- getattr(tool, "_ksadk_mcp_toolset_key", None) is None
- for tool in runner._agent.tools
- )
-
-
-@pytest.mark.asyncio
-async def test_adk_runner_invoke_roundtrip_with_remote_mcp_tools(
- monkeypatch,
- tmp_path,
- weather_mcp_server,
-):
- import google.adk.runners as adk_runners
-
- from ksadk.runners.adk_runner import ADKRunner
-
- detection = _write_adk_project(
- tmp_path,
- """
- class DemoAgent:
- def __init__(self):
- self.name = "demo-agent"
- self.tools = []
- self.instruction = "Use the weather_forecast tool."
-
- root_agent = DemoAgent()
- """,
- )
-
- class FakeRunner:
- def __init__(self, **kwargs):
- self.agent = kwargs["agent"]
-
- async def run_async(
- self,
- *,
- session_id,
- user_id,
- new_message,
- state_delta=None,
- run_config=None,
- ):
- toolsets = [
- tool for tool in self.agent.tools if hasattr(tool, "get_tools_with_prefix")
- ]
- assert toolsets
- tools = await toolsets[0].get_tools_with_prefix()
- payload = await tools[0]._run_async_impl(
- args={"city": new_message.parts[0].text},
- tool_context=SimpleNamespace(_invocation_context=None),
- credential=None,
- )
- text = payload["content"][0]["text"]
- yield SimpleNamespace(
- content=SimpleNamespace(
- parts=[SimpleNamespace(text=text, thought=False)]
- )
- )
-
- monkeypatch.delenv("KSADK_ENABLE_MCP_TOOLS", raising=False)
- monkeypatch.setenv(
- "KSADK_MCP_SERVERS",
- json.dumps(
- [
- {
- "name": "weather",
- "url": f"{weather_mcp_server}/mcp",
- "api_key": "secret-token",
- "tool_filter": ["forecast"],
- "tool_name_prefix": "weather",
- }
- ]
- ),
- )
- monkeypatch.setattr(ADKRunner, "_apply_json_patch", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_short_term_memory", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_long_term_memory", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_knowledge_base", lambda self: None)
- monkeypatch.setattr(adk_runners, "Runner", FakeRunner)
-
- runner = ADKRunner(detection, str(tmp_path))
- runner.load_agent()
-
- result = await runner.invoke({"input": "beijing"})
-
- assert result["output"] == "forecast:beijing"
- assert any(
- getattr(tool, "_ksadk_mcp_toolset_key", None)
- for tool in runner._agent.tools
- )
-
- for toolset in runner._runtime_toolsets:
- close = getattr(toolset, "close", None)
- if close is not None:
- await close()
diff --git a/tests/test_model_policy.py b/tests/test_model_policy.py
deleted file mode 100644
index 990a63e5..00000000
--- a/tests/test_model_policy.py
+++ /dev/null
@@ -1,59 +0,0 @@
-import os
-
-from ksadk.configs.settings import DEFAULT_MODEL_NAME
-from ksadk.model_policy import (
- DEFAULT_MODEL_POLICY,
- fallback_model_for_exception,
- model_policy_options_for_model,
- normalize_model_policy,
-)
-
-
-def test_default_model_policy_matches_release_defaults():
- policy = normalize_model_policy(DEFAULT_MODEL_POLICY)
-
- assert DEFAULT_MODEL_NAME == "glm-5.2"
- assert policy["primary"]["model"] == "glm-5.2"
- assert policy["multimodal"]["model"] == "kimi-k2.7-code"
- assert policy["fallback"]["model"] == "deepseek-v4-pro"
- assert policy["models"]["glm-5.2"]["reasoning"] is True
- assert policy["models"]["kimi-k2.7-code"]["reasoning"] is True
- assert policy["models"]["deepseek-v4-pro"]["reasoning"] is True
- assert policy["models"]["kimi-k2.7-code"]["options"]["temperature"] == 1
-
-
-def test_model_policy_options_apply_kimi_temperature_constraint():
- assert model_policy_options_for_model("kimi-k2.7-code") == {"temperature": 1}
- assert model_policy_options_for_model("ksyun/kimi-k2.7-code") == {"temperature": 1}
- assert model_policy_options_for_model("glm-5.2") == {}
-
-
-def test_model_policy_env_override_keeps_default_shape(monkeypatch):
- monkeypatch.setenv(
- "AGENTENGINE_MODEL_POLICY_JSON",
- '{"primary":{"model":"custom-primary"},"fallback":{"model":"custom-fallback"}}',
- )
-
- policy = normalize_model_policy(os.environ["AGENTENGINE_MODEL_POLICY_JSON"])
-
- assert policy["primary"]["model"] == "custom-primary"
- assert policy["fallback"]["model"] == "custom-fallback"
- assert policy["multimodal"]["model"] == "kimi-k2.7-code"
-
-
-def test_fallback_model_for_exception_only_accepts_transient_errors():
- assert (
- fallback_model_for_exception(RuntimeError("model unavailable"), current_model="glm-5.2")
- == "deepseek-v4-pro"
- )
- assert (
- fallback_model_for_exception(RuntimeError("invalid request 400"), current_model="glm-5.2")
- is None
- )
- assert (
- fallback_model_for_exception(
- RuntimeError("model unavailable"),
- current_model="deepseek-v4-pro",
- )
- is None
- )
diff --git a/tests/test_open_source_audit.py b/tests/test_open_source_audit.py
index 90d8d66b..ddc30788 100644
--- a/tests/test_open_source_audit.py
+++ b/tests/test_open_source_audit.py
@@ -117,6 +117,21 @@ def test_public_repo_audit_allows_curated_root_ai_guidance_files():
assert result.violations == []
+def test_public_repo_audit_allows_curated_environment_reference_doc():
+ audit = _load_audit_module()
+
+ result = audit.audit_paths(
+ "public-repo",
+ [
+ "docs/maintainer-approval-record.md",
+ "docs/reference/ksadk环境变量参考.md",
+ ],
+ )
+
+ assert result.ok is True
+ assert result.violations == []
+
+
def test_wheel_audit_blocks_hosted_ui_bundle_and_zread_snapshot():
audit = _load_audit_module()
@@ -333,10 +348,6 @@ def test_content_audit_allows_aicp_internal_endpoints_but_blocks_other_internal_
def test_content_audit_allows_supported_internal_and_registry_paths(tmp_path):
audit = _load_audit_module()
- (tmp_path / "iam.py").write_text(
- 'IAM_INNER = "iam.inner.api.ksyun.com"\n',
- encoding="utf-8",
- )
(tmp_path / "settings.py").write_text(
'KSPMAS_INTERNAL = "kspmas-internal.sdns.ksyun.com"\n',
encoding="utf-8",
@@ -361,7 +372,7 @@ def test_content_audit_allows_supported_internal_and_registry_paths(tmp_path):
)
result = audit.audit_file_contents(
- tmp_path, ["iam.py", "settings.py", "cmd_create.py", "builder.py", "regional.py", "other.py"]
+ tmp_path, ["settings.py", "cmd_create.py", "builder.py", "regional.py", "other.py"]
)
assert result.ok is False
assert [(v.path, v.rule) for v in result.violations] == [
diff --git a/tests/test_openai_protocol_e2e.py b/tests/test_openai_protocol_e2e.py
deleted file mode 100644
index f56463c3..00000000
--- a/tests/test_openai_protocol_e2e.py
+++ /dev/null
@@ -1,790 +0,0 @@
-from __future__ import annotations
-
-import base64
-import asyncio
-import importlib
-import json
-import os
-import socket
-import shutil
-import subprocess
-import tempfile
-import threading
-import time
-from contextlib import contextmanager
-from pathlib import Path
-from types import SimpleNamespace
-
-import httpx
-import pytest
-import uvicorn
-import websockets
-
-from ksadk.runners.base_runner import BaseRunner
-from ksadk.sessions.base import SessionEvent
-from ksadk.sessions.in_memory import InMemorySessionService
-
-
-class _E2ERunner(BaseRunner):
- def __init__(self):
- super().__init__(
- detection_result=SimpleNamespace(
- name="demo-agent",
- description="demo agent",
- type=SimpleNamespace(value="langgraph"),
- ),
- project_dir=".",
- )
- self.calls: list[dict] = []
- self.load_agent_calls = 0
-
- def load_agent(self) -> None:
- self.load_agent_calls += 1
-
- async def invoke(self, input_data: dict) -> dict:
- self.calls.append(input_data)
- return {"output": "assistant says hi"}
-
- async def stream(self, input_data: dict):
- self.calls.append(input_data)
- yield {"type": "final", "output": "assistant says hi"}
-
-
-@contextmanager
-def _run_real_http_server(app):
- sock = socket.socket()
- sock.bind(("127.0.0.1", 0))
- host, port = sock.getsockname()
- sock.close()
-
- config = uvicorn.Config(app, host=host, port=port, log_level="warning")
- server = uvicorn.Server(config)
- thread = threading.Thread(target=server.run, daemon=True)
- thread.start()
-
- deadline = time.time() + 5
- while not server.started and time.time() < deadline:
- time.sleep(0.05)
-
- if not server.started:
- server.should_exit = True
- thread.join(timeout=5)
- raise RuntimeError("KsADK E2E server failed to start")
-
- try:
- yield f"http://{host}:{port}"
- finally:
- server.should_exit = True
- thread.join(timeout=5)
-
-
-def _find_chromium_executable() -> str | None:
- explicit_path = os.environ.get("KSADK_E2E_CHROMIUM")
- if explicit_path and Path(explicit_path).is_file():
- return explicit_path
-
- candidates: list[Path] = []
- cache_roots = [
- Path.home() / "Library" / "Caches" / "ms-playwright",
- Path.home() / ".cache" / "ms-playwright",
- ]
- for cache_root in cache_roots:
- candidates.extend(
- cache_root.glob(
- "chromium-*/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"
- )
- )
- candidates.extend(
- cache_root.glob(
- "chromium-*/chrome-mac/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"
- )
- )
- candidates.extend(cache_root.glob("chromium-*/chrome-linux/chrome"))
-
- for candidate in candidates:
- if candidate.is_file():
- return str(candidate)
-
- for executable_name in (
- "chromium",
- "chromium-browser",
- "google-chrome",
- "google-chrome-stable",
- "chrome",
- ):
- resolved = shutil.which(executable_name)
- if resolved:
- return resolved
- return None
-
-
-def _free_port() -> int:
- sock = socket.socket()
- sock.bind(("127.0.0.1", 0))
- _, port = sock.getsockname()
- sock.close()
- return int(port)
-
-
-class _CdpBrowser:
- def __init__(self, executable_path: str):
- self.executable_path = executable_path
- self.port = _free_port()
- self._user_data_dir: tempfile.TemporaryDirectory[str] | None = None
- self._process: subprocess.Popen | None = None
- self._websocket = None
- self._next_id = 0
-
- async def __aenter__(self):
- self._user_data_dir = tempfile.TemporaryDirectory()
- self._process = subprocess.Popen(
- [
- self.executable_path,
- f"--remote-debugging-port={self.port}",
- f"--user-data-dir={self._user_data_dir.name}",
- "--headless=new",
- "--disable-background-networking",
- "--disable-dev-shm-usage",
- "--disable-gpu",
- "--no-first-run",
- "--no-default-browser-check",
- "--no-sandbox",
- "about:blank",
- ],
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL,
- )
-
- version_url = f"http://127.0.0.1:{self.port}/json/version"
- deadline = time.time() + 10
- version_payload: dict[str, str] | None = None
- async with httpx.AsyncClient(timeout=1, trust_env=False) as client:
- while time.time() < deadline:
- if self._process.poll() is not None:
- raise RuntimeError("Chromium exited before DevTools was ready")
- try:
- response = await client.get(version_url)
- if response.status_code == 200:
- version_payload = response.json()
- break
- except Exception:
- await asyncio.sleep(0.05)
- if not version_payload or not version_payload.get("webSocketDebuggerUrl"):
- raise RuntimeError("Chromium DevTools endpoint did not become ready")
-
- self._websocket = await websockets.connect(
- version_payload["webSocketDebuggerUrl"],
- max_size=None,
- )
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- if self._websocket is not None:
- await self._websocket.close()
- if self._process is not None:
- self._process.terminate()
- try:
- self._process.wait(timeout=5)
- except subprocess.TimeoutExpired:
- self._process.kill()
- self._process.wait(timeout=5)
- if self._user_data_dir is not None:
- self._user_data_dir.cleanup()
-
- async def send(
- self,
- method: str,
- params: dict | None = None,
- *,
- session_id: str | None = None,
- ) -> dict:
- assert self._websocket is not None
- self._next_id += 1
- message: dict[str, object] = {"id": self._next_id, "method": method}
- if params is not None:
- message["params"] = params
- if session_id is not None:
- message["sessionId"] = session_id
- await self._websocket.send(json.dumps(message))
-
- while True:
- raw_message = await self._websocket.recv()
- payload = json.loads(raw_message)
- if payload.get("id") != self._next_id:
- continue
- if payload.get("error"):
- raise RuntimeError(f"CDP {method} failed: {payload['error']}")
- return payload.get("result") or {}
-
- async def new_page(self, url: str) -> "_CdpPage":
- target = await self.send("Target.createTarget", {"url": "about:blank"})
- attached = await self.send(
- "Target.attachToTarget",
- {"targetId": target["targetId"], "flatten": True},
- )
- page = _CdpPage(self, attached["sessionId"])
- await page.enable()
- await page.add_script_to_evaluate_on_new_document(
- """
- (() => {
- const originalFetch = window.fetch.bind(window);
- window.__ksadkE2E = { runAgentBodies: [] };
- window.fetch = async (...args) => {
- try {
- const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
- const init = args[1] || {};
- if (String(url || '').includes('/agentengine/api/v1/RunAgent')) {
- window.__ksadkE2E.runAgentBodies.push(JSON.parse(String(init.body || '{}')));
- }
- } catch (error) {
- window.__ksadkE2E.fetchPatchError = String(error);
- }
- return originalFetch(...args);
- };
- })();
- """
- )
- await page.navigate(url)
- return page
-
-
-class _CdpPage:
- def __init__(self, browser: _CdpBrowser, session_id: str):
- self.browser = browser
- self.session_id = session_id
-
- async def enable(self) -> None:
- await self.browser.send("Page.enable", session_id=self.session_id)
- await self.browser.send("Runtime.enable", session_id=self.session_id)
-
- async def add_script_to_evaluate_on_new_document(self, source: str) -> None:
- await self.browser.send(
- "Page.addScriptToEvaluateOnNewDocument",
- {"source": source},
- session_id=self.session_id,
- )
-
- async def navigate(self, url: str) -> None:
- await self.browser.send("Page.navigate", {"url": url}, session_id=self.session_id)
-
- async def evaluate(self, expression: str, *, await_promise: bool = True):
- result = await self.browser.send(
- "Runtime.evaluate",
- {
- "expression": expression,
- "awaitPromise": await_promise,
- "returnByValue": True,
- },
- session_id=self.session_id,
- )
- if result.get("exceptionDetails"):
- raise AssertionError(result["exceptionDetails"])
- remote_object = result.get("result") or {}
- return remote_object.get("value")
-
- async def wait_for(self, expression: str, *, timeout: float = 10):
- deadline = time.time() + timeout
- while time.time() < deadline:
- value = await self.evaluate(expression)
- if value:
- return value
- await asyncio.sleep(0.1)
- raise AssertionError(f"Timed out waiting for browser expression: {expression}")
-
-
-@pytest.fixture
-def real_http_runtime(monkeypatch, tmp_path):
- server_app_module = importlib.import_module("ksadk.server.app")
-
- service = InMemorySessionService()
- runner = _E2ERunner()
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / ".agentengine" / "ui"))
- monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
- monkeypatch.delenv("OPENAI_API_BASE", raising=False)
- monkeypatch.delenv("OPENAI_API_KEY", raising=False)
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- with _run_real_http_server(server_app_module.app) as base_url:
- yield base_url, runner, service
-
-
-@pytest.mark.asyncio
-async def test_real_http_responses_image_and_file_reach_runner_canonical_fields(
- real_http_runtime,
-):
- base_url, runner, _ = real_http_runtime
- image_b64 = base64.b64encode(b"\x89PNG\r\n").decode("ascii")
- file_text = "候选人简历内容"
- file_b64 = base64.b64encode(file_text.encode("utf-8")).decode("ascii")
-
- async with httpx.AsyncClient(base_url=base_url, timeout=10, trust_env=False) as client:
- response = await client.post(
- "/v1/responses",
- json={
- "input": [
- {
- "role": "user",
- "content": [
- {"type": "input_text", "text": "请分析图片和附件"},
- {
- "type": "input_image",
- "image_url": f"data:image/png;base64,{image_b64}",
- },
- {
- "type": "input_file",
- "filename": "resume.txt",
- "file_data": file_b64,
- },
- ],
- }
- ],
- "stream": False,
- },
- )
-
- payload = response.json()
- assert response.status_code == 200
- assert payload["object"] == "response"
- assert payload["output_text"] == "assistant says hi"
- assert runner.calls[-1]["input_content"] == [
- {"type": "input_text", "text": "请分析图片和附件"},
- {"type": "input_image", "image_url": f"data:image/png;base64,{image_b64}"},
- {
- "type": "input_file",
- "filename": "resume.txt",
- "file_data": file_b64,
- },
- ]
- assert runner.calls[-1]["input_messages"] == [
- {
- "role": "user",
- "content": runner.calls[-1]["input_content"],
- }
- ]
- assert runner.calls[-1]["input_parts"][1] == {
- "inlineData": {
- "data": image_b64,
- "mimeType": "image/png",
- "displayName": "uploaded_image",
- }
- }
- assert runner.calls[-1]["current_attachments"][0]["mime_type"] == "image/png"
- assert runner.calls[-1]["current_attachment_results"][1]["text"] == file_text
- assert runner.calls[-1]["has_current_files"] is True
-
-
-@pytest.mark.asyncio
-async def test_real_http_responses_approval_resume_executes_builtin_tool(real_http_runtime):
- base_url, runner, service = real_http_runtime
- await service.create_session(agent_id="demo-agent", user_id="user", session_id="sess-e2e-approval")
- await service.append_event(
- "sess-e2e-approval",
- SessionEvent(
- author="demo-agent",
- event_type="approval_request",
- content={"role": "model", "parts": [{"text": "confirm write"}]},
- metadata={
- "interrupt_info": {
- "approval_request_id": "appr_e2e",
- "tool_name": "write_workspace_file",
- "arguments": {"path": "e2e.txt", "content": "approved"},
- "run_id": "call_e2e",
- "server_label": "ksadk",
- }
- },
- invocation_id="inv-approval",
- ),
- )
-
- async with httpx.AsyncClient(base_url=base_url, timeout=10, trust_env=False) as client:
- response = await client.post(
- "/v1/responses",
- json={
- "session_id": "sess-e2e-approval",
- "input": [
- {
- "type": "mcp_approval_response",
- "approval_request_id": "appr_e2e",
- "approve": True,
- }
- ],
- "stream": False,
- },
- )
-
- payload = response.json()
- assert response.status_code == 200
- assert payload["status"] == "completed"
- assert runner.calls[-1]["resume"] is True
- assert runner.calls[-1]["input"]["type"] == "function_call_output"
- assert runner.calls[-1]["input"]["call_id"] == "call_e2e"
- output = runner.calls[-1]["input"]["output"]
- assert output["ok"] is True
- assert Path(output["absolute_path"]).read_text(encoding="utf-8") == "approved"
-
-
-@pytest.mark.asyncio
-async def test_real_http_chat_completions_keeps_chat_response_and_converts_image_block(
- real_http_runtime,
-):
- base_url, runner, _ = real_http_runtime
- image_url = "data:image/png;base64,aW1hZ2U="
-
- async with httpx.AsyncClient(base_url=base_url, timeout=10, trust_env=False) as client:
- response = await client.post(
- "/v1/chat/completions",
- json={
- "model": "gpt-4o",
- "messages": [
- {
- "role": "user",
- "content": [
- {"type": "text", "text": "看图"},
- {"type": "image_url", "image_url": {"url": image_url}},
- ],
- }
- ],
- "stream": False,
- },
- )
-
- payload = response.json()
- assert response.status_code == 200
- assert payload["object"] == "chat.completion"
- assert payload["choices"][0]["message"]["role"] == "assistant"
- assert runner.calls[-1]["input_content"] == [
- {"type": "input_text", "text": "看图"},
- {"type": "input_image", "image_url": image_url},
- ]
- assert runner.calls[-1]["input_messages"] == [
- {
- "role": "user",
- "content": [
- {"type": "input_text", "text": "看图"},
- {"type": "input_image", "image_url": image_url},
- ],
- }
- ]
-
-
-@pytest.mark.asyncio
-async def test_real_http_run_agent_uses_responses_input_and_uploaded_file_reference(
- real_http_runtime,
-):
- base_url, runner, service = real_http_runtime
- attachment_bytes = "真实上传文件内容".encode("utf-8")
-
- async with httpx.AsyncClient(base_url=base_url, timeout=10, trust_env=False) as client:
- upload_response = await client.post(
- "/agentengine/api/v1/UploadFile",
- files={"file": ("report.txt", attachment_bytes, "text/plain")},
- )
- uploaded = upload_response.json()["Data"]["FileData"]
- response = await client.post(
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": "demo-agent",
- "ApiFormat": "responses",
- "Messages": [{"role": "user", "content": "SHOULD_NOT_USE"}],
- "ResponsesInput": [
- {
- "role": "user",
- "content": [
- {"type": "input_text", "text": "请总结上传文件"},
- {
- "type": "input_file",
- "filename": uploaded["displayName"],
- "file_url": uploaded["fileUri"],
- },
- ],
- }
- ],
- "Stream": False,
- },
- )
-
- payload = response.json()
- assert upload_response.status_code == 200
- assert response.status_code == 200
- assert payload["Data"]["object"] == "response"
- assert "SHOULD_NOT_USE" not in runner.calls[-1]["input"]
- assert "真实上传文件内容" in runner.calls[-1]["input"]
- assert runner.calls[-1]["input_content"] == [
- {"type": "input_text", "text": "请总结上传文件"},
- {
- "type": "input_file",
- "filename": "report.txt",
- "file_url": uploaded["fileUri"],
- },
- ]
- assert runner.calls[-1]["current_attachments"][0]["file_uri"] == uploaded["fileUri"]
- assert runner.calls[-1]["current_attachment_results"][0]["text"] == "真实上传文件内容"
-
- session_id = payload["Data"]["session_id"]
- events = await service.get_events(session_id)
- assert events[0].content["parts"] == [
- {"type": "input_text", "text": "请总结上传文件"},
- {
- "type": "input_file",
- "filename": "report.txt",
- "file_url": uploaded["fileUri"],
- },
- ]
- assert events[0].metadata["attachments"] == [
- {
- "display_name": "report.txt",
- "file_uri": uploaded["fileUri"],
- "is_text": True,
- "mime_type": "text/plain",
- "size_bytes": len(attachment_bytes),
- "transport": "reference",
- }
- ]
- assert events[0].metadata["attachment_results"][0]["text_excerpt"] == "真实上传文件内容"
-
-
-@pytest.mark.asyncio
-async def test_real_http_run_agent_accepts_responses_input_without_legacy_messages(
- real_http_runtime,
-):
- base_url, runner, _ = real_http_runtime
- responses_input = [
- {
- "role": "user",
- "content": [{"type": "input_text", "text": "只使用 ResponsesInput"}],
- }
- ]
-
- async with httpx.AsyncClient(base_url=base_url, timeout=10, trust_env=False) as client:
- response = await client.post(
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": "demo-agent",
- "ApiFormat": "responses",
- "ResponsesInput": responses_input,
- "Stream": False,
- },
- )
-
- payload = response.json()
- assert response.status_code == 200
- assert payload["Data"]["object"] == "response"
- assert runner.calls[-1]["input_content"] == responses_input[0]["content"]
- assert runner.calls[-1]["input_messages"] == responses_input
-
-
-@pytest.mark.asyncio
-async def test_real_http_run_agent_uses_user_id_for_responses_runtime_trace(
- real_http_runtime,
-):
- base_url, runner, service = real_http_runtime
-
- async with httpx.AsyncClient(base_url=base_url, timeout=10, trust_env=False) as client:
- response = await client.post(
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": "demo-agent",
- "UserId": "ui-user-1",
- "ApiFormat": "responses",
- "Messages": [{"role": "user", "content": "hello"}],
- "ResponsesInput": [
- {
- "role": "user",
- "content": [{"type": "input_text", "text": "hello"}],
- }
- ],
- "Stream": False,
- },
- )
-
- payload = response.json()
- assert response.status_code == 200
- session_id = payload["Data"]["session_id"]
- session = await service.get_session(session_id)
- assert session is not None
- assert session.user_id == "ui-user-1"
- assert runner.calls[-1]["platform_context"]["user_id"] == "ui-user-1"
-
-
-@pytest.mark.asyncio
-async def test_real_http_static_ui_bundle_contains_responses_input_payload_builder(
- real_http_runtime,
-):
- base_url, _, _ = real_http_runtime
-
- async with httpx.AsyncClient(base_url=base_url, timeout=10, trust_env=False) as client:
- root_response = await client.get("/")
- marker = 'src="./assets/'
- start = root_response.text.index(marker) + len('src=".')
- end = root_response.text.index('"', start)
- asset_path = root_response.text[start:end]
- asset_response = await client.get(asset_path)
-
- assert root_response.status_code == 200
- assert asset_response.status_code == 200
- assert "ResponsesInput" in asset_response.text
- assert "file_url" in asset_response.text
-
-
-@pytest.mark.asyncio
-async def test_real_browser_hosted_ui_file_upload_sends_responses_input_to_runner(
- real_http_runtime,
-):
- chromium = _find_chromium_executable()
- if not chromium:
- pytest.skip("Chromium is required for the real Hosted UI browser E2E test")
-
- base_url, runner, _ = real_http_runtime
-
- async with _CdpBrowser(chromium) as browser:
- page = await browser.new_page(f"{base_url}/chat")
- await page.wait_for(
- """
- Boolean(
- document.querySelector('textarea') &&
- document.querySelector('input[type="file"]') &&
- document.querySelector('button[type="submit"]')
- )
- """
- )
- await page.evaluate(
- """
- (async () => {
- const fileInput = document.querySelector('input[type="file"]');
- const textarea = document.querySelector('textarea');
- const file = new File(
- [new TextEncoder().encode('真实浏览器上传内容')],
- 'browser-report.txt',
- { type: 'text/plain' }
- );
- const transfer = new DataTransfer();
- transfer.items.add(file);
- Object.defineProperty(fileInput, 'files', {
- value: transfer.files,
- configurable: true,
- });
- fileInput.dispatchEvent(new Event('change', { bubbles: true }));
- await new Promise((resolve) => setTimeout(resolve, 100));
-
- const valueSetter = Object.getOwnPropertyDescriptor(
- HTMLTextAreaElement.prototype,
- 'value'
- ).set;
- valueSetter.call(textarea, '请总结浏览器附件');
- textarea.dispatchEvent(new Event('input', { bubbles: true }));
- await new Promise((resolve) => setTimeout(resolve, 50));
- textarea.form.requestSubmit();
- return true;
- })()
- """
- )
-
- deadline = time.time() + 10
- while time.time() < deadline and not runner.calls:
- await asyncio.sleep(0.1)
- assert runner.calls, "Hosted UI did not reach RunAgent/runner"
- run_agent_bodies = await page.wait_for(
- "window.__ksadkE2E?.runAgentBodies?.length && window.__ksadkE2E.runAgentBodies",
- )
-
- run_agent_body = run_agent_bodies[0]
- assert run_agent_body["ApiFormat"] == "responses"
- assert run_agent_body["ResponsesInput"][0]["content"][0] == {
- "type": "input_text",
- "text": "请总结浏览器附件",
- }
- uploaded_part = run_agent_body["ResponsesInput"][0]["content"][1]
- assert uploaded_part["type"] == "input_file"
- assert uploaded_part["filename"] == "browser-report.txt"
- assert uploaded_part["file_url"].startswith("ksadk-upload://")
- assert run_agent_body["Messages"] == run_agent_body["ResponsesInput"]
-
- runner_payload = runner.calls[-1]
- assert runner_payload["input_content"] == run_agent_body["ResponsesInput"][0]["content"]
- assert runner_payload["current_attachments"][0]["display_name"] == "browser-report.txt"
- assert runner_payload["current_attachment_results"][0]["text"] == "真实浏览器上传内容"
- assert runner_payload["has_current_files"] is True
-
-
-@pytest.mark.asyncio
-async def test_real_browser_hosted_ui_image_upload_sends_input_image_to_runner(
- real_http_runtime,
-):
- chromium = _find_chromium_executable()
- if not chromium:
- pytest.skip("Chromium is required for the real Hosted UI browser E2E test")
-
- base_url, runner, _ = real_http_runtime
-
- async with _CdpBrowser(chromium) as browser:
- page = await browser.new_page(f"{base_url}/chat")
- await page.wait_for(
- """
- Boolean(
- document.querySelector('textarea') &&
- document.querySelector('input[type="file"]') &&
- document.querySelector('button[type="submit"]')
- )
- """
- )
- await page.evaluate(
- """
- (async () => {
- const fileInput = document.querySelector('input[type="file"]');
- const textarea = document.querySelector('textarea');
- const file = new File(
- [new Uint8Array([0x89, 0x50, 0x4e, 0x47])],
- 'browser-image.png',
- { type: 'image/png' }
- );
- const transfer = new DataTransfer();
- transfer.items.add(file);
- Object.defineProperty(fileInput, 'files', {
- value: transfer.files,
- configurable: true,
- });
- fileInput.dispatchEvent(new Event('change', { bubbles: true }));
- await new Promise((resolve) => setTimeout(resolve, 100));
-
- const valueSetter = Object.getOwnPropertyDescriptor(
- HTMLTextAreaElement.prototype,
- 'value'
- ).set;
- valueSetter.call(textarea, '请看看这张图');
- textarea.dispatchEvent(new Event('input', { bubbles: true }));
- await new Promise((resolve) => setTimeout(resolve, 50));
- textarea.form.requestSubmit();
- return true;
- })()
- """
- )
-
- deadline = time.time() + 10
- while time.time() < deadline and not runner.calls:
- await asyncio.sleep(0.1)
- assert runner.calls, "Hosted UI did not reach RunAgent/runner"
- run_agent_bodies = await page.wait_for(
- "window.__ksadkE2E?.runAgentBodies?.length && window.__ksadkE2E.runAgentBodies",
- )
-
- run_agent_body = run_agent_bodies[0]
- assert run_agent_body["ApiFormat"] == "responses"
- assert run_agent_body["ResponsesInput"][0]["content"][0] == {
- "type": "input_text",
- "text": "请看看这张图",
- }
- image_part = run_agent_body["ResponsesInput"][0]["content"][1]
- assert image_part["type"] == "input_image"
- assert image_part["image_url"].startswith("data:image/png;base64,")
- assert "UploadFile" not in [
- body.get("Action")
- for body in run_agent_bodies
- if isinstance(body, dict)
- ]
-
- runner_payload = runner.calls[-1]
- assert runner_payload["input_content"] == run_agent_body["ResponsesInput"][0]["content"]
- assert runner_payload["current_attachments"][0]["display_name"] == "uploaded_image"
- assert runner_payload["current_attachments"][0]["mime_type"] == "image/png"
- assert runner_payload["has_current_files"] is True
diff --git a/tests/test_openclaw_env_vars.py b/tests/test_openclaw_env_vars.py
deleted file mode 100644
index 169872da..00000000
--- a/tests/test_openclaw_env_vars.py
+++ /dev/null
@@ -1,509 +0,0 @@
-import asyncio
-import re
-import json
-import pytest
-
-from ksadk.cli import cmd_openclaw
-
-
-class _FakeOpenClawBootstrapClient:
- kwargs = None
- bootstrap_kwargs = None
-
- def __init__(self, *args, **kwargs):
- self.__class__.kwargs = dict(kwargs)
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
- async def get_client_bootstrap_config(self, **kwargs):
- self.__class__.bootstrap_kwargs = dict(kwargs)
- return {
- "configs": {
- "bootstrap.default_image": "registry.example.com/openclaw:db",
- }
- }
-
-
-def test_fetch_openclaw_bootstrap_config_ignores_dry_run(monkeypatch):
- _FakeOpenClawBootstrapClient.kwargs = None
- _FakeOpenClawBootstrapClient.bootstrap_kwargs = None
- monkeypatch.setenv("AGENTENGINE_GLOBAL_DRY_RUN", "1")
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawBootstrapClient)
-
- result = asyncio.run(cmd_openclaw._fetch_bootstrap_config("pre-online"))
-
- assert result["configs"]["bootstrap.default_image"] == "registry.example.com/openclaw:db"
- assert _FakeOpenClawBootstrapClient.kwargs["region"] == "pre-online"
- assert _FakeOpenClawBootstrapClient.bootstrap_kwargs["ignore_dry_run"] is True
-
-
-def test_build_openclaw_env_vars_defaults_to_trusted_proxy(monkeypatch):
- monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {})
- monkeypatch.delenv("OPENCLAW_GATEWAY_AUTH_MODE", raising=False)
- monkeypatch.delenv("OPENCLAW_TRUSTED_PROXY_USER_HEADER", raising=False)
- monkeypatch.delenv("OPENCLAW_TRUSTED_PROXIES", raising=False)
-
- env = cmd_openclaw._build_openclaw_env_vars()
-
- assert env["OPENCLAW_GATEWAY_AUTH_MODE"] == "trusted-proxy"
- assert env["OPENCLAW_TRUSTED_PROXY_USER_HEADER"] == "x-forwarded-user"
- assert env["OPENCLAW_INTERNAL_TRUSTED_PROXY_USER"] == "openclaw-backend"
- assert env["OPENCLAW_INTERNAL_TRUSTED_PROXY_USER_HEADER"] == "x-forwarded-user"
- assert env["OPENCLAW_TRUSTED_PROXIES"] == "127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,35.0.0.0/8"
-
-
-def test_build_openclaw_env_vars_switches_to_token_mode_when_token_configured(monkeypatch):
- monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {})
- monkeypatch.setenv("OPENCLAW_GATEWAY_TOKEN", "gateway-token-demo")
-
- env = cmd_openclaw._build_openclaw_env_vars()
-
- assert env["OPENCLAW_GATEWAY_AUTH_MODE"] == "token"
- assert env["OPENCLAW_GATEWAY_TOKEN"] == "gateway-token-demo"
- assert env["OPENCLAW_GATEWAY_PASSWORD"] == "gateway-token-demo"
-
-
-def test_build_openclaw_env_vars_accepts_password_alias_for_token_mode(monkeypatch):
- monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {})
- monkeypatch.setenv("OPENCLAW_GATEWAY_AUTH_MODE", "token")
- monkeypatch.setenv("OPENCLAW_GATEWAY_PASSWORD", "gateway-password-demo")
-
- env = cmd_openclaw._build_openclaw_env_vars()
-
- assert env["OPENCLAW_GATEWAY_AUTH_MODE"] == "token"
- assert env["OPENCLAW_GATEWAY_TOKEN"] == "gateway-password-demo"
- assert env["OPENCLAW_GATEWAY_PASSWORD"] == "gateway-password-demo"
-
-
-def test_build_openclaw_env_vars_rejects_mismatched_token_and_password(monkeypatch):
- monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {})
- monkeypatch.setenv("OPENCLAW_GATEWAY_AUTH_MODE", "token")
- monkeypatch.setenv("OPENCLAW_GATEWAY_TOKEN", "gateway-token-demo")
- monkeypatch.setenv("OPENCLAW_GATEWAY_PASSWORD", "gateway-password-other")
-
- with pytest.raises(ValueError, match="OPENCLAW_GATEWAY_TOKEN.*OPENCLAW_GATEWAY_PASSWORD"):
- cmd_openclaw._build_openclaw_env_vars()
-
-
-def test_build_openclaw_env_vars_rejects_token_secret_outside_token_mode(monkeypatch):
- monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {})
- monkeypatch.setenv("OPENCLAW_GATEWAY_AUTH_MODE", "trusted-proxy")
- monkeypatch.setenv("OPENCLAW_GATEWAY_TOKEN", "gateway-token-demo")
-
- with pytest.raises(ValueError, match="仅在 OPENCLAW_GATEWAY_AUTH_MODE=token 时支持"):
- cmd_openclaw._build_openclaw_env_vars()
-
-
-def test_build_openclaw_env_vars_requires_secret_for_token_mode(monkeypatch):
- monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {})
- monkeypatch.setenv("OPENCLAW_GATEWAY_AUTH_MODE", "token")
- monkeypatch.delenv("OPENCLAW_GATEWAY_TOKEN", raising=False)
- monkeypatch.delenv("OPENCLAW_GATEWAY_PASSWORD", raising=False)
-
- with pytest.raises(ValueError, match="OPENCLAW_GATEWAY_TOKEN"):
- cmd_openclaw._build_openclaw_env_vars()
-
-
-def test_build_openclaw_env_vars_uses_custom_trusted_proxy_env(monkeypatch):
- monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {})
- monkeypatch.setenv("OPENCLAW_GATEWAY_AUTH_MODE", "trusted-proxy")
- monkeypatch.setenv("OPENCLAW_TRUSTED_PROXY_USER_HEADER", "x-auth-request-user")
- monkeypatch.setenv("OPENCLAW_INTERNAL_TRUSTED_PROXY_USER", "internal-agent")
- monkeypatch.setenv("OPENCLAW_TRUSTED_PROXIES", '["10.244.0.0/16","10.96.0.0/12"]')
-
- env = cmd_openclaw._build_openclaw_env_vars()
-
- assert env["OPENCLAW_GATEWAY_AUTH_MODE"] == "trusted-proxy"
- assert env["OPENCLAW_TRUSTED_PROXY_USER_HEADER"] == "x-auth-request-user"
- assert env["OPENCLAW_INTERNAL_TRUSTED_PROXY_USER"] == "internal-agent"
- assert env["OPENCLAW_INTERNAL_TRUSTED_PROXY_USER_HEADER"] == "x-auth-request-user"
- assert env["OPENCLAW_TRUSTED_PROXIES"] == "10.244.0.0/16,10.96.0.0/12"
-
-
-def test_build_openclaw_env_vars_defaults_to_auto_approval_first(monkeypatch):
- monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {})
- monkeypatch.delenv("OPENCLAW_EXEC_HOST", raising=False)
- monkeypatch.delenv("OPENCLAW_EXEC_ASK", raising=False)
- monkeypatch.delenv("OPENCLAW_EXEC_ASK_FALLBACK", raising=False)
- monkeypatch.delenv("OPENCLAW_EXEC_AUTO_ALLOW_SKILLS", raising=False)
- monkeypatch.delenv("OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED", raising=False)
- monkeypatch.delenv("OPENCLAW_EXEC_ALLOWLIST", raising=False)
- monkeypatch.delenv("OPENCLAW_FS_WORKSPACE_ONLY", raising=False)
- monkeypatch.delenv("OPENCLAW_MODEL_API_KEY_SECRET_SOURCE", raising=False)
- monkeypatch.delenv("OPENCLAW_MODEL_API_KEY_SECRET_FILE_PATH", raising=False)
- monkeypatch.delenv("OPENCLAW_EXEC_STRICT_MODE", raising=False)
- monkeypatch.delenv("OPENCLAW_EXEC_SAFE_MODE", raising=False)
-
- env = cmd_openclaw._build_openclaw_env_vars()
-
- assert env["OPENCLAW_EXEC_HOST"] == "gateway"
- assert env["OPENCLAW_EXEC_STRICT_MODE"] == "false"
- assert env["OPENCLAW_EXEC_UNSAFE_MODE"] == "true"
- assert env["OPENCLAW_EXEC_SECURITY"] == "full"
- assert env["OPENCLAW_EXEC_ASK"] == "off"
- assert env["OPENCLAW_EXEC_ASK_FALLBACK"] == "full"
- assert env["OPENCLAW_EXEC_AUTO_ALLOW_SKILLS"] == "false"
- assert env["OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED"] == "false"
- assert env["OPENCLAW_FS_WORKSPACE_ONLY"] == "false"
- assert env["OPENCLAW_MODEL_API_KEY_SECRET_SOURCE"] == "file"
- assert "OPENCLAW_EXEC_ALLOWLIST" not in env
- assert "OPENCLAW_MODEL_API_KEY_SECRET_FILE_PATH" not in env
-
-
-def test_build_openclaw_env_vars_exposes_exec_confirmation_controls(monkeypatch):
- monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {})
- monkeypatch.setenv("OPENCLAW_EXEC_HOST", "node")
- monkeypatch.setenv("OPENCLAW_EXEC_SECURITY", "deny")
- monkeypatch.setenv("OPENCLAW_EXEC_ASK", "on-miss")
- monkeypatch.setenv("OPENCLAW_EXEC_ASK_FALLBACK", "allowlist")
- monkeypatch.setenv("OPENCLAW_EXEC_AUTO_ALLOW_SKILLS", "true")
- monkeypatch.setenv("OPENCLAW_ELEVATED_ENABLED", "true")
- monkeypatch.setenv("OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED", "false")
- monkeypatch.setenv("OPENCLAW_EXEC_ALLOWLIST", "/opt/tools/read-only")
- monkeypatch.setenv("OPENCLAW_FS_WORKSPACE_ONLY", "false")
- monkeypatch.setenv("OPENCLAW_MODEL_API_KEY_SECRET_SOURCE", "env")
- monkeypatch.setenv("OPENCLAW_MODEL_API_KEY_SECRET_FILE_PATH", "/tmp/runtime-secrets.json")
-
- env = cmd_openclaw._build_openclaw_env_vars()
-
- assert env["OPENCLAW_EXEC_HOST"] == "node"
- assert env["OPENCLAW_EXEC_SECURITY"] == "deny"
- assert env["OPENCLAW_EXEC_ASK"] == "on-miss"
- assert env["OPENCLAW_EXEC_ASK_FALLBACK"] == "allowlist"
- assert env["OPENCLAW_EXEC_AUTO_ALLOW_SKILLS"] == "true"
- assert env["OPENCLAW_ELEVATED_ENABLED"] == "true"
- assert env["OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED"] == "false"
- assert env["OPENCLAW_EXEC_ALLOWLIST"] == "/opt/tools/read-only"
- assert env["OPENCLAW_FS_WORKSPACE_ONLY"] == "false"
- assert env["OPENCLAW_MODEL_API_KEY_SECRET_SOURCE"] == "env"
- assert env["OPENCLAW_MODEL_API_KEY_SECRET_FILE_PATH"] == "/tmp/runtime-secrets.json"
-
-
-def test_build_openclaw_env_vars_enables_strict_mode_when_requested(monkeypatch):
- monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {})
- monkeypatch.setenv("OPENCLAW_EXEC_STRICT_MODE", "true")
- monkeypatch.delenv("OPENCLAW_EXEC_SECURITY", raising=False)
- monkeypatch.delenv("OPENCLAW_EXEC_ASK_FALLBACK", raising=False)
- monkeypatch.delenv("OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED", raising=False)
-
- env = cmd_openclaw._build_openclaw_env_vars()
-
- assert env["OPENCLAW_EXEC_STRICT_MODE"] == "true"
- assert env["OPENCLAW_EXEC_UNSAFE_MODE"] == "false"
- assert env["OPENCLAW_EXEC_SECURITY"] == "allowlist"
- assert env["OPENCLAW_EXEC_ASK"] == "off"
- assert env["OPENCLAW_EXEC_ASK_FALLBACK"] == "allowlist"
- assert env["OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED"] == "true"
-
-
-def test_build_openclaw_env_vars_applies_strict_security_profile(monkeypatch):
- monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {})
- monkeypatch.setenv("OPENCLAW_EXEC_STRICT_MODE", "false")
- monkeypatch.setenv("OPENCLAW_EXEC_SECURITY", "full")
- monkeypatch.setenv("OPENCLAW_EXEC_ASK_FALLBACK", "full")
- monkeypatch.setenv("OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED", "false")
- monkeypatch.setenv("OPENCLAW_FS_WORKSPACE_ONLY", "true")
-
- env = cmd_openclaw._build_openclaw_env_vars(security_profile="strict")
-
- assert env["OPENCLAW_EXEC_STRICT_MODE"] == "true"
- assert env["OPENCLAW_EXEC_UNSAFE_MODE"] == "false"
- assert env["OPENCLAW_EXEC_SECURITY"] == "allowlist"
- assert env["OPENCLAW_EXEC_ASK"] == "off"
- assert env["OPENCLAW_EXEC_ASK_FALLBACK"] == "allowlist"
- assert env["OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED"] == "true"
- assert env["OPENCLAW_FS_WORKSPACE_ONLY"] == "false"
-
-
-def test_build_openclaw_env_vars_applies_strictest_security_profile(monkeypatch):
- monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {})
- monkeypatch.setenv("OPENCLAW_EXEC_SECURITY", "allowlist")
- monkeypatch.setenv("OPENCLAW_EXEC_ASK_FALLBACK", "allowlist")
- monkeypatch.setenv("OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED", "true")
- monkeypatch.setenv("OPENCLAW_FS_WORKSPACE_ONLY", "false")
-
- env = cmd_openclaw._build_openclaw_env_vars(security_profile="strictest")
-
- assert env["OPENCLAW_EXEC_STRICT_MODE"] == "true"
- assert env["OPENCLAW_EXEC_UNSAFE_MODE"] == "false"
- assert env["OPENCLAW_EXEC_SECURITY"] == "deny"
- assert env["OPENCLAW_EXEC_ASK"] == "off"
- assert env["OPENCLAW_EXEC_ASK_FALLBACK"] == "deny"
- assert env["OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED"] == "false"
- assert env["OPENCLAW_FS_WORKSPACE_ONLY"] == "true"
-
-
-def test_build_openclaw_env_vars_defaults_exec_to_relaxed_without_explicit_profile(monkeypatch):
- monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {})
- monkeypatch.delenv("OPENCLAW_EXEC_STRICT_MODE", raising=False)
- monkeypatch.delenv("OPENCLAW_EXEC_SECURITY", raising=False)
- monkeypatch.delenv("OPENCLAW_EXEC_ASK_FALLBACK", raising=False)
- monkeypatch.delenv("OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED", raising=False)
-
- env = cmd_openclaw._build_openclaw_env_vars()
-
- assert env["OPENCLAW_EXEC_STRICT_MODE"] == "false"
- assert env["OPENCLAW_EXEC_UNSAFE_MODE"] == "true"
- assert env["OPENCLAW_EXEC_SECURITY"] == "full"
- assert env["OPENCLAW_EXEC_ASK_FALLBACK"] == "full"
- assert env["OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED"] == "false"
-
-
-def test_build_openclaw_env_vars_injects_default_model_policy(monkeypatch):
- monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {})
- monkeypatch.delenv("OPENCLAW_DEFAULT_MODEL", raising=False)
- monkeypatch.delenv("OPENAI_MODEL_NAME", raising=False)
- monkeypatch.delenv("MODEL_NAME", raising=False)
- monkeypatch.delenv("LLM_MODEL", raising=False)
- monkeypatch.delenv("OPENCLAW_MODEL_BASE_URL", raising=False)
- monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
- monkeypatch.delenv("OPENAI_API_BASE", raising=False)
- monkeypatch.delenv("OPENCLAW_MODEL_CATALOG_JSON", raising=False)
- monkeypatch.delenv("OPENCLAW_MODEL_PROVIDER_ID", raising=False)
- monkeypatch.delenv("OPENCLAW_MODEL_API", raising=False)
-
- env = cmd_openclaw._build_openclaw_env_vars()
-
- assert "OPENCLAW_DEFAULT_MODEL" not in env
- assert env["OPENAI_MODEL_NAME"] == "ksyun/glm-5.2"
- assert env["OPENCLAW_FALLBACK_MODEL"] == "ksyun/deepseek-v4-pro"
- assert env["OPENCLAW_IMAGE_MODEL"] == "ksyun/kimi-k2.7-code"
- assert "AGENTENGINE_MODEL_POLICY_JSON" in env
- catalog = json.loads(env["OPENCLAW_MODEL_CATALOG_JSON"])
- assert [item["id"] for item in catalog] == ["glm-5.2", "kimi-k2.7-code", "deepseek-v4-pro"]
- assert {item["id"]: item["reasoning"] for item in catalog} == {
- "glm-5.2": True,
- "kimi-k2.7-code": True,
- "deepseek-v4-pro": True,
- }
- assert "OPENCLAW_MODEL_BASE_URL" not in env
- assert "OPENCLAW_MODEL_PROVIDER_ID" not in env
- assert "OPENCLAW_MODEL_API" not in env
-
-
-def test_openclaw_model_policy_env_keeps_default_primary_with_catalog(monkeypatch):
- monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {})
- monkeypatch.delenv("OPENCLAW_DEFAULT_MODEL", raising=False)
- monkeypatch.delenv("OPENAI_MODEL_NAME", raising=False)
- monkeypatch.delenv("MODEL_NAME", raising=False)
- monkeypatch.delenv("LLM_MODEL", raising=False)
- monkeypatch.delenv("OPENCLAW_MODEL_BASE_URL", raising=False)
- monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
- monkeypatch.delenv("OPENAI_API_BASE", raising=False)
- monkeypatch.delenv("OPENCLAW_MODEL_PROVIDER_ID", raising=False)
- monkeypatch.delenv("OPENCLAW_MODEL_API", raising=False)
- monkeypatch.setenv("OPENCLAW_MODEL_CATALOG_JSON", '[{"id":"kimi-k2.7-code"},{"id":"glm-5.2"}]')
-
- env = cmd_openclaw._build_openclaw_env_vars()
-
- assert env["OPENAI_MODEL_NAME"] == "ksyun/glm-5.2"
- assert env["OPENCLAW_MODEL_CATALOG_JSON"] == '[{"id":"kimi-k2.7-code"},{"id":"glm-5.2"}]'
- assert "OPENCLAW_MODEL_BASE_URL" not in env
- assert "OPENCLAW_MODEL_PROVIDER_ID" not in env
- assert "OPENCLAW_MODEL_API" not in env
-
-
-def test_build_openclaw_env_vars_global_model_preference_keeps_dual_catalog(monkeypatch):
- monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {})
- monkeypatch.delenv("OPENCLAW_DEFAULT_MODEL", raising=False)
- monkeypatch.delenv("OPENCLAW_MODEL_CATALOG_JSON", raising=False)
- monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1")
-
- env = cmd_openclaw._build_openclaw_env_vars()
-
- assert env["OPENAI_MODEL_NAME"] == "ksyun/glm-5.1"
- assert "OPENCLAW_DEFAULT_MODEL" not in env
- catalog = json.loads(env["OPENCLAW_MODEL_CATALOG_JSON"])
- assert [item["id"] for item in catalog] == ["glm-5.2", "kimi-k2.7-code", "deepseek-v4-pro"]
-
-
-def test_build_openclaw_env_vars_explicit_glm5_is_forwarded_without_catalog(monkeypatch):
- monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {})
- monkeypatch.delenv("OPENCLAW_MODEL_CATALOG_JSON", raising=False)
- monkeypatch.setenv("OPENCLAW_DEFAULT_MODEL", "ksyun/glm-5.1")
-
- env = cmd_openclaw._build_openclaw_env_vars()
-
- assert env["OPENCLAW_DEFAULT_MODEL"] == "ksyun/glm-5.1"
- catalog = json.loads(env["OPENCLAW_MODEL_CATALOG_JSON"])
- assert [item["id"] for item in catalog] == ["glm-5.2", "kimi-k2.7-code", "deepseek-v4-pro"]
-
-
-def test_build_openclaw_env_vars_preserves_explicit_model_catalog(monkeypatch):
- monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {})
- monkeypatch.setenv("OPENCLAW_MODEL_CATALOG_JSON", '[{"id":"glm-5.1"}]')
-
- env = cmd_openclaw._build_openclaw_env_vars()
-
- assert env["OPENCLAW_MODEL_CATALOG_JSON"] == '[{"id":"glm-5.1"}]'
-
-
-def test_openclaw_provider_model_metadata_builds_catalog_for_creation(monkeypatch):
- monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {})
- monkeypatch.delenv("OPENCLAW_MODEL_CATALOG_JSON", raising=False)
- monkeypatch.setenv("OPENAI_MODEL_NAME", "deepseek-v4-pro")
-
- env = cmd_openclaw._build_openclaw_env_vars()
- changed = cmd_openclaw._apply_openclaw_provider_model_metadata(
- env,
- {
- "id": "deepseek-v4-pro",
- "context_window_tokens": 1_000_000,
- "max_output_tokens": 384_000,
- },
- )
-
- assert changed is True
- catalog = json.loads(env["OPENCLAW_MODEL_CATALOG_JSON"])
- assert [item["id"] for item in catalog] == [
- "glm-5.2",
- "kimi-k2.7-code",
- "deepseek-v4-pro",
- ]
- assert catalog[1]["options"] == {"temperature": 1}
- assert catalog[-1] == {
- "id": "deepseek-v4-pro",
- "name": "deepseek-v4-pro",
- "api": "openai-completions",
- "reasoning": True,
- "input": ["text", "image"],
- "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0},
- "contextWindow": 1_000_000,
- "maxTokens": 384_000,
- }
-
-
-def test_openclaw_provider_model_metadata_preserves_explicit_catalog_items(monkeypatch):
- monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {})
- monkeypatch.setenv(
- "OPENCLAW_MODEL_CATALOG_JSON",
- json.dumps(
- [
- {"id": "custom-model", "name": "custom-model"},
- {"id": "deepseek-v4-pro", "name": "old"},
- ]
- ),
- )
-
- env = cmd_openclaw._build_openclaw_env_vars()
- changed = cmd_openclaw._apply_openclaw_provider_model_metadata(
- env,
- {
- "id": "deepseek-v4-pro",
- "context_window_tokens": 1_000_000,
- }
- )
-
- assert changed is True
- catalog = json.loads(env["OPENCLAW_MODEL_CATALOG_JSON"])
- assert catalog[0]["id"] == "custom-model"
- assert catalog[1]["id"] == "deepseek-v4-pro"
- assert catalog[1]["contextWindow"] == 1_000_000
-
-
-def test_build_openclaw_env_vars_forwards_explicit_web_tool_overrides(monkeypatch):
- monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {})
- monkeypatch.setenv("OPENCLAW_WEB_FETCH_ENABLED", "true")
- monkeypatch.setenv("OPENCLAW_WEB_SEARCH_PROVIDER", "perplexity")
- monkeypatch.setenv("OPENCLAW_WEB_SEARCH_BASE_URL", "https://search.example.com/v1")
- monkeypatch.setenv("OPENCLAW_WEB_SEARCH_MODEL", "sonar-pro")
- monkeypatch.setenv("OPENCLAW_WEB_SEARCH_API_KEY_SECRET_SOURCE", "env")
- monkeypatch.setenv("OPENCLAW_WEB_SEARCH_API_KEY_SECRET_PROVIDER", "default")
- monkeypatch.setenv("OPENCLAW_WEB_SEARCH_API_KEY_SECRET_ID", "OPENCLAW_WEB_SEARCH_API_KEY")
-
- env = cmd_openclaw._build_openclaw_env_vars()
-
- assert env["OPENCLAW_WEB_FETCH_ENABLED"] == "true"
- assert env["OPENCLAW_WEB_SEARCH_PROVIDER"] == "perplexity"
- assert env["OPENCLAW_WEB_SEARCH_BASE_URL"] == "https://search.example.com/v1"
- assert env["OPENCLAW_WEB_SEARCH_MODEL"] == "sonar-pro"
- assert env["OPENCLAW_WEB_SEARCH_API_KEY_SECRET_SOURCE"] == "env"
- assert env["OPENCLAW_WEB_SEARCH_API_KEY_SECRET_PROVIDER"] == "default"
- assert env["OPENCLAW_WEB_SEARCH_API_KEY_SECRET_ID"] == "OPENCLAW_WEB_SEARCH_API_KEY"
-
-
-def test_build_openclaw_env_vars_forwards_explicit_builtin_browser_toggle(monkeypatch):
- monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {})
- monkeypatch.setenv("OPENCLAW_BROWSER_ENABLED", "true")
-
- env = cmd_openclaw._build_openclaw_env_vars()
-
- assert env["OPENCLAW_BROWSER_ENABLED"] == "true"
- assert env["OPENCLAW_BROWSER_NO_SANDBOX"] == "true"
- assert env["OPENCLAW_BROWSER_HEADLESS"] == "true"
-
-
-def test_build_openclaw_env_vars_forwards_explicit_browser_ssrf_policy_json(monkeypatch):
- monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {})
- monkeypatch.setenv(
- "OPENCLAW_BROWSER_SSRF_POLICY_JSON",
- '{"dangerouslyAllowPrivateNetwork":false,"hostnameAllowlist":["docs.example.com"]}',
- )
-
- env = cmd_openclaw._build_openclaw_env_vars()
-
- assert env["OPENCLAW_BROWSER_SSRF_POLICY_JSON"] == (
- '{"dangerouslyAllowPrivateNetwork":false,"hostnameAllowlist":["docs.example.com"]}'
- )
-
-
-def test_build_openclaw_env_vars_forwards_channel_bootstrap_json(monkeypatch):
- monkeypatch.setattr(cmd_openclaw, "_GLOBAL_ENV_CACHE", {})
- monkeypatch.setenv(
- "OPENCLAW_CHANNEL_BOOTSTRAP_JSON",
- '{"wps-xiezuo":{"appId":"app-demo","appSecret":"secret-demo"},"feishu":{"appId":"app-demo"}}',
- )
-
- env = cmd_openclaw._build_openclaw_env_vars()
-
- assert env["OPENCLAW_CHANNEL_BOOTSTRAP_JSON"] == (
- '{"wps-xiezuo":{"appId":"app-demo","appSecret":"secret-demo"},"feishu":{"appId":"app-demo"}}'
- )
-
-
-def test_parse_extra_openclaw_env_pairs_supports_custom_keys_and_explicit_override():
- parsed = cmd_openclaw._parse_extra_openclaw_env_pairs(
- (
- "FOO=bar",
- "OPENCLAW_GATEWAY_PORT=9090",
- "FOO=baz",
- "EMPTY_VALUE=",
- )
- )
-
- assert parsed == {
- "FOO": "baz",
- "OPENCLAW_GATEWAY_PORT": "9090",
- "EMPTY_VALUE": "",
- }
-
-
-def test_parse_extra_openclaw_env_pairs_rejects_invalid_items():
- with pytest.raises(ValueError, match="KEY=VALUE"):
- cmd_openclaw._parse_extra_openclaw_env_pairs(("MISSING_EQUALS",))
-
- with pytest.raises(ValueError, match="合法的环境变量名"):
- cmd_openclaw._parse_extra_openclaw_env_pairs(("1BAD=value",))
-
- with pytest.raises(ValueError, match="trusted-proxy、token 或 none"):
- cmd_openclaw._parse_extra_openclaw_env_pairs(("OPENCLAW_GATEWAY_AUTH_MODE=password",))
-
-
-def test_parse_extra_openclaw_env_pairs_accepts_token_auth_mode():
- parsed = cmd_openclaw._parse_extra_openclaw_env_pairs(("OPENCLAW_GATEWAY_AUTH_MODE=token",))
-
- assert parsed == {"OPENCLAW_GATEWAY_AUTH_MODE": "token"}
-
-
-def test_generate_default_openclaw_name_is_high_entropy():
- name1 = cmd_openclaw._generate_default_openclaw_name()
- name2 = cmd_openclaw._generate_default_openclaw_name()
-
- assert name1 != name2
- assert len(name1) <= 64
- assert name1.startswith("openclaw-gateway-")
- assert re.fullmatch(r"openclaw-gateway-\d{10}-[0-9a-f]{6}", name1) is not None
diff --git a/tests/test_openclaw_gateway.py b/tests/test_openclaw_gateway.py
deleted file mode 100644
index 4589e689..00000000
--- a/tests/test_openclaw_gateway.py
+++ /dev/null
@@ -1,127 +0,0 @@
-import asyncio
-
-import pytest
-
-from ksadk.openclaw_gateway import DashboardAccessInfo, OpenClawGatewayClient
-from ksadk.openclaw_gateway import OpenClawGatewayError
-
-
-class _CapturingGatewayClient(OpenClawGatewayClient):
- def __init__(self):
- super().__init__(region="pre-online", agent_id="ar-demo-1")
- self.connect_request = None
-
- async def build_access_info(self, **_kwargs):
- return DashboardAccessInfo(
- agent_id="ar-demo-1",
- agent_name="demo",
- access_url="http://dashboard.example.com/s/link",
- ws_url="ws://dashboard.example.com/",
- cookie_header="sid=demo",
- origin="http://dashboard.example.com",
- )
-
- async def _connect_ws(self, _ws_url, _headers):
- return object()
-
- async def _wait_for_connect_challenge(self, *, timeout_ms=10_000):
- return "nonce-demo"
-
- async def request(self, method, params=None, *, timeout_ms=30_000):
- if method == "connect":
- self.connect_request = dict(params or {})
- return {"features": {"methods": []}}
-
-
-def test_openclaw_gateway_client_uses_current_protocol_v4_for_managed_runtime():
- client = _CapturingGatewayClient()
-
- asyncio.run(client.connect())
-
- assert client.connect_request["minProtocol"] == 4
- assert client.connect_request["maxProtocol"] == 4
-
-
-class _FakeCookieJar:
- def get_dict(self):
- return {"ae_ui_session": "sid-demo"}
-
-
-class _FakeSession:
- def __init__(self, response):
- self.response = response
- self.cookies = _FakeCookieJar()
- self.calls = []
-
- def get(self, url, *, allow_redirects, timeout):
- self.calls.append(
- {
- "url": url,
- "allow_redirects": allow_redirects,
- "timeout": timeout,
- }
- )
- return self.response
-
-
-class _FakeResponse:
- def __init__(self, status_code):
- self.status_code = status_code
-
-
-def test_build_access_info_accepts_short_link_redirect_without_following(monkeypatch):
- client = OpenClawGatewayClient(region="pre-online", agent_id="ar-demo-1")
- client.session = _FakeSession(_FakeResponse(302))
-
- async def fake_create_dashboard_access_link(**_kwargs):
- return {"access_url": "http://dashboard.example.com/s/link", "link_id": "link"}
-
- class FakeAgentEngineClient:
- def __init__(self, **_kwargs):
- pass
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, *_args):
- return None
-
- create_dashboard_access_link = staticmethod(fake_create_dashboard_access_link)
-
- monkeypatch.setattr("ksadk.openclaw_gateway.AgentEngineClient", FakeAgentEngineClient)
-
- info = asyncio.run(client.build_access_info())
-
- assert info.cookie_header == "ae_ui_session=sid-demo"
- assert client.session.calls == [
- {
- "url": "http://dashboard.example.com/s/link",
- "allow_redirects": False,
- "timeout": 30,
- }
- ]
-
-
-def test_build_access_info_rejects_non_redirect_short_link(monkeypatch):
- client = OpenClawGatewayClient(region="pre-online", agent_id="ar-demo-1")
- client.session = _FakeSession(_FakeResponse(200))
-
- async def fake_create_dashboard_access_link(**_kwargs):
- return {"access_url": "http://dashboard.example.com/s/link", "link_id": "link"}
-
- class FakeAgentEngineClient:
- def __init__(self, **_kwargs):
- pass
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, *_args):
- return None
-
- create_dashboard_access_link = staticmethod(fake_create_dashboard_access_link)
-
- monkeypatch.setattr("ksadk.openclaw_gateway.AgentEngineClient", FakeAgentEngineClient)
-
- with pytest.raises(OpenClawGatewayError, match="HTTP 200"):
- asyncio.run(client.build_access_info())
diff --git a/tests/test_orchestration_agents.py b/tests/test_orchestration_agents.py
deleted file mode 100644
index e58fe1f1..00000000
--- a/tests/test_orchestration_agents.py
+++ /dev/null
@@ -1,327 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-
-import pytest
-
-from ksadk.agents import (
- AgentEvent,
- EventType,
- LoopAgent,
- OrchestrationContext,
- ParallelAgent,
- RunnerAgent,
- SequentialAgent,
-)
-
-
-async def _collect_events(agent, context: OrchestrationContext) -> list[AgentEvent]:
- return [event async for event in agent.run_async(context)]
-
-
-def _event_types(events: list[AgentEvent]) -> list[EventType]:
- return [event.event_type for event in events]
-
-
-@pytest.mark.asyncio
-async def test_sequential_agent_runs_sub_agents_in_order():
- async def research(context: OrchestrationContext) -> dict:
- return {
- "data": "research-notes",
- "state_delta": {"research": "research-notes"},
- }
-
- async def write(context: OrchestrationContext) -> dict:
- return {
- "data": f"draft:{context.get('research')}",
- "state_delta": {"draft": f"draft:{context.get('research')}"},
- }
-
- pipeline = SequentialAgent(name="pipeline", sub_agents=[research, write])
- context = OrchestrationContext(session_id="s1", state={"input": "topic"})
-
- events = await _collect_events(pipeline, context)
-
- assert context.state["research"] == "research-notes"
- assert context.state["draft"] == "draft:research-notes"
- assert [event.agent_name for event in events if event.event_type == EventType.TEXT_OUTPUT] == [
- "research",
- "write",
- ]
- assert _event_types(events).count(EventType.AGENT_START) >= 3
- assert _event_types(events).count(EventType.AGENT_END) >= 3
-
-
-@pytest.mark.asyncio
-async def test_parallel_agent_isolates_branch_context_and_merges_results():
- async def alpha(context: OrchestrationContext) -> dict:
- await asyncio.sleep(0.01)
- return {
- "data": "alpha-output",
- "state_delta": {"shared": "alpha", "alpha_only": 1},
- }
-
- async def beta(context: OrchestrationContext) -> dict:
- return {
- "data": "beta-output",
- "state_delta": {"shared": "beta", "beta_only": 2},
- }
-
- agent = ParallelAgent(name="fanout", sub_agents=[alpha, beta])
- context = OrchestrationContext(session_id="s1", state={"input": "topic", "seed": "base"})
-
- events = await _collect_events(agent, context)
-
- text_events = [event for event in events if event.event_type == EventType.TEXT_OUTPUT]
- assert {event.branch for event in text_events} == {"alpha", "beta"}
- assert context.state["seed"] == "base"
- assert context.state["alpha_only"] == 1
- assert context.state["beta_only"] == 2
- assert context.state["fanout_results"]["alpha"]["shared"] == "alpha"
- assert context.state["fanout_results"]["beta"]["shared"] == "beta"
- assert context.state["fanout_conflicts"]["shared"] == {"alpha": "alpha", "beta": "beta"}
-
-
-@pytest.mark.asyncio
-async def test_parallel_agent_does_not_leak_nested_state_between_branches():
- async def alpha(context: OrchestrationContext) -> dict:
- context.state["shared"]["items"].append("alpha")
- return {"state_delta": {"alpha_only": 1}}
-
- async def beta(context: OrchestrationContext) -> dict:
- context.state["shared"]["items"].append("beta")
- return {"state_delta": {"beta_only": 2}}
-
- agent = ParallelAgent(name="fanout", sub_agents=[alpha, beta])
- context = OrchestrationContext(state={"shared": {"items": []}})
-
- await _collect_events(agent, context)
-
- assert context.state["shared"] == {"items": []}
- assert context.state["fanout_results"]["alpha"]["shared"] == {"items": ["alpha"]}
- assert context.state["fanout_results"]["beta"]["shared"] == {"items": ["beta"]}
- assert context.state["fanout_conflicts"]["shared"] == {
- "alpha": {"items": ["alpha"]},
- "beta": {"items": ["beta"]},
- }
-
-
-@pytest.mark.asyncio
-async def test_parallel_agent_reraises_branch_failures():
- async def ok(context: OrchestrationContext) -> dict:
- return {"data": "ok", "state_delta": {"ok": True}}
-
- async def boom(context: OrchestrationContext) -> dict:
- raise RuntimeError("boom")
-
- agent = ParallelAgent(name="fanout", sub_agents=[ok, boom])
- context = OrchestrationContext()
- events: list[AgentEvent] = []
-
- with pytest.raises(RuntimeError, match="boom"):
- async for event in agent.run_async(context):
- events.append(event)
-
- assert any(
- event.agent_name == "boom"
- and event.event_type == EventType.ERROR
- and event.data == "boom"
- for event in events
- )
- assert any(
- event.agent_name == "fanout"
- and event.event_type == EventType.ERROR
- and event.data == "boom"
- for event in events
- )
-
-
-@pytest.mark.asyncio
-async def test_loop_agent_exits_on_max_iterations():
- async def tick(context: OrchestrationContext) -> dict:
- count = context.get("count", 0) + 1
- return {"data": count, "state_delta": {"count": count}}
-
- agent = LoopAgent(name="ticker", sub_agents=[tick], max_iterations=3)
- context = OrchestrationContext()
-
- events = await _collect_events(agent, context)
-
- assert context.state["count"] == 3
- assert [
- event.metadata["iteration"]
- for event in events
- if "iteration" in event.metadata
- ] == [0, 1, 2]
-
-
-@pytest.mark.asyncio
-async def test_loop_agent_exits_on_exit_condition():
- async def increment(context: OrchestrationContext) -> dict:
- count = context.get("count", 0) + 1
- return {"data": count, "state_delta": {"count": count}}
-
- agent = LoopAgent(
- name="until-two",
- sub_agents=[increment],
- exit_condition=lambda context: context.get("count", 0) >= 2,
- max_iterations=10,
- )
- context = OrchestrationContext()
-
- await _collect_events(agent, context)
-
- assert context.state["count"] == 2
-
-
-@pytest.mark.asyncio
-async def test_loop_agent_exits_on_escalate_event():
- async def review(context: OrchestrationContext) -> dict:
- count = context.get("count", 0) + 1
- return {
- "event_type": EventType.ESCALATE,
- "data": "needs-human",
- "state_delta": {"count": count},
- "escalate": True,
- }
-
- agent = LoopAgent(name="review-loop", sub_agents=[review], max_iterations=10)
- context = OrchestrationContext()
-
- events = await _collect_events(agent, context)
-
- assert context.state["count"] == 1
- assert any(event.event_type == EventType.ESCALATE for event in events)
-
-
-@pytest.mark.asyncio
-async def test_nested_orchestration_agents_share_state_through_parent_context():
- async def draft(context: OrchestrationContext) -> dict:
- return {"data": "draft", "state_delta": {"draft": "v1"}}
-
- async def review_a(context: OrchestrationContext) -> dict:
- return {"data": "review-a", "state_delta": {"score_a": 0.7}}
-
- async def review_b(context: OrchestrationContext) -> dict:
- return {"data": "review-b", "state_delta": {"score_b": 0.9}}
-
- parallel_reviews = ParallelAgent(name="reviews", sub_agents=[review_a, review_b])
- workflow = SequentialAgent(name="workflow", sub_agents=[draft, parallel_reviews])
- context = OrchestrationContext(state={"input": "topic"})
-
- await _collect_events(workflow, context)
-
- assert context.state["draft"] == "v1"
- assert context.state["score_a"] == 0.7
- assert context.state["score_b"] == 0.9
- assert context.state["reviews_results"]["review_a"]["score_a"] == 0.7
- assert context.state["reviews_results"]["review_b"]["score_b"] == 0.9
-
-
-class _InvokeOnlyRunner:
- def __init__(self):
- self.calls = []
-
- async def invoke(self, input_data):
- self.calls.append(input_data)
- return {"output": f"invoke:{input_data['input']}", "state_delta": {"runner_mode": "invoke"}}
-
-
-class _StreamingRunner:
- def __init__(self):
- self.calls = []
-
- async def invoke(self, input_data):
- self.calls.append(("invoke", input_data))
- return {"output": "unused"}
-
- async def stream(self, input_data):
- self.calls.append(("stream", input_data))
- yield {"delta": "hello", "type": "text"}
- yield {"delta": " world", "type": "text"}
-
-
-@pytest.mark.asyncio
-async def test_runner_adapter_supports_invoke_and_stream_modes():
- invoke_runner = _InvokeOnlyRunner()
- stream_runner = _StreamingRunner()
- agent = SequentialAgent(
- name="pipeline",
- sub_agents=[
- RunnerAgent(name="invoke_runner", runner=invoke_runner),
- stream_runner,
- ],
- )
- context = OrchestrationContext(session_id="session-1", state={"input": "hi"})
-
- events = await _collect_events(agent, context)
-
- assert invoke_runner.calls == [
- {"input": "hi", "state": {"input": "hi"}, "session_id": "session-1", "branch": ""}
- ]
- assert stream_runner.calls == [
- (
- "stream",
- {
- "input": "hi",
- "state": {
- "input": "hi",
- "runner_mode": "invoke",
- "invoke_runner_output": "invoke:hi",
- },
- "session_id": "session-1",
- "branch": "",
- },
- )
- ]
- assert context.state["runner_mode"] == "invoke"
- assert context.state["invoke_runner_output"] == "invoke:hi"
- assert context.state["streaming_runner_output"] == "hello world"
- assert [
- event.data for event in events if event.agent_name == "streaming_runner"
- ] == ["hello", " world"]
-
-
-@pytest.mark.asyncio
-async def test_orchestration_agent_yields_error_event_before_reraising():
- async def ok(context: OrchestrationContext) -> dict:
- return {"data": "ok"}
-
- async def boom(context: OrchestrationContext) -> dict:
- raise RuntimeError("boom")
-
- agent = SequentialAgent(name="pipeline", sub_agents=[ok, boom])
- context = OrchestrationContext()
- events: list[AgentEvent] = []
-
- with pytest.raises(RuntimeError, match="boom"):
- async for event in agent.run_async(context):
- events.append(event)
-
- assert any(
- event.agent_name == "pipeline"
- and event.event_type == EventType.ERROR
- and event.data == "boom"
- for event in events
- )
-
-
-def test_name_validation_rejects_invalid_and_duplicate_names():
- async def duplicate(context: OrchestrationContext) -> dict:
- return {"data": "one"}
-
- duplicate.__name__ = "same"
-
- async def also_duplicate(context: OrchestrationContext) -> dict:
- return {"data": "two"}
-
- also_duplicate.__name__ = "same"
-
- with pytest.raises(ValueError, match="valid identifier"):
- SequentialAgent(name="not valid", sub_agents=[])
-
- with pytest.raises(ValueError, match="unique"):
- SequentialAgent(name="pipeline", sub_agents=[duplicate, also_duplicate])
-
- with pytest.raises(ValueError, match="valid identifier"):
- SequentialAgent(name="pipeline", sub_agents=[lambda context: context])
diff --git a/tests/test_patch_langchain.py b/tests/test_patch_langchain.py
deleted file mode 100644
index 177e07f1..00000000
--- a/tests/test_patch_langchain.py
+++ /dev/null
@@ -1,93 +0,0 @@
-from __future__ import annotations
-
-from langchain_core.messages import HumanMessage
-from langchain_openai import ChatOpenAI
-
-from ksadk.runtime_context import PlatformInvocationContext, platform_invocation_scope
-from ksadk.runners.patch_langchain import apply_patch
-
-
-def _context() -> PlatformInvocationContext:
- return PlatformInvocationContext(
- agent_id="demo-agent",
- user_id="user",
- session_id="sess-1",
- history=[],
- input_content=[],
- input_messages=[],
- input_parts=[],
- attachments=[],
- attachment_results=[],
- current_attachments=[],
- current_attachment_results=[],
- has_current_files=False,
- runner_type="langgraph",
- model="gpt-4o",
- model_options={"thinking": {"type": "disabled"}},
- )
-
-
-def test_chat_openai_patch_maps_request_model_options_for_chat_completions():
- apply_patch()
- llm = ChatOpenAI(model="gpt-4o", api_key="sk-test", use_responses_api=False)
-
- with platform_invocation_scope(_context()):
- payload = llm._get_request_payload([HumanMessage(content="hello")])
-
- assert "reasoning_effort" not in payload
- assert payload["extra_body"]["max_reasoning_tokens"] == 0
- assert payload["extra_body"]["enable_thinking"] is False
- assert payload["extra_body"]["chat_template_kwargs"]["enable_thinking"] is False
- assert "thinking" not in payload["extra_body"]
-
-
-def test_chat_openai_patch_keeps_supported_reasoning_effort_for_chat_completions():
- apply_patch()
- llm = ChatOpenAI(model="gpt-4o", api_key="sk-test", use_responses_api=False)
- context = _context()
- context.model_options = {"reasoning": {"effort": "low"}}
-
- with platform_invocation_scope(context):
- payload = llm._get_request_payload([HumanMessage(content="hello")])
-
- assert payload["reasoning_effort"] == "low"
-
-
-def test_chat_openai_patch_maps_enabled_thinking_to_reasoning_effort_for_chat_completions():
- apply_patch()
- llm = ChatOpenAI(model="gpt-4o", api_key="sk-test", use_responses_api=False)
- context = _context()
- context.model_options = {"thinking": {"type": "enabled"}}
-
- with platform_invocation_scope(context):
- payload = llm._get_request_payload([HumanMessage(content="hello")])
-
- assert payload["reasoning_effort"] == "medium"
- assert "extra_body" not in payload or "thinking" not in payload.get("extra_body", {})
-
-
-def test_chat_openai_patch_maps_request_model_options_for_responses_api():
- apply_patch()
- llm = ChatOpenAI(model="gpt-4o", api_key="sk-test", use_responses_api=True)
-
- with platform_invocation_scope(_context()):
- payload = llm._get_request_payload([HumanMessage(content="hello")])
-
- assert payload["reasoning"] == {"effort": "none"}
- assert payload["extra_body"]["thinking"] == {"type": "disabled"}
- assert payload["extra_body"]["max_reasoning_tokens"] == 0
- assert payload["extra_body"]["enable_thinking"] is False
- assert payload["extra_body"]["chat_template_kwargs"]["enable_thinking"] is False
-
-
-def test_chat_openai_patch_preserves_temperature_override():
- apply_patch()
- llm = ChatOpenAI(model="kimi-k2.7-code", api_key="sk-test", use_responses_api=False)
- context = _context()
- context.model = "kimi-k2.7-code"
- context.model_options = {"temperature": 1}
-
- with platform_invocation_scope(context):
- payload = llm._get_request_payload([HumanMessage(content="hello")])
-
- assert payload["temperature"] == 1
diff --git a/tests/test_platform_memory_tools.py b/tests/test_platform_memory_tools.py
deleted file mode 100644
index beee3df0..00000000
--- a/tests/test_platform_memory_tools.py
+++ /dev/null
@@ -1,142 +0,0 @@
-from __future__ import annotations
-
-from ksadk.runtime_context import PlatformInvocationContext, platform_invocation_scope
-
-
-class _FakeMemoryService:
- def __init__(self):
- self.search_calls: list[tuple[str, str, int | None]] = []
- self.save_calls: list[tuple[str, str, dict]] = []
- self._backend = None
-
- def search_text(self, *, user_id: str, query: str, top_k: int | None = None) -> str:
- self.search_calls.append((user_id, query, top_k))
- return f"memories for {user_id}: {query}"
-
- def save_text(self, *, user_id: str, content: str, metadata: dict) -> bool:
- self.save_calls.append((user_id, content, metadata))
- return True
-
-
-class _AcceptedButUnverifiedMemoryService(_FakeMemoryService):
- def __init__(self):
- super().__init__()
- class SdkLTMBackend:
- last_error = ""
-
- def get_session_status(self, *, user_id: str, session_id: str) -> dict:
- return {"SessionId": session_id, "State": 0}
-
- self._backend = SdkLTMBackend()
-
- def search_entries(self, *, user_id: str, query: str, top_k: int | None = None) -> list[str]:
- self.search_calls.append((user_id, query, top_k))
- return []
-
-
-class _FailingMemoryService(_FakeMemoryService):
- def __init__(self):
- super().__init__()
- self._backend = type("Backend", (), {"last_error": "NotFound: missing memory"})()
-
- def save_text(self, *, user_id: str, content: str, metadata: dict) -> bool:
- self.save_calls.append((user_id, content, metadata))
- return False
-
-
-def _context() -> PlatformInvocationContext:
- return PlatformInvocationContext(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-1",
- history=[{"role": "user", "content": "hello"}],
- input_content=[],
- input_messages=[],
- input_parts=[],
- attachments=[],
- attachment_results=[],
- current_attachments=[],
- current_attachment_results=[],
- has_current_files=False,
- runner_type="langgraph",
- )
-
-
-def test_load_memory_uses_platform_invocation_context(monkeypatch):
- from ksadk.memory.tool import load_memory
-
- service = _FakeMemoryService()
- monkeypatch.setattr("ksadk.memory.tool._get_or_create_service", lambda: service)
-
- with platform_invocation_scope(_context()):
- result = load_memory("project status")
-
- assert result == "memories for user-1: project status"
- assert service.search_calls == [("user-1", "project status", None)]
-
-
-def test_save_memory_persists_agent_and_session_metadata(monkeypatch):
- from ksadk.memory.tool import save_memory
-
- service = _FakeMemoryService()
- monkeypatch.setattr("ksadk.memory.tool._get_or_create_service", lambda: service)
-
- with platform_invocation_scope(_context()):
- result = save_memory("用户喜欢云主机")
-
- assert result == {"ok": True, "status": "persisted", "message": "记忆已保存。"}
- assert service.save_calls == [
- (
- "user-1",
- "用户喜欢云主机",
- {
- "agent_id": "demo-agent",
- "session_id": "sess-1",
- "runner_type": "langgraph",
- },
- )
- ]
-
-
-def test_save_memory_without_runtime_context_returns_diagnostic(monkeypatch):
- from ksadk.memory.tool import save_memory
-
- service = _FakeMemoryService()
- monkeypatch.setattr("ksadk.memory.tool._get_or_create_service", lambda: service)
-
- result = save_memory("no context")
-
- assert result["ok"] is False
- assert "缺少运行时上下文" in result["message"]
- assert service.save_calls == []
-
-
-def test_save_memory_failure_includes_backend_error(monkeypatch):
- from ksadk.memory.tool import save_memory
-
- service = _FailingMemoryService()
- monkeypatch.setattr("ksadk.memory.tool._get_or_create_service", lambda: service)
-
- with platform_invocation_scope(_context()):
- result = save_memory("用户喜欢云主机")
-
- assert result["ok"] is False
- assert "记忆保存失败" in result["message"]
- assert "NotFound: missing memory" in result["message"]
-
-
-def test_save_memory_reports_unverified_sdk_acceptance(monkeypatch):
- from ksadk.memory.tool import save_memory
-
- service = _AcceptedButUnverifiedMemoryService()
- monkeypatch.setattr("ksadk.memory.tool._get_or_create_service", lambda: service)
-
- with platform_invocation_scope(_context()):
- result = save_memory("用户喜欢云主机")
-
- assert result["ok"] is False
- assert result["status"] == "accepted_not_extracted"
- assert "尚未抽取" in result["message"]
- assert result["session_id"] == "sess-1"
- assert result["session_state"] == 0
- assert service.search_calls == [("user-1", "用户喜欢云主机", 1)]
diff --git a/tests/test_postgres_session_service.py b/tests/test_postgres_session_service.py
deleted file mode 100644
index d376136e..00000000
--- a/tests/test_postgres_session_service.py
+++ /dev/null
@@ -1,224 +0,0 @@
-from __future__ import annotations
-
-import os
-import sys
-from types import SimpleNamespace
-
-import pytest
-
-from ksadk.sessions.errors import SessionBackendUnavailable
-from ksadk.sessions.base import SessionEvent
-
-pytestmark = pytest.mark.asyncio
-
-
-async def test_postgres_session_service_uses_configured_connect_timeout(monkeypatch):
- from ksadk.sessions.postgres_service import PostgresSessionService
-
- observed: dict[str, object] = {}
-
- async def fake_create_pool(**kwargs):
- observed.update(kwargs)
- raise TimeoutError("connect timed out")
-
- monkeypatch.setitem(sys.modules, "asyncpg", SimpleNamespace(create_pool=fake_create_pool))
-
- service = PostgresSessionService(
- dsn="postgresql://ksadk:secret@db.example.test:5432/session",
- connect_timeout=0.25,
- )
-
- with pytest.raises(SessionBackendUnavailable) as exc_info:
- await service.create_session("demo-agent", "user-1")
-
- assert observed["timeout"] == 0.25
- assert "Postgres session backend unavailable" in str(exc_info.value)
- assert "secret" not in str(exc_info.value)
-
-
-async def test_postgres_session_service_two_instances_share_sessions_events_and_state():
- dsn = os.getenv("KSADK_TEST_POSTGRES_DSN")
- if not dsn:
- pytest.skip("Set KSADK_TEST_POSTGRES_DSN to run Postgres session integration tests")
-
- from ksadk.sessions.postgres_service import PostgresSessionService
-
- namespace = "pytest_cross_pod"
- service_a = PostgresSessionService(dsn=dsn, namespace=namespace)
- service_b = PostgresSessionService(dsn=dsn, namespace=namespace)
- session_id = "pytest-sess-cross-pod"
-
- try:
- await service_a.delete_session(session_id)
- created = await service_a.create_session(
- agent_id="demo-agent",
- user_id="user-1",
- session_id=session_id,
- )
- await service_a.append_event(
- session_id,
- SessionEvent(
- id="pytest-evt-1",
- author="user",
- event_type="user_message",
- content={"role": "user", "parts": [{"text": "hello"}]},
- state_delta={"turns": 1},
- metadata={"tenant_id": "tenant-a"},
- ),
- )
- await service_a.update_state(
- agent_id="demo-agent",
- user_id="user-1",
- session_id=session_id,
- scope="runner_runtime:langgraph",
- state_delta={"path": "replay", "level": "semantic"},
- )
-
- listed = await service_b.list_sessions("demo-agent", "user-1")
- fetched = await service_b.get_session(session_id)
- events = await service_b.get_events(session_id)
- session_state = await service_b.get_state("demo-agent", "user-1", session_id, "session")
- runtime_state = await service_b.get_state(
- "demo-agent",
- "user-1",
- session_id,
- "runner_runtime:langgraph",
- )
-
- assert created.id == session_id
- assert session_id in [session.id for session in listed]
- assert fetched is not None
- assert fetched.state == {"turns": 1}
- assert [event.id for event in events] == ["pytest-evt-1"]
- assert events[0].seq_id == 1
- assert session_state is not None
- assert session_state.state == {"turns": 1}
- assert runtime_state is not None
- assert runtime_state.state == {"path": "replay", "level": "semantic"}
- finally:
- await service_a.delete_session(session_id)
- await service_a.aclose()
- await service_b.aclose()
-
-
-async def test_postgres_session_service_get_events_filters_by_after_seq_id():
- dsn = os.getenv("KSADK_TEST_POSTGRES_DSN")
- if not dsn:
- pytest.skip("Set KSADK_TEST_POSTGRES_DSN to run Postgres session integration tests")
-
- from ksadk.sessions.postgres_service import PostgresSessionService
-
- namespace = "pytest_after_seq"
- service = PostgresSessionService(dsn=dsn, namespace=namespace)
- session_id = "pytest-sess-after-seq"
-
- try:
- await service.delete_session(session_id)
- await service.create_session(
- agent_id="demo-agent",
- user_id="user-1",
- session_id=session_id,
- )
- for index in range(4):
- await service.append_event(
- session_id,
- SessionEvent(
- id=f"pytest-evt-after-{index + 1}",
- author="user",
- event_type="text",
- content={"index": index},
- ),
- )
-
- all_events = await service.get_events(session_id)
- assert [event.seq_id for event in all_events] == [1, 2, 3, 4]
-
- after2 = await service.get_events(session_id, after_seq_id=2)
- assert [event.seq_id for event in after2] == [3, 4]
-
- after0 = await service.get_events(session_id, after_seq_id=0)
- assert [event.seq_id for event in after0] == [1, 2, 3, 4]
-
- after_max = await service.get_events(session_id, after_seq_id=4)
- assert [event.seq_id for event in after_max] == []
-
- after_limit = await service.get_events(session_id, after_seq_id=2, limit=1)
- assert [event.seq_id for event in after_limit] == [4]
- finally:
- await service.delete_session(session_id)
- await service.aclose()
-
-
-async def test_postgres_session_service_get_events_filters_by_before_seq_id():
- dsn = os.getenv("KSADK_TEST_POSTGRES_DSN")
- if not dsn:
- pytest.skip("Set KSADK_TEST_POSTGRES_DSN to run Postgres session integration tests")
-
- from ksadk.sessions.postgres_service import PostgresSessionService
-
- namespace = "pytest_before_seq"
- service = PostgresSessionService(dsn=dsn, namespace=namespace)
- session_id = "pytest-sess-before-seq"
-
- try:
- await service.delete_session(session_id)
- await service.create_session(
- agent_id="demo-agent",
- user_id="user-1",
- session_id=session_id,
- )
- for index in range(5):
- await service.append_event(
- session_id,
- SessionEvent(
- id=f"pytest-evt-before-{index + 1}",
- author="user",
- event_type="text",
- content={"index": index},
- ),
- )
-
- before4 = await service.get_events(session_id, before_seq_id=4)
- assert [event.seq_id for event in before4] == [1, 2, 3]
- assert await service.count_events(session_id, before_seq_id=4) == 3
-
- before4_limit = await service.get_events(session_id, before_seq_id=4, limit=2)
- assert [event.seq_id for event in before4_limit] == [2, 3]
-
- before1 = await service.get_events(session_id, before_seq_id=1)
- assert before1 == []
- finally:
- await service.delete_session(session_id)
- await service.aclose()
-
-
-async def test_postgres_session_service_namespaces_isolate_same_session_id():
- dsn = os.getenv("KSADK_TEST_POSTGRES_DSN")
- if not dsn:
- pytest.skip("Set KSADK_TEST_POSTGRES_DSN to run Postgres session integration tests")
-
- from ksadk.sessions.postgres_service import PostgresSessionService
-
- session_id = "pytest-sess-same-id"
- service_a = PostgresSessionService(dsn=dsn, namespace="pytest_tenant_a")
- service_b = PostgresSessionService(dsn=dsn, namespace="pytest_tenant_b")
-
- try:
- await service_a.delete_session(session_id)
- await service_b.delete_session(session_id)
- await service_a.create_session("agent-a", "user-1", session_id=session_id)
- await service_b.create_session("agent-b", "user-1", session_id=session_id)
-
- assert [session.agent_id for session in await service_a.list_sessions("agent-a", "user-1")] == [
- "agent-a"
- ]
- assert [session.agent_id for session in await service_b.list_sessions("agent-b", "user-1")] == [
- "agent-b"
- ]
- assert await service_a.list_sessions("agent-b", "user-1") == []
- assert await service_b.list_sessions("agent-a", "user-1") == []
- finally:
- await service_a.delete_session(session_id)
- await service_b.delete_session(session_id)
- await service_a.aclose()
- await service_b.aclose()
diff --git a/tests/test_public_release_positioning.py b/tests/test_public_release_positioning.py
index 0265ed80..074b2d7d 100644
--- a/tests/test_public_release_positioning.py
+++ b/tests/test_public_release_positioning.py
@@ -1,17 +1,38 @@
from __future__ import annotations
from pathlib import Path
+import re
import subprocess
import tomllib
+from urllib.parse import urlparse
ROOT = Path(__file__).resolve().parents[1]
+DOCS_ROOT_URL = "https://kingsoftcloud.github.io/ksadk-python/"
+ZH_DOC_URLS = {
+ f"{DOCS_ROOT_URL}cn/docs/framework/getting-started/quickstart/",
+ f"{DOCS_ROOT_URL}cn/docs/framework/getting-started/why-ksadk/",
+ f"{DOCS_ROOT_URL}cn/docs/framework/getting-started/architecture/",
+ f"{DOCS_ROOT_URL}cn/docs/framework/getting-started/comparison/",
+ f"{DOCS_ROOT_URL}cn/docs/framework/guides/observability-tracing/",
+}
+EN_DOC_URLS = {
+ f"{DOCS_ROOT_URL}en/docs/framework/getting-started/quickstart/",
+ f"{DOCS_ROOT_URL}en/docs/framework/getting-started/why-ksadk/",
+ f"{DOCS_ROOT_URL}en/docs/framework/getting-started/architecture/",
+ f"{DOCS_ROOT_URL}en/docs/framework/getting-started/comparison/",
+ f"{DOCS_ROOT_URL}en/docs/framework/guides/observability-tracing/",
+}
def _read(relative_path: str) -> str:
return (ROOT / relative_path).read_text(encoding="utf-8")
+def _github_pages_urls(markdown: str) -> set[str]:
+ return set(re.findall(r"https://kingsoftcloud\.github\.io/ksadk-python/[^>\s)\"]*", markdown))
+
+
def test_public_readme_positions_ksadk_as_runtime_platform():
readme = _read("README.md")
for expected in (
@@ -41,10 +62,11 @@ def test_public_readme_positions_ksadk_as_runtime_platform():
def test_public_readme_language_variants_keep_homepage_shape():
+ root_readme = _read("README.md")
zh_readme = _read("README.zh-CN.md")
en_readme = _read("README.en.md")
- for text in (zh_readme, en_readme):
+ for text in (root_readme, zh_readme, en_readme):
assert "Kingsoft Cloud Agent Development Kit" in text
assert "ksadk-runtime-platform-hero-wide.png" in text
assert "ksadk-web-ui-screenshot.png" in text
@@ -53,6 +75,45 @@ def test_public_readme_language_variants_keep_homepage_shape():
assert "发布版本:" not in text
assert "## 0.6." not in text
+ assert _github_pages_urls(root_readme) == {DOCS_ROOT_URL, *ZH_DOC_URLS}
+ assert _github_pages_urls(zh_readme) == {DOCS_ROOT_URL, *ZH_DOC_URLS}
+ assert _github_pages_urls(en_readme) == {DOCS_ROOT_URL, *EN_DOC_URLS}
+ for stale_path in (
+ "ksadk-python/getting-started/quickstart/",
+ "ksadk-python/guides/observability-tracing/",
+ "ksadk-python/en/getting-started/quickstart/",
+ "ksadk-python/en/guides/observability-tracing/",
+ "public-docs/assets/",
+ ):
+ assert stale_path not in root_readme
+ assert stale_path not in zh_readme
+ assert stale_path not in en_readme
+
+
+def test_public_readme_docs_links_match_fumadocs_routes():
+ docs_site = ROOT / "docs-site" / "content" / "docs"
+ checks = {
+ "README.md": "cn",
+ "README.zh-CN.md": "cn",
+ "README.en.md": "en",
+ }
+
+ for readme_path, expected_locale in checks.items():
+ text = _read(readme_path)
+ urls = _github_pages_urls(text)
+ docs_urls = [url for url in urls if "/docs/" in url]
+ assert docs_urls, f"{readme_path} should link to Fumadocs pages"
+ for url in docs_urls:
+ path = urlparse(url).path.removeprefix("/ksadk-python/").strip("/")
+ parts = path.split("/")
+ assert parts[0] == expected_locale
+ assert parts[1] == "docs"
+ doc_segments = parts[2:]
+ suffix = ".en.mdx" if expected_locale == "en" else ".mdx"
+ candidate = docs_site.joinpath(*doc_segments).with_suffix(suffix)
+ index_candidate = docs_site.joinpath(*doc_segments, f"index{suffix}")
+ assert candidate.exists() or index_candidate.exists(), url
+
def test_public_metadata_uses_runtime_platform_positioning():
pyproject = tomllib.loads(_read("pyproject.toml"))
@@ -92,13 +153,16 @@ def test_pypi_publish_workflow_uses_trusted_publishing_and_bundles_ksadk_web():
assert "release:" in workflow
assert "- published" in workflow
assert "workflow_dispatch:" in workflow
- assert 'default: "0.2.16"' in workflow
- assert "KSADK_WEB_VERSION: ${{ github.event.inputs.ksadk_web_version || '0.2.16' }}" in workflow
+ assert 'default: "0.2.18"' in workflow
+ assert "approved_source_commit:" in workflow
+ assert "Reviewed source commit SHA recorded in docs/maintainer-approval-record.md" in workflow
+ assert "KSADK_WEB_VERSION: ${{ github.event.inputs.ksadk_web_version || '0.2.18' }}" in workflow
+ assert "KSADK_APPROVED_SOURCE_COMMIT: ${{ github.event.inputs.approved_source_commit || vars.KSADK_APPROVED_SOURCE_COMMIT }}" in workflow
assert "make sync-ksadk-web-static" in workflow
assert "make public-preflight" in workflow
assert "make public-publish-gate" in workflow
assert "make open-source-audit-dist" in ci_workflow
- assert 'KSADK_WEB_VERSION: "0.2.16"' in ci_workflow
+ assert 'KSADK_WEB_VERSION: "0.2.18"' in ci_workflow
assert "PUBLIC_KSADK_WEB_VERSION" not in ci_workflow
assert "KSADK_WEB_VERSION ?= latest" in makefile
assert "PUBLIC_TEST_TARGETS ?= tests/test_public_release_positioning.py tests/test_config_env_registry.py" in makefile
@@ -108,8 +172,11 @@ def test_pypi_publish_workflow_uses_trusted_publishing_and_bundles_ksadk_web():
assert "public-release-approval-check:" in makefile
assert "public-publish-gate: public-release-approval-check" in makefile
assert "scripts/check_approval_record.py" in makefile
+ assert '--expected-current-commit "$${KSADK_APPROVED_SOURCE_COMMIT:-}"' not in makefile
+ assert "KSADK_APPROVED_SOURCE_COMMIT is required" in makefile
assert "public-build-check: clean-dist sync-ksadk-web-static" in makefile
- assert "public-preflight: public-version-gate public-audit sync-ksadk-web-static public-test public-docs-build public-build-check" in makefile
+ assert "public-preflight: public-version-gate public-audit sync-ksadk-web-static public-test docs-site-build public-build-check" in makefile
+ assert "NEXT_PUBLIC_BASE_PATH=/ksadk-python pnpm build:static" in makefile
assert "PYPI_API_TOKEN" not in workflow
assert "password:" not in workflow
@@ -142,13 +209,16 @@ def test_public_release_approval_template_tracks_current_version():
def test_source_repository_does_not_track_generated_ksadk_web_static():
gitignore = _read(".gitignore")
pyproject = _read("pyproject.toml")
- web_ui_files = subprocess.run(
- ["git", "ls-files", "ksadk/server/web-ui/**"],
- cwd=ROOT,
- check=True,
- text=True,
- stdout=subprocess.PIPE,
- ).stdout
+ if (ROOT / ".git").exists():
+ web_ui_files = subprocess.run(
+ ["git", "ls-files", "ksadk/server/web-ui/**"],
+ cwd=ROOT,
+ check=True,
+ text=True,
+ stdout=subprocess.PIPE,
+ ).stdout
+ else:
+ web_ui_files = "\n".join(str(path.relative_to(ROOT)) for path in (ROOT / "ksadk/server/web-ui").glob("**/*") if path.is_file())
assert "ksadk/server/static/**" in gitignore
assert "ksadk/server/web-ui/" in gitignore
diff --git a/tests/test_remote_runner.py b/tests/test_remote_runner.py
deleted file mode 100644
index 021b7823..00000000
--- a/tests/test_remote_runner.py
+++ /dev/null
@@ -1,643 +0,0 @@
-import pytest
-
-from ksadk.runners.remote_runner import RemoteRunner
-
-
-class _FakeResponse:
- def __init__(self, *, json_payload=None, lines=None):
- self._json_payload = json_payload or {}
- self._lines = lines or []
-
- def raise_for_status(self):
- return None
-
- def json(self):
- return self._json_payload
-
- async def aiter_lines(self):
- for line in self._lines:
- yield line
-
-
-class _FakeStream:
- def __init__(self, response):
- self.response = response
-
- async def __aenter__(self):
- return self.response
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
-
-class _FakeAsyncClient:
- calls = []
- post_payload = {
- "output": [
- {
- "content": [
- {
- "type": "output_text",
- "text": "hello responses",
- }
- ]
- }
- ]
- }
- stream_lines = [
- "event: response.output_text.delta",
- 'data: {"type":"response.output_text.delta","delta":"hello"}',
- "event: response.reasoning.delta",
- 'data: {"type":"response.reasoning.delta","delta":"thinking"}',
- "data: [DONE]",
- ]
-
- def __init__(self, **kwargs):
- self.kwargs = kwargs
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
- async def post(self, url, json=None, headers=None):
- self.__class__.calls.append(
- {"method": "POST", "url": url, "json": json, "headers": headers}
- )
- return _FakeResponse(json_payload=self.post_payload)
-
- def stream(self, method, url, json=None, headers=None):
- self.__class__.calls.append(
- {"method": method, "url": url, "json": json, "headers": headers}
- )
- return _FakeStream(_FakeResponse(lines=self.stream_lines))
-
-
-@pytest.mark.asyncio
-async def test_remote_runner_responses_invoke_keeps_external_responses_stateless_by_default(
- monkeypatch,
-):
- import httpx
-
- _FakeAsyncClient.calls = []
- monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
- runner = RemoteRunner(
- endpoint="https://agent.example.com", api_key="ak-demo", api_format="responses"
- )
-
- payload = await runner.invoke(
- {
- "input": "hi",
- "session_id": "sess-1",
- "platform_context": {"agent_id": "demo-agent"},
- }
- )
-
- assert payload == {"output": "hello responses"}
- assert _FakeAsyncClient.calls[0]["url"] == "https://agent.example.com/v1/responses"
- assert _FakeAsyncClient.calls[0]["json"] == {
- "input": "hi",
- "stream": False,
- }
- assert _FakeAsyncClient.calls[0]["headers"]["Authorization"] == "Bearer ak-demo"
-
-
-@pytest.mark.asyncio
-async def test_remote_runner_responses_payload_injects_builtin_tool_schemas(monkeypatch):
- import httpx
-
- _FakeAsyncClient.calls = []
- monkeypatch.setenv("KSADK_BUILTIN_TOOLS_MODE", "deferred")
- monkeypatch.setenv("KSADK_BUILTIN_TOOLS_PROFILE", "coding")
- monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
- runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses")
-
- await runner.invoke({"input": "hi"})
-
- tools = _FakeAsyncClient.calls[0]["json"]["tools"]
- names = [tool["name"] for tool in tools]
- assert names == ["tool_search", "tool_dispatcher"]
- assert tools[0]["type"] == "function"
- assert tools[0]["parameters"]["type"] == "object"
-
-
-@pytest.mark.asyncio
-async def test_remote_runner_responses_payload_injects_deferred_direct_tool_schemas(monkeypatch):
- import httpx
-
- _FakeAsyncClient.calls = []
- monkeypatch.setenv("KSADK_BUILTIN_TOOLS_MODE", "deferred")
- monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
- runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses")
-
- await runner.invoke(
- {
- "input": "now edit the file",
- "deferred_tool_names": ["read_workspace_file", "edit_workspace_file"],
- }
- )
-
- names = [tool["name"] for tool in _FakeAsyncClient.calls[0]["json"]["tools"]]
- assert names == ["tool_search", "tool_dispatcher", "read_workspace_file", "edit_workspace_file"]
-
-
-@pytest.mark.asyncio
-async def test_remote_runner_responses_does_not_inject_external_deferred_tools(monkeypatch):
- import httpx
-
- from ksadk.toolsets import clear_external_tools, register_external_tools
-
- class WeatherForecastTool:
- name = "weather_forecast"
- description = "Get weather forecast from an MCP server."
- args = {"city": {"type": "string"}}
-
- _FakeAsyncClient.calls = []
- monkeypatch.setenv("KSADK_BUILTIN_TOOLS_MODE", "deferred")
- monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
- clear_external_tools()
- try:
- register_external_tools([WeatherForecastTool()], group="mcp:weather")
- runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses")
-
- await runner.invoke(
- {
- "input": "now check weather",
- "deferred_tool_names": ["weather_forecast"],
- }
- )
- finally:
- clear_external_tools()
-
- names = [tool["name"] for tool in _FakeAsyncClient.calls[0]["json"]["tools"]]
- assert names == ["tool_search", "tool_dispatcher"]
-
-
-@pytest.mark.asyncio
-async def test_remote_runner_responses_invoke_preserves_usage(monkeypatch):
- import httpx
-
- class UsageClient(_FakeAsyncClient):
- post_payload = {
- "output_text": "hello responses",
- "usage": {
- "input_tokens": 9,
- "output_tokens": 4,
- "total_tokens": 13,
- },
- }
-
- UsageClient.calls = []
- monkeypatch.setattr(httpx, "AsyncClient", UsageClient)
- runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses")
-
- payload = await runner.invoke({"input": "hi"})
-
- assert payload == {
- "output": "hello responses",
- "usage": {
- "input_tokens": 9,
- "output_tokens": 4,
- "total_tokens": 13,
- },
- }
-
-
-@pytest.mark.asyncio
-async def test_remote_runner_responses_invoke_forwards_explicit_conversation(monkeypatch):
- import httpx
-
- _FakeAsyncClient.calls = []
- monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
- runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses")
-
- await runner.invoke(
- {
- "input": "hi",
- "conversation": "customer-thread-1",
- }
- )
-
- assert _FakeAsyncClient.calls[0]["json"]["conversation"] == "customer-thread-1"
-
-
-@pytest.mark.asyncio
-async def test_remote_runner_responses_explicit_conversation_does_not_send_ksadk_history(
- monkeypatch,
-):
- import httpx
-
- _FakeAsyncClient.calls = []
- monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
- runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses")
-
- await runner.invoke(
- {
- "input": "hi",
- "conversation": {"id": "customer-thread-1"},
- "history": [{"role": "user", "content": "old"}],
- }
- )
-
- assert _FakeAsyncClient.calls[0]["json"]["conversation"] == "customer-thread-1"
- assert "conversation_history" not in _FakeAsyncClient.calls[0]["json"]
-
-
-@pytest.mark.asyncio
-async def test_remote_runner_responses_normalizes_chat_style_input_for_openclaw(monkeypatch):
- import httpx
-
- _FakeAsyncClient.calls = []
- monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
- runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses")
-
- await runner.invoke(
- {
- "input": [
- {"role": "system", "content": "You are concise."},
- {"role": "user", "content": [{"type": "input_text", "text": "你好"}]},
- ]
- }
- )
-
- assert _FakeAsyncClient.calls[0]["json"]["input"] == "你好"
-
-
-@pytest.mark.asyncio
-async def test_remote_runner_responses_keeps_standard_item_array_and_previous_response_id(
- monkeypatch,
-):
- import httpx
-
- _FakeAsyncClient.calls = []
- monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
- runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses")
-
- await runner.invoke(
- {
- "input": {
- "type": "function_call_output",
- "call_id": "call_123",
- "output": "ok",
- },
- "previous_response_id": "resp_123",
- }
- )
-
- assert _FakeAsyncClient.calls[0]["json"]["input"] == [
- {
- "type": "function_call_output",
- "call_id": "call_123",
- "output": "ok",
- }
- ]
- assert _FakeAsyncClient.calls[0]["json"]["previous_response_id"] == "resp_123"
- assert "conversation" not in _FakeAsyncClient.calls[0]["json"]
-
-
-@pytest.mark.asyncio
-async def test_remote_runner_responses_stream_parses_text_and_reasoning(monkeypatch):
- import httpx
-
- _FakeAsyncClient.calls = []
- monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
- runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses")
-
- chunks = [chunk async for chunk in runner.stream({"input": "hi"})]
-
- assert _FakeAsyncClient.calls[0]["url"] == "https://agent.example.com/v1/responses"
- assert chunks == [
- {"delta": "hello", "type": "text"},
- {"delta": "thinking", "type": "thinking"},
- ]
-
-
-@pytest.mark.asyncio
-async def test_remote_runner_responses_stream_sends_hermes_conversation_and_history(
- monkeypatch,
-):
- import httpx
-
- _FakeAsyncClient.calls = []
- monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
- runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses")
-
- chunks = [
- chunk
- async for chunk in runner.stream(
- {
- "input": "s6-overlay是什么",
- "session_id": "sess-1",
- "responses_conversation": True,
- "platform_context": {"agent_id": "demo-agent"},
- "history": [
- {"role": "user", "content": "tini 是什么"},
- {"role": "model", "content": "tini 是容器 init 进程。"},
- {"role": "user", "content": "s6-overlay是什么"},
- ],
- }
- )
- ]
-
- assert chunks
- assert _FakeAsyncClient.calls[0]["json"]["input"] == "s6-overlay是什么"
- assert _FakeAsyncClient.calls[0]["json"]["conversation"] == "agentengine:demo-agent:sess-1"
- assert "session_id" not in _FakeAsyncClient.calls[0]["json"]
- assert _FakeAsyncClient.calls[0]["json"]["conversation_history"] == [
- {"role": "user", "content": [{"type": "input_text", "text": "tini 是什么"}]},
- {
- "role": "assistant",
- "content": [{"type": "input_text", "text": "tini 是容器 init 进程。"}],
- },
- ]
-
-
-@pytest.mark.asyncio
-async def test_remote_runner_responses_stream_does_not_mix_conversation_with_previous_response_id(
- monkeypatch,
-):
- import httpx
-
- _FakeAsyncClient.calls = []
- monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
- runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses")
-
- chunks = [
- chunk
- async for chunk in runner.stream(
- {
- "input": "继续",
- "session_id": "sess-1",
- "responses_conversation": True,
- "previous_response_id": "resp_123",
- "history": [{"role": "user", "content": "旧消息"}],
- "platform_context": {"agent_id": "demo-agent"},
- }
- )
- ]
-
- assert chunks
- assert _FakeAsyncClient.calls[0]["json"]["previous_response_id"] == "resp_123"
- assert "conversation" not in _FakeAsyncClient.calls[0]["json"]
- assert "conversation_history" not in _FakeAsyncClient.calls[0]["json"]
-
-
-@pytest.mark.asyncio
-async def test_remote_runner_responses_stream_parses_native_tool_items(monkeypatch):
- import httpx
-
- class ToolStreamClient(_FakeAsyncClient):
- stream_lines = [
- "event: response.output_item.added",
- (
- 'data: {"output_index":0,"item":{"id":"fc_1","type":"function_call",'
- '"name":"search","arguments":""}}'
- ),
- "",
- "event: response.function_call_arguments.delta",
- 'data: {"item_id":"fc_1","delta":"{\\"q\\":"}',
- "",
- "event: response.function_call_arguments.delta",
- 'data: {"item_id":"fc_1","delta":"\\"openclaw\\"}"}',
- "",
- "event: response.function_call_arguments.done",
- 'data: {"item_id":"fc_1","arguments":"{\\"q\\":\\"openclaw\\"}"}',
- "",
- "event: response.output_item.done",
- (
- 'data: {"output_index":0,"item":{"id":"out_1",'
- '"type":"function_call_output","call_id":"fc_1","output":{"ok":true}}}'
- ),
- "",
- "event: response.completed",
- (
- 'data: {"response":{"id":"resp_1","output":[{"id":"fc_1",'
- '"type":"function_call","name":"search",'
- '"arguments":"{\\"q\\":\\"openclaw\\"}"},{"id":"out_1",'
- '"type":"function_call_output","call_id":"fc_1","output":{"ok":true}}]}}'
- ),
- "",
- "data: [DONE]",
- ]
-
- ToolStreamClient.calls = []
- monkeypatch.setattr(httpx, "AsyncClient", ToolStreamClient)
- runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses")
-
- chunks = [chunk async for chunk in runner.stream({"input": "hi"})]
-
- assert chunks == [
- {"type": "tool_call", "tool_name": "search", "tool_args": "", "status": "running"},
- {"type": "tool_call", "tool_name": "search", "tool_args": '{"q":', "status": "running"},
- {
- "type": "tool_call",
- "tool_name": "search",
- "tool_args": '{"q":"openclaw"}',
- "status": "running",
- },
- {
- "type": "tool_call",
- "tool_name": "search",
- "tool_args": '{"q":"openclaw"}',
- "status": "running",
- },
- {"type": "tool_result", "tool_name": "search", "tool_output": '{\n "ok": true\n}'},
- {
- "type": "responses_output",
- "output": [
- {
- "id": "fc_1",
- "type": "function_call",
- "name": "search",
- "arguments": '{"q":"openclaw"}',
- },
- {
- "id": "out_1",
- "type": "function_call_output",
- "call_id": "fc_1",
- "output": {"ok": True},
- },
- ],
- "response_id": "resp_1",
- },
- ]
-
-
-@pytest.mark.asyncio
-async def test_remote_runner_responses_can_send_openclaw_session_header(monkeypatch):
- import httpx
-
- _FakeAsyncClient.calls = []
- monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
- runner = RemoteRunner(
- endpoint="https://agent.example.com",
- api_key="gateway-token",
- api_format="responses",
- responses_session_header="x-openclaw-session-key",
- )
-
- await runner.invoke({"input": "hi", "session_id": "sess-1"})
-
- assert _FakeAsyncClient.calls[0]["headers"]["Authorization"] == "Bearer gateway-token"
- assert _FakeAsyncClient.calls[0]["headers"]["x-openclaw-session-key"] == "sess-1"
- assert "session_id" not in _FakeAsyncClient.calls[0]["json"]
- assert "conversation" not in _FakeAsyncClient.calls[0]["json"]
-
-
-@pytest.mark.asyncio
-async def test_remote_runner_responses_stream_surfaces_failed_event(monkeypatch):
- import httpx
-
- class FailedStreamClient(_FakeAsyncClient):
- stream_lines = [
- "event: response.failed",
- 'data: {"response":{"error":{"code":"api_error","message":"internal error"}}}',
- "",
- "data: [DONE]",
- ]
-
- FailedStreamClient.calls = []
- monkeypatch.setattr(httpx, "AsyncClient", FailedStreamClient)
- runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses")
-
- chunks = [chunk async for chunk in runner.stream({"input": "hi"})]
-
- assert chunks == [{"type": "error", "message": "internal error"}]
-
-
-@pytest.mark.asyncio
-async def test_remote_runner_chat_completions_stream_requests_and_preserves_usage(monkeypatch):
- import httpx
-
- class ChatStreamUsageClient(_FakeAsyncClient):
- stream_lines = [
- 'data: {"choices":[{"delta":{"content":"hello"}}]}',
- "",
- (
- 'data: {"choices":[],"usage":{"prompt_tokens":15,'
- '"completion_tokens":6,"total_tokens":21}}'
- ),
- "",
- "data: [DONE]",
- ]
-
- ChatStreamUsageClient.calls = []
- monkeypatch.setattr(httpx, "AsyncClient", ChatStreamUsageClient)
- runner = RemoteRunner(endpoint="https://agent.example.com", api_format="chat_completions")
-
- chunks = [chunk async for chunk in runner.stream({"input": "hi"})]
-
- assert ChatStreamUsageClient.calls[0]["json"]["stream_options"] == {"include_usage": True}
- assert chunks == [
- {"delta": "hello", "type": "text"},
- {
- "type": "final",
- "usage": {
- "prompt_tokens": 15,
- "completion_tokens": 6,
- "total_tokens": 21,
- },
- },
- ]
-
-
-@pytest.mark.asyncio
-async def test_remote_runner_chat_completions_stream_emits_final_without_usage(monkeypatch):
- import httpx
-
- class ChatStreamNoUsageClient(_FakeAsyncClient):
- stream_lines = [
- 'data: {"choices":[{"delta":{"content":"hello"}}]}',
- "",
- "data: [DONE]",
- ]
-
- ChatStreamNoUsageClient.calls = []
- monkeypatch.setattr(httpx, "AsyncClient", ChatStreamNoUsageClient)
- runner = RemoteRunner(endpoint="https://agent.example.com", api_format="chat_completions")
-
- chunks = [chunk async for chunk in runner.stream({"input": "hi"})]
-
- assert chunks == [
- {"delta": "hello", "type": "text"},
- {"output": "hello", "type": "final"},
- ]
-
-
-@pytest.mark.asyncio
-async def test_remote_runner_responses_stream_preserves_completed_usage(monkeypatch):
- import httpx
-
- class ResponsesStreamUsageClient(_FakeAsyncClient):
- stream_lines = [
- "event: response.completed",
- (
- 'data: {"response":{"id":"resp_1","output":[{"id":"msg_1",'
- '"type":"message","content":[{"type":"output_text","text":"hello"}]}],'
- '"usage":{"input_tokens":9,"output_tokens":4,"total_tokens":13}}}'
- ),
- "",
- "data: [DONE]",
- ]
-
- ResponsesStreamUsageClient.calls = []
- monkeypatch.setattr(httpx, "AsyncClient", ResponsesStreamUsageClient)
- runner = RemoteRunner(endpoint="https://agent.example.com", api_format="responses")
-
- chunks = [chunk async for chunk in runner.stream({"input": "hi"})]
-
- assert chunks == [
- {
- "type": "responses_output",
- "output": [
- {
- "id": "msg_1",
- "type": "message",
- "content": [{"type": "output_text", "text": "hello"}],
- }
- ],
- "response_id": "resp_1",
- "usage": {
- "input_tokens": 9,
- "output_tokens": 4,
- "total_tokens": 13,
- },
- }
- ]
-
-
-@pytest.mark.asyncio
-async def test_remote_runner_chat_completions_invoke_preserves_usage(monkeypatch):
- import httpx
-
- class ChatUsageClient(_FakeAsyncClient):
- post_payload = {
- "choices": [
- {
- "message": {
- "content": "hello chat",
- }
- }
- ],
- "usage": {
- "prompt_tokens": 15,
- "completion_tokens": 6,
- "total_tokens": 21,
- },
- }
-
- ChatUsageClient.calls = []
- monkeypatch.setattr(httpx, "AsyncClient", ChatUsageClient)
- runner = RemoteRunner(endpoint="https://agent.example.com", api_format="chat_completions")
-
- payload = await runner.invoke({"input": "hi"})
-
- assert payload == {
- "output": "hello chat",
- "usage": {
- "prompt_tokens": 15,
- "completion_tokens": 6,
- "total_tokens": 21,
- },
- }
diff --git a/tests/test_resource_output_snapshots.py b/tests/test_resource_output_snapshots.py
deleted file mode 100644
index bc9c4da0..00000000
--- a/tests/test_resource_output_snapshots.py
+++ /dev/null
@@ -1,231 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from click.testing import CliRunner
-
-from ksadk.cli import cmd_dashboard
-from ksadk.cli.cmd_mcp import mcp
-from ksadk.cli.cmd_openclaw import openclaw
-from ksadk.cli.cmd_version import version
-
-
-SNAPSHOT_FILE = Path(__file__).parent / "snapshots" / "resource_output_snapshots.txt"
-
-
-def load_section_snapshots(path: Path) -> dict[str, str]:
- sections: dict[str, str] = {}
- current_name: str | None = None
- current_lines: list[str] = []
-
- for line in path.read_text(encoding="utf-8").splitlines():
- if line.startswith("=== ") and line.endswith(" ==="):
- if current_name is not None:
- sections[current_name] = "\n".join(current_lines).rstrip() + "\n"
- current_name = line[4:-4]
- current_lines = []
- continue
- current_lines.append(line)
-
- if current_name is not None:
- sections[current_name] = "\n".join(current_lines).rstrip() + "\n"
-
- return sections
-
-
-def _normalize_output(text: str) -> str:
- return text.rstrip() + "\n"
-
-
-class _FakeMCPClient:
- def __init__(self, *args, **kwargs):
- pass
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
- async def list_mcps(self, **kwargs):
- return {
- "mcps": [
- {
- "mcp_id": "mcp-1",
- "name": "demo-mcp",
- "status": "running",
- "mcp_endpoint": "https://demo.example.com/mcp",
- }
- ],
- "total": 1,
- }
-
- async def get_mcp(self, mcp_id):
- return {
- "mcp_id": mcp_id,
- "name": "demo-mcp",
- "status": "running",
- "region": "cn-beijing-6",
- "endpoint": "https://demo.example.com",
- "mcp_endpoint": "https://demo.example.com/mcp",
- "enable_auth": True,
- "tools": ["search"],
- "created_at": "2026-03-20T12:00:00Z",
- "updated_at": "2026-03-20T12:05:00Z",
- }
-
- async def get_mcp_by_name(self, name, region=None):
- return await self.get_mcp(name)
-
- async def close(self):
- return None
-
-
-class _FakeOpenClawClient:
- def __init__(self, *args, **kwargs):
- pass
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
- async def list_agents(self, **kwargs):
- return {
- "agents": [
- {
- "agent_id": "ar-openclaw-1",
- "name": "demo-openclaw",
- "status": "running",
- "endpoint": "https://openclaw.example.com",
- "region": "cn-beijing-6",
- "account_id": "2000003485",
- }
- ],
- "total": 1,
- }
-
- async def get_agent(self, **kwargs):
- return {
- "basic": {
- "agent_id": kwargs.get("agent_id") or "ar-openclaw-1",
- "name": "demo-openclaw",
- "status": "RUNNING",
- "framework": "openclaw",
- "region": "cn-beijing-6",
- "created_at": "2026-03-20T12:00:00Z",
- "updated_at": "2026-03-20T12:05:00Z",
- },
- "quick_access": {
- "public_endpoint": "https://openclaw.example.com",
- },
- "deployment": {
- "artifact_path": "hub.kce.ksyun.com/openclaw:latest",
- },
- }
-
- async def close(self):
- return None
-
-
-class _FakeVersionClient:
- async def list_versions(self, agent_id, page, size):
- return {
- "versions": [
- {
- "tag": "v1.0.0",
- "status": "current",
- "traffic_percentage": 100,
- "created_at": "2026-03-20T12:00:00Z",
- "description": "Auto-released by deploy at 2026-03-20",
- }
- ],
- "total": 1,
- }
-
- async def close(self):
- return None
-
-
-async def _fake_resolve_target_agent_id(**kwargs):
- return "ar-version-1"
-
-
-async def _fake_resolve_agent_detail(*_args, **_kwargs):
- return (
- {
- "agent_id": "ar-demo",
- "name": "demo-agent",
- "framework": "langgraph",
- "endpoint": "https://agent.example.com",
- },
- type("Ref", (), {"source": "cli", "source_text": "CLI", "value": "ar-demo"})(),
- False,
- )
-
-
-async def _fake_list_dashboard_access_links(**_kwargs):
- return {
- "total": 1,
- "links": [
- {
- "link_id": "lnk-1",
- "link_type": "share",
- "status": "active",
- "path": "/",
- "expires_at": None,
- "created_at": "2026-03-20T12:00:00Z",
- }
- ],
- }
-
-
-async def _fake_delete_dashboard_access_link(**_kwargs):
- return {"deleted": True}
-
-
-def test_resource_output_snapshots(monkeypatch):
- runner = CliRunner()
- snapshots = load_section_snapshots(SNAPSHOT_FILE)
-
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeMCPClient)
- result = runner.invoke(mcp, ["list"])
- assert result.exit_code == 0, result.output
- assert _normalize_output(result.output) == snapshots["mcp_list"]
-
- result = runner.invoke(mcp, ["status", "mcp-1"])
- assert result.exit_code == 0, result.output
- assert _normalize_output(result.output) == snapshots["mcp_status"]
-
- monkeypatch.setattr("ksadk.api.AgentEngineClient", _FakeOpenClawClient)
- monkeypatch.setattr("ksadk.cli.cmd_openclaw._GLOBAL_ENV_CACHE", {})
- result = runner.invoke(
- openclaw,
- ["list"],
- env={"KSYUN_ACCOUNT_ID": "2000003485"},
- )
- assert result.exit_code == 0, result.output
- assert _normalize_output(result.output) == snapshots["openclaw_list"]
-
- result = runner.invoke(openclaw, ["status", "ar-openclaw-1"])
- assert result.exit_code == 0, result.output
- assert _normalize_output(result.output) == snapshots["openclaw_status"]
-
- monkeypatch.setattr("ksadk.cli.cmd_version._get_client", lambda *, region, dry_run=False: _FakeVersionClient())
- monkeypatch.setattr("ksadk.cli.cmd_version._resolve_target_agent_id", _fake_resolve_target_agent_id)
- result = runner.invoke(version, ["list", "--agent", "demo-agent"])
- assert result.exit_code == 0, result.output
- assert _normalize_output(result.output) == snapshots["version_list"]
-
- monkeypatch.setattr(cmd_dashboard, "_resolve_agent_detail", _fake_resolve_agent_detail)
- monkeypatch.setattr(cmd_dashboard, "_list_dashboard_access_links", _fake_list_dashboard_access_links)
- monkeypatch.setattr(cmd_dashboard, "_delete_dashboard_access_link", _fake_delete_dashboard_access_link)
- monkeypatch.setattr(cmd_dashboard, "load_state", lambda _cwd: {})
- result = runner.invoke(cmd_dashboard.dashboard, ["share", "list", "ar-demo"])
- assert result.exit_code == 0, result.output
- assert _normalize_output(result.output) == snapshots["dashboard_share_list"]
-
- result = runner.invoke(cmd_dashboard.dashboard, ["share", "revoke", "lnk-1", "--yes"])
- assert result.exit_code == 0, result.output
- assert _normalize_output(result.output) == snapshots["dashboard_share_revoke"]
diff --git a/tests/test_runner.py b/tests/test_runner.py
deleted file mode 100644
index 08100e7f..00000000
--- a/tests/test_runner.py
+++ /dev/null
@@ -1,1051 +0,0 @@
-"""Tests for the current runner contract."""
-
-from __future__ import annotations
-
-import base64
-import os
-import textwrap
-from types import SimpleNamespace
-from types import ModuleType
-from typing import Any
-from uuid import uuid4
-
-import pytest
-
-from ksadk.detection import DetectionResult, FrameworkType
-from ksadk.runners.base_runner import BaseRunner
-from ksadk.runners.factory import create_runner
-
-
-class _StubRunner(BaseRunner):
- def __init__(self, detection_result: Any, project_dir: str):
- super().__init__(detection_result, project_dir)
- self.agent = "stub-agent"
-
- def load_agent(self) -> None:
- self._agent = self.agent
-
- async def invoke(self, input_data):
- return {"output": input_data}
-
- async def stream(self, input_data):
- yield {"output": input_data}
-
-
-class _AsyncClosableToolset:
- def __init__(self):
- self.closed = 0
-
- async def close(self):
- self.closed += 1
-
-
-class _SyncClosableToolset:
- def __init__(self):
- self.closed = 0
-
- def close(self):
- self.closed += 1
-
-
-class _AsyncAClosableToolset:
- def __init__(self):
- self.closed = 0
-
- async def aclose(self):
- self.closed += 1
-
-
-class _FailingClosableToolset:
- def __init__(self):
- self.closed = 0
-
- async def close(self):
- self.closed += 1
- raise RuntimeError("close failed")
-
-
-def _install_runner_module(monkeypatch, module_path: str, class_name: str):
- fake_module = ModuleType(module_path)
-
- class _FrameworkRunner(_StubRunner):
- pass
-
- _FrameworkRunner.__name__ = class_name
- setattr(fake_module, class_name, _FrameworkRunner)
- monkeypatch.setitem(__import__("sys").modules, module_path, fake_module)
- return _FrameworkRunner
-
-
-def _write_adk_project(tmp_path, source: str) -> DetectionResult:
- package_name = f"demo_agent_{uuid4().hex[:8]}"
- package_dir = tmp_path / package_name
- package_dir.mkdir()
- (package_dir / "__init__.py").write_text("", encoding="utf-8")
- (package_dir / "agent.py").write_text(textwrap.dedent(source), encoding="utf-8")
- return DetectionResult(
- type=FrameworkType.ADK,
- name="demo-agent",
- entry_point=f"{package_name}/agent.py",
- package_path=str(package_dir),
- agent_variable="root_agent",
- confidence=1.0,
- )
-
-
-def _tool_names(tools: list[Any]) -> list[str]:
- return [getattr(tool, "name", None) or getattr(tool, "__name__", "") for tool in tools]
-
-
-def _write_detection(
- framework_type: FrameworkType,
- *,
- entry_point: str = "demo/agent.py",
- package_path: str = "/tmp/demo",
-) -> DetectionResult:
- return DetectionResult(
- type=framework_type,
- name="demo-agent",
- entry_point=entry_point,
- package_path=package_path,
- agent_variable="root_agent",
- confidence=1.0,
- )
-
-
-@pytest.mark.parametrize(
- ("framework_type", "module_path", "class_name"),
- [
- (FrameworkType.ADK, "ksadk.runners.adk_runner", "ADKRunner"),
- (FrameworkType.LANGGRAPH, "ksadk.runners.langgraph_runner", "LangGraphRunner"),
- (FrameworkType.LANGCHAIN, "ksadk.runners.langchain_runner", "LangChainRunner"),
- (FrameworkType.DEEPAGENTS, "ksadk.runners.deepagents_runner", "DeepAgentsRunner"),
- ],
-)
-def test_create_runner_dispatches_by_framework(
- monkeypatch,
- framework_type,
- module_path: str,
- class_name: str,
-):
- expected_class = _install_runner_module(monkeypatch, module_path, class_name)
- detection = DetectionResult(
- type=framework_type,
- name="demo-agent",
- entry_point="demo/agent.py",
- package_path="/tmp/demo",
- agent_variable="root_agent",
- confidence=1.0,
- )
-
- runner = create_runner(detection, "/workspace/demo")
-
- assert isinstance(runner, expected_class)
- assert runner.detection_result == detection
- assert runner.project_dir == "/workspace/demo"
-
-
-def test_create_runner_rejects_unknown_framework():
- detection = DetectionResult(
- type=FrameworkType.UNKNOWN,
- name="unknown-agent",
- entry_point="",
- package_path="",
- )
-
- with pytest.raises(ValueError, match="不支持的框架类型"):
- create_runner(detection, "/workspace/demo")
-
-
-def test_base_runner_extracts_usage_from_langchain_message_metadata():
- detection = _write_detection(FrameworkType.LANGCHAIN)
- runner = _StubRunner(detection, "/workspace/demo")
- message = SimpleNamespace(
- content="ok",
- usage_metadata={
- "input_tokens": 11,
- "output_tokens": 7,
- "total_tokens": 18,
- "input_token_details": {"cached": 3},
- "output_token_details": {"reasoning": 2},
- },
- )
-
- assert runner._extract_usage({"messages": [SimpleNamespace(content="older"), message]}) == {
- "input_tokens": 11,
- "output_tokens": 7,
- "total_tokens": 18,
- "input_token_details": {"cached": 3},
- "output_token_details": {"reasoning": 2},
- }
-
-
-def test_base_runner_extracts_usage_from_openai_token_usage():
- detection = _write_detection(FrameworkType.LANGCHAIN)
- runner = _StubRunner(detection, "/workspace/demo")
- message = SimpleNamespace(
- content="ok",
- response_metadata={
- "token_usage": {
- "prompt_tokens": 8,
- "completion_tokens": 5,
- "total_tokens": 13,
- "prompt_tokens_details": {"cached_tokens": 4},
- "completion_tokens_details": {"reasoning_tokens": 2},
- }
- },
- )
-
- assert runner._extract_usage(message) == {
- "input_tokens": 8,
- "output_tokens": 5,
- "total_tokens": 13,
- "input_token_details": {"cached": 4},
- "output_token_details": {"reasoning": 2},
- }
-
-
-def test_base_runner_does_not_invent_usage_from_empty_metadata():
- detection = _write_detection(FrameworkType.LANGCHAIN)
- runner = _StubRunner(detection, "/workspace/demo")
-
- assert runner._extract_usage(SimpleNamespace(usage_metadata={})) == {}
- assert runner._extract_usage(SimpleNamespace(response_metadata={"token_usage": {}})) == {}
-
-
-def test_base_runner_default_runtime_capabilities_are_explicitly_unsupported():
- detection = _write_detection(FrameworkType.LANGCHAIN)
- runner = _StubRunner(detection, "/workspace/demo")
-
- capabilities = runner.get_runtime_capabilities()
-
- assert capabilities["Framework"] == "langchain"
- assert capabilities["CancelRun"]["Supported"] is False
- assert capabilities["CancelRun"]["RequestResults"] == ["unsupported"]
- assert capabilities["Checkpoint"]["Supported"] is False
- assert capabilities["Checkpoint"]["Backend"] == "none"
- assert capabilities["Checkpoint"]["Durable"] is False
- assert capabilities["ResumeRun"]["Supported"] is False
- assert capabilities["ResumeRun"]["ResumeMode"] == "none"
- assert capabilities["SessionContinuity"]["Supported"] is True
- assert capabilities["SessionContinuity"]["Type"] == "semantic_replay"
-
-
-def test_base_runner_runtime_capabilities_detect_cancel_override():
- class _CancellableRunner(_StubRunner):
- def request_cancel(self, invocation_id: str) -> str:
- return "accepted"
-
- runner = _CancellableRunner(_write_detection(FrameworkType.LANGCHAIN), "/workspace/demo")
-
- capabilities = runner.get_runtime_capabilities()
-
- assert capabilities["CancelRun"]["Supported"] is True
- assert capabilities["CancelRun"]["RequestResults"] == ["accepted", "not_found", "unsupported"]
-
-
-def test_langgraph_runner_checkpoint_ref_extracts_next_node():
- from ksadk.runners.langgraph_runner import LangGraphRunner
-
- detection = _write_detection(FrameworkType.LANGGRAPH)
- runner = LangGraphRunner(detection, "/workspace/demo")
- state = SimpleNamespace(
- config={
- "configurable": {
- "thread_id": "sess-1",
- "checkpoint_ns": "ns",
- "checkpoint_id": "ckpt-1",
- }
- },
- next=("fetch_sources",),
- )
-
- framework_ref = runner._checkpoint_ref_from_state(state)
-
- assert framework_ref["langgraph"]["thread_id"] == "sess-1"
- assert framework_ref["langgraph"]["checkpoint_id"] == "ckpt-1"
- assert framework_ref["langgraph"]["checkpoint_ns"] == "ns"
- assert framework_ref["langgraph"]["next_node"] == "fetch_sources"
- assert framework_ref["langgraph"]["next_nodes"] == ["fetch_sources"]
-
-
-def test_adk_runner_declares_native_session_continuity_without_checkpoint_resume(tmp_path):
- from ksadk.runners.adk_runner import ADKRunner
-
- runner = ADKRunner(_write_detection(FrameworkType.ADK), str(tmp_path))
- runner._short_term_memory = object()
-
- capabilities = runner.get_runtime_capabilities()
-
- assert capabilities["Framework"] == "adk"
- assert capabilities["SessionContinuity"]["Supported"] is True
- assert capabilities["SessionContinuity"]["Type"] == "native_session"
- assert capabilities["Checkpoint"]["Supported"] is False
- assert capabilities["ResumeRun"]["Supported"] is False
- assert capabilities["ResumeRun"]["ResumeMode"] == "forward_only"
- assert "ADK native session" in capabilities["ResumeRun"]["Reason"]
-
-
-def test_langgraph_runner_declares_time_travel_resume_mode(monkeypatch):
- from ksadk.runners.langgraph_runner import LangGraphRunner
-
- detection = _write_detection(FrameworkType.LANGGRAPH)
- runner = LangGraphRunner(detection, "/workspace/demo")
- runner._agent = SimpleNamespace(checkpointer=object())
- monkeypatch.setenv("KSADK_CHECKPOINT_BACKEND", "postgres")
-
- capabilities = runner.get_runtime_capabilities()
-
- assert capabilities["Checkpoint"]["Supported"] is True
- assert capabilities["Checkpoint"]["Backend"] == "postgres"
- assert capabilities["ResumeRun"]["Supported"] is True
- assert capabilities["ResumeRun"]["ResumeMode"] == "time_travel"
- assert capabilities["ResumeRun"]["Reason"] == ""
-
-
-def test_create_runner_uses_custom_runner_class(monkeypatch, tmp_path):
- runner_class = _install_runner_module(monkeypatch, "demo_agent.runner", "CustomRunner")
- detection = DetectionResult(
- type=FrameworkType.LANGGRAPH,
- name="demo-agent",
- entry_point="agent.py",
- package_path=str(tmp_path),
- agent_variable="root_agent",
- runner_class="demo_agent.runner.CustomRunner",
- confidence=1.0,
- )
-
- runner = create_runner(detection, str(tmp_path))
-
- assert isinstance(runner, runner_class)
- assert runner.detection_result is detection
- assert runner.project_dir == str(tmp_path)
-
-
-def test_create_runner_rejects_custom_runner_that_is_not_base_runner(monkeypatch, tmp_path):
- fake_module = ModuleType("demo_agent.bad_runner")
-
- class BadRunner:
- pass
-
- fake_module.BadRunner = BadRunner
- monkeypatch.setitem(__import__("sys").modules, "demo_agent.bad_runner", fake_module)
- detection = DetectionResult(
- type=FrameworkType.LANGGRAPH,
- name="demo-agent",
- entry_point="agent.py",
- package_path=str(tmp_path),
- agent_variable="root_agent",
- runner_class="demo_agent.bad_runner.BadRunner",
- confidence=1.0,
- )
-
- with pytest.raises(TypeError, match="自定义 Runner 必须继承 BaseRunner"):
- create_runner(detection, str(tmp_path))
-
-
-def test_runners_package_exports_only_create_runner():
- import ksadk.runners as runners
-
- assert hasattr(runners, "create_runner")
- assert set(runners.__all__) == {"BaseRunner", "create_runner"}
-
-
-@pytest.mark.asyncio
-async def test_base_runner_close_and_async_context_are_noops():
- detection = _write_detection(FrameworkType.LANGCHAIN)
- runner = _StubRunner(detection, "/workspace/demo")
-
- async with runner as active_runner:
- assert active_runner is runner
-
- assert await runner.close() is None
-
-
-@pytest.mark.asyncio
-async def test_adk_runner_close_releases_runtime_toolsets_once(tmp_path):
- from ksadk.runners.adk_runner import ADKRunner
-
- runner = ADKRunner(_write_detection(FrameworkType.ADK), str(tmp_path))
- async_close = _AsyncClosableToolset()
- sync_close = _SyncClosableToolset()
- async_aclose = _AsyncAClosableToolset()
- runner._runtime_toolsets = [async_close, sync_close, async_aclose]
-
- await runner.close()
- await runner.close()
-
- assert async_close.closed == 1
- assert sync_close.closed == 1
- assert async_aclose.closed == 1
- assert runner._runtime_toolsets == []
-
-
-@pytest.mark.asyncio
-async def test_adk_runner_close_continues_after_toolset_failure(tmp_path, caplog):
- from ksadk.runners.adk_runner import ADKRunner
-
- runner = ADKRunner(_write_detection(FrameworkType.ADK), str(tmp_path))
- failing = _FailingClosableToolset()
- ok = _AsyncClosableToolset()
- runner._runtime_toolsets = [failing, ok]
-
- await runner.close()
-
- assert failing.closed == 1
- assert ok.closed == 1
- assert runner._runtime_toolsets == []
- assert "Failed to close runtime toolset" in caplog.text
-
-
-def test_langchain_runner_prepare_for_request_reloads_agent_when_model_changes(
- monkeypatch,
- tmp_path,
-):
- import ksadk.runners.langchain_runner as langchain_runner_module
-
- loaded_models: list[tuple[str | None, bool]] = []
-
- def fake_load_agent_module(project_dir: str, entry_point: str, agent_variable: str, *, force_reload: bool = False):
- loaded_models.append((os.getenv("OPENAI_MODEL_NAME"), force_reload))
- return SimpleNamespace(invoke=lambda *args, **kwargs: None), ModuleType("demo.agent")
-
- monkeypatch.setattr(langchain_runner_module, "load_agent_module", fake_load_agent_module)
- monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1")
- monkeypatch.setenv("MODEL_NAME", "glm-5.1")
-
- runner = langchain_runner_module.LangChainRunner(
- _write_detection(FrameworkType.LANGCHAIN),
- str(tmp_path),
- )
- runner.load_agent()
- runner.prepare_for_request("gpt-4o")
-
- assert loaded_models == [("glm-5.1", False), ("gpt-4o", True)]
-
-
-def test_langgraph_runner_prepare_for_request_reloads_agent_when_model_changes(
- monkeypatch,
- tmp_path,
-):
- import ksadk.runners.langgraph_runner as langgraph_runner_module
-
- loaded_models: list[tuple[str | None, bool]] = []
-
- def fake_load_agent_module(project_dir: str, entry_point: str, agent_variable: str, *, force_reload: bool = False):
- loaded_models.append((os.getenv("OPENAI_MODEL_NAME"), force_reload))
- return SimpleNamespace(invoke=lambda *args, **kwargs: None), ModuleType("demo.agent")
-
- monkeypatch.setattr(langgraph_runner_module, "load_agent_module", fake_load_agent_module)
- monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1")
- monkeypatch.setenv("MODEL_NAME", "glm-5.1")
-
- runner = langgraph_runner_module.LangGraphRunner(
- _write_detection(FrameworkType.LANGGRAPH),
- str(tmp_path),
- )
- runner.load_agent()
- runner.prepare_for_request("gpt-4o")
-
- assert loaded_models == [("glm-5.1", False), ("gpt-4o", True)]
-
-
-def test_adk_runner_prepare_for_request_updates_explicit_model_tree(monkeypatch, tmp_path):
- from ksadk.runners.adk_runner import ADKRunner
-
- class FakeLiteLlm:
- def __init__(self, model: str):
- self.model = model
-
- child_agent = SimpleNamespace(model=FakeLiteLlm("openai/glm-5.1"), sub_agents=[])
- root_agent = SimpleNamespace(model=FakeLiteLlm("openai/glm-5.1"), sub_agents=[child_agent])
-
- runner = ADKRunner(_write_detection(FrameworkType.ADK), str(tmp_path))
- runner._agent = root_agent
-
- monkeypatch.delenv("OPENAI_MODEL_NAME", raising=False)
- monkeypatch.delenv("MODEL_NAME", raising=False)
-
- runner.prepare_for_request("gpt-4o")
-
- assert root_agent.model.model == "openai/gpt-4o"
- assert child_agent.model.model == "openai/gpt-4o"
- assert os.environ["OPENAI_MODEL_NAME"] == "gpt-4o"
- assert os.environ["MODEL_NAME"] == "gpt-4o"
-
-
-def test_adk_runner_prepare_for_request_restores_default_model_when_request_omits_model(
- monkeypatch,
- tmp_path,
-):
- from ksadk.runners.adk_runner import ADKRunner
-
- class FakeLiteLlm:
- def __init__(self, model: str):
- self.model = model
-
- child_agent = SimpleNamespace(model=FakeLiteLlm("openai/deepseek-v3.2"), sub_agents=[])
- root_agent = SimpleNamespace(model=FakeLiteLlm("openai/deepseek-v3.2"), sub_agents=[child_agent])
-
- runner = ADKRunner(_write_detection(FrameworkType.ADK), str(tmp_path))
- runner._agent = root_agent
- runner._default_model_name = "deepseek-v3.2"
- runner._default_model_reference = "openai/deepseek-v3.2"
- runner._active_model_name = "openai/deepseek-v3.2"
-
- monkeypatch.setenv("OPENAI_MODEL_NAME", "deepseek-v3.2")
- monkeypatch.setenv("MODEL_NAME", "deepseek-v3.2")
-
- runner.prepare_for_request("dummy")
- assert root_agent.model.model == "openai/dummy"
- assert child_agent.model.model == "openai/dummy"
-
- runner.prepare_for_request(None)
-
- assert root_agent.model.model == "openai/deepseek-v3.2"
- assert child_agent.model.model == "openai/deepseek-v3.2"
- assert os.environ["OPENAI_MODEL_NAME"] == "deepseek-v3.2"
- assert os.environ["MODEL_NAME"] == "deepseek-v3.2"
-
-
-def test_base_runner_run_server_registers_runner(monkeypatch):
- recorded: dict[str, Any] = {}
-
- class _DemoRunner(_StubRunner):
- pass
-
- fake_server_module = ModuleType("ksadk.server")
- fake_server_module.app = object()
- fake_server_module.set_runner = lambda runner: recorded.setdefault("runner", runner)
-
- fake_uvicorn_module = ModuleType("uvicorn")
- fake_uvicorn_module.run = lambda app, host, port: recorded.update(
- {"app": app, "host": host, "port": port}
- )
-
- monkeypatch.setitem(__import__("sys").modules, "ksadk.server", fake_server_module)
- monkeypatch.setitem(__import__("sys").modules, "uvicorn", fake_uvicorn_module)
-
- detection = DetectionResult(
- type=FrameworkType.LANGGRAPH,
- name="demo-agent",
- entry_point="demo/agent.py",
- package_path="/tmp/demo",
- )
- runner = _DemoRunner(detection, "/workspace/demo")
-
- runner.run_server(port=9000)
-
- assert recorded["runner"] is runner
- assert recorded["app"] is fake_server_module.app
- assert recorded["host"] == "0.0.0.0"
- assert recorded["port"] == 9000
-
-
-def test_adk_runner_load_agent_does_not_inject_legacy_sandbox_tools_by_default(
- monkeypatch, tmp_path
-):
- import google.adk.runners as adk_runners
-
- from ksadk.runners.adk_runner import ADKRunner
-
- detection = _write_adk_project(
- tmp_path,
- """
- class DemoAgent:
- def __init__(self):
- self.name = "demo-agent"
- self.tools = []
- self.instruction = "Be helpful."
-
- root_agent = DemoAgent()
- """,
- )
-
- class FakeRunner:
- instances: list["FakeRunner"] = []
-
- def __init__(self, **kwargs):
- self.kwargs = kwargs
- FakeRunner.instances.append(self)
-
- monkeypatch.delenv("KSADK_SKILLS_MODE", raising=False)
- monkeypatch.setattr(ADKRunner, "_apply_json_patch", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_short_term_memory", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_long_term_memory", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_knowledge_base", lambda self: None)
- monkeypatch.setattr(adk_runners, "Runner", FakeRunner)
- runner = ADKRunner(detection, str(tmp_path))
- runner.load_agent()
-
- assert _tool_names(runner._agent.tools) == []
- assert len(FakeRunner.instances) == 1
-
-
-def test_adk_runner_load_agent_injects_builtin_tools_when_enabled(
- monkeypatch, tmp_path
-):
- import google.adk.runners as adk_runners
-
- from ksadk.runners.adk_runner import ADKRunner
-
- detection = _write_adk_project(
- tmp_path,
- """
- class DemoAgent:
- def __init__(self):
- self.name = "demo-agent"
- self.tools = []
- self.instruction = "Be helpful."
-
- root_agent = DemoAgent()
- """,
- )
-
- class FakeRunner:
- def __init__(self, **kwargs):
- self.kwargs = kwargs
-
- monkeypatch.setenv("KSADK_BUILTIN_TOOLS_MODE", "deferred")
- monkeypatch.setenv("KSADK_BUILTIN_TOOLS_PROFILE", "coding")
- monkeypatch.setattr(ADKRunner, "_apply_json_patch", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_short_term_memory", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_long_term_memory", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_knowledge_base", lambda self: None)
- monkeypatch.setattr(adk_runners, "Runner", FakeRunner)
-
- runner = ADKRunner(detection, str(tmp_path))
- runner.load_agent()
-
- tool_names = _tool_names(runner._agent.tools)
- assert tool_names == ["tool_search", "tool_dispatcher"]
- assert "execute_bash" not in tool_names
- assert "execute_python" not in tool_names
-
-
-def test_adk_runner_injects_deferred_direct_tools_for_request(monkeypatch, tmp_path):
- import google.adk.runners as adk_runners
-
- from ksadk.runners.adk_runner import ADKRunner
-
- detection = _write_adk_project(
- tmp_path,
- """
- class DemoAgent:
- def __init__(self):
- self.name = "demo-agent"
- self.tools = []
- self.instruction = "Be helpful."
-
- root_agent = DemoAgent()
- """,
- )
-
- class FakeRunner:
- def __init__(self, **kwargs):
- self.kwargs = kwargs
-
- monkeypatch.setenv("KSADK_BUILTIN_TOOLS_MODE", "deferred")
- monkeypatch.setattr(ADKRunner, "_apply_json_patch", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_short_term_memory", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_long_term_memory", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_knowledge_base", lambda self: None)
- monkeypatch.setattr(adk_runners, "Runner", FakeRunner)
-
- runner = ADKRunner(detection, str(tmp_path))
- runner.load_agent()
- runner.inject_deferred_tools_for_request(["read_workspace_file", "edit_workspace_file"])
-
- assert _tool_names(runner._agent.tools) == [
- "tool_search",
- "tool_dispatcher",
- "read_workspace_file",
- "edit_workspace_file",
- ]
-
-
-def test_adk_runner_load_agent_deduplicates_existing_execute_skills(monkeypatch, tmp_path):
- import google.adk.runners as adk_runners
-
- from ksadk.runners.adk_runner import ADKRunner
-
- detection = _write_adk_project(
- tmp_path,
- """
- def execute_skills(workflow_prompt: str) -> dict:
- return {"stdout": workflow_prompt}
-
- def keep_tool(value: str) -> str:
- return value
-
- class DemoAgent:
- def __init__(self):
- self.name = "demo-agent"
- self.tools = [keep_tool, execute_skills]
- self.instruction = "Be helpful."
-
- root_agent = DemoAgent()
- """,
- )
-
- class FakeRunner:
- def __init__(self, **kwargs):
- self.kwargs = kwargs
-
- monkeypatch.setenv("KSADK_SKILLS_MODE", "sandbox")
- monkeypatch.setenv("KSADK_SKILL_RUNTIME_BACKEND", "disabled")
- monkeypatch.setenv("KSADK_SKILL_SPACE_IDS", "ss-1")
- monkeypatch.setattr(ADKRunner, "_apply_json_patch", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_short_term_memory", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_long_term_memory", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_knowledge_base", lambda self: None)
- monkeypatch.setattr(adk_runners, "Runner", FakeRunner)
-
- runner = ADKRunner(detection, str(tmp_path))
- runner.load_agent()
-
- tool_names = _tool_names(runner._agent.tools)
- assert tool_names.count("execute_skills") == 1
- assert "keep_tool" in tool_names
-
-
-def test_adk_runner_load_agent_skips_skill_runtime_when_not_in_sandbox_mode(
- monkeypatch, tmp_path
-):
- import google.adk.runners as adk_runners
-
- from ksadk.runners.adk_runner import ADKRunner
-
- detection = _write_adk_project(
- tmp_path,
- """
- class DemoAgent:
- def __init__(self):
- self.name = "demo-agent"
- self.tools = []
- self.instruction = "Be helpful."
-
- root_agent = DemoAgent()
- """,
- )
-
- class FakeRunner:
- def __init__(self, **kwargs):
- self.kwargs = kwargs
-
- monkeypatch.setenv("KSADK_SKILLS_MODE", "local")
- monkeypatch.setattr(ADKRunner, "_apply_json_patch", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_short_term_memory", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_long_term_memory", lambda self: None)
- monkeypatch.setattr(ADKRunner, "_init_knowledge_base", lambda self: None)
- monkeypatch.setattr(adk_runners, "Runner", FakeRunner)
-
- runner = ADKRunner(detection, str(tmp_path))
- runner.load_agent()
-
- assert _tool_names(runner._agent.tools) == []
-
-
-def test_adk_runner_build_adk_content_supports_inline_and_reference_attachments(tmp_path, monkeypatch):
- from ksadk.runners.adk_runner import ADKRunner
-
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / ".agentengine" / "ui"))
- detection = SimpleNamespace(
- entry_point="agent.py",
- agent_variable="root_agent",
- name="demo-agent",
- )
- runner = ADKRunner(detection, str(tmp_path))
- archive_path = tmp_path / ".agentengine" / "ui" / "files" / "abc123.zip"
- archive_path.parent.mkdir(parents=True, exist_ok=True)
- archive_path.write_bytes(b"PK\x03\x04demo-zip")
-
- content = runner._build_adk_content(
- "请总结附件",
- [
- {
- "display_name": "notes.txt",
- "mime_type": "text/plain",
- "transport": "inline",
- "data": base64.b64encode("候选人简历内容".encode("utf-8")).decode("ascii"),
- },
- {
- "display_name": "bundle.zip",
- "mime_type": "application/zip",
- "transport": "reference",
- "file_uri": "ksadk-upload://abc123",
- "storage_path": str(archive_path),
- },
- ],
- )
-
- assert content.parts[0].text == "请总结附件"
- assert content.parts[1].inline_data.data == "候选人简历内容".encode("utf-8")
- assert content.parts[2].inline_data.data == b"PK\x03\x04demo-zip"
-
-
-def test_adk_runner_build_adk_content_does_not_read_arbitrary_local_file_uri(tmp_path):
- from ksadk.runners.adk_runner import ADKRunner
-
- detection = SimpleNamespace(
- entry_point="agent.py",
- agent_variable="root_agent",
- name="demo-agent",
- )
- runner = ADKRunner(detection, str(tmp_path))
-
- secret_path = tmp_path / "secret.txt"
- secret_path.write_text("should-not-leak", encoding="utf-8")
-
- content = runner._build_adk_content(
- "请分析附件",
- [
- {
- "display_name": "secret.txt",
- "mime_type": "text/plain",
- "transport": "reference",
- "file_uri": f"local:{secret_path}",
- }
- ],
- )
-
- assert len(content.parts) == 1
- assert content.parts[0].text == "请分析附件"
-
-
-def test_adk_runner_build_adk_content_skips_images_for_text_only_models(tmp_path):
- from ksadk.runners.adk_runner import ADKRunner
-
- detection = SimpleNamespace(
- entry_point="agent.py",
- agent_variable="root_agent",
- name="demo-agent",
- )
- runner = ADKRunner(detection, str(tmp_path))
-
- content = runner._build_adk_content(
- "请分析这张图",
- [
- {
- "display_name": "diagram.png",
- "mime_type": "image/png",
- "transport": "inline",
- "data": base64.b64encode(b"fake-png-bytes").decode("ascii"),
- }
- ],
- model_metadata={
- "capabilities": {
- "multimodal_input_image": False,
- }
- },
- )
-
- assert len(content.parts) == 2
- assert content.parts[0].text == "请分析这张图"
- assert "当前模型不支持图片输入" in content.parts[1].text
-
-
-@pytest.mark.asyncio
-async def test_adk_runner_invoke_forwards_attachment_results_via_state_delta(tmp_path, monkeypatch):
- from google.genai import types
- from ksadk.runners.adk_runner import ADKRunner
-
- detection = SimpleNamespace(
- entry_point="agent.py",
- agent_variable="root_agent",
- name="demo-agent",
- )
- runner = ADKRunner(detection, str(tmp_path))
- runner._agent = SimpleNamespace(name="demo-agent")
-
- captured: dict[str, Any] = {}
-
- class _FakeRunner:
- async def run_async(self, *, session_id, user_id, new_message, state_delta=None, run_config=None):
- captured["session_id"] = session_id
- captured["user_id"] = user_id
- captured["new_message"] = new_message
- captured["state_delta"] = state_delta
- yield SimpleNamespace(content=SimpleNamespace(parts=[types.Part(text="ok")]))
-
- async def _fake_ensure_session(external_session_id=None):
- return "adk-session-1"
-
- monkeypatch.setattr(runner, "_ensure_session", _fake_ensure_session)
- monkeypatch.setattr(runner, "_prepare_trace_metadata", lambda session_id: ("", [], "", "demo-agent"))
- runner._runner = _FakeRunner()
-
- result = await runner.invoke(
- {
- "session_id": "external-session",
- "input": "请分析附件",
- "attachments": [],
- "input_parts": [{"text": "请分析附件"}],
- "attachment_results": [{"display_name": "resume.pdf", "kind": "document"}],
- "current_attachments": [],
- "current_attachment_results": [{"display_name": "resume.pdf", "kind": "document"}],
- "has_current_files": True,
- }
- )
-
- assert result["output"] == "ok"
- assert captured["session_id"] == "adk-session-1"
- assert captured["state_delta"] == {
- "input_parts": [{"text": "请分析附件"}],
- "attachments": [],
- "attachment_results": [{"display_name": "resume.pdf", "kind": "document"}],
- "current_attachments": [],
- "current_attachment_results": [{"display_name": "resume.pdf", "kind": "document"}],
- "has_current_files": True,
- }
-
-
-@pytest.mark.asyncio
-async def test_adk_runner_invoke_extracts_usage_from_final_event(tmp_path, monkeypatch):
- from google.genai import types
- from ksadk.runners.adk_runner import ADKRunner
-
- detection = SimpleNamespace(
- entry_point="agent.py",
- agent_variable="root_agent",
- name="demo-agent",
- )
- runner = ADKRunner(detection, str(tmp_path))
- runner._agent = SimpleNamespace(name="demo-agent")
-
- class _FakeRunner:
- async def run_async(self, *, session_id, user_id, new_message, state_delta=None, run_config=None):
- del session_id, user_id, new_message, state_delta, run_config
- yield SimpleNamespace(
- usage_metadata={
- "input_tokens": 12,
- "output_tokens": 5,
- "total_tokens": 17,
- "input_token_details": {},
- "output_token_details": {"reasoning": 2},
- },
- content=SimpleNamespace(parts=[types.Part(text="ok")]),
- )
-
- async def _fake_ensure_session(external_session_id=None):
- del external_session_id
- return "adk-session-usage"
-
- monkeypatch.setattr(runner, "_ensure_session", _fake_ensure_session)
- monkeypatch.setattr(runner, "_prepare_trace_metadata", lambda session_id: ("", [], "", "demo-agent"))
- runner._runner = _FakeRunner()
-
- result = await runner.invoke({"session_id": "external-session", "input": "hello"})
-
- assert result["output"] == "ok"
- # 累积后空 input_token_details 不保留(无意义),output_token_details 有值保留
- assert result["usage"] == {
- "input_tokens": 12,
- "output_tokens": 5,
- "total_tokens": 17,
- "output_token_details": {"reasoning": 2},
- }
- # last_usage = 最后一次调用快照(单 event 时 = usage 自身)
- assert result["metadata"]["last_usage"]["input_tokens"] == 12
-
-
-@pytest.mark.asyncio
-async def test_adk_runner_invoke_accumulates_usage_across_events(tmp_path, monkeypatch):
- """多 event(agent loop 多次 LLM 调用)usage 累加,last_usage = 末个 event。"""
- from google.genai import types
- from ksadk.runners.adk_runner import ADKRunner
-
- detection = SimpleNamespace(
- entry_point="agent.py", agent_variable="root_agent", name="demo-agent",
- )
- runner = ADKRunner(detection, str(tmp_path))
- runner._agent = SimpleNamespace(name="demo-agent")
-
- class _FakeRunner:
- async def run_async(self, *, session_id, user_id, new_message, state_delta=None, run_config=None):
- del session_id, user_id, new_message, state_delta, run_config
- # 两次 LLM 调用(tool loop):第一次 input=4000,第二次 input=5000(含历史)
- yield SimpleNamespace(
- usage_metadata={"input_tokens": 4000, "output_tokens": 100, "total_tokens": 4100},
- content=SimpleNamespace(parts=[]),
- )
- yield SimpleNamespace(
- usage_metadata={"input_tokens": 5000, "output_tokens": 800, "total_tokens": 5800,
- "input_token_details": {"cached": 4500}},
- content=SimpleNamespace(parts=[types.Part(text="final")]),
- )
-
- async def _fake_ensure_session(external_session_id=None):
- return "adk-session-accum"
-
- monkeypatch.setattr(runner, "_ensure_session", _fake_ensure_session)
- monkeypatch.setattr(runner, "_prepare_trace_metadata", lambda session_id: ("", [], "", "demo-agent"))
- runner._runner = _FakeRunner()
-
- result = await runner.invoke({"session_id": "external-session", "input": "hello"})
-
- # 累积值:input/output/total 相加,details 逐键求和
- assert result["usage"]["input_tokens"] == 9000
- assert result["usage"]["output_tokens"] == 900
- assert result["usage"]["total_tokens"] == 9900
- assert result["usage"]["input_token_details"]["cached"] == 4500
- # last_usage = 最后一次调用(窗口占用 = 末次 input)
- assert result["metadata"]["last_usage"]["input_tokens"] == 5000
- assert result["metadata"]["last_usage"]["input_token_details"]["cached"] == 4500
-
-
-@pytest.mark.asyncio
-async def test_adk_runner_stream_extracts_usage_details_from_final_event(tmp_path, monkeypatch):
- from google.genai import types
- from ksadk.runners.adk_runner import ADKRunner
-
- detection = SimpleNamespace(
- entry_point="agent.py",
- agent_variable="root_agent",
- name="demo-agent",
- )
- runner = ADKRunner(detection, str(tmp_path))
- runner._agent = SimpleNamespace(name="demo-agent")
-
- class _FakeRunner:
- async def run_async(self, *, session_id, user_id, new_message, state_delta=None, run_config=None):
- del session_id, user_id, new_message, state_delta, run_config
- yield SimpleNamespace(
- partial=True,
- content=SimpleNamespace(parts=[types.Part(text="hello")]),
- )
- yield SimpleNamespace(
- usage_metadata={
- "prompt_token_count": 12,
- "candidates_token_count": 5,
- "total_token_count": 17,
- "cached_content_token_count": 4,
- "tool_use_prompt_token_count": 3,
- "thoughts_token_count": 2,
- },
- content=SimpleNamespace(parts=[]),
- )
-
- async def _fake_ensure_session(external_session_id=None):
- del external_session_id
- return "adk-session-stream-usage"
-
- monkeypatch.setattr(runner, "_ensure_session", _fake_ensure_session)
- monkeypatch.setattr(runner, "_prepare_trace_metadata", lambda session_id: ("", [], "", "demo-agent"))
- runner._runner = _FakeRunner()
-
- chunks = [chunk async for chunk in runner.stream({"session_id": "external-session", "input": "hello"})]
-
- final = chunks[-1]
- assert final["output"] == "hello"
- assert final["type"] == "final"
- assert final["usage"] == {
- "input_tokens": 12,
- "output_tokens": 5,
- "total_tokens": 17,
- "input_token_details": {"cached": 4, "tool_use": 3},
- "output_token_details": {"reasoning": 2},
- }
- # last_usage = 最后一次调用快照
- assert final["metadata"]["last_usage"]["input_tokens"] == 12
- assert final["metadata"]["last_usage"]["input_token_details"]["cached"] == 4
diff --git a/tests/test_runner_langfuse_callbacks.py b/tests/test_runner_langfuse_callbacks.py
deleted file mode 100644
index ad6b9a64..00000000
--- a/tests/test_runner_langfuse_callbacks.py
+++ /dev/null
@@ -1,114 +0,0 @@
-import importlib
-import sys
-import types
-
-import pytest
-
-
-@pytest.fixture(autouse=True)
-def _isolate_langfuse_callback_env(monkeypatch):
- for key in (
- "CLOUD_MONITOR_LANGFUSE_ENABLED",
- "CLOUD_MONITOR_LANGFUSE_HOST",
- "CLOUD_MONITOR_LANGFUSE_PUBLIC_KEY",
- "CLOUD_MONITOR_LANGFUSE_SECRET_KEY",
- "CLOUD_MONITOR_OTLP_ENDPOINT",
- "LANGFUSE_BASE_URL",
- "LANGFUSE_HOST",
- "LANGFUSE_PUBLIC_KEY",
- "LANGFUSE_SECRET_KEY",
- "LANGFUSE_USE_CALLBACK",
- "OTEL_SERVICE_NAME",
- ):
- monkeypatch.delenv(key, raising=False)
-
-
-class _FakeLangfuse:
- instances = []
-
- def __init__(self, **kwargs):
- self.kwargs = kwargs
- self.__class__.instances.append(kwargs)
-
-
-class _FakeCallbackHandler:
- instances = []
-
- def __init__(self, *, public_key=None, trace_context=None):
- self.public_key = public_key
- self.trace_context = trace_context
- self.__class__.instances.append(self)
-
-
-def _install_fake_langfuse(monkeypatch):
- _FakeLangfuse.instances.clear()
- _FakeCallbackHandler.instances.clear()
- monkeypatch.setitem(
- sys.modules,
- "langfuse",
- types.SimpleNamespace(Langfuse=_FakeLangfuse),
- )
- monkeypatch.setitem(
- sys.modules,
- "langfuse.langchain",
- types.SimpleNamespace(CallbackHandler=_FakeCallbackHandler),
- )
-
-
-def _reload_module(monkeypatch):
- module = importlib.import_module("ksadk.runners.utils.langfuse")
- module = importlib.reload(module)
- monkeypatch.setattr(module, "_langfuse_callback", None)
- monkeypatch.setattr(module, "_cloud_monitor_langfuse_callback", None)
- return module
-
-
-def test_get_langfuse_callbacks_returns_primary_and_cloud_monitor(monkeypatch):
- _install_fake_langfuse(monkeypatch)
- monkeypatch.setenv("LANGFUSE_USE_CALLBACK", "true")
- monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-primary")
- monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-primary")
- monkeypatch.setenv("LANGFUSE_BASE_URL", "https://trace-pre.example.com")
- monkeypatch.setenv("CLOUD_MONITOR_LANGFUSE_PUBLIC_KEY", "pk-cloud")
- monkeypatch.setenv("CLOUD_MONITOR_LANGFUSE_SECRET_KEY", "sk-cloud")
- monkeypatch.setenv("CLOUD_MONITOR_LANGFUSE_HOST", "https://cn-beijing-6.otlp.ksyun.com:4318")
-
- module = _reload_module(monkeypatch)
-
- callbacks = module.get_langfuse_callbacks()
-
- assert [callback.public_key for callback in callbacks] == ["pk-primary", "pk-cloud"]
- assert [
- {key: value for key, value in instance.items() if key != "tracer_provider"}
- for instance in _FakeLangfuse.instances
- ] == [
- {
- "public_key": "pk-primary",
- "secret_key": "sk-primary",
- "base_url": "https://trace-pre.example.com",
- },
- {
- "public_key": "pk-cloud",
- "secret_key": "sk-cloud",
- "base_url": "https://cn-beijing-6.otlp.ksyun.com:4318",
- },
- ]
- assert (
- _FakeLangfuse.instances[0]["tracer_provider"]
- is not _FakeLangfuse.instances[1]["tracer_provider"]
- )
-
-
-def test_get_langfuse_callbacks_skips_incomplete_cloud_monitor_config(monkeypatch):
- _install_fake_langfuse(monkeypatch)
- monkeypatch.setenv("LANGFUSE_USE_CALLBACK", "true")
- monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-primary")
- monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-primary")
- monkeypatch.setenv("LANGFUSE_BASE_URL", "https://trace-pre.example.com")
- monkeypatch.setenv("CLOUD_MONITOR_LANGFUSE_PUBLIC_KEY", "pk-cloud")
-
- module = _reload_module(monkeypatch)
-
- callbacks = module.get_langfuse_callbacks()
-
- assert [callback.public_key for callback in callbacks] == ["pk-primary"]
diff --git a/tests/test_runtime_common_memory_backend.py b/tests/test_runtime_common_memory_backend.py
deleted file mode 100644
index caa8145a..00000000
--- a/tests/test_runtime_common_memory_backend.py
+++ /dev/null
@@ -1,178 +0,0 @@
-from __future__ import annotations
-
-import importlib
-
-import pytest
-
-
-VALID_MEM0_UUID = "e52b7fac-e641-4b34-b9f7-6b0b9f190cd4"
-
-
-def _memory_backend_module():
- return importlib.import_module("ksadk_runtime_common.memory_backend")
-
-
-def _manifest_module():
- return importlib.import_module("ksadk_runtime_common.memory_backend.manifest")
-
-
-def test_runtime_common_package_is_importable():
- module = importlib.import_module("ksadk_runtime_common")
-
- assert hasattr(module, "create_workspace_files_router")
- assert hasattr(module, "workspace_files_enabled")
-
-
-def test_render_openclaw_default_manifest_returns_empty_patch():
- memory_backend = _memory_backend_module()
-
- result = memory_backend.render_memory_backend_config(
- {
- "schema_version": "v1",
- "backend_type": "openclaw_default",
- }
- )
-
- assert result.model_dump() == {
- "backend_type": "openclaw_default",
- "config_patch": {},
- "required_env": [],
- "plugin_ids": [],
- "disabled_plugin_ids": ["openclaw-mem0", "memory-lancedb"],
- "clear_plugin_slots": ["memory"],
- }
-
-
-def test_render_mem0_manifest_requires_runtime_env(monkeypatch):
- memory_backend = _memory_backend_module()
- monkeypatch.delenv("MEM0_API_KEY", raising=False)
- monkeypatch.delenv("MEM0_USER_ID", raising=False)
- monkeypatch.delenv("MEM0_BASE_URL", raising=False)
-
- with pytest.raises(ValueError, match="MEM0_API_KEY"):
- memory_backend.render_memory_backend_config(
- {
- "schema_version": "v1",
- "backend_type": "mem0",
- "config": {
- "mem0_instance_id": VALID_MEM0_UUID,
- },
- }
- )
-
-
-def test_render_mem0_manifest_to_openclaw_patch(monkeypatch):
- memory_backend = _memory_backend_module()
- monkeypatch.setenv(
- "MEM0_API_KEY",
- f"2000104981.{VALID_MEM0_UUID}:mem0-secret",
- )
- monkeypatch.setenv("MEM0_USER_ID", "2000104981")
- monkeypatch.setenv("MEM0_BASE_URL", "http://mem-service.example.com")
-
- result = memory_backend.render_memory_backend_config(
- {
- "schema_version": "v1",
- "backend_type": "mem0",
- "config": {
- "mem0_instance_id": VALID_MEM0_UUID,
- "mem0_region": "cn-qingyangtest-1",
- },
- "secrets_env": {
- "api_key": "MEM0_API_KEY",
- "user_id": "MEM0_USER_ID",
- "base_url": "MEM0_BASE_URL",
- },
- }
- )
-
- assert result.model_dump() == {
- "backend_type": "mem0",
- "config_patch": {
- "plugins": {
- "slots": {
- "memory": "openclaw-mem0",
- },
- "entries": {
- "openclaw-mem0": {
- "enabled": True,
- "config": {
- "mode": "platform",
- "apiKey": f"2000104981.{VALID_MEM0_UUID}:mem0-secret",
- "baseUrl": "http://mem-service.example.com",
- "userId": "2000104981",
- },
- },
- },
- }
- },
- "required_env": ["MEM0_API_KEY", "MEM0_USER_ID", "MEM0_BASE_URL"],
- "plugin_ids": ["openclaw-mem0"],
- "disabled_plugin_ids": [],
- "clear_plugin_slots": [],
- }
-
-
-def test_render_lancedb_manifest_to_openclaw_patch():
- memory_backend = _memory_backend_module()
-
- result = memory_backend.render_memory_backend_config(
- {
- "schema_version": "v1",
- "backend_type": "lancedb",
- }
- )
-
- assert result.model_dump() == {
- "backend_type": "lancedb",
- "config_patch": {
- "plugins": {
- "slots": {
- "memory": "memory-lancedb",
- },
- "entries": {
- "memory-lancedb": {
- "enabled": True,
- },
- },
- }
- },
- "required_env": [],
- "plugin_ids": ["memory-lancedb"],
- "disabled_plugin_ids": ["openclaw-mem0"],
- "clear_plugin_slots": [],
- }
-
-
-def test_render_lancedb_manifest_passes_optional_config_to_plugin():
- memory_backend = _memory_backend_module()
-
- result = memory_backend.render_memory_backend_config(
- {
- "schema_version": "v1",
- "backend_type": "lancedb",
- "config": {
- "dbPath": "/home/node/.openclaw/memory/lancedb",
- },
- }
- )
-
- assert result.config_patch["plugins"]["entries"]["memory-lancedb"] == {
- "enabled": True,
- "config": {
- "dbPath": "/home/node/.openclaw/memory/lancedb",
- },
- }
-
-
-def test_manifest_model_instances_are_revalidated_against_schema():
- memory_backend = _memory_backend_module()
- manifest_module = _manifest_module()
- manifest = manifest_module.MemoryBackendManifest(
- schema_version="v1",
- backend_type="mem0",
- config={"mem0_instance_id": "not-a-uuid"},
- )
-
- with pytest.raises(ValueError, match="mem0_instance_id"):
- memory_backend.render_memory_backend_config(manifest)
diff --git a/tests/test_sandbox_backend.py b/tests/test_sandbox_backend.py
deleted file mode 100644
index 8af2cec4..00000000
--- a/tests/test_sandbox_backend.py
+++ /dev/null
@@ -1,703 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-import pytest
-
-from ksadk.sandbox import (
- E2BSandboxBackend,
- LocalProcessSandboxBackend,
- SandboxCommandResult,
- SandboxError,
- SandboxInputFile,
- SandboxSpec,
- SandboxType,
- create_sandbox_backend,
-)
-from ksadk.runtime_context import tool_execution_scope
-from ksadk.sandbox.registry import GLOBAL_SANDBOX_REGISTRY, SandboxRegistry
-from ksadk.toolsets.sandbox import run_code, run_command, sandbox_status
-
-
-@pytest.fixture(autouse=True)
-def _reset_sandbox_registry(monkeypatch):
- # 禁用后台 sweep 线程,避免测试间相互干扰。
- monkeypatch.setenv("KSADK_SANDBOX_SWEEP_INTERVAL_SECONDS", "0")
- GLOBAL_SANDBOX_REGISTRY.reset_for_tests()
- yield
- GLOBAL_SANDBOX_REGISTRY.reset_for_tests()
-
-
-def test_sandbox_factory_creates_e2b_backend(monkeypatch):
- monkeypatch.setenv("KSADK_SANDBOX_BACKEND", "e2b")
- monkeypatch.setenv("KSADK_SANDBOX_TEMPLATE_ID", "tpl-aio")
-
- backend = create_sandbox_backend(sandbox_cls=object)
-
- assert isinstance(backend, E2BSandboxBackend)
- assert backend.spec.template_id == "tpl-aio"
-
-
-def test_run_code_returns_snippet_runner_boundary_metadata(monkeypatch):
- class FakeSession:
- sandbox_id = "sbx-code"
-
- def write_file(self, path, data):
- self.path = path
- self.data = data
-
- def run_command(self, command, timeout=None, env=None, cwd=None):
- return SandboxCommandResult(stdout="42\n", stderr="", exit_code=0)
-
- class FakeBackend:
- isolated = True
-
- def create_session(self, **_kwargs):
- return FakeSession()
-
- monkeypatch.setattr("ksadk.toolsets.sandbox.create_sandbox_backend", lambda: FakeBackend())
- monkeypatch.setenv("KSADK_SANDBOX_BACKEND", "e2b")
- monkeypatch.setenv("KSADK_SANDBOX_TEMPLATE_ID", "tpl-aio")
-
- result = run_code("print(42)", language="python")
-
- assert result["ok"] is True
- assert result["execution_model"] == "snippet_runner"
- assert result["boundary"]
- assert result["sandbox_id"] == "sbx-code"
-
-
-def test_run_code_requires_isolated_backend(monkeypatch, tmp_path):
- monkeypatch.setenv("KSADK_SANDBOX_BACKEND", "local_process")
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / "ui"))
-
- result = run_code("print('local')", language="python")
-
- assert result["ok"] is False
- assert result["error_type"] == "isolated_sandbox_required"
- assert "requires an isolated sandbox" in result["error_message"]
-
-
-def test_sandbox_factory_refuses_e2b_without_template(monkeypatch):
- monkeypatch.setenv("KSADK_SANDBOX_BACKEND", "e2b")
- monkeypatch.delenv("KSADK_SANDBOX_TEMPLATE_ID", raising=False)
- monkeypatch.delenv("KSADK_SKILL_RUNTIME_TEMPLATE_ID", raising=False)
-
- with pytest.raises(SandboxError, match="template id"):
- create_sandbox_backend()
-
-
-def test_sandbox_factory_creates_local_process_backend_when_explicit(monkeypatch):
- monkeypatch.setenv("KSADK_SANDBOX_BACKEND", "local_process")
-
- backend = create_sandbox_backend()
-
- assert isinstance(backend, LocalProcessSandboxBackend)
- assert backend.isolated is False
-
-
-def test_sandbox_factory_requires_gate_for_pod_process(monkeypatch):
- monkeypatch.setenv("KSADK_SANDBOX_BACKEND", "pod_process")
- monkeypatch.delenv("KSADK_ALLOW_POD_PROCESS_TOOLS", raising=False)
-
- with pytest.raises(SandboxError, match="KSADK_ALLOW_POD_PROCESS_TOOLS"):
- create_sandbox_backend()
-
-
-def test_sandbox_factory_supports_runtime_template_alias(monkeypatch):
- monkeypatch.setenv("KSADK_SANDBOX_BACKEND", "e2b")
- monkeypatch.delenv("KSADK_SANDBOX_TEMPLATE_ID", raising=False)
- monkeypatch.setenv("KSADK_SKILL_RUNTIME_TEMPLATE_ID", "tpl-skill")
-
- backend = create_sandbox_backend(sandbox_cls=object)
-
- assert isinstance(backend, E2BSandboxBackend)
- assert backend.spec.template_id == "tpl-skill"
-
-
-def test_e2b_sandbox_backend_create_write_run_and_kill(tmp_path: Path):
- calls: list[tuple[str, object]] = []
- source = tmp_path / "input.txt"
- source.write_text("hello", encoding="utf-8")
-
- class FakeResult:
- stdout = "ok\n"
- stderr = ""
- exit_code = 0
-
- class FakeFiles:
- def write(self, path, data):
- calls.append(("file_write", (path, data)))
-
- class FakeCommands:
- def run(self, command: str, **kwargs):
- calls.append(("run", command))
- calls.append(("run_kwargs", kwargs))
- return FakeResult()
-
- class FakeSandbox:
- sandbox_id = "sbx-123"
-
- def __init__(self):
- self.files = FakeFiles()
- self.commands = FakeCommands()
-
- @classmethod
- def create(cls, **kwargs):
- calls.append(("create", kwargs))
- return cls()
-
- def kill(self):
- calls.append(("kill", self.sandbox_id))
-
- backend = E2BSandboxBackend(
- spec=SandboxSpec(
- template_id="tpl-aio",
- sandbox_type=SandboxType.AIO,
- timeout=123,
- allow_internet_access=True,
- metadata={"purpose": "test"},
- env={"BASE_ENV": "1"},
- ),
- sandbox_cls=FakeSandbox,
- )
-
- session = backend.create_session(
- session_id="sess-1",
- env={"REQUEST_ENV": "2"},
- input_files=[SandboxInputFile(source=source, target_path="/tmp/input.txt")],
- )
- result = session.run_command("python -V", timeout=30, env={"REQUEST_ENV": "command"})
- session.kill()
-
- assert result == SandboxCommandResult(stdout="ok\n", stderr="", exit_code=0)
- assert calls[0] == (
- "create",
- {
- "template": "tpl-aio",
- "timeout": 123,
- "metadata": {
- "runtime": "ksadk",
- "sandbox_type": "aio",
- "purpose": "test",
- "session_id": "sess-1",
- },
- "envs": {"BASE_ENV": "1", "REQUEST_ENV": "2"},
- "allow_internet_access": True,
- },
- )
- assert ("file_write", ("/tmp/input.txt", b"hello")) in calls
- assert ("run", "python -V") in calls
- assert ("run_kwargs", {"timeout": 30, "envs": {"REQUEST_ENV": "command"}}) in calls
- assert calls[-1] == ("kill", "sbx-123")
-
-
-def test_e2b_sandbox_backend_waits_for_startup_command_readiness(monkeypatch):
- monkeypatch.setattr("ksadk.sandbox.backends.e2b.time.sleep", lambda _seconds: None)
- calls: list[str] = []
-
- class NotFoundException(Exception):
- pass
-
- class FakeResult:
- stdout = "ready\n"
- stderr = ""
- exit_code = 0
-
- class FakeCommands:
- def run(self, command: str, **kwargs):
- calls.append(command)
- if len(calls) == 1:
- raise NotFoundException()
- return FakeResult()
-
- class FakeFiles:
- def write(self, path: str, data: str | bytes):
- pass
-
- class FakeSandbox:
- sandbox_id = "sbx-123"
- def __init__(self):
- self.commands = FakeCommands()
- self.files = FakeFiles()
-
- @classmethod
- def create(cls, **kwargs):
- return cls()
-
- backend = E2BSandboxBackend(
- spec=SandboxSpec(template_id="tpl-aio"),
- sandbox_cls=FakeSandbox,
- )
- e2b_session = backend.create_session(session_id="sess-1")
-
- result = e2b_session.run_command("python -V")
-
- assert result == SandboxCommandResult(stdout="ready\n", stderr="", exit_code=0)
- assert calls == ["true", "true", "python -V"]
-
-
-def test_e2b_sandbox_backend_waits_for_startup_filesystem_readiness(monkeypatch):
- monkeypatch.setattr("ksadk.sandbox.backends.e2b.time.sleep", lambda _seconds: None)
- calls: list[tuple[str, str | bytes]] = []
-
- class FileNotFoundException(Exception):
- pass
-
- class FakeResult:
- stdout = ""
- stderr = ""
- exit_code = 0
-
- class FakeCommands:
- def run(self, command: str, **kwargs):
- return FakeResult()
-
- class FakeFiles:
- def write(self, path: str, data: str | bytes):
- calls.append((path, data))
- if len(calls) == 1:
- raise FileNotFoundException()
-
- class FakeSandbox:
- sandbox_id = "sbx-123"
- def __init__(self):
- self.commands = FakeCommands()
- self.files = FakeFiles()
-
- @classmethod
- def create(cls, **kwargs):
- return cls()
-
- source = Path(__file__)
- backend = E2BSandboxBackend(
- spec=SandboxSpec(template_id="tpl-aio"),
- sandbox_cls=FakeSandbox,
- )
- backend.create_session(
- session_id="sess-1",
- input_files=[SandboxInputFile(source=source, target_path="/tmp/input.txt")],
- )
-
- assert calls[0] == ("/tmp/.ksadk-sandbox-ready", "")
- assert calls[1] == ("/tmp/.ksadk-sandbox-ready", "")
- assert calls[2][0] == "/tmp/input.txt"
-
-
-def test_e2b_sandbox_backend_requires_template_id():
- with pytest.raises(SandboxError, match="template id"):
- E2BSandboxBackend(spec=SandboxSpec(template_id=""))
-
-
-def test_sandbox_type_parses_console_types():
- assert SandboxType.from_value("All-in-one") is SandboxType.AIO
- assert SandboxType.from_value("CodeInterpreter") is SandboxType.CODE
- assert SandboxType.from_value("Browser") is SandboxType.BROWSER
- assert SandboxType.from_value("Private") is SandboxType.PRIVATE
-
-
-def test_local_process_backend_runs_inside_workspace(tmp_path):
- backend = LocalProcessSandboxBackend(workspace_root=tmp_path)
- session = backend.create_session(session_id="sess-1")
-
- result = session.run_command("pwd && python -c 'print(123)'")
-
- assert result.exit_code == 0
- assert str(tmp_path) in result.stdout
- assert "123" in result.stdout
-
-
-def test_local_process_backend_rejects_cwd_escape(tmp_path):
- backend = LocalProcessSandboxBackend(workspace_root=tmp_path)
- session = backend.create_session(session_id="sess-1")
-
- result = session.run_command("pwd", env={"KSADK_COMMAND_CWD": "/"})
-
- assert result.exit_code == 126
- assert "cwd must stay inside" in result.stderr
-
-
-def test_local_process_backend_kills_process_group_on_timeout(tmp_path):
- backend = LocalProcessSandboxBackend(workspace_root=tmp_path)
- session = backend.create_session(session_id="sess-1")
-
- result = session.run_command("python -c 'import subprocess, time; subprocess.Popen([\"sleep\", \"5\"]); time.sleep(5)'", timeout=1)
-
- assert result.exit_code == 124
- assert "command timed out" in result.stderr
-
-
-def test_sandbox_registry_reuses_session_and_sweeps_idle_entries():
- calls: list[str] = []
-
- class FakeSession:
- sandbox_id = "fake-1"
-
- def kill(self):
- calls.append("kill")
-
- class FakeBackend:
- def create_session(self, *, session_id, env=None, input_files=None):
- calls.append(f"create:{session_id}")
- return FakeSession()
-
- registry = SandboxRegistry()
- first, created_first = registry.get_or_create(
- key="run-1",
- backend_name="fake",
- backend=FakeBackend(),
- ttl_seconds=100,
- idle_ttl_seconds=10,
- isolated=True,
- now=100.0,
- )
- second, created_second = registry.get_or_create(
- key="run-1",
- backend_name="fake",
- backend=FakeBackend(),
- ttl_seconds=100,
- idle_ttl_seconds=10,
- isolated=True,
- now=105.0,
- )
- swept = registry.sweep(now=116.0, idle_ttl_seconds=10)
-
- assert first is second
- assert created_first is True
- assert created_second is False
- assert swept == 1
- assert calls == ["create:run-1", "kill"]
-
-
-def test_sandbox_registry_quota_reclaims_oldest_entry():
- killed: list[str] = []
-
- class FakeSession:
- def __init__(self, sandbox_id: str):
- self.sandbox_id = sandbox_id
-
- def kill(self):
- killed.append(self.sandbox_id)
-
- class FakeBackend:
- def create_session(self, *, session_id, env=None, input_files=None):
- return FakeSession(session_id)
-
- registry = SandboxRegistry()
- registry.get_or_create(key="old", backend_name="fake", backend=FakeBackend(), ttl_seconds=100, isolated=True, now=100.0, max_sessions=2)
- registry.get_or_create(key="middle", backend_name="fake", backend=FakeBackend(), ttl_seconds=100, isolated=True, now=101.0, max_sessions=2)
- registry.get_or_create(key="new", backend_name="fake", backend=FakeBackend(), ttl_seconds=100, isolated=True, now=102.0, max_sessions=2)
-
- assert killed == ["old"]
- assert {entry.key for entry in registry.entries()} == {"middle", "new"}
-
-
-def test_run_command_syncs_workspace_files_to_new_sandbox(monkeypatch, tmp_path):
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / "ui"))
- monkeypatch.setenv("KSADK_SANDBOX_BACKEND", "e2b")
- monkeypatch.setenv("KSADK_SANDBOX_TEMPLATE_ID", "tpl-aio")
- workspace = tmp_path / "ui" / "workspace"
- workspace.mkdir(parents=True)
- (workspace / "app.py").write_text("print('synced')\n", encoding="utf-8")
-
- created_input_files: list[SandboxInputFile] = []
-
- class FakeResult:
- stdout = "ok\n"
- stderr = ""
- exit_code = 0
-
- class FakeSession:
- sandbox_id = "sbx-sync"
-
- def write_file(self, path, data):
- pass
-
- def read_file(self, path):
- return ""
-
- def run_command(self, command, *, timeout=None, env=None, cwd=None):
- return FakeResult()
-
- def get_host(self, port):
- return "https://example.com"
-
- def kill(self):
- pass
-
- class FakeBackend:
- def create_session(self, *, session_id, env=None, input_files=None):
- created_input_files.extend(input_files or [])
- return FakeSession()
-
- from ksadk.sandbox.registry import GLOBAL_SANDBOX_REGISTRY
-
- GLOBAL_SANDBOX_REGISTRY.clear()
- monkeypatch.setattr("ksadk.toolsets.sandbox.create_sandbox_backend", lambda: FakeBackend())
-
- result = run_command("python -V")
-
- assert result["ok"] is True
- assert [(item.source, item.target_path) for item in created_input_files] == [
- (workspace / "app.py", "/workspace/app.py")
- ]
-
-
-def test_run_command_and_run_code_reuse_context_session_sandbox_key(monkeypatch, tmp_path):
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / "ui"))
- monkeypatch.setenv("KSADK_SANDBOX_BACKEND", "e2b")
- monkeypatch.setenv("KSADK_SANDBOX_TEMPLATE_ID", "tpl-aio")
- (tmp_path / "ui" / "workspace").mkdir(parents=True)
- created_session_ids: list[str] = []
- commands: list[str] = []
-
- class FakeResult:
- stdout = "ok\n"
- stderr = ""
- exit_code = 0
-
- class FakeSession:
- sandbox_id = "sbx-shared"
-
- def write_file(self, path, data):
- pass
-
- def run_command(self, command, *, timeout=None, env=None, cwd=None):
- commands.append(command)
- return FakeResult()
-
- def kill(self):
- pass
-
- class FakeBackend:
- def create_session(self, *, session_id, env=None, input_files=None):
- created_session_ids.append(session_id)
- return FakeSession()
-
- GLOBAL_SANDBOX_REGISTRY.clear()
- monkeypatch.setattr("ksadk.toolsets.sandbox.create_sandbox_backend", lambda: FakeBackend())
-
- with tool_execution_scope(session_id="sess-1", run_id="run-1", invocation_id="inv-1"):
- command_result = run_command("python -V")
- code_result = run_code("print(42)")
-
- assert command_result["ok"] is True
- assert code_result["ok"] is True
- assert created_session_ids == ["ksadk-session:sess-1"]
- assert commands[0] == "python -V"
- assert commands[1].startswith("python /tmp/ksadk-run-code-")
- GLOBAL_SANDBOX_REGISTRY.clear()
-
-
-def test_sandbox_context_key_isolated_by_session_and_env_override_wins(monkeypatch, tmp_path):
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / "ui"))
- monkeypatch.setenv("KSADK_SANDBOX_BACKEND", "e2b")
- monkeypatch.setenv("KSADK_SANDBOX_TEMPLATE_ID", "tpl-aio")
- (tmp_path / "ui" / "workspace").mkdir(parents=True)
- created_session_ids: list[str] = []
-
- class FakeResult:
- stdout = "ok\n"
- stderr = ""
- exit_code = 0
-
- class FakeSession:
- def __init__(self, sandbox_id: str):
- self.sandbox_id = sandbox_id
-
- def run_command(self, command, *, timeout=None, env=None, cwd=None):
- return FakeResult()
-
- def kill(self):
- pass
-
- class FakeBackend:
- def create_session(self, *, session_id, env=None, input_files=None):
- created_session_ids.append(session_id)
- return FakeSession(f"sbx-{len(created_session_ids)}")
-
- GLOBAL_SANDBOX_REGISTRY.clear()
- monkeypatch.setattr("ksadk.toolsets.sandbox.create_sandbox_backend", lambda: FakeBackend())
-
- with tool_execution_scope(session_id="sess-a"):
- assert run_command("python -V")["ok"] is True
- with tool_execution_scope(session_id="sess-b"):
- assert run_command("python -V")["ok"] is True
- monkeypatch.setenv("KSADK_SANDBOX_SESSION_ID", "manual")
- with tool_execution_scope(session_id="sess-c"):
- assert run_command("python -V")["ok"] is True
-
- assert created_session_ids == ["ksadk-session:sess-a", "ksadk-session:sess-b", "manual"]
- GLOBAL_SANDBOX_REGISTRY.clear()
-
-
-def test_sandbox_direct_calls_retain_prefix_fallback_keys(monkeypatch, tmp_path):
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / "ui"))
- monkeypatch.setenv("KSADK_SANDBOX_BACKEND", "e2b")
- monkeypatch.setenv("KSADK_SANDBOX_TEMPLATE_ID", "tpl-aio")
- (tmp_path / "ui" / "workspace").mkdir(parents=True)
- created_session_ids: list[str] = []
-
- class FakeResult:
- stdout = "ok\n"
- stderr = ""
- exit_code = 0
-
- class FakeSession:
- sandbox_id = "sbx-direct"
-
- def write_file(self, path, data):
- pass
-
- def run_command(self, command, *, timeout=None, env=None, cwd=None):
- return FakeResult()
-
- def kill(self):
- pass
-
- class FakeBackend:
- def create_session(self, *, session_id, env=None, input_files=None):
- created_session_ids.append(session_id)
- return FakeSession()
-
- GLOBAL_SANDBOX_REGISTRY.clear()
- monkeypatch.setattr("ksadk.toolsets.sandbox.create_sandbox_backend", lambda: FakeBackend())
-
- assert run_command("python -V")["ok"] is True
- assert run_code("print(42)")["ok"] is True
-
- assert created_session_ids == ["ksadk-direct-shared", "ksadk-code-shared"]
- GLOBAL_SANDBOX_REGISTRY.clear()
-
-
-def test_sandbox_status_reports_idle_ttl_and_quota(monkeypatch):
- monkeypatch.setenv("KSADK_SANDBOX_BACKEND", "local_process")
- monkeypatch.setenv("KSADK_SANDBOX_IDLE_TTL_SECONDS", "12")
- monkeypatch.setenv("KSADK_SANDBOX_MAX_SESSIONS", "3")
-
- result = sandbox_status()
-
- assert result["ok"] is True
- assert result["isolated"] is False
- assert result["idle_ttl_seconds"] == 12
- assert result["max_sessions"] == 3
-
-
-def test_sandbox_registry_clear_is_idempotent():
- # clear() 幂等:多次调用不抛异常(atexit 和 server shutdown 都可能调)。
- GLOBAL_SANDBOX_REGISTRY.clear()
- GLOBAL_SANDBOX_REGISTRY.clear()
- assert GLOBAL_SANDBOX_REGISTRY.entries() == []
-
-
-def test_sandbox_registry_sweep_thread_disabled_when_interval_zero(monkeypatch):
- monkeypatch.setenv("KSADK_SANDBOX_SWEEP_INTERVAL_SECONDS", "0")
- registry = SandboxRegistry()
- registry._start_sweep_thread()
- assert registry._sweep_thread is None
- registry.reset_for_tests()
-
-
-def test_sandbox_registry_sweep_thread_starts_when_interval_positive(monkeypatch):
- monkeypatch.setenv("KSADK_SANDBOX_SWEEP_INTERVAL_SECONDS", "1")
- registry = SandboxRegistry()
-
- class FakeSession:
- sandbox_id = "sweep-1"
- killed = False
-
- def kill(self):
- self.killed = True
-
- class FakeBackend:
- def create_session(self, *, session_id, env=None, input_files=None):
- return FakeSession()
-
- # 首次 get_or_create 创建 entry 后应懒启动后台 sweep 线程。
- registry.get_or_create(
- key="sweep-test",
- backend_name="fake",
- backend=FakeBackend(),
- ttl_seconds=1,
- idle_ttl_seconds=1,
- isolated=True,
- now=0.0,
- )
- assert registry._sweep_thread is not None
- assert registry._sweep_thread.is_alive()
- registry.reset_for_tests()
- assert registry._sweep_thread is None
- assert registry.entries() == []
-
-
-def test_sandbox_registry_concurrent_get_or_create_does_not_deadlock(monkeypatch):
- monkeypatch.setenv("KSADK_SANDBOX_SWEEP_INTERVAL_SECONDS", "0")
- import threading
-
- class FakeSession:
- def __init__(self, sid):
- self.sandbox_id = sid
- self.killed = False
-
- def kill(self):
- self.killed = True
-
- class FakeBackend:
- def __init__(self):
- self._counter = 0
- self._lock = threading.Lock()
-
- def create_session(self, *, session_id, env=None, input_files=None):
- with self._lock:
- self._counter += 1
- return FakeSession(f"sbx-{session_id}-{self._counter}")
-
- registry = SandboxRegistry()
- backend = FakeBackend()
- errors: list[Exception] = []
-
- def worker(idx: int):
- try:
- registry.get_or_create(
- key=f"concurrent-{idx}",
- backend_name="fake",
- backend=backend,
- ttl_seconds=100,
- isolated=True,
- )
- except Exception as exc:
- errors.append(exc)
-
- threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
- for t in threads:
- t.start()
- for t in threads:
- t.join(timeout=5.0)
- assert not errors
- assert len(registry.entries()) == 8
- registry.reset_for_tests()
-
-
-def test_shutdown_runner_resources_clears_sandbox_registry(monkeypatch):
- import asyncio
- import sys
-
- import ksadk.server.app # noqa: F401 触发模块注册到 sys.modules
- app_module = sys.modules["ksadk.server.app"]
-
- # 用一个带空 close() 的 mock runner,触发完整 shutdown 路径(含 sandbox clear)。
- class FakeRunner:
- async def close(self):
- return None
-
- monkeypatch.setattr(app_module, "runner", FakeRunner())
- calls: list[bool] = []
- monkeypatch.setattr(
- GLOBAL_SANDBOX_REGISTRY,
- "clear",
- lambda: calls.append(True),
- )
-
- asyncio.run(app_module._shutdown_runner_resources())
-
- assert calls == [True]
diff --git a/tests/test_semantic_circuit_breaker.py b/tests/test_semantic_circuit_breaker.py
deleted file mode 100644
index abfbf611..00000000
--- a/tests/test_semantic_circuit_breaker.py
+++ /dev/null
@@ -1,139 +0,0 @@
-"""L4 semantic 熔断单测:连续失败超阈值后跳过 LLM 直接走 extractive。
-
-Codex 指出:summarize_compaction 捕获异常返回 extractive,外层看成功,
-governance compact failure 不触发。因此 semantic 熔断需独立计数(本模块级)。
-"""
-
-from __future__ import annotations
-
-import asyncio
-import pytest
-
-from ksadk.conversations import semantic_summary as ss
-from ksadk.conversations.semantic_summary import (
- CompactionSummaryResult,
- summarize_compaction,
-)
-
-
-class _FailingClient:
- """模拟 LLM 调用总是失败的 client。"""
-
- @property
- def is_available(self) -> bool:
- return True
-
- async def summarize(self, *, model, messages, timeout_ms):
- raise RuntimeError("simulated LLM timeout")
-
-
-class _SuccessClient:
- """模拟 LLM 调用成功的 client(返回带 块)。"""
-
- @property
- def is_available(self) -> bool:
- return True
-
- async def summarize(self, *, model, messages, timeout_ms):
- return "xok summary", {"input_tokens": 10}
-
-
-def _setup_client(monkeypatch, client):
- monkeypatch.setattr(ss, "resolve_summary_model_client", lambda: client)
- # 让 semantic_compaction_disabled 返回 False(确保走 semantic 路径)。
- monkeypatch.setattr(ss, "semantic_compaction_disabled", lambda: False)
-
-
-@pytest.fixture(autouse=True)
-def _reset_semantic_failures():
- """每个测试前重置 semantic 失败计数,避免测试间污染。"""
- ss._reset_semantic_failures()
- yield
- ss._reset_semantic_failures()
-
-
-@pytest.mark.asyncio
-async def test_semantic_failure_increments_counter(monkeypatch):
- _setup_client(monkeypatch, _FailingClient())
- # 确保不被熔断拦截(阈值设高)。
- monkeypatch.setenv("KSADK_MAX_CONSECUTIVE_SEMANTIC_FAILURES", "100")
-
- result = await summarize_compaction(
- groups_to_compact=[],
- previous_summary="",
- pinned_state={},
- model_metadata=None,
- model="test-model",
- )
-
- assert result.summary_strategy == "extractive" # 失败回退 extractive
- assert result.fallback_reason == "simulated LLM timeout"
- assert ss._semantic_summary_failures == 1
-
-
-@pytest.mark.asyncio
-async def test_circuit_opens_after_threshold_failures(monkeypatch):
- _setup_client(monkeypatch, _FailingClient())
- monkeypatch.setenv("KSADK_MAX_CONSECUTIVE_SEMANTIC_FAILURES", "2")
-
- # 第一次失败:计数=1,未熔断,走 try。
- r1 = await summarize_compaction(
- groups_to_compact=[], previous_summary="", pinned_state={},
- model_metadata=None, model="m",
- )
- assert r1.fallback_reason == "simulated LLM timeout"
- assert ss._semantic_summary_failures == 1
-
- # 第二次失败:计数=2,达到阈值。
- r2 = await summarize_compaction(
- groups_to_compact=[], previous_summary="", pinned_state={},
- model_metadata=None, model="m",
- )
- assert r2.fallback_reason == "simulated LLM timeout"
- assert ss._semantic_summary_failures == 2
-
- # 第三次:熔断已开,直接走 extractive 不调 LLM,fallback_reason 变成 circuit_open。
- r3 = await summarize_compaction(
- groups_to_compact=[], previous_summary="", pinned_state={},
- model_metadata=None, model="m",
- )
- assert r3.summary_strategy == "extractive"
- assert r3.fallback_reason == "semantic_circuit_open"
- # 熔断后不再调 LLM,计数不增加。
- assert ss._semantic_summary_failures == 2
-
-
-@pytest.mark.asyncio
-async def test_success_resets_counter(monkeypatch):
- _setup_client(monkeypatch, _FailingClient())
- monkeypatch.setenv("KSADK_MAX_CONSECUTIVE_SEMANTIC_FAILURES", "5")
-
- # 一次失败。
- await summarize_compaction(
- groups_to_compact=[], previous_summary="", pinned_state={},
- model_metadata=None, model="m",
- )
- assert ss._semantic_summary_failures == 1
-
- # 切回成功 client。
- _setup_client(monkeypatch, _SuccessClient())
- r = await summarize_compaction(
- groups_to_compact=[], previous_summary="", pinned_state={},
- model_metadata=None, model="m",
- )
- assert r.summary_strategy == "semantic"
- assert ss._semantic_summary_failures == 0 # 成功清零
-
-
-@pytest.mark.asyncio
-async def test_circuit_disabled_when_threshold_zero(monkeypatch):
- _setup_client(monkeypatch, _FailingClient())
- monkeypatch.setenv("KSADK_MAX_CONSECUTIVE_SEMANTIC_FAILURES", "0")
-
- # 阈值 0 = 禁用熔断,失败多次仍走 try(每次都调 LLM 失败)。
- for _ in range(5):
- r = await summarize_compaction(
- groups_to_compact=[], previous_summary="", pinned_state={},
- model_metadata=None, model="m",
- )
- assert r.fallback_reason == "simulated LLM timeout" # 不是 circuit_open
diff --git a/tests/test_server_app_fastapi_compat.py b/tests/test_server_app_fastapi_compat.py
deleted file mode 100644
index 8ad01fea..00000000
--- a/tests/test_server_app_fastapi_compat.py
+++ /dev/null
@@ -1,40 +0,0 @@
-from __future__ import annotations
-
-import importlib
-import sys
-
-import fastapi
-
-
-def test_server_app_imports_when_fastapi_removes_add_event_handler(monkeypatch):
- original_fastapi = fastapi.FastAPI
-
- class FastAPIWithoutAddEventHandler(original_fastapi):
- def __getattribute__(self, name):
- if name == "add_event_handler":
- raise AttributeError(
- "'FastAPI' object has no attribute 'add_event_handler'"
- )
- return super().__getattribute__(name)
-
- monkeypatch.setattr(fastapi, "FastAPI", FastAPIWithoutAddEventHandler)
- sys.modules.pop("ksadk.server", None)
- sys.modules.pop("ksadk.server.app", None)
-
- module = importlib.import_module("ksadk.server.app")
-
- assert module.app is not None
-
-
-def test_server_app_import_does_not_load_deploy_providers():
- sys.modules.pop("ksadk.server", None)
- sys.modules.pop("ksadk.server.app", None)
- sys.modules.pop("ksadk.deployment", None)
- sys.modules.pop("ksadk.deployment.providers", None)
- sys.modules.pop("ksadk.builders.ks3_uploader", None)
-
- module = importlib.import_module("ksadk.server.app")
-
- assert module.app is not None
- assert "ksadk.deployment.providers" not in sys.modules
- assert "ksadk.builders.ks3_uploader" not in sys.modules
diff --git a/tests/test_server_file_upload_parsing.py b/tests/test_server_file_upload_parsing.py
deleted file mode 100644
index a391c912..00000000
--- a/tests/test_server_file_upload_parsing.py
+++ /dev/null
@@ -1,129 +0,0 @@
-import base64
-
-from ksadk.server.api_models import FileData, InlineData, Part
-from ksadk.server.app import _attachment_from_part, _extract_user_input_from_parts
-
-
-def test_extract_user_input_from_text_part():
- parts = [Part(text="看下这个候选人简历")]
- text = _extract_user_input_from_parts(parts)
- assert text == "看下这个候选人简历"
-
-
-def test_extract_user_input_from_inline_text_file():
- content = "张三\n8年经验\n熟悉LangGraph"
- encoded = base64.b64encode(content.encode("utf-8")).decode("ascii")
- parts = [
- Part(
- inlineData=InlineData(
- data=encoded,
- mimeType="text/plain",
- displayName="张三.txt",
- )
- )
- ]
-
- text = _extract_user_input_from_parts(parts)
- assert "[上传文件: 张三.txt]" in text
- assert "8年经验" in text
-
-
-def test_extract_user_input_from_binary_file_keeps_metadata():
- encoded = base64.b64encode(b"\x89PNG\r\n").decode("ascii")
- parts = [
- Part(
- inlineData=InlineData(
- data=encoded,
- mimeType="image/png",
- displayName="avatar.png",
- )
- )
- ]
-
- text = _extract_user_input_from_parts(parts)
- assert "avatar.png" in text
- assert "image/png" in text
-
-
-def test_extract_user_input_from_file_reference():
- parts = [
- Part(
- fileData=FileData(
- fileUri="ks3://bucket/path/a.txt",
- mimeType="text/plain",
- displayName="a.txt",
- )
- )
- ]
-
- text = _extract_user_input_from_parts(parts)
- assert "上传文件引用" in text
- assert "a.txt" in text
-
-
-def test_extract_user_input_from_local_file_reference_outside_uploads_dir_keeps_reference_only(tmp_path):
- attachment_path = tmp_path / "resume.txt"
- attachment_path.write_text("张三\n8年经验\n熟悉LangGraph", encoding="utf-8")
- parts = [
- Part(
- fileData=FileData(
- fileUri=f"local:{attachment_path}",
- mimeType="text/plain",
- displayName="resume.txt",
- )
- )
- ]
-
- text = _extract_user_input_from_parts(parts)
- assert "上传文件引用" in text
- assert "resume.txt" in text
- assert "8年经验" not in text
-
-
-def test_extract_user_input_from_opaque_upload_handle_reads_text(monkeypatch, tmp_path):
- ui_dir = tmp_path / ".agentengine" / "ui"
- uploads_dir = ui_dir / "files"
- uploads_dir.mkdir(parents=True)
- stored_file = uploads_dir / "abc123.txt"
- stored_file.write_text("候选人简历内容\n熟悉DeepAgents", encoding="utf-8")
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir))
-
- parts = [
- Part(
- fileData=FileData(
- fileUri="ksadk-upload://abc123",
- mimeType="text/plain",
- displayName="resume.txt",
- )
- )
- ]
-
- text = _extract_user_input_from_parts(parts)
- assert "[上传文件: resume.txt]" in text
- assert "候选人简历内容" in text
-
-
-def test_attachment_from_part_resolves_storage_path_for_upload_handle(monkeypatch, tmp_path):
- ui_dir = tmp_path / ".agentengine" / "ui"
- uploads_dir = ui_dir / "files"
- uploads_dir.mkdir(parents=True)
- stored_file = uploads_dir / "abc123.txt"
- stored_file.write_text("hello", encoding="utf-8")
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir))
-
- attachment = _attachment_from_part(
- Part(
- fileData=FileData(
- fileUri="ksadk-upload://abc123",
- mimeType="text/plain",
- displayName="resume.txt",
- )
- )
- )
-
- assert attachment is not None
- assert attachment["transport"] == "reference"
- assert attachment["file_uri"] == "ksadk-upload://abc123"
- assert attachment["storage_path"] == str(stored_file)
- assert attachment["size_bytes"] == 5
- assert attachment["is_text"] is True
diff --git a/tests/test_server_session_app.py b/tests/test_server_session_app.py
deleted file mode 100644
index ceb2a77b..00000000
--- a/tests/test_server_session_app.py
+++ /dev/null
@@ -1,4338 +0,0 @@
-from __future__ import annotations
-
-import base64
-import asyncio
-import importlib
-import json
-from types import SimpleNamespace
-
-import httpx
-import pytest
-from starlette.background import BackgroundTask
-from fastapi.responses import Response
-
-import ksadk.conversations as conversation
-from ksadk.runners.base_runner import BaseRunner
-from ksadk.server.api_models import AgentRunRequest, InlineData, Part
-from ksadk.sessions.base import SessionEvent
-from ksadk.sessions.errors import SessionBackendUnavailable
-from ksadk.sessions.in_memory import InMemorySessionService
-
-
-class _DummyRunner(BaseRunner):
- def __init__(self):
- super().__init__(
- detection_result=SimpleNamespace(
- name="demo-agent",
- type=SimpleNamespace(value="mock"),
- ),
- project_dir=".",
- )
- self.calls: list[dict] = []
-
- def load_agent(self) -> None:
- return None
-
- async def invoke(self, input_data: dict) -> dict:
- self.calls.append(input_data)
- return {"output": "assistant says hi"}
-
- async def stream(self, input_data: dict):
- self.calls.append(input_data)
- yield {"type": "final", "output": "assistant says hi"}
-
-
-class _CustomUiRunner(_DummyRunner):
- def __init__(self, project_dir: str):
- super().__init__()
- self.project_dir = project_dir
- self.detection_result.type = SimpleNamespace(value="langgraph")
-
-
-class _CheckpointResumeRunner(_DummyRunner):
- def describe_checkpoint_capability(self) -> dict:
- return {
- "Supported": True,
- "Backend": "postgres",
- "Scope": "shared",
- "Durable": True,
- "SharedAcrossPods": True,
- "Reason": "",
- }
-
- async def invoke(self, input_data: dict) -> dict:
- self.calls.append(input_data)
- return {
- "output": "resumed from checkpoint",
- "metadata": {
- "agentengine": {
- "run_id": str(input_data.get("run_id") or ""),
- "framework": "langgraph",
- "framework_ref": input_data.get("framework_ref") or {},
- }
- },
- }
-
-
-class _CheckpointMetadataRunner(_DummyRunner):
- async def invoke(self, input_data: dict) -> dict:
- self.calls.append(input_data)
- session_id = str(input_data.get("session_id") or "")
- return {
- "output": "checkpoint ready",
- "metadata": {
- "agentengine": {
- "run_id": "run-hosted",
- "framework": "langgraph",
- "framework_ref": {
- "langgraph": {
- "thread_id": session_id,
- "checkpoint_id": "ckpt-hosted",
- }
- },
- }
- },
- }
-
-
-class _OverrideStreamingRunner(BaseRunner):
- def __init__(self):
- super().__init__(
- detection_result=SimpleNamespace(
- name="demo-agent",
- type=SimpleNamespace(value="mock"),
- ),
- project_dir=".",
- )
-
- def load_agent(self) -> None:
- return None
-
- async def invoke(self, input_data: dict) -> dict:
- return {"output": "goodbye"}
-
- async def stream(self, input_data: dict):
- yield {"type": "text", "delta": "hel"}
- yield {"type": "text", "delta": "lo"}
- yield {"type": "final", "output": "goodbye"}
-
-
-class _ThinkingOnlyFinalRunner(_OverrideStreamingRunner):
- async def stream(self, input_data: dict):
- yield {"type": "thinking", "delta": "先想一下"}
- yield {"type": "final", "output": "final answer"}
-
-
-class _SlowStreamingRunner(_OverrideStreamingRunner):
- async def stream(self, input_data: dict):
- yield {"type": "text", "delta": "hel"}
- await asyncio.sleep(0.05)
- yield {"type": "text", "delta": "lo"}
- yield {"type": "final", "output": "hello"}
-
-
-class _UnavailableSessionService(InMemorySessionService):
- async def create_session(self, *args, **kwargs):
- raise SessionBackendUnavailable("Postgres session backend unavailable")
-
- async def list_sessions(self, *args, **kwargs):
- raise SessionBackendUnavailable("Postgres session backend unavailable")
-
- async def count_sessions(self, *args, **kwargs):
- raise SessionBackendUnavailable("Postgres session backend unavailable")
-
-
-class _CancellableStreamingRunner(_OverrideStreamingRunner):
- def __init__(self):
- super().__init__()
- self.cancel_requests: list[str] = []
-
- async def stream(self, input_data: dict):
- yield {"type": "text", "delta": "hel"}
- await asyncio.Event().wait()
-
- def request_cancel(self, invocation_id: str) -> str:
- self.cancel_requests.append(invocation_id)
- return "accepted"
-
-
-class _ModelAwareRunner(_DummyRunner):
- def __init__(self):
- super().__init__()
- self.prepared_models: list[str | None] = []
-
- def prepare_for_request(self, model: str | None) -> None:
- self.prepared_models.append(model)
-
-
-class _ExternalModelsAsyncClient:
- """给 ListAgentModels 用的外部模型目录假客户端。"""
-
- def __init__(self, *args, payload=None, error: Exception | None = None, **kwargs):
- self._payload = payload
- self._error = error
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return None
-
- async def get(self, url: str, headers: dict | None = None):
- if self._error is not None:
- raise self._error
- request = httpx.Request("GET", url, headers=headers)
- return httpx.Response(200, json=self._payload, request=request)
-
-
-def _sse_payloads(response_text: str) -> list[dict]:
- return [
- json.loads(line.removeprefix("data: "))
- for line in response_text.splitlines()
- if line.startswith("data: ")
- ]
-
-
-def _sse_events(response_text: str) -> list[tuple[str, dict]]:
- current_event = "message"
- events: list[tuple[str, dict]] = []
- for line in response_text.splitlines():
- if line.startswith("event: "):
- current_event = line.removeprefix("event: ").strip() or "message"
- continue
- if not line.startswith("data: "):
- continue
- payload = line.removeprefix("data: ").strip()
- if not payload or payload == "[DONE]":
- current_event = "message"
- continue
- events.append((current_event, json.loads(payload)))
- current_event = "message"
- return events
-
-
-@pytest.mark.asyncio
-async def test_ui_bootstrap_advertises_checkpoint_resume_capabilities(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _DummyRunner()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/GetAgentUiBootstrap",
- json={"AgentId": "demo-agent"},
- )
-
- assert response.status_code == 200
- run_lifecycle = response.json()["Data"]["Capabilities"]["RunLifecycle"]
- assert run_lifecycle["Enabled"] is True
- assert run_lifecycle["Resume"] is True
- assert run_lifecycle["Abort"] is True
- assert run_lifecycle["Checkpoints"] is True
- assert run_lifecycle["CheckpointResume"] is True
- assert run_lifecycle["CheckpointResumePreview"] is True
- capabilities = response.json()["Data"]["Capabilities"]
- assert capabilities["RuntimeCapabilities"]["Framework"] == "mock"
- assert capabilities["RuntimeCapabilities"]["Checkpoint"]["Supported"] is False
- assert capabilities["RuntimeCapabilities"]["ResumeRun"]["ResumeMode"] == "none"
- assert capabilities["CheckpointResumeCapability"]["Supported"] is False
-
-
-@pytest.mark.asyncio
-async def test_ui_bootstrap_exposes_custom_ui_metadata(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- project_dir = "/tmp/custom-ui-agent"
- runner = _CustomUiRunner(project_dir)
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
- monkeypatch.setattr(
- server_app_module,
- "_resolve_agent_ui_spec",
- lambda: {
- "enabled": True,
- "ui_profile": "custom",
- "ui_path": "/",
- "ui_url": None,
- "ui_bundle_path": f"{project_dir}/research-ui/dist",
- },
- )
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/GetAgentUiBootstrap",
- json={"AgentId": "demo-agent"},
- )
-
- assert response.status_code == 200
- payload = response.json()
- assert payload["Data"]["SharePermissions"] == {
- "Interactive": True,
- "DefaultPath": "/",
- "SharePath": "/",
- }
- assert payload["Data"]["CustomUI"] == {
- "Enabled": True,
- "Profile": "custom",
- "Path": "/",
- "Url": None,
- "BundlePath": f"{project_dir}/research-ui/dist",
- }
-
-
-@pytest.mark.asyncio
-async def test_ui_bootstrap_defaults_to_runtime_agent_id(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _DummyRunner()
-
- monkeypatch.setenv("AGENT_RUNTIME_ID", "ar-hosted-runtime")
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post("/agentengine/api/v1/GetAgentUiBootstrap", json={})
-
- assert response.status_code == 200
- assert response.json()["Data"]["Agent"]["AgentId"] == "ar-hosted-runtime"
-
-
-def test_resolve_agent_ui_spec_uses_custom_ui_env_fallback(monkeypatch, tmp_path):
- server_app_module = importlib.import_module("ksadk.server.app")
- project_dir = tmp_path / "agent"
- bundle_dir = project_dir / "research-ui" / "dist"
- bundle_dir.mkdir(parents=True)
- (bundle_dir / "index.html").write_text("Custom UI", encoding="utf-8")
- runner = _CustomUiRunner(str(project_dir))
-
- server_app_module.set_runner(runner)
- monkeypatch.setenv("KSADK_UI_PROFILE", "custom")
- monkeypatch.setenv("KSADK_UI_PATH", "/")
- monkeypatch.setenv("KSADK_UI_BUNDLE_PATH", "research-ui/dist")
-
- spec = server_app_module._resolve_agent_ui_spec()
-
- assert spec["enabled"] is True
- assert spec["ui_profile"] == "custom"
- assert spec["ui_path"] == "/"
- assert spec["source"] == "custom"
- assert spec["ui_bundle_path"] == str(bundle_dir.resolve())
-
-
-def test_resolve_agent_ui_spec_auto_detects_project_custom_ui_bundle(monkeypatch, tmp_path):
- server_app_module = importlib.import_module("ksadk.server.app")
- project_dir = tmp_path / "agent"
- bundle_dir = project_dir / "research-ui" / "dist"
- bundle_dir.mkdir(parents=True)
- (bundle_dir / "index.html").write_text("Custom UI", encoding="utf-8")
- runner = _CustomUiRunner(str(project_dir))
-
- server_app_module.set_runner(runner)
- monkeypatch.delenv("KSADK_UI_PROFILE", raising=False)
- monkeypatch.delenv("KSADK_UI_PATH", raising=False)
- monkeypatch.delenv("KSADK_UI_BUNDLE_PATH", raising=False)
-
- spec = server_app_module._resolve_agent_ui_spec()
-
- assert spec["enabled"] is True
- assert spec["ui_profile"] == "custom"
- assert spec["ui_path"] == "/"
- assert spec["source"] == "custom"
-
-
-@pytest.mark.asyncio
-async def test_run_sse_uses_new_session_service(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _DummyRunner()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/run_sse",
- json=AgentRunRequest(
- appName="demo-agent",
- userId="user-1",
- sessionId=None,
- newMessage={"role": "user", "parts": [{"text": "hello"}]},
- streaming=False,
- stateDelta={"topic": "billing"},
- ).model_dump(),
- )
-
- assert response.status_code == 200
- first_line = next(line for line in response.text.splitlines() if line.startswith("data: "))
- payload = json.loads(first_line.removeprefix("data: "))
- session_id = payload["sessionId"]
-
- session = await service.get_session(session_id)
- assert session is not None
- # run_status 事件现在携带 state_delta.active_run(与 agentengine-server 对齐),
- # completed 后 active_run 反映终态。
- assert session.state["topic"] == "billing"
- assert session.state["active_run"]["status"] == "completed"
- # active_run 现含 run_mode/run_trigger(普通前台 run 默认 foreground/new_run)
- assert session.state["active_run"]["run_mode"] == "foreground"
- assert session.state["active_run"]["run_trigger"] == "new_run"
-
- events = await service.get_events(session_id)
- assert [event.author for event in events] == ["user", "demo-agent", "demo-agent", "demo-agent"]
- assert [event.event_type for event in events] == [
- "user_message",
- "run_status",
- "assistant_message",
- "run_status",
- ]
- assert events[0].content["parts"][0]["text"] == "hello"
- assert events[2].content["parts"][0]["text"] == "assistant says hi"
- assert events[0].metadata["agent_input"] == "hello"
-
- assert runner.calls == [
- {
- "session_id": session_id,
- "input": "hello",
- "history": [{"role": "user", "content": "hello"}],
- "input_content": [{"type": "input_text", "text": "hello"}],
- "input_messages": [
- {"role": "user", "content": [{"type": "input_text", "text": "hello"}]}
- ],
- "input_parts": [{"text": "hello"}],
- "attachments": [],
- "attachment_results": [],
- "current_attachments": [],
- "current_attachment_results": [],
- "has_current_files": False,
- "model": None,
- }
- ]
-
-
-@pytest.mark.asyncio
-async def test_run_sse_passes_attachment_results_to_runner(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _DummyRunner()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/run_sse",
- json=AgentRunRequest(
- appName="demo-agent",
- userId="user-1",
- sessionId=None,
- newMessage={
- "role": "user",
- "parts": [
- {"text": "请分析附件"},
- Part(
- inlineData=InlineData(
- displayName="resume.txt",
- mimeType="text/plain",
- data=base64.b64encode("候选人简历内容".encode("utf-8")).decode("ascii"),
- )
- ).model_dump(exclude_none=True),
- ],
- },
- streaming=False,
- ).model_dump(),
- )
-
- assert response.status_code == 200
- assert runner.calls[-1]["current_attachments"] == [
- {
- "display_name": "resume.txt",
- "mime_type": "text/plain",
- "transport": "inline",
- "data": base64.b64encode("候选人简历内容".encode("utf-8")).decode("ascii"),
- "is_text": True,
- "size_bytes": len("候选人简历内容".encode("utf-8")),
- }
- ]
- assert runner.calls[-1]["current_attachment_results"] == [
- {
- "display_name": "resume.txt",
- "mime_type": "text/plain",
- "transport": "inline",
- "file_uri": "",
- "size_bytes": len("候选人简历内容".encode("utf-8")),
- "kind": "text",
- "status": "ok",
- "warnings": [],
- "extraction_method": "text_decode",
- "text_excerpt": "候选人简历内容",
- "text": "候选人简历内容",
- }
- ]
- assert runner.calls[-1]["has_current_files"] is True
- assert runner.calls[-1]["attachment_results"] == [
- {
- "display_name": "resume.txt",
- "mime_type": "text/plain",
- "transport": "inline",
- "file_uri": "",
- "size_bytes": len("候选人简历内容".encode("utf-8")),
- "kind": "text",
- "status": "ok",
- "warnings": [],
- "extraction_method": "text_decode",
- "text_excerpt": "候选人简历内容",
- "text": "候选人简历内容",
- }
- ]
-
-
-@pytest.mark.asyncio
-async def test_create_session_rejects_explicit_session_owned_by_other_agent_or_user(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- await service.create_session(
- agent_id="other-agent",
- user_id="other-user",
- session_id="shared-session",
- )
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/apps/demo-agent/users/user-1/sessions",
- json={"sessionId": "shared-session"},
- )
-
- assert response.status_code == 409
- assert "different agent or user" in response.json()["detail"]
-
-
-@pytest.mark.asyncio
-async def test_run_sse_rejects_explicit_session_owned_by_other_agent_or_user(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _DummyRunner()
- await service.create_session(
- agent_id="other-agent",
- user_id="other-user",
- session_id="shared-session",
- )
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/run_sse",
- json=AgentRunRequest(
- appName="demo-agent",
- userId="user-1",
- sessionId="shared-session",
- newMessage={"role": "user", "parts": [{"text": "hello"}]},
- streaming=False,
- ).model_dump(),
- )
-
- assert response.status_code == 409
- assert "different agent or user" in response.json()["detail"]
- assert runner.calls == []
-
-
-@pytest.mark.asyncio
-async def test_attachment_content_route_serves_uploaded_binary(monkeypatch, tmp_path):
- server_app_module = importlib.import_module("ksadk.server.app")
- ui_dir = tmp_path / ".agentengine" / "ui"
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir))
- service = InMemorySessionService()
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- upload_response = await client.post(
- "/agentengine/api/v1/UploadFile",
- files={"file": ("arch.png", b"\x89PNG\r\n\x1a\nbinary", "image/png")},
- )
-
- assert upload_response.status_code == 200
- file_uri = upload_response.json()["Data"]["FileData"]["fileUri"]
-
- content_response = await client.get(
- "/agentengine/api/v1/AttachmentContent",
- params={"FileUri": file_uri},
- )
-
- assert content_response.status_code == 200
- assert content_response.headers["content-type"].startswith("image/png")
- assert content_response.content == b"\x89PNG\r\n\x1a\nbinary"
-
-
-@pytest.mark.asyncio
-async def test_workspace_files_runtime_routes_use_state_dir_workspace_root(monkeypatch, tmp_path):
- server_app_module = importlib.import_module("ksadk.server.app")
- ui_dir = tmp_path / ".agentengine" / "ui"
- workspace_dir = ui_dir / "workspace"
- workspace_dir.mkdir(parents=True, exist_ok=True)
- (workspace_dir / "existing").mkdir(parents=True, exist_ok=True)
- (workspace_dir / "existing" / "hello.txt").write_text("hello workspace", encoding="utf-8")
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir))
-
- service = InMemorySessionService()
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- list_response = await client.get("/_ksadk/workspace/v1/entries", params={"path": "."})
- upload_response = await client.post(
- "/_ksadk/workspace/v1/files/uploads/report.txt",
- files={"file": ("report.txt", b"workspace upload", "text/plain")},
- )
- download_response = await client.get("/_ksadk/workspace/v1/files/uploads/report.txt")
-
- assert list_response.status_code == 200
- list_payload = list_response.json()
- assert list_payload["Root"] == "workspace"
- assert list_payload["Path"] == "."
- assert {entry["Path"] for entry in list_payload["Entries"]} == {"existing"}
- assert list_payload["Entries"][0]["Type"] == "directory"
-
- assert upload_response.status_code == 200
- assert upload_response.json()["Entry"]["Path"] == "uploads/report.txt"
- assert (workspace_dir / "uploads" / "report.txt").read_text(encoding="utf-8") == "workspace upload"
-
- assert download_response.status_code == 200
- assert download_response.content == b"workspace upload"
- assert download_response.headers["content-type"].startswith("text/plain")
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- delete_response = await client.delete("/_ksadk/workspace/v1/files/uploads/report.txt")
-
- assert delete_response.status_code == 200
- assert delete_response.json() == {"Deleted": True}
- assert not (workspace_dir / "uploads" / "report.txt").exists()
-
-
-@pytest.mark.asyncio
-async def test_workspace_files_runtime_routes_delete_empty_directory(monkeypatch, tmp_path):
- server_app_module = importlib.import_module("ksadk.server.app")
- ui_dir = tmp_path / ".agentengine" / "ui"
- workspace_dir = ui_dir / "workspace"
- empty_dir = workspace_dir / "empty-folder"
- empty_dir.mkdir(parents=True, exist_ok=True)
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir))
-
- service = InMemorySessionService()
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- runtime_response = await client.delete("/_ksadk/workspace/v1/files/empty-folder")
-
- assert runtime_response.status_code == 200
- assert runtime_response.json() == {"Deleted": True}
- assert not empty_dir.exists()
-
-
-@pytest.mark.asyncio
-async def test_workspace_files_runtime_routes_delete_empty_directory_with_trailing_slash(
- monkeypatch,
- tmp_path,
-):
- server_app_module = importlib.import_module("ksadk.server.app")
- ui_dir = tmp_path / ".agentengine" / "ui"
- workspace_dir = ui_dir / "workspace"
- empty_dir = workspace_dir / "empty-folder"
- empty_dir.mkdir(parents=True, exist_ok=True)
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir))
-
- service = InMemorySessionService()
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- runtime_response = await client.delete("/_ksadk/workspace/v1/files/empty-folder/")
-
- assert runtime_response.status_code == 200
- assert runtime_response.json() == {"Deleted": True}
- assert not empty_dir.exists()
-
-
-@pytest.mark.asyncio
-async def test_workspace_files_action_route_deletes_empty_directory(monkeypatch, tmp_path):
- server_app_module = importlib.import_module("ksadk.server.app")
- ui_dir = tmp_path / ".agentengine" / "ui"
- workspace_dir = ui_dir / "workspace"
- empty_dir = workspace_dir / "empty-folder"
- empty_dir.mkdir(parents=True, exist_ok=True)
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir))
-
- service = InMemorySessionService()
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- action_response = await client.post(
- "/agentengine/api/v1/DeleteWorkspaceFile",
- json={"AgentId": "demo-agent", "Path": "empty-folder"},
- )
-
- assert action_response.status_code == 200
- assert action_response.json()["Data"] == {"Deleted": True}
- assert not empty_dir.exists()
-
-
-@pytest.mark.asyncio
-async def test_workspace_files_runtime_routes_reject_non_empty_directory_delete(monkeypatch, tmp_path):
- server_app_module = importlib.import_module("ksadk.server.app")
- ui_dir = tmp_path / ".agentengine" / "ui"
- workspace_dir = ui_dir / "workspace"
- non_empty_dir = workspace_dir / "docs"
- non_empty_dir.mkdir(parents=True, exist_ok=True)
- (non_empty_dir / "readme.txt").write_text("keep me", encoding="utf-8")
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir))
-
- service = InMemorySessionService()
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.delete("/_ksadk/workspace/v1/files/docs")
-
- assert response.status_code == 409
- assert response.json()["detail"] == "workspace directory is not empty"
- assert (non_empty_dir / "readme.txt").exists()
-
-
-@pytest.mark.asyncio
-async def test_workspace_files_runtime_route_serves_html_preview_inline(monkeypatch, tmp_path):
- server_app_module = importlib.import_module("ksadk.server.app")
- ui_dir = tmp_path / ".agentengine" / "ui"
- workspace_dir = ui_dir / "workspace"
- workspace_dir.mkdir(parents=True, exist_ok=True)
- (workspace_dir / "showcase").mkdir(parents=True, exist_ok=True)
- (workspace_dir / "showcase" / "index.html").write_text(
- 'Features',
- encoding="utf-8",
- )
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir))
-
- service = InMemorySessionService()
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.get("/_ksadk/workspace/v1/files/showcase/index.html")
-
- assert response.status_code == 200
- assert response.headers["content-type"].startswith("text/html")
- assert "content-disposition" not in response.headers
- csp = response.headers.get("content-security-policy", "")
- assert "sandbox allow-scripts allow-downloads" in csp
- assert "style-src 'unsafe-inline' data: 'self' https:" in csp
- assert "img-src data: blob: 'self' https:" in csp
- assert "connect-src 'none'" in csp
- assert '' in response.text
- assert "data-ksadk-preview-anchor-handler" in response.text
-
-
-@pytest.mark.asyncio
-async def test_workspace_files_runtime_routes_reject_path_escape(monkeypatch, tmp_path):
- server_app_module = importlib.import_module("ksadk.server.app")
- ui_dir = tmp_path / ".agentengine" / "ui"
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir))
-
- service = InMemorySessionService()
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.get(
- "/_ksadk/workspace/v1/entries",
- params={"path": "../outside"},
- )
-
- assert response.status_code == 400
- assert response.json()["detail"] == "workspace path escapes the workspace root"
-
-
-@pytest.mark.asyncio
-async def test_workspace_files_action_routes_match_runtime_contract(monkeypatch, tmp_path):
- server_app_module = importlib.import_module("ksadk.server.app")
- ui_dir = tmp_path / ".agentengine" / "ui"
- workspace_dir = ui_dir / "workspace"
- workspace_dir.mkdir(parents=True, exist_ok=True)
- (workspace_dir / "existing").mkdir(parents=True, exist_ok=True)
- (workspace_dir / "existing" / "hello.txt").write_text("hello workspace", encoding="utf-8")
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(ui_dir))
-
- service = InMemorySessionService()
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- list_response = await client.post(
- "/agentengine/api/v1/ListWorkspaceFiles",
- json={"AgentId": "demo-agent", "Path": "."},
- )
- upload_response = await client.post(
- "/agentengine/api/v1/AddWorkspaceFile",
- data={"AgentId": "demo-agent", "Path": "uploads/report.txt"},
- files={"file": ("report.txt", b"workspace upload", "text/plain")},
- )
- download_response = await client.get(
- "/agentengine/api/v1/GetWorkspaceFileContent",
- params={"AgentId": "demo-agent", "FilePath": "uploads/report.txt"},
- )
-
- assert list_response.status_code == 200
- list_payload = list_response.json()["Data"]
- assert list_payload["Root"] == "workspace"
- assert list_payload["Path"] == "."
- assert {entry["Path"] for entry in list_payload["Entries"]} == {"existing"}
-
- assert upload_response.status_code == 200
- assert upload_response.json()["Data"]["Entry"]["Path"] == "uploads/report.txt"
- assert (workspace_dir / "uploads" / "report.txt").read_text(encoding="utf-8") == "workspace upload"
-
- assert download_response.status_code == 200
- assert download_response.content == b"workspace upload"
- assert download_response.headers["content-type"].startswith("text/plain")
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- delete_response = await client.post(
- "/agentengine/api/v1/DeleteWorkspaceFile",
- json={"AgentId": "demo-agent", "Path": "uploads/report.txt"},
- )
-
- assert delete_response.status_code == 200
- assert delete_response.json()["Data"] == {"Deleted": True}
- assert not (workspace_dir / "uploads" / "report.txt").exists()
-
-
-@pytest.mark.asyncio
-async def test_list_sessions_projects_heuristic_title_for_existing_fallback_session(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- created = await service.create_session(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-heuristic-read",
- )
- await service.update_session_metadata(
- created.id,
- title="你好,请介绍一下你自己",
- title_source="fallback_first_prompt",
- first_prompt="你好,请介绍一下你自己",
- summary="你好!我是企业高端招聘全流程助手,可以协助你完成职位分析、候选人筛选和面试建议生成。",
- )
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ListSessions",
- json={"AgentId": "demo-agent", "UserId": "user-1"},
- )
-
- assert response.status_code == 200
- session = response.json()["Data"]["Sessions"][0]
- assert session["Title"] == "招聘助手能力"
- assert session["TitleSource"] == "heuristic"
-
-
-@pytest.mark.asyncio
-async def test_list_sessions_prefers_active_resume_invocation_over_old_terminal_run(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- await service.create_session(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-active-resume",
- )
- await service.append_event(
- "sess-active-resume",
- SessionEvent(
- author="demo",
- event_type="run_status",
- invocation_id="run-original",
- content={"status": "completed"},
- metadata={"status": "completed", "run_id": "run-original"},
- ),
- )
- await service.append_event(
- "sess-active-resume",
- SessionEvent(
- author="demo",
- event_type="run_resume",
- invocation_id="run-resume-1",
- content={"checkpoint_id": "ckpt-1"},
- metadata={
- "run_id": "run-original",
- "checkpoint_id": "ckpt-1",
- "resume_attempt_id": "run-resume-1",
- },
- ),
- )
- await service.append_event(
- "sess-active-resume",
- SessionEvent(
- author="demo",
- event_type="run_status",
- invocation_id="run-resume-1",
- content={"status": "in_progress"},
- metadata={"status": "in_progress", "run_id": "run-resume-1"},
- ),
- )
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ListSessions",
- json={"AgentId": "demo-agent", "UserId": "user-1"},
- )
-
- assert response.status_code == 200
- session = response.json()["Data"]["Sessions"][0]
- assert session["ActiveInvocationId"] == "run-resume-1"
- assert session["ActiveRunStatus"] == "in_progress"
-
-
-@pytest.mark.asyncio
-async def test_runtime_local_list_sessions_returns_page_metadata(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- for index in range(5):
- await service.create_session(
- agent_id="demo-agent",
- user_id="user-1",
- session_id=f"sess-page-{index}",
- )
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ListSessions",
- json={
- "AgentId": "demo-agent",
- "UserId": "user-1",
- "Page": 2,
- "PageSize": 2,
- },
- )
-
- assert response.status_code == 200
- data = response.json()["Data"]
- assert data["Page"] == 2
- assert data["PageSize"] == 2
- assert data["Total"] == 5
- assert len(data["Sessions"]) == 2
-
-
-@pytest.mark.asyncio
-async def test_list_sessions_hydrates_summary_from_event_log_when_session_row_is_empty(monkeypatch):
- """ListSessions 不能只返回空壳 session;event log 已有事实时要回填标题与 active run。"""
- server_app_module = importlib.import_module("ksadk.server.app")
- conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
- service = InMemorySessionService()
- await service.create_session(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-event-backed-summary",
- )
- user_prompt = (
- "请启动 Deep Research 调研任务,不要只做普通聊天回答。\n\n"
- "研究主题:调研 2026 Q2和Q3金山云股票风险和评估建议\n\n"
- "研究深度:med。"
- )
- await conversation_runtime.append_conversation_event(
- session_id="sess-event-backed-summary",
- author="user",
- role="user",
- text=user_prompt,
- invocation_id="run_sess_event_backed_summary",
- event_type="user_message",
- metadata={"agent_input": user_prompt},
- session_service_provider=lambda: service,
- )
- await conversation_runtime.append_run_status_event(
- session_id="sess-event-backed-summary",
- author="demo-agent",
- status="in_progress",
- invocation_id="run_sess_event_backed_summary",
- session_service_provider=lambda: service,
- )
- await conversation_runtime.append_conversation_event(
- session_id="sess-event-backed-summary",
- author="demo-agent",
- role="model",
- text="Planner 已生成 5 个检索 query。",
- invocation_id="run_sess_event_backed_summary",
- event_type="stage_tool_result",
- metadata={
- "run_id": "run_sess_event_backed_summary",
- "tool_output": {
- "topic": "调研 2026 Q2和Q3金山云股票风险和评估建议",
- },
- },
- session_service_provider=lambda: service,
- )
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ListSessions",
- json={"AgentId": "demo-agent", "UserId": "user-1"},
- )
-
- assert response.status_code == 200
- session = response.json()["Data"]["Sessions"][0]
- normalized_prompt = " ".join(user_prompt.split())
- assert session["SessionId"] == "sess-event-backed-summary"
- assert session["FirstPrompt"] == normalized_prompt
- assert session["LastPrompt"] == normalized_prompt
- assert session["Title"]
- assert session["Title"] != "sess-event-backed-summary"
- assert session["ActiveInvocationId"] == "run_sess_event_backed_summary"
- assert session["ActiveRunStatus"] == "in_progress"
-
-
-@pytest.mark.asyncio
-async def test_session_actions_prefer_latest_run_status_when_previous_run_completed(monkeypatch):
- """切换/刷新会话时,新后台 run 进行中不能被同 session 的旧 completed run 盖掉。"""
- server_app_module = importlib.import_module("ksadk.server.app")
- conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
- service = InMemorySessionService()
- session = await service.create_session(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-new-run-active",
- )
-
- await conversation_runtime.append_run_status_event(
- session_id=session.id,
- author="demo-agent",
- status="completed",
- invocation_id="run_old_completed",
- session_service_provider=lambda: service,
- )
- await conversation_runtime.append_run_status_event(
- session_id=session.id,
- author="demo-agent",
- status="in_progress",
- invocation_id="run_new_active",
- session_service_provider=lambda: service,
- )
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- listed = await client.post(
- "/agentengine/api/v1/ListSessions",
- json={"AgentId": "demo-agent", "UserId": "user-1"},
- )
- fetched = await client.post(
- "/agentengine/api/v1/GetSession",
- json={"SessionId": session.id},
- )
-
- assert listed.status_code == 200
- assert fetched.status_code == 200
- for payload in (
- listed.json()["Data"]["Sessions"][0],
- fetched.json()["Data"]["Session"],
- ):
- assert payload["ActiveInvocationId"] == "run_new_active"
- assert payload["ActiveRunStatus"] == "in_progress"
-
-
-@pytest.mark.asyncio
-async def test_session_actions_return_503_when_session_backend_unavailable(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = _UnavailableSessionService()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- create_response = await client.post(
- "/agentengine/api/v1/CreateSession",
- json={"AgentId": "demo-agent", "UserId": "user-1"},
- )
- list_response = await client.post(
- "/agentengine/api/v1/ListSessions",
- json={"AgentId": "demo-agent", "UserId": "user-1"},
- )
-
- assert create_response.status_code == 503
- assert create_response.json()["detail"]["code"] == "session_backend_unavailable"
- assert list_response.status_code == 503
- assert list_response.json()["detail"]["code"] == "session_backend_unavailable"
-
-
-@pytest.mark.asyncio
-async def test_session_actions_do_not_return_inline_attachment_data_in_state(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- session = await service.create_session(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-inline-state",
- )
- await service.update_state(
- agent_id="demo-agent",
- user_id="user-1",
- session_id=session.id,
- scope="session",
- state_delta={
- "__ksadk_attachment_context__": {
- "attachments": [
- {
- "display_name": "photo.png",
- "mime_type": "image/png",
- "transport": "inline",
- "data": base64.b64encode(b"image bytes").decode("ascii"),
- "size_bytes": 11,
- }
- ],
- "attachment_results": [
- {
- "display_name": "photo.png",
- "mime_type": "image/png",
- "transport": "inline",
- "text": "识别出的文字",
- "text_excerpt": "识别出的文字",
- }
- ],
- }
- },
- )
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- listed = await client.post(
- "/agentengine/api/v1/ListSessions",
- json={"AgentId": "demo-agent", "UserId": "user-1"},
- )
- fetched = await client.post(
- "/agentengine/api/v1/GetSession",
- json={"SessionId": session.id},
- )
-
- assert listed.status_code == 200
- assert fetched.status_code == 200
- for payload in (
- listed.json()["Data"]["Sessions"][0],
- fetched.json()["Data"]["Session"],
- ):
- state_context = payload["State"]["__ksadk_attachment_context__"]
- attachment = state_context["attachments"][0]
- assert attachment == {
- "display_name": "photo.png",
- "mime_type": "image/png",
- "transport": "inline",
- "size_bytes": 11,
- }
- assert "data" not in json.dumps(state_context, ensure_ascii=False)
- assert state_context["attachment_results"][0]["text_excerpt"] == "识别出的文字"
- assert "text" not in state_context["attachment_results"][0]
-
-
-@pytest.mark.asyncio
-async def test_local_feedback_actions_upsert_get_and_delete(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- session = await service.create_session(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-local-feedback",
- )
- assistant_event = await service.append_event(
- session.id,
- SessionEvent.from_dict(
- {
- "author": "demo-agent",
- "event_type": "assistant_message",
- "content": {"role": "model", "parts": [{"text": "assistant says hi"}]},
- "metadata": {
- "response_id": "resp_local_feedback",
- "trace_id": "trace-local",
- "root_span_id": "span-local",
- },
- },
- session_id=session.id,
- ),
- )
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- created = await client.post(
- "/agentengine/api/v1/UpsertResponseFeedback",
- json={
- "AgentId": "demo-agent",
- "SessionId": session.id,
- "ResponseId": "resp_local_feedback",
- "EventId": assistant_event.id,
- "Rating": "down",
- "Comment": "不够具体",
- },
- )
- fetched = await client.post(
- "/agentengine/api/v1/GetResponseFeedback",
- json={
- "AgentId": "demo-agent",
- "SessionId": session.id,
- "ResponseId": "resp_local_feedback",
- },
- )
- deleted = await client.post(
- "/agentengine/api/v1/DeleteResponseFeedback",
- json={
- "AgentId": "demo-agent",
- "SessionId": session.id,
- "ResponseId": "resp_local_feedback",
- },
- )
- fetched_after_delete = await client.post(
- "/agentengine/api/v1/GetResponseFeedback",
- json={
- "AgentId": "demo-agent",
- "SessionId": session.id,
- "ResponseId": "resp_local_feedback",
- },
- )
-
- assert created.status_code == 200
- feedback = created.json()["Data"]["Feedback"]
- assert feedback["AgentId"] == "demo-agent"
- assert feedback["SessionId"] == session.id
- assert feedback["ResponseId"] == "resp_local_feedback"
- assert feedback["EventId"] == assistant_event.id
- assert feedback["Rating"] == "down"
- assert feedback["Comment"] == "不够具体"
- assert feedback["TraceId"] == "trace-local"
- assert feedback["RootSpanId"] == "span-local"
-
- assert fetched.status_code == 200
- assert fetched.json()["Data"]["Feedback"]["Rating"] == "down"
- assert deleted.status_code == 200
- assert deleted.json()["Data"] == {"Deleted": True}
- assert fetched_after_delete.status_code == 200
- assert fetched_after_delete.json()["Data"]["Feedback"] is None
-
-
-@pytest.mark.asyncio
-async def test_run_sse_stream_emits_authoritative_final_event_when_output_overrides_partials(
- monkeypatch,
-):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _OverrideStreamingRunner()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/run_sse",
- json=AgentRunRequest(
- appName="demo-agent",
- userId="user-1",
- sessionId=None,
- newMessage={"role": "user", "parts": [{"text": "hello"}]},
- streaming=True,
- ).model_dump(),
- )
-
- assert response.status_code == 200
- payloads = _sse_payloads(response.text)
- assert [payload["content"]["parts"][0]["text"] for payload in payloads] == [
- "hel",
- "lo",
- "goodbye",
- ]
- assert payloads[0]["partial"] is True
- assert payloads[1]["partial"] is True
- assert "partial" not in payloads[2]
-
- session_id = payloads[0]["sessionId"]
- events = await service.get_events(session_id)
- assert [event.author for event in events] == ["user", "demo-agent", "demo-agent", "demo-agent"]
- assert [event.event_type for event in events] == [
- "user_message",
- "run_status",
- "assistant_message",
- "run_status",
- ]
- assert events[-2].content["parts"][0]["text"] == "goodbye"
-
-
-@pytest.mark.asyncio
-async def test_run_sse_stream_emits_compaction_status_events(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
- model_context_module = importlib.import_module("ksadk.conversations.model_context")
- service = InMemorySessionService()
- runner = _OverrideStreamingRunner()
- session = await service.create_session(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="session-with-history",
- )
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
- monkeypatch.setattr(conversation_runtime, "AUTOCOMPACT_KEEP_TAIL_GROUPS", 1)
- monkeypatch.setattr(model_context_module, "DEFAULT_CONTEXT_WINDOW_TOKENS", 30)
- monkeypatch.setattr(model_context_module, "DEFAULT_MAX_OUTPUT_TOKENS", 0)
- monkeypatch.setattr(model_context_module, "AUTOCOMPACT_SUMMARY_RESERVE_TOKENS", 0)
- monkeypatch.setattr(model_context_module, "AUTOCOMPACT_BUFFER_TOKENS", 2)
-
- for turn_index in range(2):
- invocation_id = f"seed-{turn_index}"
- seed_text = f"历史消息 {turn_index} " + ("很长 " * 12)
- await conversation_runtime.append_conversation_event(
- session_id=session.id,
- author="user",
- role="user",
- text=seed_text,
- invocation_id=invocation_id,
- event_type="user_message",
- session_service_provider=lambda: service,
- metadata={"agent_input": seed_text},
- )
- await conversation_runtime.append_conversation_event(
- session_id=session.id,
- author="demo-agent",
- role="model",
- text=f"历史回复 {turn_index} " + ("继续 " * 12),
- invocation_id=invocation_id,
- event_type="assistant_message",
- session_service_provider=lambda: service,
- )
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/run_sse",
- json=AgentRunRequest(
- appName="demo-agent",
- userId="user-1",
- sessionId=session.id,
- newMessage={"role": "user", "parts": [{"text": "请继续基于历史回答"}]},
- streaming=True,
- ).model_dump(),
- )
-
- assert response.status_code == 200
- events = _sse_events(response.text)
- event_names = [event_name for event_name, _ in events]
- assert event_names[:2] == [
- "response.compaction.start",
- "response.compaction.done",
- ]
- assert event_names.count("message") >= 2
-
- persisted_events = await service.get_events(session.id)
- assert [event.event_type for event in persisted_events] == [
- "user_message",
- "assistant_message",
- "user_message",
- "assistant_message",
- "user_message",
- "compaction_boundary",
- "context_checkpoint",
- "run_status",
- "assistant_message",
- "run_status",
- ]
-
-
-@pytest.mark.asyncio
-async def test_run_sse_stream_completes_and_persists_reasoning_when_no_text_deltas(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _ThinkingOnlyFinalRunner()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/run_sse",
- json=AgentRunRequest(
- appName="demo-agent",
- userId="user-1",
- sessionId="sess-run-sse-thinking",
- newMessage={"role": "user", "parts": [{"text": "hello"}]},
- streaming=True,
- ).model_dump(),
- )
-
- assert response.status_code == 200
- events = await service.get_events("sess-run-sse-thinking")
- assert [event.event_type for event in events] == [
- "user_message",
- "run_status",
- "reasoning",
- "assistant_message",
- "run_status",
- ]
- assert events[2].content["parts"][0]["text"] == "先想一下"
- assert events[-2].content["parts"][0]["text"] == "final answer"
- assert events[-1].content["status"] == "completed"
-
-
-@pytest.mark.asyncio
-async def test_run_sse_prepares_runner_model_and_forwards_model_to_invoke(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _ModelAwareRunner()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/run_sse",
- json=AgentRunRequest(
- appName="demo-agent",
- userId="user-1",
- sessionId=None,
- newMessage={"role": "user", "parts": [{"text": "hello"}]},
- streaming=False,
- model="gpt-4o",
- ).model_dump(),
- )
-
- assert response.status_code == 200
- assert runner.prepared_models == ["gpt-4o"]
- assert runner.calls[-1]["model"] == "gpt-4o"
-
-
-@pytest.mark.asyncio
-async def test_chat_completions_forwards_model_to_runner(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _ModelAwareRunner()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/v1/chat/completions",
- json={
- "messages": [{"role": "user", "content": "hello"}],
- "stream": False,
- "model": "glm-5.1",
- "account_id": "acct-chat",
- },
- )
-
- assert response.status_code == 200
- assert runner.prepared_models == ["glm-5.1"]
- assert runner.calls[-1]["model"] == "glm-5.1"
- assert runner.calls[-1]["platform_context"]["account_id"] == "acct-chat"
-
-
-@pytest.mark.asyncio
-async def test_chat_completions_converts_chat_content_blocks_to_runner_responses_input(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _DummyRunner()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- image_url = "data:image/png;base64,aW1hZ2U="
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/v1/chat/completions",
- json={
- "messages": [
- {
- "role": "user",
- "content": [
- {"type": "text", "text": "看图"},
- {"type": "image_url", "image_url": {"url": image_url}},
- ],
- }
- ],
- "stream": False,
- "model": "gpt-4o",
- },
- )
-
- payload = response.json()
- assert response.status_code == 200
- assert payload["object"] == "chat.completion"
- assert payload["choices"][0]["message"]["role"] == "assistant"
- assert runner.calls[-1]["input_content"] == [
- {"type": "input_text", "text": "看图"},
- {"type": "input_image", "image_url": image_url},
- ]
- assert runner.calls[-1]["input_messages"] == [
- {
- "role": "user",
- "content": [
- {"type": "input_text", "text": "看图"},
- {"type": "input_image", "image_url": image_url},
- ],
- }
- ]
- assert runner.calls[-1]["input_parts"] == [
- {"text": "看图"},
- {
- "inlineData": {
- "data": "aW1hZ2U=",
- "mimeType": "image/png",
- "displayName": "uploaded_image",
- }
- },
- ]
-
-
-@pytest.mark.asyncio
-async def test_chat_completions_non_stream_preserves_response_feedback_metadata(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- runner = _ModelAwareRunner()
-
- async def _fake_invoke_conversation_once(**kwargs):
- return "sess-trace", {
- "output_text": "assistant says hi",
- "metadata": {
- "trace_id": "08c19ddddce0b1ddd29407dc637e1c89",
- "root_span_id": "74cc406c8e9ded4a",
- },
- }
-
- monkeypatch.setattr(
- server_app_module.conversation,
- "invoke_conversation_once",
- _fake_invoke_conversation_once,
- )
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/v1/chat/completions",
- json={
- "messages": [{"role": "user", "content": "hello"}],
- "stream": False,
- "model": "glm-5.1",
- },
- )
-
- assert response.status_code == 200
- payload = response.json()
- assert payload["metadata"] == {
- "trace_id": "08c19ddddce0b1ddd29407dc637e1c89",
- "root_span_id": "74cc406c8e9ded4a",
- }
-
-
-@pytest.mark.asyncio
-async def test_chat_completions_passes_attachment_results_to_runner(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _DummyRunner()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- attachment_b64 = base64.b64encode("候选人简历内容".encode("utf-8")).decode("ascii")
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/v1/chat/completions",
- json={
- "messages": [
- {
- "role": "user",
- "content": [
- {"text": "请分析附件"},
- {
- "inlineData": {
- "displayName": "resume.txt",
- "mimeType": "text/plain",
- "data": attachment_b64,
- }
- },
- ],
- }
- ],
- "stream": False,
- },
- )
-
- assert response.status_code == 200
- assert runner.calls[-1]["attachment_results"] == [
- {
- "display_name": "resume.txt",
- "mime_type": "text/plain",
- "transport": "inline",
- "file_uri": "",
- "size_bytes": len("候选人简历内容".encode("utf-8")),
- "kind": "text",
- "status": "ok",
- "warnings": [],
- "extraction_method": "text_decode",
- "text_excerpt": "候选人简历内容",
- "text": "候选人简历内容",
- }
- ]
-
-
-@pytest.mark.asyncio
-async def test_chat_completions_reuses_prior_attachment_results_on_follow_up_turn(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _DummyRunner()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- attachment_b64 = base64.b64encode("候选人简历内容".encode("utf-8")).decode("ascii")
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- first_response = await client.post(
- "/v1/chat/completions",
- json={
- "messages": [
- {
- "role": "user",
- "content": [
- {"text": "请分析附件"},
- {
- "inlineData": {
- "displayName": "resume.txt",
- "mimeType": "text/plain",
- "data": attachment_b64,
- }
- },
- ],
- }
- ],
- "stream": False,
- },
- )
- first_payload = first_response.json()
- session_id = first_payload["session_id"]
-
- second_response = await client.post(
- "/v1/chat/completions",
- json={
- "messages": [{"role": "user", "content": "继续分析"}],
- "session_id": session_id,
- "stream": False,
- },
- )
-
- assert first_response.status_code == 200
- assert second_response.status_code == 200
- assert runner.calls[-1]["attachment_results"] == [
- {
- "display_name": "resume.txt",
- "mime_type": "text/plain",
- "transport": "inline",
- "size_bytes": len("候选人简历内容".encode("utf-8")),
- "kind": "text",
- "status": "ok",
- "warnings": [],
- "extraction_method": "text_decode",
- "text_excerpt": "候选人简历内容",
- }
- ]
-
-
-@pytest.mark.asyncio
-async def test_list_agent_models_action_normalizes_default_metadata(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- real_async_client = httpx.AsyncClient
- monkeypatch.setenv("OPENAI_BASE_URL", "https://kspmas.ksyun.com/v1")
- monkeypatch.setenv("OPENAI_API_KEY", "secret-key")
- monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1")
- monkeypatch.setattr(
- "httpx.AsyncClient",
- lambda *args, **kwargs: _ExternalModelsAsyncClient(
- *args,
- payload={"data": [{"id": "glm-5.1"}]},
- **kwargs,
- ),
- )
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with real_async_client(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ListAgentModels",
- json={"AgentId": "demo-agent"},
- )
-
- assert response.status_code == 200
- payload = response.json()["Data"]
- assert payload["Current"] == "glm-5.1"
- assert payload["Models"] == [
- {
- "id": "glm-5.1",
- "display_name": "glm-5.1",
- "context_window_tokens": 200000,
- "max_output_tokens": 32000,
- "auto_compact_threshold_tokens": 167000,
- "auto_compact_threshold_percentage": 84,
- "capabilities": {
- "function_calling": True,
- "structured_output": True,
- "context_caching": True,
- "multimodal_input_image": False,
- "multimodal_input_video": False,
- "multimodal_input_file": False,
- },
- "limits": {
- "context_window_tokens": 200000,
- "max_input_tokens": 200000,
- "max_output_tokens": 32000,
- "max_reasoning_tokens": 32000,
- "rpm": 500,
- "tpm": 1000000,
- },
- "pricing": {
- "online_input_per_million": 4.0,
- "online_output_per_million": 18.0,
- "batch_input_per_million": 2.0,
- "batch_output_per_million": 9.0,
- "online_cache_hit_input_per_million": 1.0,
- "batch_cache_hit_input_per_million": 1.0,
- },
- }
- ]
-
-
-@pytest.mark.asyncio
-async def test_list_agent_models_action_preserves_upstream_fields_and_normalizes_aliases(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- real_async_client = httpx.AsyncClient
- monkeypatch.setenv("OPENAI_BASE_URL", "https://kspmas.ksyun.com/v1")
- monkeypatch.setenv("OPENAI_MODEL_NAME", "kimi-k2.6")
- monkeypatch.setattr(
- "httpx.AsyncClient",
- lambda *args, **kwargs: _ExternalModelsAsyncClient(
- *args,
- payload={
- "data": [
- {
- "id": "kimi-k2.6",
- "owned_by": "ksyun",
- "context_length": 131072,
- "max_tokens": 4096,
- }
- ]
- },
- **kwargs,
- ),
- )
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with real_async_client(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ListAgentModels",
- json={"AgentId": "demo-agent"},
- )
-
- assert response.status_code == 200
- item = response.json()["Data"]["Models"][0]
- assert item["id"] == "kimi-k2.6"
- assert item["owned_by"] == "ksyun"
- assert item["context_length"] == 131072
- assert item["max_tokens"] == 4096
- assert item["context_window_tokens"] == 131072
- assert item["max_output_tokens"] == 4096
- assert item["limits"]["context_window_tokens"] == 131072
- assert item["limits"]["max_output_tokens"] == 4096
-
-
-@pytest.mark.asyncio
-async def test_list_agent_models_action_normalizes_kspmas_string_token_limits(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- real_async_client = httpx.AsyncClient
- monkeypatch.setenv("OPENAI_BASE_URL", "https://kspmas.ksyun.com/v1")
- monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1")
- monkeypatch.setattr(
- "httpx.AsyncClient",
- lambda *args, **kwargs: _ExternalModelsAsyncClient(
- *args,
- payload={
- "data": [
- {
- "id": "glm-5.1",
- "context_length": "200k",
- "max_completion_tokens": "128k",
- "architecture": {
- "input_modalities": ["文字"],
- "output_modalities": ["文字"],
- },
- "pricing": {
- "prompt": "6",
- "completion": "24",
- },
- },
- {
- "id": "deepseek-v3.2",
- "context_length": "128",
- "max_completion_tokens": "32",
- },
- ]
- },
- **kwargs,
- ),
- )
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with real_async_client(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ListAgentModels",
- json={"AgentId": "demo-agent"},
- )
-
- assert response.status_code == 200
- items = {item["id"]: item for item in response.json()["Data"]["Models"]}
- assert items["glm-5.1"]["context_window_tokens"] == 200000
- assert items["glm-5.1"]["max_output_tokens"] == 128000
- assert items["glm-5.1"]["limits"]["context_window_tokens"] == 200000
- assert items["glm-5.1"]["limits"]["max_output_tokens"] == 128000
- assert items["glm-5.1"]["auto_compact_threshold_tokens"] == 167000
- assert items["glm-5.1"]["architecture"]["input_modalities"] == ["文字"]
- assert items["glm-5.1"]["capabilities"]["multimodal_input_image"] is False
- assert items["glm-5.1"]["pricing"]["prompt"] == "6"
- assert items["deepseek-v3.2"]["context_window_tokens"] == 128000
- assert items["deepseek-v3.2"]["max_output_tokens"] == 32000
- assert items["deepseek-v3.2"]["auto_compact_threshold_tokens"] == 95000
-
-
-@pytest.mark.asyncio
-async def test_list_agent_models_action_without_api_base_returns_default_metadata(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
- monkeypatch.delenv("OPENAI_API_BASE", raising=False)
- monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1")
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ListAgentModels",
- json={"AgentId": "demo-agent"},
- )
-
- assert response.status_code == 200
- payload = response.json()["Data"]
- assert payload["Current"] == "glm-5.1"
- assert [item["id"] for item in payload["Models"]] == ["glm-5.1"]
- assert payload["Models"][0]["context_window_tokens"] == 200000
- assert payload["Models"][0]["limits"]["max_output_tokens"] == 32000
-
-
-@pytest.mark.asyncio
-async def test_openai_models_route_exposes_current_catalog(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
- monkeypatch.delenv("OPENAI_API_BASE", raising=False)
- monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1")
- transport = httpx.ASGITransport(app=server_app_module.app)
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.get("/v1/models")
-
- assert response.status_code == 200
- payload = response.json()
- assert payload["object"] == "list"
- assert payload["current"] == "glm-5.1"
- assert [item["id"] for item in payload["data"]] == ["glm-5.1"]
-
-
-@pytest.mark.asyncio
-async def test_responses_fetches_remote_model_metadata_and_passes_to_runner(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- real_async_client = httpx.AsyncClient
- service = InMemorySessionService()
- runner = _DummyRunner()
-
- monkeypatch.setattr(server_app_module, "runner", runner)
- monkeypatch.setattr(server_app_module, "_runner_loaded", True)
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- monkeypatch.setenv("OPENAI_BASE_URL", "https://kspmas.ksyun.com/v1")
- monkeypatch.setenv("OPENAI_API_KEY", "secret-key")
- monkeypatch.setattr(
- "httpx.AsyncClient",
- lambda *args, **kwargs: _ExternalModelsAsyncClient(
- *args,
- payload={
- "data": [
- {
- "id": "kimi-k2.6",
- "architecture": {
- "input_modalities": ["文字", "图片", "视频"],
- "output_modalities": ["文字"],
- },
- }
- ]
- },
- **kwargs,
- ),
- )
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with real_async_client(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/v1/responses",
- json={
- "model": "kimi-k2.6",
- "input": "请分析图片",
- "stream": False,
- },
- )
-
- assert response.status_code == 200
- assert runner.calls[0]["model_metadata"]["id"] == "kimi-k2.6"
- assert runner.calls[0]["model_metadata"]["architecture"]["input_modalities"] == ["文字", "图片", "视频"]
- assert runner.calls[0]["model_metadata"]["capabilities"]["multimodal_input_image"] is True
-
-
-@pytest.mark.asyncio
-async def test_responses_uses_official_conversation_as_runtime_session(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _DummyRunner()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/v1/responses",
- json={
- "input": "hello",
- "conversation": "conv-a",
- "safety_identifier": "user-a",
- "account_id": "acct-a",
- "stream": False,
- },
- )
-
- assert response.status_code == 200
- payload = response.json()
- assert payload["session_id"] == "conv-a"
- session = await service.get_session("conv-a")
- assert session is not None
- assert session.user_id == "user-a"
- assert runner.calls[-1]["session_id"] == "conv-a"
- assert runner.calls[-1]["platform_context"]["user_id"] == "user-a"
- assert runner.calls[-1]["platform_context"]["account_id"] == "acct-a"
-
-
-@pytest.mark.asyncio
-async def test_responses_uses_runtime_agent_id_for_hosted_session_lifecycle(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _CheckpointMetadataRunner()
-
- monkeypatch.setenv("AGENT_RUNTIME_ID", "ar-hosted-runtime")
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/v1/responses",
- json={
- "input": "hello",
- "conversation": "conv-hosted",
- "stream": False,
- },
- )
- checkpoints_response = await client.post(
- "/agentengine/api/v1/ListSessionCheckpoints",
- json={"AgentId": "ar-hosted-runtime", "SessionId": "conv-hosted"},
- )
-
- assert response.status_code == 200
- session = await service.get_session("conv-hosted")
- assert session is not None
- assert session.agent_id == "ar-hosted-runtime"
- assert checkpoints_response.status_code == 200
- checkpoints = checkpoints_response.json()["Data"]["Checkpoints"]
- assert checkpoints[0]["RunId"] == "run-hosted"
- assert checkpoints[0]["CheckpointId"] == "ckpt-hosted"
-
-
-@pytest.mark.asyncio
-async def test_responses_accepts_agentengine_checkpoint_resume_input(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
- service = InMemorySessionService()
- runner = _CheckpointResumeRunner()
-
- await service.create_session(agent_id="demo-agent", user_id="user-a", session_id="conv-resume")
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
- await conversation_runtime.append_run_checkpoint_event(
- session_id="conv-resume",
- author="demo-agent",
- run_id="run-1",
- checkpoint_id="ckpt-1",
- framework="langgraph",
- framework_ref={
- "langgraph": {
- "thread_id": "tenant:agent:conv-resume",
- "checkpoint_id": "ckpt-1",
- }
- },
- invocation_id="inv-checkpoint",
- session_service_provider=lambda: service,
- )
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/v1/responses",
- json={
- "input": [
- {
- "type": "agentengine.resume_checkpoint",
- "run_id": "run-1",
- "checkpoint_id": "ckpt-1",
- "resume_attempt_id": "resume-1",
- "framework": "langgraph",
- "framework_ref": {
- "langgraph": {
- "thread_id": "forged-client-thread",
- "checkpoint_id": "forged-client-checkpoint",
- }
- },
- }
- ],
- "conversation": "conv-resume",
- "safety_identifier": "user-a",
- "stream": False,
- },
- )
-
- assert response.status_code == 200
- payload = response.json()
- assert payload["metadata"]["agentengine"]["run_id"] == "run-1"
- assert payload["metadata"]["agentengine"]["framework_ref"]["langgraph"]["checkpoint_id"] == "ckpt-1"
- assert runner.calls[-1]["checkpoint_resume"] is True
- assert runner.calls[-1]["run_id"] == "run-1"
- assert runner.calls[-1]["framework_ref"]["langgraph"]["thread_id"] == "tenant:agent:conv-resume"
- assert runner.calls[-1]["framework_ref"]["langgraph"]["checkpoint_id"] == "ckpt-1"
- events = await service.get_events("conv-resume")
- assert [event.event_type for event in events] == [
- "run_checkpoint",
- "run_resume",
- "run_status",
- "run_status",
- "assistant_message",
- "run_status",
- ]
- # resume 现在会先写 run_status(resuming) 再写 run_status(in_progress)
- assert [event.content["status"] for event in events if event.event_type == "run_status"] == [
- "resuming",
- "in_progress",
- "completed",
- ]
- assert len([event for event in events if event.event_type == "run_checkpoint"]) == 1
-
-
-@pytest.mark.asyncio
-async def test_responses_rejects_agentengine_checkpoint_resume_without_server_checkpoint(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _CheckpointResumeRunner()
-
- await service.create_session(agent_id="demo-agent", user_id="user-a", session_id="conv-resume-missing")
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/v1/responses",
- json={
- "input": [
- {
- "type": "agentengine.resume_checkpoint",
- "run_id": "run-1",
- "checkpoint_id": "ckpt-1",
- "framework": "langgraph",
- "framework_ref": {
- "langgraph": {
- "thread_id": "client-only-thread",
- "checkpoint_id": "ckpt-1",
- }
- },
- }
- ],
- "conversation": "conv-resume-missing",
- "safety_identifier": "user-a",
- "stream": False,
- },
- )
-
- assert response.status_code == 404
- assert runner.calls == []
- assert await service.get_events("conv-resume-missing") == []
-
-
-@pytest.mark.asyncio
-async def test_stream_responses_checkpoint_resume_rejects_concurrent_resume_for_same_run(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
- service = InMemorySessionService()
- runner = _CancellableStreamingRunner()
-
- await service.create_session(agent_id="demo-agent", user_id="user-a", session_id="conv-resume-stream")
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
- await conversation_runtime.append_run_checkpoint_event(
- session_id="conv-resume-stream",
- author="demo-agent",
- run_id="run-1",
- checkpoint_id="ckpt-1",
- framework="langgraph",
- framework_ref={
- "langgraph": {
- "thread_id": "tenant:agent:conv-resume-stream",
- "checkpoint_id": "ckpt-1",
- }
- },
- invocation_id="inv-checkpoint",
- session_service_provider=lambda: service,
- )
-
- original_detached_streaming_response = server_app_module._detached_streaming_response
-
- def start_detached_stream_and_return_accepted(source, *, invocation_id=None, **kwargs):
- original_detached_streaming_response(source, invocation_id=invocation_id, **kwargs)
- return Response(status_code=202)
-
- monkeypatch.setattr(
- server_app_module,
- "_detached_streaming_response",
- start_detached_stream_and_return_accepted,
- )
-
- resume_input = {
- "type": "agentengine.resume_checkpoint",
- "run_id": "run-1",
- "checkpoint_id": "ckpt-1",
- "resume_attempt_id": "resume-1",
- "framework": "langgraph",
- "framework_ref": {
- "langgraph": {
- "thread_id": "forged-client-thread",
- "checkpoint_id": "forged-client-checkpoint",
- }
- },
- }
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- first_response = await client.post(
- "/v1/responses",
- json={
- "input": [resume_input],
- "conversation": "conv-resume-stream",
- "safety_identifier": "user-a",
- "metadata": {"agentengine": {"invocation_id": "responses-resume-1"}},
- "stream": True,
- },
- )
- second_response = await client.post(
- "/v1/responses",
- json={
- "input": [{**resume_input, "resume_attempt_id": "resume-2"}],
- "conversation": "conv-resume-stream",
- "safety_identifier": "user-a",
- "metadata": {"agentengine": {"invocation_id": "responses-resume-2"}},
- "stream": True,
- },
- )
-
- assert first_response.status_code == 202
- assert second_response.status_code == 409
- detail = second_response.json()["detail"]
- assert detail["code"] == "resume_already_running"
- # 409 detail 字段契约:snake_case(与 checkpoint_not_resumable 对齐)
- assert isinstance(detail["session_id"], str) and detail["session_id"]
- assert isinstance(detail["invocation_id"], str) and detail["invocation_id"]
- assert isinstance(detail["run_id"], str) and detail["run_id"]
-
-
-@pytest.mark.asyncio
-async def test_run_agent_responses_checkpoint_resume_resolves_framework_ref_from_server(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
- service = InMemorySessionService()
- runner = _CheckpointResumeRunner()
-
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-runagent-resume")
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
- await conversation_runtime.append_run_checkpoint_event(
- session_id="sess-runagent-resume",
- author="demo-agent",
- run_id="run-1",
- checkpoint_id="ckpt-1",
- framework="langgraph",
- framework_ref={
- "langgraph": {
- "thread_id": "tenant:agent:sess-runagent-resume",
- "checkpoint_id": "ckpt-1",
- }
- },
- invocation_id="inv-checkpoint",
- session_service_provider=lambda: service,
- )
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": "demo-agent",
- "SessionId": "sess-runagent-resume",
- "UserId": "user-1",
- "ApiFormat": "responses",
- "Stream": False,
- "ResponsesInput": [
- {
- "type": "agentengine.resume_checkpoint",
- "run_id": "run-1",
- "checkpoint_id": "ckpt-1",
- "resume_attempt_id": "resume-1",
- "framework": "langgraph",
- "framework_ref": {
- "langgraph": {
- "thread_id": "forged-client-thread",
- "checkpoint_id": "forged-client-checkpoint",
- }
- },
- }
- ],
- },
- )
-
- assert response.status_code == 200
- payload = response.json()["Data"]
- assert payload["metadata"]["agentengine"]["run_id"] == "run-1"
- assert (
- payload["metadata"]["agentengine"]["framework_ref"]["langgraph"]["thread_id"]
- == "tenant:agent:sess-runagent-resume"
- )
- assert runner.calls[-1]["framework_ref"]["langgraph"]["thread_id"] == "tenant:agent:sess-runagent-resume"
-
-
-@pytest.mark.asyncio
-async def test_run_agent_stream_checkpoint_resume_rejects_concurrent_resume_for_same_run(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
- service = InMemorySessionService()
- runner = _CancellableStreamingRunner()
-
- await service.create_session(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-runagent-resume-concurrent",
- )
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
- await conversation_runtime.append_run_checkpoint_event(
- session_id="sess-runagent-resume-concurrent",
- author="demo-agent",
- run_id="run-1",
- checkpoint_id="ckpt-1",
- framework="langgraph",
- framework_ref={
- "langgraph": {
- "thread_id": "tenant:agent:sess-runagent-resume-concurrent",
- "checkpoint_id": "ckpt-1",
- }
- },
- invocation_id="inv-checkpoint",
- session_service_provider=lambda: service,
- )
-
- original_detached_streaming_response = server_app_module._detached_streaming_response
-
- def start_detached_stream_and_return_accepted(source, *, invocation_id=None, **kwargs):
- original_detached_streaming_response(source, invocation_id=invocation_id, **kwargs)
- return Response(status_code=202)
-
- monkeypatch.setattr(
- server_app_module,
- "_detached_streaming_response",
- start_detached_stream_and_return_accepted,
- )
-
- resume_input = [
- {
- "type": "agentengine.resume_checkpoint",
- "run_id": "run-1",
- "checkpoint_id": "ckpt-1",
- "resume_attempt_id": "resume-1",
- "framework": "langgraph",
- "framework_ref": {
- "langgraph": {
- "thread_id": "forged-client-thread",
- "checkpoint_id": "forged-client-checkpoint",
- }
- },
- }
- ]
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- first_response = await client.post(
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": "demo-agent",
- "SessionId": "sess-runagent-resume-concurrent",
- "UserId": "user-1",
- "ApiFormat": "responses",
- "Stream": True,
- "InvocationId": "runagent-resume-1",
- "ResponsesInput": resume_input,
- },
- )
- second_response = await client.post(
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": "demo-agent",
- "SessionId": "sess-runagent-resume-concurrent",
- "UserId": "user-1",
- "ApiFormat": "responses",
- "Stream": True,
- "InvocationId": "runagent-resume-2",
- "ResponsesInput": resume_input,
- },
- )
-
- assert first_response.status_code == 202
- assert second_response.status_code == 409
- assert second_response.json()["detail"]["code"] == "resume_already_running"
-
-
-@pytest.mark.asyncio
-async def test_responses_accepts_official_conversation_object(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _DummyRunner()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/v1/responses",
- json={
- "input": "hello",
- "conversation": {"id": "conv-object"},
- "safety_identifier": "user-object",
- "stream": False,
- },
- )
-
- assert response.status_code == 200
- assert response.json()["session_id"] == "conv-object"
- session = await service.get_session("conv-object")
- assert session is not None
- assert session.user_id == "user-object"
-
-
-@pytest.mark.asyncio
-async def test_responses_uses_agentengine_metadata_invocation_id(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _DummyRunner()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/v1/responses",
- json={
- "input": "hello",
- "conversation": "conv-invocation",
- "metadata": {"agentengine": {"invocation_id": "run-known-invocation"}},
- "stream": False,
- },
- )
-
- assert response.status_code == 200
- events = await service.get_events("conv-invocation")
- assert events[0].invocation_id == "run-known-invocation"
-
-
-@pytest.mark.asyncio
-async def test_stream_responses_uses_agentengine_metadata_invocation_id(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _DummyRunner()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- async with client.stream(
- "POST",
- "/v1/responses",
- json={
- "input": "hello",
- "conversation": "conv-stream-invocation",
- "metadata": {"agentengine": {"invocation_id": "run-known-stream"}},
- "stream": True,
- },
- ) as response:
- chunks = []
- assert response.status_code == 200
- async for _line in response.aiter_lines():
- chunks.append(_line)
-
- events = await service.get_events("conv-stream-invocation")
- assert events, chunks
- assert events[0].invocation_id == "run-known-stream"
-
-
-@pytest.mark.asyncio
-async def test_stream_responses_registers_invocation_for_cancel_run(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _CancellableStreamingRunner()
- invocation_id = "run-responses-cancel"
- captured_invocations: list[str | None] = []
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- def fake_detached_streaming_response(source, *, invocation_id=None, **kwargs):
- del kwargs
- captured_invocations.append(invocation_id)
- return Response(status_code=202)
-
- monkeypatch.setattr(server_app_module, "_detached_streaming_response", fake_detached_streaming_response)
- response = await server_app_module.responses(
- server_app_module.ResponsesRequest(
- input="hello",
- conversation="conv-stream-cancel",
- metadata={"agentengine": {"invocation_id": invocation_id}},
- stream=True,
- )
- )
-
- assert response.status_code == 202
- assert captured_invocations == [invocation_id]
-
-
-@pytest.mark.asyncio
-async def test_responses_uses_deprecated_user_when_safety_identifier_missing(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _DummyRunner()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/v1/responses",
- json={
- "input": "hello",
- "conversation": "conv-user",
- "user": "deprecated-user",
- "stream": False,
- },
- )
-
- assert response.status_code == 200
- session = await service.get_session("conv-user")
- assert session is not None
- assert session.user_id == "deprecated-user"
-
-
-@pytest.mark.asyncio
-async def test_stream_responses_user_and_account_reach_platform_context(monkeypatch):
- service = InMemorySessionService()
- runner = _DummyRunner()
-
- chunks = [
- chunk
- async for chunk in conversation.stream_responses_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="ui-user-1",
- messages=[{"role": "user", "content": "hello"}],
- session_id="sess-hosted-stream",
- model=None,
- account_id="acct-1",
- prepare_runner=lambda _runner, _model: None,
- session_service_provider=lambda: service,
- )
- ]
-
- session = await service.get_session("sess-hosted-stream")
- assert session is not None
- assert session.user_id == "ui-user-1"
- assert runner.calls, chunks
- platform_context = runner.calls[-1]["platform_context"]
- assert platform_context["user_id"] == "ui-user-1"
- assert platform_context["account_id"] == "acct-1"
- assert platform_context["session_id"] == "sess-hosted-stream"
-
-
-@pytest.mark.asyncio
-async def test_responses_rejects_conflicting_conversation_and_legacy_session_id(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _DummyRunner()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/v1/responses",
- json={
- "input": "hello",
- "conversation": "conv-a",
- "session_id": "legacy-b",
- "stream": False,
- },
- )
-
- assert response.status_code == 400
- assert "conversation" in response.text
- assert "session_id" in response.text
-
-
-@pytest.mark.asyncio
-async def test_responses_rejects_conversation_with_previous_response_id(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _DummyRunner()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/v1/responses",
- json={
- "input": "hello",
- "conversation": "conv-a",
- "previous_response_id": "resp_previous",
- "stream": False,
- },
- )
-
- assert response.status_code == 400
- assert "conversation" in response.text
- assert "previous_response_id" in response.text
-
-
-@pytest.mark.asyncio
-async def test_responses_legacy_session_id_still_works(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _DummyRunner()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/v1/responses",
- json={
- "input": "hello",
- "session_id": "legacy-session",
- "stream": False,
- },
- )
-
- assert response.status_code == 200
- assert response.json()["session_id"] == "legacy-session"
- session = await service.get_session("legacy-session")
- assert session is not None
- assert session.user_id == "user"
-
-
-@pytest.mark.asyncio
-async def test_responses_events_are_visible_through_runtime_local_list_session_events(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _DummyRunner()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- run_response = await client.post(
- "/v1/responses",
- json={
- "input": "hello",
- "conversation": "conv-events",
- "safety_identifier": "user-events",
- "stream": False,
- },
- )
- events_response = await client.post(
- "/agentengine/api/v1/ListSessionEvents",
- json={"SessionId": "conv-events"},
- )
-
- assert run_response.status_code == 200
- assert events_response.status_code == 200
- events = events_response.json()["Data"]["Events"]
- message_events = [event for event in events if event["EventType"] in {"user_message", "assistant_message"}]
- assert [event["Author"] for event in message_events] == ["user", "demo-agent"]
- assert message_events[0]["Content"]["parts"][0]["text"] == "hello"
- assert message_events[1]["Content"]["parts"][0]["text"] == "assistant says hi"
-
-
-@pytest.mark.asyncio
-async def test_runtime_local_list_session_events_returns_total_and_page(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- await service.create_session(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-events-page",
- )
- for index in range(4):
- await service.append_event(
- "sess-events-page",
- SessionEvent(
- author="user",
- event_type="user_message",
- content={"index": index},
- ),
- )
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ListSessionEvents",
- json={
- "SessionId": "sess-events-page",
- "Offset": 0,
- "Limit": 2,
- },
- )
-
- assert response.status_code == 200
- data = response.json()["Data"]
- assert data["Offset"] == 0
- assert data["Limit"] == 2
- assert data["Total"] == 4
- assert [event["SeqId"] for event in data["Events"]] == [3, 4]
-
-
-@pytest.mark.asyncio
-async def test_runtime_local_list_session_events_filters_by_after_seq_id(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- await service.create_session(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-events-after",
- )
- for index in range(4):
- await service.append_event(
- "sess-events-after",
- SessionEvent(
- author="user",
- event_type="user_message",
- content={"index": index},
- ),
- )
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ListSessionEvents",
- json={
- "SessionId": "sess-events-after",
- "AfterSeqId": 2,
- },
- )
-
- assert response.status_code == 200
- data = response.json()["Data"]
- assert [event["SeqId"] for event in data["Events"]] == [3, 4]
- assert data["Total"] == 2
- assert data["AfterSeqId"] == 2
-
-
-@pytest.mark.asyncio
-async def test_runtime_local_list_session_events_filters_by_before_seq_id(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- await service.create_session(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-events-before",
- )
- for index in range(5):
- await service.append_event(
- "sess-events-before",
- SessionEvent(
- author="user",
- event_type="user_message",
- content={"index": index},
- ),
- )
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ListSessionEvents",
- json={
- "SessionId": "sess-events-before",
- "BeforeSeqId": 4,
- "Limit": 2,
- },
- )
-
- assert response.status_code == 200
- data = response.json()["Data"]
- assert [event["SeqId"] for event in data["Events"]] == [2, 3]
- assert data["Total"] == 3
- assert data["BeforeSeqId"] == 4
-
-
-@pytest.mark.asyncio
-async def test_list_session_checkpoints_filters_by_agent_session_and_run(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
- service = InMemorySessionService()
- runner = _DummyRunner()
-
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-checkpoints")
- await service.create_session(agent_id="other-agent", user_id="user-1", session_id="sess-other")
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- await conversation_runtime.append_run_checkpoint_event(
- session_id="sess-checkpoints",
- author="demo-agent",
- run_id="run-1",
- checkpoint_id="ckpt-1",
- framework="langgraph",
- framework_ref={"langgraph": {"thread_id": "tenant:agent:sess-checkpoints", "checkpoint_id": "ckpt-1"}},
- phase="tool_result",
- invocation_id="inv-1",
- session_service_provider=lambda: service,
- )
- await conversation_runtime.append_run_checkpoint_event(
- session_id="sess-checkpoints",
- author="demo-agent",
- run_id="run-2",
- checkpoint_id="ckpt-2",
- framework="langgraph",
- framework_ref={"langgraph": {"thread_id": "tenant:agent:sess-checkpoints", "checkpoint_id": "ckpt-2"}},
- phase="completed",
- invocation_id="inv-2",
- session_service_provider=lambda: service,
- )
- await conversation_runtime.append_run_checkpoint_event(
- session_id="sess-other",
- author="other-agent",
- run_id="run-1",
- checkpoint_id="ckpt-other",
- framework="langgraph",
- framework_ref={"langgraph": {"thread_id": "tenant:other:sess-other", "checkpoint_id": "ckpt-other"}},
- invocation_id="inv-other",
- session_service_provider=lambda: service,
- )
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ListSessionCheckpoints",
- json={"AgentId": "demo-agent", "SessionId": "sess-checkpoints", "RunId": "run-1"},
- )
- wrong_agent = await client.post(
- "/agentengine/api/v1/ListSessionCheckpoints",
- json={"AgentId": "other-agent", "SessionId": "sess-checkpoints"},
- )
-
- assert response.status_code == 200
- checkpoints = response.json()["Data"]["Checkpoints"]
- assert [item["CheckpointId"] for item in checkpoints] == ["ckpt-1"]
- assert checkpoints[0]["RunId"] == "run-1"
- assert checkpoints[0]["Framework"] == "langgraph"
- assert checkpoints[0]["FrameworkRef"]["langgraph"]["thread_id"] == "tenant:agent:sess-checkpoints"
- assert wrong_agent.status_code == 404
-
-
-@pytest.mark.asyncio
-async def test_list_session_checkpoints_returns_business_resume_fields(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
- service = InMemorySessionService()
-
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-business-checkpoints")
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- await conversation_runtime.append_run_checkpoint_event(
- session_id="sess-business-checkpoints",
- author="demo-agent",
- run_id="run-business",
- checkpoint_id="ckpt-metrics",
- framework="langgraph",
- framework_ref={"langgraph": {"thread_id": "tenant:agent:sess-business-checkpoints", "checkpoint_id": "ckpt-metrics"}},
- phase="指标聚合已完成,等待生成报告",
- invocation_id="inv-business",
- metadata={
- "stage": "清洗聚合指标",
- "summary": "GMV、转化率和退款率已经聚合完成",
- "next_action": "继续生成复盘报告",
- "status": "completed",
- },
- session_service_provider=lambda: service,
- )
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ListSessionCheckpoints",
- json={"AgentId": "demo-agent", "SessionId": "sess-business-checkpoints"},
- )
-
- assert response.status_code == 200
- checkpoint = response.json()["Data"]["Checkpoints"][0]
- assert checkpoint["RunId"] == "run-business"
- assert checkpoint["CheckpointId"] == "ckpt-metrics"
- assert checkpoint["Phase"] == "指标聚合已完成,等待生成报告"
- assert checkpoint["Stage"] == "清洗聚合指标"
- assert checkpoint["Summary"] == "GMV、转化率和退款率已经聚合完成"
- assert checkpoint["NextAction"] == "继续生成复盘报告"
- assert checkpoint["Status"] == "completed"
- assert checkpoint["IsResumable"] is None
- assert checkpoint["ResumeStatus"] == "unknown"
- assert checkpoint["ResumeDisabledReason"] == ""
- assert checkpoint["IsTerminal"] is False
- assert checkpoint["Backend"] == "unknown"
- assert checkpoint["Scope"] == "unknown"
- assert checkpoint["Durable"] is False
-
-
-@pytest.mark.asyncio
-async def test_list_session_checkpoints_filters_resumable_and_framework(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
- service = InMemorySessionService()
-
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-filter-checkpoints")
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- await conversation_runtime.append_run_checkpoint_event(
- session_id="sess-filter-checkpoints",
- author="demo-agent",
- run_id="run-1",
- checkpoint_id="ckpt-resumable",
- framework="langgraph",
- framework_ref={
- "langgraph": {
- "thread_id": "sess-filter-checkpoints",
- "checkpoint_id": "ckpt-resumable",
- "next_node": "search",
- }
- },
- metadata={"is_resumable": True, "backend": "postgres", "scope": "shared", "durable": True},
- session_service_provider=lambda: service,
- )
- await conversation_runtime.append_run_checkpoint_event(
- session_id="sess-filter-checkpoints",
- author="demo-agent",
- run_id="run-1",
- checkpoint_id="ckpt-terminal",
- framework="langgraph",
- framework_ref={
- "langgraph": {
- "thread_id": "sess-filter-checkpoints",
- "checkpoint_id": "ckpt-terminal",
- "next_node": "",
- }
- },
- metadata={"is_terminal": True, "is_resumable": False},
- session_service_provider=lambda: service,
- )
- await conversation_runtime.append_run_checkpoint_event(
- session_id="sess-filter-checkpoints",
- author="demo-agent",
- run_id="run-1",
- checkpoint_id="ckpt-adk",
- framework="adk",
- framework_ref={"adk": {"session_id": "sess-filter-checkpoints"}},
- metadata={"is_resumable": False},
- session_service_provider=lambda: service,
- )
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ListSessionCheckpoints",
- json={
- "AgentId": "demo-agent",
- "SessionId": "sess-filter-checkpoints",
- "Framework": "langgraph",
- "OnlyResumable": True,
- },
- )
-
- assert response.status_code == 200
- checkpoints = response.json()["Data"]["Checkpoints"]
- assert [item["CheckpointId"] for item in checkpoints] == ["ckpt-resumable"]
- assert checkpoints[0]["IsResumable"] is True
- assert checkpoints[0]["ResumeStatus"] == "resumable"
- assert checkpoints[0]["NextNode"] == "search"
- assert checkpoints[0]["Backend"] == "postgres"
-
-
-@pytest.mark.asyncio
-async def test_list_session_checkpoints_is_the_single_checkpoint_listing_endpoint(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
- service = InMemorySessionService()
-
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-list-checkpoints")
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- for index in range(3):
- await conversation_runtime.append_run_checkpoint_event(
- session_id="sess-list-checkpoints",
- author="demo-agent",
- run_id="run-page",
- checkpoint_id=f"ckpt-{index}",
- framework="langgraph",
- framework_ref={
- "langgraph": {
- "thread_id": "sess-list-checkpoints",
- "checkpoint_id": f"ckpt-{index}",
- "next_node": "stage",
- }
- },
- metadata={
- "is_resumable": True,
- "backend": "postgres",
- "scope": "shared",
- "durable": True,
- "stage_index": index + 1,
- "total_stages": 3,
- },
- session_service_provider=lambda: service,
- )
-
- payload = {
- "AgentId": "demo-agent",
- "SessionId": "sess-list-checkpoints",
- "RunId": "run-page",
- "Offset": 1,
- "Limit": 1,
- }
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- removed_response = await client.post("/agentengine/api/v1/ListCheckpoints", json=payload)
- response = await client.post("/agentengine/api/v1/ListSessionCheckpoints", json=payload)
- removed_preview_response = await client.post(
- "/agentengine/api/v1/PreviewCheckpointResume",
- json={**payload, "CheckpointId": "ckpt-1"},
- )
-
- assert removed_response.status_code in {404, 405}
- assert removed_preview_response.status_code in {404, 405}
- assert response.status_code == 200
- data = response.json()["Data"]
- assert data["Total"] == 3
- assert data["Offset"] == 1
- assert data["Limit"] == 1
- assert [item["CheckpointId"] for item in data["Checkpoints"]] == ["ckpt-1"]
- checkpoint = data["Checkpoints"][0]
- assert checkpoint["StageIndex"] == 2
- assert checkpoint["TotalStages"] == 3
- assert checkpoint["IsResumable"] is True
- assert checkpoint["CreatedAt"]
-
-
-@pytest.mark.asyncio
-async def test_list_session_checkpoints_includes_resume_audit_fields(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
- service = InMemorySessionService()
-
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-checkpoint-audit")
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- await conversation_runtime.append_run_checkpoint_event(
- session_id="sess-checkpoint-audit",
- author="demo-agent",
- run_id="run-audit",
- checkpoint_id="ckpt-audit",
- framework="langgraph",
- framework_ref={
- "langgraph": {
- "thread_id": "sess-checkpoint-audit",
- "checkpoint_id": "ckpt-audit",
- "next_node": "report",
- }
- },
- metadata={
- "is_resumable": True,
- "backend": "postgres",
- "scope": "shared",
- "durable": True,
- "expires_at": "2099-06-30T12:00:00Z",
- "checkpoint_status": "active",
- },
- session_service_provider=lambda: service,
- )
- for attempt_id in ("resume-1", "resume-2"):
- await conversation_runtime.append_run_resume_event(
- session_id="sess-checkpoint-audit",
- author="demo-agent",
- run_id="run-audit",
- checkpoint_id="ckpt-audit",
- resume_attempt_id=attempt_id,
- framework="langgraph",
- framework_ref={
- "langgraph": {
- "thread_id": "sess-checkpoint-audit",
- "checkpoint_id": "ckpt-audit",
- }
- },
- session_service_provider=lambda: service,
- )
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ListSessionCheckpoints",
- json={"AgentId": "demo-agent", "SessionId": "sess-checkpoint-audit"},
- )
- preview_response = await client.post(
- "/agentengine/api/v1/GetCheckpointResumePreview",
- json={
- "AgentId": "demo-agent",
- "SessionId": "sess-checkpoint-audit",
- "RunId": "run-audit",
- "CheckpointId": "ckpt-audit",
- },
- )
-
- assert response.status_code == 200
- checkpoint = response.json()["Data"]["Checkpoints"][0]
- assert checkpoint["ResumeCount"] == 2
- assert checkpoint["LastResumedAt"]
- assert checkpoint["ReplayAllowed"] is True
- assert checkpoint["ExpiresAt"] == "2099-06-30T12:00:00Z"
- assert checkpoint["CheckpointStatus"] == "resumed"
- assert checkpoint["Metadata"]["resume_count"] == 2
- assert checkpoint["Metadata"]["last_resumed_at"] == checkpoint["LastResumedAt"]
- assert preview_response.status_code == 200
- preview_checkpoint = preview_response.json()["Data"]["Preview"]["Checkpoint"]
- assert preview_checkpoint["ResumeCount"] == 2
- assert preview_checkpoint["LastResumedAt"] == checkpoint["LastResumedAt"]
-
-
-@pytest.mark.asyncio
-async def test_list_session_checkpoints_disables_expired_or_non_replayable_checkpoints(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
- service = InMemorySessionService()
-
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-checkpoint-policy")
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- await conversation_runtime.append_run_checkpoint_event(
- session_id="sess-checkpoint-policy",
- author="demo-agent",
- run_id="run-policy",
- checkpoint_id="ckpt-no-replay",
- framework="langgraph",
- framework_ref={"langgraph": {"thread_id": "sess-checkpoint-policy", "checkpoint_id": "ckpt-no-replay"}},
- metadata={"is_resumable": True, "replay_allowed": False},
- session_service_provider=lambda: service,
- )
- await conversation_runtime.append_run_resume_event(
- session_id="sess-checkpoint-policy",
- author="demo-agent",
- run_id="run-policy",
- checkpoint_id="ckpt-no-replay",
- resume_attempt_id="resume-1",
- framework="langgraph",
- framework_ref={"langgraph": {"thread_id": "sess-checkpoint-policy", "checkpoint_id": "ckpt-no-replay"}},
- session_service_provider=lambda: service,
- )
- await conversation_runtime.append_run_checkpoint_event(
- session_id="sess-checkpoint-policy",
- author="demo-agent",
- run_id="run-policy",
- checkpoint_id="ckpt-expired",
- framework="langgraph",
- framework_ref={"langgraph": {"thread_id": "sess-checkpoint-policy", "checkpoint_id": "ckpt-expired"}},
- metadata={"is_resumable": True, "expires_at": "2000-01-01T00:00:00Z"},
- session_service_provider=lambda: service,
- )
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ListSessionCheckpoints",
- json={"AgentId": "demo-agent", "SessionId": "sess-checkpoint-policy"},
- )
-
- assert response.status_code == 200
- checkpoints = {
- item["CheckpointId"]: item
- for item in response.json()["Data"]["Checkpoints"]
- }
- no_replay = checkpoints["ckpt-no-replay"]
- assert no_replay["ReplayAllowed"] is False
- assert no_replay["ResumeCount"] == 1
- assert no_replay["IsResumable"] is False
- assert no_replay["ResumeStatus"] == "disabled"
- assert no_replay["CheckpointStatus"] == "resumed"
- assert "重复恢复" in no_replay["ResumeDisabledReason"]
- expired = checkpoints["ckpt-expired"]
- assert expired["IsResumable"] is False
- assert expired["ResumeStatus"] == "disabled"
- assert expired["CheckpointStatus"] == "expired"
- assert "已过期" in expired["ResumeDisabledReason"]
-
-
-@pytest.mark.asyncio
-async def test_resume_run_rejects_checkpoint_policy_disabled_by_audit(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
- service = InMemorySessionService()
- runner = _CheckpointResumeRunner()
-
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-resume-policy")
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
- await conversation_runtime.append_run_checkpoint_event(
- session_id="sess-resume-policy",
- author="demo-agent",
- run_id="run-policy",
- checkpoint_id="ckpt-no-replay",
- framework="langgraph",
- framework_ref={"langgraph": {"thread_id": "sess-resume-policy", "checkpoint_id": "ckpt-no-replay"}},
- metadata={"is_resumable": True, "replay_allowed": False},
- session_service_provider=lambda: service,
- )
- await conversation_runtime.append_run_resume_event(
- session_id="sess-resume-policy",
- author="demo-agent",
- run_id="run-policy",
- checkpoint_id="ckpt-no-replay",
- resume_attempt_id="resume-1",
- framework="langgraph",
- framework_ref={"langgraph": {"thread_id": "sess-resume-policy", "checkpoint_id": "ckpt-no-replay"}},
- session_service_provider=lambda: service,
- )
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ResumeRun",
- json={
- "AgentId": "demo-agent",
- "SessionId": "sess-resume-policy",
- "RunId": "run-policy",
- "CheckpointId": "ckpt-no-replay",
- "ResumeAttemptId": "resume-2",
- "Stream": False,
- },
- )
-
- assert response.status_code == 409
- detail = response.json()["detail"]
- assert detail["code"] == "checkpoint_not_resumable"
- assert detail["resume_status"] == "disabled"
- assert "重复恢复" in detail["reason"]
- assert runner.calls == []
-
-
-@pytest.mark.asyncio
-async def test_resume_run_rejects_expired_checkpoint(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
- service = InMemorySessionService()
- runner = _CheckpointResumeRunner()
-
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-resume-expired")
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
- await conversation_runtime.append_run_checkpoint_event(
- session_id="sess-resume-expired",
- author="demo-agent",
- run_id="run-expired",
- checkpoint_id="ckpt-expired",
- framework="langgraph",
- framework_ref={"langgraph": {"thread_id": "sess-resume-expired", "checkpoint_id": "ckpt-expired"}},
- metadata={"is_resumable": True, "expires_at": "2000-01-01T00:00:00Z"},
- session_service_provider=lambda: service,
- )
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ResumeRun",
- json={
- "AgentId": "demo-agent",
- "SessionId": "sess-resume-expired",
- "RunId": "run-expired",
- "CheckpointId": "ckpt-expired",
- "ResumeAttemptId": "resume-expired",
- "Stream": False,
- },
- )
-
- assert response.status_code == 409
- detail = response.json()["detail"]
- assert detail["code"] == "checkpoint_not_resumable"
- assert detail["resume_status"] == "disabled"
- assert "已过期" in detail["reason"]
- assert runner.calls == []
-
-
-@pytest.mark.asyncio
-async def test_resume_run_action_reuses_checkpoint_and_records_resume(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
- service = InMemorySessionService()
- runner = _CheckpointResumeRunner()
-
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-resume-action")
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
- await conversation_runtime.append_run_checkpoint_event(
- session_id="sess-resume-action",
- author="demo-agent",
- run_id="run-1",
- checkpoint_id="ckpt-1",
- framework="langgraph",
- framework_ref={"langgraph": {"thread_id": "tenant:agent:sess-resume-action", "checkpoint_id": "ckpt-1"}},
- invocation_id="inv-1",
- metadata={
- "stage_key": "plan_research",
- "stage_index": 1,
- "total_stages": 7,
- "artifact_preview": {"path": "research/x/01-plan.json", "kind": "json"},
- },
- session_service_provider=lambda: service,
- )
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ResumeRun",
- json={
- "AgentId": "demo-agent",
- "SessionId": "sess-resume-action",
- "RunId": "run-1",
- "CheckpointId": "ckpt-1",
- "ResumeAttemptId": "resume-1",
- "Stream": False,
- },
- )
-
- assert response.status_code == 200
- payload = response.json()["Data"]
- assert payload["session_id"] == "sess-resume-action"
- assert payload["metadata"]["agentengine"]["run_id"] == "run-1"
- assert runner.calls[-1]["checkpoint_resume"] is True
- assert runner.calls[-1]["framework_ref"]["langgraph"]["checkpoint_id"] == "ckpt-1"
- assert runner.calls[-1]["metadata"]["stage_key"] == "plan_research"
- assert runner.calls[-1]["metadata"]["stage_index"] == 1
- assert runner.calls[-1]["metadata"]["total_stages"] == 7
- assert runner.calls[-1]["metadata"]["artifact_preview"]["path"] == "research/x/01-plan.json"
- events = await service.get_events("sess-resume-action")
- resume_events = [event for event in events if event.event_type == "run_resume"]
- assert len(resume_events) == 1
- assert resume_events[0].metadata["resume_attempt_id"] == "resume-1"
-
-
-@pytest.mark.asyncio
-async def test_resume_run_action_returns_noop_for_terminal_checkpoint(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
- service = InMemorySessionService()
- runner = _CheckpointResumeRunner()
-
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-resume-disabled")
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
- await conversation_runtime.append_run_checkpoint_event(
- session_id="sess-resume-disabled",
- author="demo-agent",
- run_id="run-1",
- checkpoint_id="ckpt-terminal",
- framework="langgraph",
- framework_ref={"langgraph": {"thread_id": "sess-resume-disabled", "checkpoint_id": "ckpt-terminal"}},
- metadata={
- "is_terminal": True,
- "is_resumable": False,
- "resume_disabled_reason": "该 checkpoint 已是终态;请选择更早恢复点重跑",
- },
- session_service_provider=lambda: service,
- )
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ResumeRun",
- json={
- "AgentId": "demo-agent",
- "SessionId": "sess-resume-disabled",
- "RunId": "run-1",
- "CheckpointId": "ckpt-terminal",
- "ResumeAttemptId": "resume-disabled",
- },
- )
-
- assert response.status_code == 200
- data = response.json()["Data"]
- assert data["status"] == "noop"
- assert data["Reason"]
- assert runner.calls == []
- events = await service.get_events("sess-resume-disabled")
- assert [event.event_type for event in events if event.event_type in {"run_resume", "run_status"}] == [
- "run_resume",
- "run_status",
- ]
- assert events[-1].metadata["status"] == "completed"
-
-
-@pytest.mark.asyncio
-async def test_resume_run_action_rejects_process_local_checkpoint(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
- service = InMemorySessionService()
- runner = _CheckpointResumeRunner()
-
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-resume-local")
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
- await conversation_runtime.append_run_checkpoint_event(
- session_id="sess-resume-local",
- author="demo-agent",
- run_id="run-1",
- checkpoint_id="ckpt-local",
- framework="langgraph",
- framework_ref={"langgraph": {"thread_id": "sess-resume-local", "checkpoint_id": "ckpt-local"}},
- metadata={
- "is_resumable": False,
- "backend": "memory",
- "scope": "process_local",
- "resume_disabled_reason": "进程内 checkpoint 不能跨实例恢复",
- },
- session_service_provider=lambda: service,
- )
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ResumeRun",
- json={
- "AgentId": "demo-agent",
- "SessionId": "sess-resume-local",
- "RunId": "run-1",
- "CheckpointId": "ckpt-local",
- "ResumeAttemptId": "resume-local",
- },
- )
-
- assert response.status_code == 409
- assert response.json()["detail"]["code"] == "checkpoint_not_resumable"
- assert "进程内" in response.json()["detail"]["reason"]
- assert runner.calls == []
-
-
-@pytest.mark.asyncio
-async def test_get_checkpoint_resume_preview_reports_terminal_checkpoint_as_disabled(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
- service = InMemorySessionService()
-
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-preview-terminal")
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- await conversation_runtime.append_run_checkpoint_event(
- session_id="sess-preview-terminal",
- author="demo-agent",
- run_id="run-1",
- checkpoint_id="ckpt-terminal",
- framework="langgraph",
- framework_ref={"langgraph": {"thread_id": "sess-preview-terminal", "checkpoint_id": "ckpt-terminal"}},
- metadata={"is_terminal": True, "is_resumable": False},
- session_service_provider=lambda: service,
- )
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/GetCheckpointResumePreview",
- json={
- "AgentId": "demo-agent",
- "SessionId": "sess-preview-terminal",
- "RunId": "run-1",
- "CheckpointId": "ckpt-terminal",
- },
- )
-
- assert response.status_code == 200
- preview = response.json()["Data"]["Preview"]
- assert preview["CanResume"] is False
- assert preview["ExpectedAction"] == "disabled"
- assert "终态" in preview["Reason"]
-
-
-@pytest.mark.asyncio
-async def test_resume_run_action_stream_uses_invocation_id_for_detached_cancel(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
- service = InMemorySessionService()
- runner = _CheckpointResumeRunner()
-
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-resume-stream")
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
- await conversation_runtime.append_run_checkpoint_event(
- session_id="sess-resume-stream",
- author="demo-agent",
- run_id="run-1",
- checkpoint_id="ckpt-1",
- framework="langgraph",
- framework_ref={"langgraph": {"thread_id": "tenant:agent:sess-resume-stream", "checkpoint_id": "ckpt-1"}},
- invocation_id="inv-checkpoint",
- session_service_provider=lambda: service,
- )
-
- captured_invocations: list[str | None] = []
-
- def fake_detached_streaming_response(source, *, invocation_id=None, **kwargs):
- del kwargs
- captured_invocations.append(invocation_id)
- return Response(status_code=202)
-
- monkeypatch.setattr(server_app_module, "_detached_streaming_response", fake_detached_streaming_response)
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ResumeRun",
- json={
- "AgentId": "demo-agent",
- "SessionId": "sess-resume-stream",
- "RunId": "run-1",
- "CheckpointId": "ckpt-1",
- "ResumeAttemptId": "resume-1",
- "InvocationId": "run-ui-resume-1",
- "Stream": True,
- },
- )
-
- assert response.status_code == 202
- assert captured_invocations == ["run-ui-resume-1"]
-
-
-@pytest.mark.asyncio
-async def test_resume_run_action_stream_passes_checkpoint_metadata_to_runner(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
- service = InMemorySessionService()
- runner = _CheckpointResumeRunner()
-
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-resume-stream-metadata")
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
- await conversation_runtime.append_run_checkpoint_event(
- session_id="sess-resume-stream-metadata",
- author="demo-agent",
- run_id="run-1",
- checkpoint_id="ckpt-1",
- framework="langgraph",
- framework_ref={"langgraph": {"thread_id": "tenant:agent:sess-resume-stream-metadata", "checkpoint_id": "ckpt-1"}},
- invocation_id="inv-checkpoint",
- metadata={"stage_key": "plan_research", "stage_index": 1, "total_stages": 7},
- session_service_provider=lambda: service,
- )
-
- async def consume_stream(source):
- async for _chunk in source:
- pass
-
- def fake_detached_streaming_response(source, *, invocation_id=None, **kwargs):
- del invocation_id, kwargs
- return Response(status_code=202, background=BackgroundTask(consume_stream, source))
-
- monkeypatch.setattr(server_app_module, "_detached_streaming_response", fake_detached_streaming_response)
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ResumeRun",
- json={
- "AgentId": "demo-agent",
- "SessionId": "sess-resume-stream-metadata",
- "RunId": "run-1",
- "CheckpointId": "ckpt-1",
- "ResumeAttemptId": "resume-1",
- "InvocationId": "run-ui-resume-1",
- "Stream": True,
- },
- )
-
- assert response.status_code == 202
- assert runner.calls[-1]["checkpoint_resume"] is True
- assert runner.calls[-1]["metadata"]["stage_key"] == "plan_research"
- assert runner.calls[-1]["metadata"]["stage_index"] == 1
-
-
-@pytest.mark.asyncio
-async def test_resume_run_action_stream_registers_detached_cancel(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
- service = InMemorySessionService()
- runner = _CancellableStreamingRunner()
-
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-resume-cancel")
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
- await conversation_runtime.append_run_checkpoint_event(
- session_id="sess-resume-cancel",
- author="demo-agent",
- run_id="run-1",
- checkpoint_id="ckpt-1",
- framework="langgraph",
- framework_ref={
- "langgraph": {
- "thread_id": "tenant:agent:sess-resume-cancel",
- "checkpoint_id": "ckpt-1",
- }
- },
- invocation_id="inv-checkpoint",
- session_service_provider=lambda: service,
- )
-
- invocation_id = "run-ui-resume-cancel"
- original_detached_streaming_response = server_app_module._detached_streaming_response
-
- def start_detached_stream_and_return_accepted(source, *, invocation_id=None, **kwargs):
- original_detached_streaming_response(source, invocation_id=invocation_id, **kwargs)
- return Response(status_code=202)
-
- monkeypatch.setattr(
- server_app_module,
- "_detached_streaming_response",
- start_detached_stream_and_return_accepted,
- )
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- resume_response = await client.post(
- "/agentengine/api/v1/ResumeRun",
- json={
- "AgentId": "demo-agent",
- "SessionId": "sess-resume-cancel",
- "RunId": "run-1",
- "CheckpointId": "ckpt-1",
- "ResumeAttemptId": "resume-1",
- "InvocationId": invocation_id,
- "Stream": True,
- },
- )
-
- for _ in range(20):
- events = await service.get_events("sess-resume-cancel")
- statuses = [
- event.content.get("status")
- for event in events
- if event.event_type == "run_status"
- ]
- if "in_progress" in statuses and "cancelled" not in statuses:
- break
- await asyncio.sleep(0.02)
-
- cancel_response = await client.post(
- "/agentengine/api/v1/CancelRun",
- json={"AgentId": "demo-agent", "InvocationId": invocation_id},
- )
-
- assert resume_response.status_code == 202
- assert cancel_response.status_code == 200
- cancel_data = cancel_response.json()["Data"]
- assert cancel_data["Found"] is True
- assert cancel_data["Cancelled"] is True
- assert cancel_data["RunnerCancelStatus"] == "accepted"
-
- for _ in range(20):
- events = await service.get_events("sess-resume-cancel")
- statuses = [
- event.content.get("status")
- for event in events
- if event.event_type == "run_status"
- ]
- if "cancelled" in statuses:
- break
- await asyncio.sleep(0.02)
-
- events = await service.get_events("sess-resume-cancel")
- statuses = [
- event.content.get("status")
- for event in events
- if event.event_type == "run_status"
- ]
- # resume 现在先写 run_status(resuming) 再写 in_progress,cancel 后写 cancelled。
- assert statuses == ["resuming", "in_progress", "cancelled"]
- assert runner.cancel_requests == [invocation_id]
-
-
-@pytest.mark.asyncio
-async def test_resume_run_action_stream_rejects_concurrent_resume_for_same_run(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
- service = InMemorySessionService()
- runner = _CancellableStreamingRunner()
-
- await service.create_session(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-resume-concurrent",
- )
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
- await conversation_runtime.append_run_checkpoint_event(
- session_id="sess-resume-concurrent",
- author="demo-agent",
- run_id="run-1",
- checkpoint_id="ckpt-1",
- framework="langgraph",
- framework_ref={
- "langgraph": {
- "thread_id": "tenant:agent:sess-resume-concurrent",
- "checkpoint_id": "ckpt-1",
- }
- },
- invocation_id="inv-checkpoint",
- session_service_provider=lambda: service,
- )
-
- original_detached_streaming_response = server_app_module._detached_streaming_response
-
- def start_detached_stream_and_return_accepted(source, *, invocation_id=None, **kwargs):
- original_detached_streaming_response(source, invocation_id=invocation_id, **kwargs)
- return Response(status_code=202)
-
- monkeypatch.setattr(
- server_app_module,
- "_detached_streaming_response",
- start_detached_stream_and_return_accepted,
- )
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- first_response = await client.post(
- "/agentengine/api/v1/ResumeRun",
- json={
- "AgentId": "demo-agent",
- "SessionId": "sess-resume-concurrent",
- "RunId": "run-1",
- "CheckpointId": "ckpt-1",
- "ResumeAttemptId": "resume-1",
- "InvocationId": "run-ui-resume-1",
- "Stream": True,
- },
- )
- second_response = await client.post(
- "/agentengine/api/v1/ResumeRun",
- json={
- "AgentId": "demo-agent",
- "SessionId": "sess-resume-concurrent",
- "RunId": "run-1",
- "CheckpointId": "ckpt-1",
- "ResumeAttemptId": "resume-2",
- "InvocationId": "run-ui-resume-2",
- "Stream": True,
- },
- )
-
- assert first_response.status_code == 202
- assert second_response.status_code == 409
- assert second_response.json()["detail"]["code"] == "resume_already_running"
-
-
-@pytest.mark.asyncio
-async def test_resume_run_action_rejects_unknown_checkpoint(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _CheckpointResumeRunner()
-
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-resume-missing")
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ResumeRun",
- json={
- "AgentId": "demo-agent",
- "SessionId": "sess-resume-missing",
- "RunId": "run-unknown",
- "CheckpointId": "ckpt-unknown",
- "Stream": False,
- },
- )
-
- assert response.status_code == 404
- assert runner.calls == []
- events = await service.get_events("sess-resume-missing")
- assert events == []
-
-
-@pytest.mark.asyncio
-async def test_append_run_checkpoint_event_deduplicates_same_checkpoint(monkeypatch):
- conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
- service = InMemorySessionService()
-
- await service.create_session(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-dedupe-checkpoint",
- )
-
- first = await conversation_runtime.append_run_checkpoint_event(
- session_id="sess-dedupe-checkpoint",
- author="demo-agent",
- run_id="run-1",
- checkpoint_id="ckpt-1",
- framework="langgraph",
- framework_ref={
- "langgraph": {
- "thread_id": "tenant:agent:sess-dedupe-checkpoint",
- "checkpoint_id": "ckpt-1",
- }
- },
- phase="runner",
- invocation_id="inv-runner",
- session_service_provider=lambda: service,
- )
- second = await conversation_runtime.append_run_checkpoint_event(
- session_id="sess-dedupe-checkpoint",
- author="demo-agent",
- run_id="run-1",
- checkpoint_id="ckpt-1",
- framework="langgraph",
- framework_ref={
- "langgraph": {
- "thread_id": "tenant:agent:sess-dedupe-checkpoint",
- "checkpoint_id": "ckpt-1",
- }
- },
- phase="stream",
- invocation_id="inv-framework",
- session_service_provider=lambda: service,
- )
-
- events = await service.get_events("sess-dedupe-checkpoint")
- checkpoint_events = [event for event in events if event.event_type == "run_checkpoint"]
- assert len(checkpoint_events) == 1
- assert second.id == first.id
-
-
-@pytest.mark.asyncio
-async def test_get_checkpoint_resume_preview_summarizes_checkpoint_and_tool_receipts(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
- service = InMemorySessionService()
- runner = _CheckpointResumeRunner()
-
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-preview")
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
- await service.append_event(
- "sess-preview",
- SessionEvent(
- id="evt-tool",
- author="tool",
- event_type="tool_result",
- content={"role": "user", "parts": [{"text": "{'ok': True}"}]},
- metadata={
- "tool_name": "write_workspace_file",
- "tool_args": {"path": "notes.txt", "content": "hello"},
- "tool_output": {"ok": True, "path": "notes.txt"},
- "run_id": "run-1",
- "tool_receipt": {
- "receipt_id": "tr_1",
- "idempotency_key": "tool_receipt:abc",
- "tool_name": "write_workspace_file",
- "tool_call_id": "call_write",
- "run_id": "run-1",
- "checkpoint_id": "",
- "status": "completed",
- "created_at": 10.0,
- },
- },
- invocation_id="inv-tool",
- ),
- )
- await conversation_runtime.append_run_checkpoint_event(
- session_id="sess-preview",
- author="demo-agent",
- run_id="run-1",
- checkpoint_id="ckpt-1",
- framework="langgraph",
- framework_ref={"langgraph": {"thread_id": "tenant:agent:sess-preview", "checkpoint_id": "ckpt-1"}},
- phase="tool_result",
- invocation_id="inv-1",
- session_service_provider=lambda: service,
- )
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/GetCheckpointResumePreview",
- json={
- "AgentId": "demo-agent",
- "SessionId": "sess-preview",
- "RunId": "run-1",
- "CheckpointId": "ckpt-1",
- },
- )
-
- assert response.status_code == 200
- preview = response.json()["Data"]["Preview"]
- assert preview["Checkpoint"]["CheckpointId"] == "ckpt-1"
- assert preview["Capabilities"]["CheckpointResume"] is True
- assert preview["Risk"]["Level"] == "medium"
- assert preview["Risk"]["DuplicateSideEffectRisk"] is True
- assert preview["ToolReceipts"][0]["ToolName"] == "write_workspace_file"
- assert preview["ToolReceipts"][0]["Status"] == "completed"
-
-
-@pytest.mark.asyncio
-async def test_list_tool_receipts_filters_by_agent_session_run_and_checkpoint(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _DummyRunner()
-
- await service.create_session(agent_id="demo-agent", user_id="user-1", session_id="sess-receipts")
- await service.create_session(agent_id="other-agent", user_id="user-1", session_id="sess-other-receipts")
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- await service.append_event(
- "sess-receipts",
- SessionEvent(
- id="evt-receipt-1",
- author="tool",
- event_type="tool_result",
- content={"role": "user", "parts": [{"text": "{'ok': True}"}]},
- metadata={
- "tool_name": "write_workspace_file",
- "run_id": "run-1",
- "tool_receipt": {
- "receipt_id": "tr_1",
- "idempotency_key": "tool_receipt:1",
- "tool_name": "write_workspace_file",
- "tool_call_id": "call-1",
- "run_id": "run-1",
- "checkpoint_id": "ckpt-1",
- "status": "completed",
- "replayed": False,
- },
- },
- invocation_id="inv-1",
- ),
- )
- await service.append_event(
- "sess-receipts",
- SessionEvent(
- id="evt-receipt-2",
- author="tool",
- event_type="tool_result",
- content={"role": "user", "parts": [{"text": "{'ok': True}"}]},
- metadata={
- "tool_name": "send_notification",
- "run_id": "run-2",
- "tool_receipt": {
- "receipt_id": "tr_2",
- "idempotency_key": "tool_receipt:2",
- "tool_name": "send_notification",
- "tool_call_id": "call-2",
- "run_id": "run-2",
- "checkpoint_id": "ckpt-2",
- "status": "completed",
- },
- },
- invocation_id="inv-2",
- ),
- )
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ListToolReceipts",
- json={
- "AgentId": "demo-agent",
- "SessionId": "sess-receipts",
- "RunId": "run-1",
- "CheckpointId": "ckpt-1",
- },
- )
- wrong_agent = await client.post(
- "/agentengine/api/v1/ListToolReceipts",
- json={"AgentId": "other-agent", "SessionId": "sess-receipts"},
- )
-
- assert response.status_code == 200
- receipts = response.json()["Data"]["ToolReceipts"]
- assert [receipt["ReceiptId"] for receipt in receipts] == ["tr_1"]
- assert receipts[0]["ToolName"] == "write_workspace_file"
- assert receipts[0]["RunId"] == "run-1"
- assert receipts[0]["CheckpointId"] == "ckpt-1"
- assert wrong_agent.status_code == 404
-
-
-@pytest.mark.asyncio
-async def test_run_agent_action_passes_model_options_to_runner(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _DummyRunner()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": "demo-agent",
- "Messages": [{"role": "user", "content": "hello"}],
- "Stream": False,
- "Model": "glm-5.1",
- "ModelOptions": {"thinking": {"type": "disabled"}},
- },
- )
-
- assert response.status_code == 200
- assert runner.calls[-1]["model_options"] == {
- "thinking": {"type": "disabled"},
- "reasoning": {"effort": "none"},
- "max_reasoning_tokens": 0,
- }
-
-
-@pytest.mark.asyncio
-async def test_subscribe_run_events_streams_events_appended_after_subscription(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(_DummyRunner())
-
- session = await service.create_session(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-subscribe",
- )
- in_progress = await service.append_event(
- session.id,
- SessionEvent.from_dict(
- {
- "author": "demo-agent",
- "eventType": "run_status",
- "invocationId": "inv-live",
- "content": {"status": "in_progress"},
- },
- session_id=session.id,
- ),
- )
-
- async def append_later():
- await asyncio.sleep(0.02)
- await service.append_event(
- session.id,
- SessionEvent.from_dict(
- {
- "author": "demo-agent",
- "eventType": "assistant_message",
- "invocationId": "inv-live",
- "content": {"role": "model", "parts": [{"text": "hello"}]},
- },
- session_id=session.id,
- ),
- )
- await service.append_event(
- session.id,
- SessionEvent.from_dict(
- {
- "author": "demo-agent",
- "eventType": "run_status",
- "invocationId": "inv-live",
- "content": {"status": "completed"},
- },
- session_id=session.id,
- ),
- )
-
- task = asyncio.create_task(append_later())
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.get(
- "/agentengine/api/v1/SubscribeRunEvents",
- params={
- "SessionId": session.id,
- "InvocationId": "inv-live",
- "AfterSeqId": str(in_progress.seq_id),
- },
- )
- await task
-
- assert response.status_code == 200
- payloads = [
- json.loads(line.removeprefix("data: "))
- for line in response.text.splitlines()
- if line.startswith("data: ") and line.strip() != "data: [DONE]"
- ]
- assert [payload["EventType"] for payload in payloads] == [
- "assistant_message",
- "run_status",
- ]
- assert payloads[0]["Content"]["parts"][0]["text"] == "hello"
- assert payloads[-1]["Content"]["status"] == "completed"
-
-
-@pytest.mark.asyncio
-async def test_subscribe_run_events_reconnects_without_replaying_consumed_events(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(_DummyRunner())
-
- session = await service.create_session(
- agent_id="demo-agent",
- user_id="user-1",
- session_id="sess-subscribe-reconnect",
- )
- in_progress = await service.append_event(
- session.id,
- SessionEvent.from_dict(
- {
- "author": "demo-agent",
- "eventType": "run_status",
- "invocationId": "inv-reconnect",
- "content": {"status": "in_progress"},
- },
- session_id=session.id,
- ),
- )
- assistant = await service.append_event(
- session.id,
- SessionEvent.from_dict(
- {
- "author": "demo-agent",
- "eventType": "assistant_message",
- "invocationId": "inv-reconnect",
- "content": {"role": "model", "parts": [{"text": "halfway"}]},
- },
- session_id=session.id,
- ),
- )
- await service.append_event(
- session.id,
- SessionEvent.from_dict(
- {
- "author": "demo-agent",
- "eventType": "run_status",
- "invocationId": "other-invocation",
- "content": {"status": "completed"},
- },
- session_id=session.id,
- ),
- )
-
- async def append_completed_later():
- await asyncio.sleep(0.02)
- await service.append_event(
- session.id,
- SessionEvent.from_dict(
- {
- "author": "demo-agent",
- "eventType": "run_status",
- "invocationId": "inv-reconnect",
- "content": {"status": "completed"},
- },
- session_id=session.id,
- ),
- )
-
- task = asyncio.create_task(append_completed_later())
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- first_response = await client.get(
- "/agentengine/api/v1/SubscribeRunEvents",
- params={
- "SessionId": session.id,
- "InvocationId": "inv-reconnect",
- "AfterSeqId": 0,
- },
- )
- second_response = await client.get(
- "/agentengine/api/v1/SubscribeRunEvents",
- params={
- "SessionId": session.id,
- "InvocationId": "inv-reconnect",
- "AfterSeqId": str(assistant.seq_id),
- },
- )
- await task
-
- assert first_response.status_code == 200
- first_payloads = [
- json.loads(line.removeprefix("data: "))
- for line in first_response.text.splitlines()
- if line.startswith("data: ") and line.strip() != "data: [DONE]"
- ]
- assert [payload["SeqId"] for payload in first_payloads[:2]] == [
- in_progress.seq_id,
- assistant.seq_id,
- ]
-
- assert second_response.status_code == 200
- second_payloads = [
- json.loads(line.removeprefix("data: "))
- for line in second_response.text.splitlines()
- if line.startswith("data: ") and line.strip() != "data: [DONE]"
- ]
- assert [payload["EventType"] for payload in second_payloads] == ["run_status"]
- assert second_payloads[0]["SeqId"] > assistant.seq_id
- assert second_payloads[0]["Content"]["status"] == "completed"
-
-
-@pytest.mark.asyncio
-async def test_run_agent_stream_continues_after_client_disconnect(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _SlowStreamingRunner()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- async with client.stream(
- "POST",
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": "demo-agent",
- "SessionId": "sess-detached-run",
- "Messages": [{"role": "user", "content": "hello"}],
- "Stream": True,
- "ApiFormat": "responses",
- },
- ) as response:
- assert response.status_code == 200
- async for line in response.aiter_lines():
- if line.startswith("data: ") and "response.created" in line:
- break
-
- for _ in range(20):
- events = await service.get_events("sess-detached-run")
- if events and events[-1].event_type == "run_status" and events[-1].content.get("status") == "completed":
- break
- await asyncio.sleep(0.02)
-
- events = await service.get_events("sess-detached-run")
- assert [event.event_type for event in events] == [
- "user_message",
- "run_status",
- "assistant_message",
- "run_status",
- ]
- assert events[-2].content["parts"][0]["text"] == "hello"
- assert events[-1].content["status"] == "completed"
-
-
-@pytest.mark.asyncio
-async def test_cancel_run_cancels_detached_stream_and_writes_cancelled_status(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _CancellableStreamingRunner()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- invocation_id = "inv-cancel-detached"
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- server_app_module._detached_streaming_response(
- conversation.stream_responses_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user",
- messages=[{"role": "user", "content": "hello"}],
- session_id="sess-cancel-run",
- model=None,
- prepare_runner=lambda _runner, _model: None,
- invocation_id=invocation_id,
- session_service_provider=lambda: service,
- ),
- invocation_id=invocation_id,
- )
-
- for _ in range(20):
- events = await service.get_events("sess-cancel-run")
- statuses = [
- event.content.get("status")
- for event in events
- if event.event_type == "run_status"
- ]
- if statuses == ["in_progress"]:
- break
- await asyncio.sleep(0.02)
-
- cancel_response = await client.post(
- "/agentengine/api/v1/CancelRun",
- json={"AgentId": "demo-agent", "InvocationId": invocation_id},
- )
-
- assert cancel_response.status_code == 200
- cancel_data = cancel_response.json()["Data"]
- assert cancel_data["Found"] is True
- assert cancel_data["Cancelled"] is True
- assert cancel_data["Status"] == "cancelling"
-
- for _ in range(20):
- events = await service.get_events("sess-cancel-run")
- if events and events[-1].event_type == "run_status" and events[-1].content.get("status") == "cancelled":
- break
- await asyncio.sleep(0.02)
-
- events = await service.get_events("sess-cancel-run")
- event_types = [event.event_type for event in events]
- statuses = [
- event.content.get("status")
- for event in events
- if event.event_type == "run_status"
- ]
- assert statuses == ["in_progress", "cancelled"]
- assert "assistant_message" not in event_types
- assert runner.cancel_requests == [invocation_id]
-
-
-@pytest.mark.asyncio
-async def test_delete_session_cancels_active_detached_stream_before_removing_session(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _CancellableStreamingRunner()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- invocation_id = "inv-delete-detached"
- session_id = "sess-delete-detached"
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- server_app_module._detached_streaming_response(
- conversation.stream_responses_conversation_turn(
- runner=runner,
- agent_id="demo-agent",
- user_id="user",
- messages=[{"role": "user", "content": "hello"}],
- session_id=session_id,
- model=None,
- prepare_runner=lambda _runner, _model: None,
- invocation_id=invocation_id,
- session_service_provider=lambda: service,
- ),
- invocation_id=invocation_id,
- session_id=session_id,
- )
-
- for _ in range(20):
- events = await service.get_events(session_id)
- if any(event.event_type == "run_status" for event in events):
- break
- await asyncio.sleep(0.02)
-
- response = await client.post(
- "/agentengine/api/v1/DeleteSession",
- json={"SessionId": session_id},
- )
-
- assert response.status_code == 200
- assert response.json()["Data"]["Deleted"] is True
- assert invocation_id not in server_app_module._DETACHED_STREAMS_BY_INVOCATION
- assert await service.get_session(session_id) is None
-
-
-@pytest.mark.asyncio
-async def test_cancel_run_reports_unsupported_when_runner_has_no_cancel_hook(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _OverrideStreamingRunner()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/CancelRun",
- json={"AgentId": "demo-agent", "InvocationId": "inv-unsupported"},
- )
-
- assert response.status_code == 200
- cancel_data = response.json()["Data"]
- assert cancel_data["Found"] is False
- assert cancel_data["Cancelled"] is False
- assert cancel_data["Status"] == "unsupported"
- assert cancel_data["RunnerCancelStatus"] == "unsupported"
diff --git a/tests/test_server_terminal_sessions.py b/tests/test_server_terminal_sessions.py
deleted file mode 100644
index 9d635062..00000000
--- a/tests/test_server_terminal_sessions.py
+++ /dev/null
@@ -1,281 +0,0 @@
-from __future__ import annotations
-
-import importlib
-import json
-import subprocess
-import sys
-from types import SimpleNamespace
-
-import httpx
-import pytest
-from fastapi.testclient import TestClient
-
-import ksadk.server.terminal_sessions as terminal_sessions
-from ksadk.hermes_terminal import TERMINAL_SUBPROTOCOL
-from ksadk.runners.base_runner import BaseRunner
-from ksadk.server.terminal_sessions import TerminalSession
-
-
-class _OpenClawRunner(BaseRunner):
- def __init__(self):
- super().__init__(
- detection_result=SimpleNamespace(
- name="openclaw-agent",
- type=SimpleNamespace(value="openclaw"),
- ),
- project_dir=".",
- )
-
- def load_agent(self) -> None:
- return None
-
- async def invoke(self, input_data: dict) -> dict:
- return {"output": "ok"}
-
- async def stream(self, input_data: dict):
- yield {"type": "final", "output": "ok"}
-
-
-def test_server_app_imports_when_posix_terminal_modules_are_unavailable():
- code = """
-import builtins
-real_import = builtins.__import__
-def guarded_import(name, globals=None, locals=None, fromlist=(), level=0):
- if name in {'pty', 'termios'}:
- raise ImportError(f'No module named {name!r}')
- return real_import(name, globals, locals, fromlist, level)
-builtins.__import__ = guarded_import
-import ksadk.server.app
-print('ok')
-"""
- result = subprocess.run(
- [sys.executable, "-c", code],
- cwd=".",
- text=True,
- capture_output=True,
- timeout=20,
- )
-
- assert result.returncode == 0, result.stderr
- assert result.stdout.strip() == "ok"
-
-
-def test_native_terminal_support_reports_false_without_posix_modules(monkeypatch):
- monkeypatch.setattr(terminal_sessions, "pty", None)
- monkeypatch.setattr(terminal_sessions, "termios", None)
-
- assert terminal_sessions.native_terminal_supported() is False
-
-
-@pytest.fixture()
-def server_app(monkeypatch, tmp_path):
- appmod = importlib.import_module("ksadk.server.app")
- monkeypatch.setenv("AGENTENGINE_TERMINAL_STATE_DIR", str(tmp_path / "terminal"))
- monkeypatch.setenv("KSADK_WORKSPACE_ROOT", str(tmp_path / "workspace"))
- (tmp_path / "workspace").mkdir()
- appmod.set_runner(_OpenClawRunner())
- appmod.terminal_manager.reset_for_tests()
- yield appmod
- appmod.terminal_manager.reset_for_tests()
-
-
-@pytest.mark.asyncio
-async def test_terminal_sessions_reuse_by_business_session_and_mode(server_app, monkeypatch):
- spawned: list[tuple[str, list[str]]] = []
-
- def fake_spawn(session):
- session.pid = 123
- session.fd = None
- session.status = "running"
- spawned.append((session.id, list(session.argv)))
-
- monkeypatch.setattr(server_app.terminal_manager, "_spawn_session", fake_spawn)
-
- transport = httpx.ASGITransport(app=server_app.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- first = await client.post(
- "/_ksadk/terminal/sessions",
- json={"session_id": "biz-1", "mode": "tui", "cols": 100, "rows": 30},
- )
- second = await client.post(
- "/_ksadk/terminal/sessions",
- json={"session_id": "biz-1", "mode": "tui", "cols": 90, "rows": 24},
- )
- forced = await client.post(
- "/_ksadk/terminal/sessions",
- json={"session_id": "biz-1", "mode": "tui", "force_new": True},
- )
- listing = await client.get("/_ksadk/terminal/sessions", params={"session_id": "biz-1", "mode": "tui"})
-
- assert first.status_code == 200
- assert second.status_code == 200
- assert forced.status_code == 200
- first_id = first.json()["session"]["terminal_session_id"]
- assert second.json()["session"]["terminal_session_id"] == first_id
- assert forced.json()["session"]["terminal_session_id"] != first_id
- assert len(spawned) == 2
- assert listing.status_code == 200
- sessions = listing.json()["sessions"]
- assert {item["session_id"] for item in sessions} == {"biz-1"}
- assert {item["mode"] for item in sessions} == {"tui"}
- state_files = list((server_app.Path(server_app.os.environ["AGENTENGINE_TERMINAL_STATE_DIR"])).glob("term-*.json"))
- assert state_files
- persisted = [json.loads(path.read_text(encoding="utf-8")) for path in state_files]
- assert any(item["terminal_session_id"] == first_id and item["session_id"] == "biz-1" for item in persisted)
-
-
-@pytest.mark.asyncio
-async def test_terminal_session_delete_marks_deleted_and_removes_from_reuse(server_app, monkeypatch):
- def fake_spawn(session):
- session.pid = 123
- session.fd = None
- session.status = "running"
-
- monkeypatch.setattr(server_app.terminal_manager, "_spawn_session", fake_spawn)
-
- transport = httpx.ASGITransport(app=server_app.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- created = await client.post(
- "/_ksadk/terminal/sessions",
- json={"session_id": "biz-2", "mode": "tui"},
- )
- terminal_session_id = created.json()["session"]["terminal_session_id"]
- deleted = await client.delete(f"/_ksadk/terminal/sessions/{terminal_session_id}")
- recreated = await client.post(
- "/_ksadk/terminal/sessions",
- json={"session_id": "biz-2", "mode": "tui"},
- )
-
- assert deleted.status_code == 200
- assert deleted.json()["deleted"] is True
- assert recreated.json()["session"]["terminal_session_id"] != terminal_session_id
-
-
-def test_terminal_websocket_attach_replays_and_detaches_without_deleting(server_app):
- session = TerminalSession(
- id="term-replay",
- session_id="biz-3",
- mode="tui",
- status="detached",
- pid=123,
- fd=None,
- )
- session.replay_buffer.extend(b"previous output")
- server_app.terminal_manager.sessions[session.id] = session
-
- with TestClient(server_app.app) as client:
- with client.websocket_connect(
- f"/_ksadk/terminal/ws?terminal_session_id={session.id}",
- subprotocols=[TERMINAL_SUBPROTOCOL],
- ) as websocket:
- ready = websocket.receive_json()
- replay = websocket.receive_bytes()
- websocket.close()
- retained = server_app.terminal_manager.sessions[session.id]
-
- assert ready == {"type": "ready", "terminal_session_id": session.id}
- assert replay == b"previous output"
- assert retained.status == "detached"
- assert retained.deleted is False
-
-
-def test_legacy_terminal_websocket_keeps_ephemeral_cleanup_semantics(server_app, monkeypatch):
- started: list[str] = []
- terminated: list[str] = []
-
- def fake_spawn(session):
- session.pid = 123
- session.fd = None
- session.status = "running"
- started.append(session.id)
-
- def fake_terminate(session):
- session.deleted = True
- session.status = "deleted"
- terminated.append(session.id)
-
- async def fake_attach(_ws, session):
- session.status = "detached"
-
- monkeypatch.setattr(server_app.terminal_manager, "_spawn_session", fake_spawn)
- monkeypatch.setattr(server_app.terminal_manager, "_terminate_session", fake_terminate)
- monkeypatch.setattr(server_app.terminal_manager, "_attach_existing", fake_attach)
-
- with TestClient(server_app.app) as client:
- with client.websocket_connect(
- "/_ksadk/terminal/ws",
- subprotocols=[TERMINAL_SUBPROTOCOL],
- ) as websocket:
- websocket.send_json({"type": "start", "session_id": "legacy-biz", "mode": "tui"})
-
- assert len(started) == 1
- assert terminated == started
- assert started[0] not in server_app.terminal_manager.sessions
-
-
-def test_terminal_tui_command_binds_product_resume_id(server_app, monkeypatch):
- monkeypatch.setattr(terminal_sessions.shutil, "which", lambda command: f"/usr/bin/{command}")
-
- session = TerminalSession(
- id="term-command",
- session_id="biz-4",
- mode="tui",
- framework="openclaw",
- )
-
- assert server_app.terminal_manager._resolve_terminal_command(session) == [
- "openclaw",
- "tui",
- "--session",
- "biz-4",
- ]
-
- session.framework = "hermes"
- assert server_app.terminal_manager._resolve_terminal_command(session) == [
- "hermes",
- "chat",
- "--resume",
- "biz-4",
- ]
-
-
-def test_terminal_tui_resume_flag_can_be_disabled(server_app, monkeypatch):
- monkeypatch.setattr(terminal_sessions.shutil, "which", lambda command: f"/usr/bin/{command}")
- monkeypatch.setenv("OPENCLAW_TERMINAL_RESUME_ENABLED", "false")
- monkeypatch.setenv("HERMES_TERMINAL_RESUME_ENABLED", "false")
-
- session = TerminalSession(
- id="term-command",
- session_id="biz-4",
- mode="tui",
- framework="openclaw",
- )
-
- assert server_app.terminal_manager._resolve_terminal_command(session) == [
- "openclaw",
- "tui",
- "--session",
- "biz-4",
- ]
-
- session.framework = "hermes"
- assert server_app.terminal_manager._resolve_terminal_command(session) == ["hermes", "chat"]
-
-
-def test_terminal_tui_openclaw_session_flag_can_be_overridden(server_app, monkeypatch):
- monkeypatch.setattr(terminal_sessions.shutil, "which", lambda command: f"/usr/bin/{command}")
- monkeypatch.setenv("OPENCLAW_TERMINAL_SESSION_FLAG", "--conversation")
-
- session = TerminalSession(
- id="term-command",
- session_id="biz-4",
- mode="tui",
- framework="openclaw",
- )
-
- assert server_app.terminal_manager._resolve_terminal_command(session) == [
- "openclaw",
- "tui",
- "--conversation",
- "biz-4",
- ]
diff --git a/tests/test_server_workspace_preview_security.py b/tests/test_server_workspace_preview_security.py
deleted file mode 100644
index 249523f3..00000000
--- a/tests/test_server_workspace_preview_security.py
+++ /dev/null
@@ -1,66 +0,0 @@
-from __future__ import annotations
-
-import io
-import importlib
-import zipfile
-from pathlib import Path
-
-from fastapi.testclient import TestClient
-
-appmod = importlib.import_module("ksadk.server.app")
-
-
-def _client_with_workspace(monkeypatch, tmp_path: Path) -> tuple[TestClient, Path]:
- session_dir = tmp_path / "session"
- workspace = session_dir / "workspace"
- workspace.mkdir(parents=True)
- monkeypatch.setattr(appmod, "resolve_local_session_dir", lambda: session_dir)
- return TestClient(appmod.app), workspace
-
-
-def test_workspace_html_route_applies_sandbox_csp(monkeypatch, tmp_path: Path):
- client, workspace = _client_with_workspace(monkeypatch, tmp_path)
- (workspace / "index.html").write_text("ok", encoding="utf-8")
-
- response = client.get("/agentengine/api/v1/ws/agent-1/index.html")
-
- assert response.status_code == 200
- csp = response.headers.get("content-security-policy", "")
- assert "sandbox allow-scripts allow-downloads" in csp
- assert "connect-src 'none'" in csp
- assert "script-src 'unsafe-inline' 'unsafe-eval' 'self' https:" in csp
- assert "img-src data: blob: 'self' https:" in csp
- assert '' in response.text
- assert "data-ksadk-preview-anchor-handler" in response.text
-
-
-def test_export_workspace_zip_does_not_follow_symlink_escape(monkeypatch, tmp_path: Path):
- client, workspace = _client_with_workspace(monkeypatch, tmp_path)
- (workspace / "safe.txt").write_text("safe", encoding="utf-8")
- outside_secret = tmp_path / "secret.txt"
- outside_secret.write_text("secret", encoding="utf-8")
- (workspace / "leak.txt").symlink_to(outside_secret)
-
- response = client.get("/agentengine/api/v1/ExportWorkspaceZip")
-
- assert response.status_code == 200
- with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
- names = set(archive.namelist())
- assert "safe.txt" in names
- assert "leak.txt" not in names
-
-
-def test_workspace_raw_export_zip_route_matches_public_contract(monkeypatch, tmp_path: Path):
- client, workspace = _client_with_workspace(monkeypatch, tmp_path)
- assets = workspace / "slide-deck"
- assets.mkdir()
- (assets / "deck.md").write_text("# hello", encoding="utf-8")
-
- response = client.get("/_ksadk/workspace/v1/export-zip", params={"path": "slide-deck"})
-
- assert response.status_code == 200
- assert response.headers["content-type"].startswith("application/zip")
- assert "workspace-slide-deck.zip" in response.headers["content-disposition"]
- with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
- assert archive.namelist() == ["slide-deck/deck.md"]
- assert archive.read("slide-deck/deck.md") == b"# hello"
diff --git a/tests/test_session_continuity.py b/tests/test_session_continuity.py
deleted file mode 100644
index 5f9855a3..00000000
--- a/tests/test_session_continuity.py
+++ /dev/null
@@ -1,178 +0,0 @@
-from __future__ import annotations
-
-import sqlite3
-from pathlib import Path
-from types import SimpleNamespace
-
-import httpx
-import pytest
-
-from ksadk.runners.base_runner import BaseRunner
-from ksadk.sessions.local_service import LocalSessionService
-
-
-class _ContinuityRunner(BaseRunner):
- def __init__(self):
- super().__init__(
- detection_result=SimpleNamespace(
- name="demo-agent",
- type=SimpleNamespace(value="langchain"),
- ),
- project_dir=".",
- )
-
- def load_agent(self) -> None:
- return None
-
- async def invoke(self, input_data: dict) -> dict:
- return {"output": "ok"}
-
- async def stream(self, input_data: dict):
- yield {"type": "final", "output": "ok"}
-
-
-@pytest.mark.asyncio
-async def test_local_session_service_migrates_legacy_tables_to_namespaced_schema(tmp_path):
- db_path = tmp_path / "sessions.sqlite"
- connection = sqlite3.connect(db_path)
- connection.executescript(
- """
- CREATE TABLE sessions (
- id TEXT PRIMARY KEY,
- agent_id TEXT NOT NULL,
- user_id TEXT NOT NULL,
- title TEXT NOT NULL DEFAULT '',
- title_source TEXT NOT NULL DEFAULT '',
- summary TEXT NOT NULL DEFAULT '',
- first_prompt TEXT NOT NULL DEFAULT '',
- last_prompt TEXT NOT NULL DEFAULT '',
- state_json TEXT NOT NULL DEFAULT '{}',
- created_at REAL NOT NULL,
- updated_at REAL NOT NULL,
- version INTEGER NOT NULL DEFAULT 0
- );
- CREATE TABLE events (
- id TEXT PRIMARY KEY,
- session_id TEXT NOT NULL,
- author TEXT NOT NULL,
- event_type TEXT NOT NULL,
- content_json TEXT NOT NULL DEFAULT '{}',
- timestamp REAL NOT NULL,
- state_delta_json TEXT NOT NULL DEFAULT '{}',
- seq_id INTEGER NOT NULL,
- invocation_id TEXT,
- metadata_json TEXT NOT NULL DEFAULT '{}'
- );
- CREATE TABLE states (
- scope TEXT NOT NULL,
- agent_id TEXT NOT NULL,
- user_id TEXT NOT NULL DEFAULT '',
- session_id TEXT NOT NULL DEFAULT '',
- state_json TEXT NOT NULL DEFAULT '{}',
- version INTEGER NOT NULL DEFAULT 0,
- updated_at REAL NOT NULL,
- PRIMARY KEY (scope, agent_id, user_id, session_id)
- );
- INSERT INTO sessions (
- id, agent_id, user_id, title, title_source, summary, first_prompt, last_prompt,
- state_json, created_at, updated_at, version
- ) VALUES (
- 'sess-1', 'demo-agent', 'user', 'old title', 'heuristic', 'summary',
- 'first', 'last', '{\"topic\": \"billing\"}', 1, 2, 3
- );
- INSERT INTO events (
- id, session_id, author, event_type, content_json, timestamp, state_delta_json, seq_id, invocation_id, metadata_json
- ) VALUES (
- 'evt-1', 'sess-1', 'user', 'user_message', '{\"role\": \"user\", \"parts\": [{\"text\": \"hello\"}]}',
- 1, '{}', 1, NULL, '{}'
- );
- INSERT INTO states (
- scope, agent_id, user_id, session_id, state_json, version, updated_at
- ) VALUES (
- 'session', 'demo-agent', 'user', 'sess-1', '{\"topic\": \"billing\"}', 1, 2
- );
- """
- )
- connection.commit()
- connection.close()
-
- service = LocalSessionService(db_path=db_path)
- session = await service.get_session("sess-1")
-
- assert session is not None
- assert session.state == {"topic": "billing"}
- assert [event.id for event in session.events] == ["evt-1"]
-
- tables = {
- row[0]
- for row in sqlite3.connect(db_path).execute(
- "SELECT name FROM sqlite_master WHERE type='table'"
- ).fetchall()
- }
- assert "ksadk_sessions" in tables
- assert "ksadk_events" in tables
- assert "ksadk_states" in tables
-
-
-@pytest.mark.asyncio
-async def test_get_session_action_exposes_continuity_metadata(monkeypatch, tmp_path):
- server_app_module = __import__("ksadk.server.app", fromlist=["app"])
- service = LocalSessionService(db_path=tmp_path / "sessions.sqlite")
- await service.create_session("demo-agent", "user", session_id="sess-1")
- await service.update_session_metadata(
- "sess-1",
- title="hello",
- title_source="heuristic",
- summary="assistant says hi",
- first_prompt="hello",
- last_prompt="hello",
- )
- runner = _ContinuityRunner()
-
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/GetSession",
- json={"SessionId": "sess-1"},
- )
-
- assert response.status_code == 200
- continuity = response.json()["Data"]["Session"]["Continuity"]
- assert continuity["Level"] == "semantic"
- assert continuity["Path"] == "replay"
- assert continuity["Runner"] == "langchain"
-
-
-@pytest.mark.asyncio
-async def test_bootstrap_exposes_session_backend_diagnostics(monkeypatch):
- server_app_module = __import__("ksadk.server.app", fromlist=["app"])
- runner = _ContinuityRunner()
- server_app_module.set_runner(runner)
- monkeypatch.setattr(
- server_app_module,
- "describe_session_backend",
- lambda: {
- "Backend": "postgres",
- "Shared": True,
- "ProductionSafe": True,
- "ContinuityDefault": "semantic/replay",
- },
- )
-
- transport = httpx.ASGITransport(app=server_app_module.app)
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/GetAgentUiBootstrap",
- json={"AgentId": "demo-agent"},
- )
-
- assert response.status_code == 200
- session_backend = response.json()["Data"]["SessionBackend"]
- assert session_backend["Backend"] == "postgres"
- assert session_backend["Shared"] is True
- assert session_backend["ProductionSafe"] is True
- assert session_backend["ContinuityDefault"] == "semantic/replay"
- assert "Dsn" not in session_backend
diff --git a/tests/test_session_title.py b/tests/test_session_title.py
deleted file mode 100644
index bfff353d..00000000
--- a/tests/test_session_title.py
+++ /dev/null
@@ -1,78 +0,0 @@
-from __future__ import annotations
-
-import pytest
-
-from ksadk.conversations.session_title import (
- SessionTitleClient,
- build_heuristic_title,
- build_session_title_messages,
-)
-
-
-@pytest.mark.asyncio
-async def test_session_title_client_disables_thinking_for_fast_title_generation(monkeypatch):
- captured_payload: dict = {}
-
- class _Response:
- def raise_for_status(self) -> None:
- return None
-
- def json(self) -> dict:
- return {
- "choices": [{"message": {"content": "能力介绍"}}],
- "usage": {"total_tokens": 8},
- }
-
- class _AsyncClient:
- def __init__(self, *, timeout):
- self.timeout = timeout
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
- async def post(self, url, *, headers, json):
- captured_payload.update(json)
- return _Response()
-
- monkeypatch.setattr("ksadk.conversations.session_title.httpx.AsyncClient", _AsyncClient)
-
- client = SessionTitleClient(api_base="https://models.example/v1", api_key="sk-test")
- title, usage = await client.generate_title(
- model="glm-5.1",
- messages=[{"role": "user", "content": "你好"}],
- timeout_ms=1000,
- )
-
- assert title == "能力介绍"
- assert usage == {"total_tokens": 8}
- assert captured_payload["stream"] is False
- assert captured_payload["temperature"] == 0
- assert "reasoning_effort" not in captured_payload
- assert captured_payload["extra_body"]["max_reasoning_tokens"] == 0
- assert "thinking" not in captured_payload["extra_body"]
-
-
-def test_session_title_helpers_strip_inline_think_markup():
- title = build_heuristic_title(
- first_prompt="你好,请介绍一下你自己",
- assistant_text="先判断身份。我是招聘助手,可以筛选简历。",
- )
- messages = build_session_title_messages(
- first_prompt="你好,请介绍一下你自己",
- assistant_text="先判断身份。我是招聘助手,可以筛选简历。",
- )
-
- assert title == "招聘助手能力"
- assert " ADKRunner:
- detection = SimpleNamespace(entry_point="agent.py", agent_variable="root_agent")
- return ADKRunner(detection, "/tmp/test-project")
-
-
-def test_platform_session_service_prefers_ksadk_stm_path(monkeypatch, tmp_path):
- target = tmp_path / "shared-sessions.sqlite"
- monkeypatch.delenv("AGENTENGINE_SESSION_BACKEND", raising=False)
- monkeypatch.delenv("AGENTENGINE_UI_DIR", raising=False)
- monkeypatch.setenv("KSADK_STM_BACKEND", "sqlite")
- monkeypatch.setenv("KSADK_STM_PATH", str(target))
-
- service = create_session_service()
-
- assert isinstance(service, LocalSessionService)
- assert service.db_path == target.resolve()
-
-
-def test_platform_session_service_supports_memory_backend(monkeypatch):
- monkeypatch.setenv("KSADK_SESSION_BACKEND", "memory")
-
- service = create_session_service()
-
- assert isinstance(service, InMemorySessionService)
-
-
-def test_platform_session_service_treats_local_as_sqlite(monkeypatch, tmp_path):
- target = tmp_path / "sessions.sqlite"
- monkeypatch.delenv("AGENTENGINE_SESSION_BACKEND", raising=False)
- monkeypatch.setenv("KSADK_SESSION_BACKEND", "local")
- monkeypatch.setenv("KSADK_SESSION_PATH", str(target))
-
- service = create_session_service()
-
- assert isinstance(service, LocalSessionService)
- assert service.db_path == target.resolve()
-
-
-def test_platform_session_service_accepts_sqlite_alias(monkeypatch, tmp_path):
- target = tmp_path / "sessions.sqlite"
- monkeypatch.delenv("AGENTENGINE_SESSION_BACKEND", raising=False)
- monkeypatch.setenv("KSADK_SESSION_BACKEND", "sqlite")
- monkeypatch.setenv("KSADK_SESSION_PATH", str(target))
-
- service = create_session_service()
-
- assert isinstance(service, LocalSessionService)
- assert service.db_path == target.resolve()
-
-
-def test_platform_session_service_requires_postgres_dsn(monkeypatch):
- monkeypatch.setenv("KSADK_SESSION_BACKEND", "postgres")
- monkeypatch.delenv("KSADK_SESSION_DSN", raising=False)
- monkeypatch.delenv("KSADK_STM_URL", raising=False)
- monkeypatch.delenv("KSADK_STM_DB_URL", raising=False)
-
- with pytest.raises(ValueError, match="KSADK_SESSION_DSN"):
- create_session_service()
-
-
-def test_describe_session_backend_marks_postgres_as_shared(monkeypatch):
- dsn = "".join(
- [
- "postgresql://",
- "user",
- ":",
- "pass",
- "@",
- "example.invalid:5432/example_db",
- ]
- )
- monkeypatch.setenv("KSADK_SESSION_BACKEND", "postgres")
- monkeypatch.setenv("KSADK_SESSION_DSN", dsn)
-
- payload = describe_session_backend()
-
- assert payload["Backend"] == "postgres"
- assert payload["Shared"] is True
- assert payload["ProductionSafe"] is True
- assert payload["ContinuityDefault"] == "semantic/replay"
- assert "Dsn" not in payload
- assert "Namespace" not in payload
-
-
-def test_describe_session_backend_marks_local_as_not_shared(monkeypatch, tmp_path):
- monkeypatch.setenv("KSADK_SESSION_BACKEND", "local")
- monkeypatch.setenv("KSADK_SESSION_PATH", str(tmp_path / "sessions.sqlite"))
-
- payload = describe_session_backend()
-
- assert payload["Backend"] == "local"
- assert payload["Shared"] is False
- assert payload["ProductionSafe"] is False
- assert payload["ContinuityDefault"] == "local_only"
-
-
-def test_platform_session_service_keeps_legacy_stm_db_path_alias(monkeypatch, tmp_path):
- target = tmp_path / "legacy-sessions.sqlite"
- monkeypatch.delenv("AGENTENGINE_SESSION_BACKEND", raising=False)
- monkeypatch.delenv("AGENTENGINE_UI_DIR", raising=False)
- monkeypatch.setenv("KSADK_STM_BACKEND", "sqlite")
- monkeypatch.delenv("KSADK_STM_PATH", raising=False)
- monkeypatch.setenv("KSADK_STM_DB_PATH", str(target))
-
- service = create_session_service()
-
- assert isinstance(service, LocalSessionService)
- assert service.db_path == target.resolve()
-
-
-def test_short_term_memory_from_env_prefers_stm_path_alias(monkeypatch):
- from ksadk.memory.adk.short_term_memory import ShortTermMemory
-
- monkeypatch.setenv("KSADK_STM_BACKEND", "sqlite")
- monkeypatch.setenv("KSADK_STM_PATH", "/tmp/shared-sessions.sqlite")
- monkeypatch.delenv("KSADK_STM_DB_PATH", raising=False)
- monkeypatch.delenv("KSADK_ADK_SESSION_PATH", raising=False)
-
- stm = ShortTermMemory.from_env()
-
- assert stm.backend == "sqlite"
- assert stm.local_database_path == "/tmp/shared-sessions.sqlite"
-
-
-def test_short_term_memory_from_env_prefers_adk_session_override(monkeypatch):
- from ksadk.memory.adk.short_term_memory import ShortTermMemory
-
- monkeypatch.setenv("KSADK_STM_BACKEND", "sqlite")
- monkeypatch.setenv("KSADK_STM_PATH", "/tmp/shared-sessions.sqlite")
- monkeypatch.setenv("KSADK_ADK_SESSION_PATH", "/tmp/adk-private.sqlite")
-
- stm = ShortTermMemory.from_env()
-
- assert stm.local_database_path == "/tmp/adk-private.sqlite"
-
-
-def test_short_term_memory_from_env_falls_back_to_unified_session_dsn(monkeypatch):
- from ksadk.memory.adk.short_term_memory import ShortTermMemory
-
- dsn = "postgresql+asyncpg://user:pass@example.invalid:5432/session_db"
- monkeypatch.delenv("KSADK_ADK_SESSION_BACKEND", raising=False)
- monkeypatch.delenv("KSADK_ADK_SESSION_URL", raising=False)
- monkeypatch.delenv("KSADK_STM_BACKEND", raising=False)
- monkeypatch.delenv("KSADK_STM_URL", raising=False)
- monkeypatch.delenv("KSADK_STM_DB_URL", raising=False)
- monkeypatch.setenv("KSADK_SESSION_BACKEND", "postgres")
- monkeypatch.setenv("KSADK_SESSION_DSN", dsn)
-
- stm = ShortTermMemory.from_env()
-
- assert stm.backend == "database"
- assert stm.db_url == dsn
-
-
-def test_adk_runner_short_term_memory_initializes_from_unified_session_env(monkeypatch):
- dsn = "postgresql+asyncpg://user:pass@example.invalid:5432/session_db"
- monkeypatch.delenv("KSADK_ADK_SESSION_BACKEND", raising=False)
- monkeypatch.delenv("KSADK_ADK_SESSION_URL", raising=False)
- monkeypatch.delenv("KSADK_STM_BACKEND", raising=False)
- monkeypatch.delenv("KSADK_STM_URL", raising=False)
- monkeypatch.delenv("KSADK_STM_DB_URL", raising=False)
- monkeypatch.setenv("KSADK_SESSION_BACKEND", "postgres")
- monkeypatch.setenv("KSADK_SESSION_DSN", dsn)
- runner = _make_adk_runner()
-
- stm = runner._init_short_term_memory()
-
- assert stm is not None
- assert stm.backend == "database"
- assert stm.db_url == dsn
-
-
-def test_short_term_memory_from_env_requires_dsn_for_unified_postgres(monkeypatch):
- from ksadk.memory.adk.short_term_memory import ShortTermMemory
-
- monkeypatch.delenv("KSADK_ADK_SESSION_BACKEND", raising=False)
- monkeypatch.delenv("KSADK_ADK_SESSION_URL", raising=False)
- monkeypatch.delenv("KSADK_STM_BACKEND", raising=False)
- monkeypatch.delenv("KSADK_STM_URL", raising=False)
- monkeypatch.delenv("KSADK_STM_DB_URL", raising=False)
- monkeypatch.setenv("KSADK_SESSION_BACKEND", "postgres")
- monkeypatch.delenv("KSADK_SESSION_DSN", raising=False)
-
- with pytest.raises(ValueError, match="KSADK_SESSION_DSN"):
- ShortTermMemory.from_env()
-
-
-def test_adk_runner_short_term_memory_uses_framework_specific_override(monkeypatch):
- monkeypatch.setenv("KSADK_STM_BACKEND", "sqlite")
- monkeypatch.setenv("KSADK_STM_PATH", "/tmp/shared-sessions.sqlite")
- monkeypatch.setenv("KSADK_ADK_SESSION_PATH", "/tmp/adk-private.sqlite")
- runner = _make_adk_runner()
-
- stm = runner._init_short_term_memory()
-
- assert stm is not None
- assert stm.local_database_path == "/tmp/adk-private.sqlite"
-
-
-def test_platform_session_service_accepts_registered_backend(monkeypatch):
- def factory(config, project_dir):
- assert config.backend == "custom"
- assert project_dir == "/tmp/custom-project"
- return InMemorySessionService()
-
- register_session_backend("custom", factory)
- monkeypatch.setenv("KSADK_SESSION_BACKEND", "custom")
-
- service = create_session_service(project_dir="/tmp/custom-project")
-
- assert isinstance(service, InMemorySessionService)
diff --git a/tests/test_storage_defaults.py b/tests/test_storage_defaults.py
deleted file mode 100644
index 1bd8cc08..00000000
--- a/tests/test_storage_defaults.py
+++ /dev/null
@@ -1,65 +0,0 @@
-import pytest
-
-from ksadk.cli.storage import (
- DEFAULT_STORAGE_SIZE_GI,
- build_storage_config,
- resolve_default_storage_mount_path,
- validate_storage_mount_path,
- validate_storage_size_gi,
-)
-
-
-def test_resolve_default_storage_mount_path_for_frameworks():
- assert resolve_default_storage_mount_path("adk") == "/home/node/.agentengine"
- assert resolve_default_storage_mount_path("langchain") == "/home/node/.agentengine"
- assert resolve_default_storage_mount_path("langgraph") == "/home/node/.agentengine"
- assert resolve_default_storage_mount_path("deepagents") == "/home/node/.agentengine"
- assert resolve_default_storage_mount_path("hermes") == "/home/node/.hermes"
- assert resolve_default_storage_mount_path("openclaw") == "/home/node/.openclaw"
-
-
-def test_validate_storage_size_gi_enforces_range():
- assert validate_storage_size_gi(None) == DEFAULT_STORAGE_SIZE_GI
- assert validate_storage_size_gi(20) == 20
- assert validate_storage_size_gi(500) == 500
- with pytest.raises(Exception):
- validate_storage_size_gi(19)
- with pytest.raises(Exception):
- validate_storage_size_gi(501)
-
-
-def test_validate_storage_mount_path_requires_absolute_path():
- assert validate_storage_mount_path("/home/node/.hermes/") == "/home/node/.hermes"
- with pytest.raises(Exception):
- validate_storage_mount_path("relative/path")
- with pytest.raises(Exception):
- validate_storage_mount_path("/")
-
-
-def test_build_storage_config_defaults_for_serverless_targets():
- assert build_storage_config("hermes", target="serverless") == {
- "mount_path": "/home/node/.hermes",
- "size_gi": 20,
- }
- assert build_storage_config("openclaw", target="serverless") == {
- "mount_path": "/home/node/.openclaw",
- "size_gi": 20,
- }
- # 非 hermes/openclaw 框架默认不挂盘(产品要求 adk/langgraph 这类默认不传挂盘参数)
- assert build_storage_config("langgraph", target="kce") is None
- assert build_storage_config("adk", target="serverless") is None
- assert build_storage_config("langchain", target="serverless") is None
- assert build_storage_config("deepagents", target="serverless") is None
- # 用户显式指定 --storage-mount-path 时,任意框架都挂
- assert build_storage_config("langgraph", target="serverless", mount_path="/data") == {
- "mount_path": "/data",
- "size_gi": 20,
- }
- assert build_storage_config("adk", target="kce", mount_path="/home/node/.agentengine") == {
- "mount_path": "/home/node/.agentengine",
- "size_gi": 20,
- }
- # 仅显式 size、未指定 mount_path 时,非默认框架仍不挂(mount_path 才是挂盘意图的判据)
- assert build_storage_config("langgraph", target="serverless", size_gi=50) is None
- assert build_storage_config("langgraph", target="serverless", no_storage=True) is None
- assert build_storage_config("langgraph", target="docker") is None
diff --git a/tests/test_tool_gateway.py b/tests/test_tool_gateway.py
deleted file mode 100644
index 1ac982ec..00000000
--- a/tests/test_tool_gateway.py
+++ /dev/null
@@ -1,119 +0,0 @@
-from __future__ import annotations
-
-from ksadk.tools.gateway import (
- ToolGateway,
- ToolPolicy,
- approval_interrupt_info_from_result,
- build_tool_receipt_idempotency_key,
- check_command_policy,
- default_tool_gateway,
- tool_policy_requires_approval,
-)
-
-
-def test_tool_gateway_imports_public_api():
- gateway = default_tool_gateway({"delete_file": ToolPolicy(risk_level="high")})
-
- assert isinstance(gateway, ToolGateway)
-
-
-def test_tool_policy_requires_approval_only_in_strict_mode():
- policy = ToolPolicy(risk_level="high")
-
- assert tool_policy_requires_approval(policy, approval_mode="off") is False
- assert tool_policy_requires_approval(policy, approval_mode="permissive") is False
- assert tool_policy_requires_approval(policy, approval_mode="strict") is True
-
-
-def test_tool_gateway_returns_approval_request_in_strict_mode(monkeypatch):
- monkeypatch.setenv("KSADK_TOOL_APPROVAL_MODE", "strict")
- gateway = ToolGateway({"write_file": ToolPolicy(risk_level="medium", side_effects=("workspace_write",))})
-
- result = gateway.invoke("write_file", lambda: {"ok": True})
-
- assert result["type"] == "approval_required"
- assert result["approval_required"] is True
- assert result["approval_request"]["tool_name"] == "write_file"
- assert result["approval_request"]["risk_level"] == "medium"
- assert result["approval_request"]["side_effects"] == ["workspace_write"]
-
-
-def test_tool_gateway_runs_approved_call_in_strict_mode(monkeypatch):
- monkeypatch.setenv("KSADK_TOOL_APPROVAL_MODE", "strict")
- gateway = ToolGateway({"write_file": ToolPolicy(risk_level="medium")})
-
- assert gateway.invoke("write_file", lambda value: {"ok": True, "value": value}, 3, approval={"approved": True}) == {
- "ok": True,
- "value": 3,
- }
-
-
-def test_tool_receipt_idempotency_key_is_stable_for_argument_order():
- left = build_tool_receipt_idempotency_key(
- session_id="sess-1",
- run_id="run-1",
- checkpoint_id="ckpt-1",
- tool_call_id="call-1",
- tool_name="write_workspace_file",
- tool_args={"content": "hello", "path": "notes.txt"},
- )
- right = build_tool_receipt_idempotency_key(
- session_id="sess-1",
- run_id="run-1",
- checkpoint_id="ckpt-1",
- tool_call_id="call-1",
- tool_name="write_workspace_file",
- tool_args={"path": "notes.txt", "content": "hello"},
- )
-
- assert left == right
- assert left.startswith("tool_receipt:")
-
-
-def test_approval_interrupt_info_from_result_normalizes_payload():
- result = {
- "type": "approval_required",
- "approval_request": {
- "id": "appr_123",
- "tool_name": "write_file",
- "tool_args": {"path": "demo.txt"},
- "risk_level": "medium",
- "side_effects": ["workspace_write"],
- },
- }
-
- interrupt = approval_interrupt_info_from_result(result, fallback_tool_name="fallback", run_id="run_1")
-
- assert interrupt == {
- "id": "appr_123",
- "approval_request_id": "appr_123",
- "tool_name": "write_file",
- "arguments": {"path": "demo.txt"},
- "risk_level": "medium",
- "side_effects": ["workspace_write"],
- "server_label": "ksadk",
- "run_id": "run_1",
- }
-
-
-def test_check_command_policy_allows_read_only_git_commands():
- result = check_command_policy("git diff -- ksadk/toolsets/workspace.py")
-
- assert result["ok"] is True
- assert result["decision"] == "allow"
-
-
-def test_check_command_policy_rejects_dangerous_commands():
- result = check_command_policy("git reset --hard HEAD")
-
- assert result["ok"] is False
- assert result["decision"] == "reject"
- assert result["error_type"] == "command_rejected"
-
-
-def test_check_command_policy_rejects_recursive_rm_without_force():
- result = check_command_policy("rm -r workspace")
-
- assert result["ok"] is False
- assert result["decision"] == "reject"
- assert result["error_type"] == "command_rejected"
diff --git a/tests/test_tool_result_budget.py b/tests/test_tool_result_budget.py
deleted file mode 100644
index 75b3ab00..00000000
--- a/tests/test_tool_result_budget.py
+++ /dev/null
@@ -1,92 +0,0 @@
-from __future__ import annotations
-
-import json
-
-from ksadk.conversations.context import project_model_messages
-from ksadk.sessions import SessionEvent
-from ksadk.tools.result_budget import ToolResultBudget, budget_tool_output
-
-
-def test_budget_tool_output_persists_large_text(tmp_path):
- budget = ToolResultBudget(
- max_chars=20,
- preview_chars=8,
- persist_threshold_chars=12,
- persist_dir=tmp_path,
- )
-
- result = budget_tool_output(
- tool_name="run_command",
- field_name="stdout",
- value="abcdefghijklmnopqrstuvwxyz",
- metadata={"tool_use_id": "call_123"},
- budget=budget,
- )
-
- assert result["stdout"] == "abcdefgh"
- assert result["truncated"] is True
- assert result["original_chars"] == 26
- assert result["preview_chars"] == 8
- assert result["persisted"]["mime_type"] == "text/plain"
- persisted_path = tmp_path / "call_123.stdout.txt"
- assert result["persisted"]["path"] == str(persisted_path)
- assert persisted_path.read_text(encoding="utf-8") == "abcdefghijklmnopqrstuvwxyz"
-
-
-def test_budget_tool_output_serializes_json_values(tmp_path):
- budget = ToolResultBudget(
- max_chars=10,
- preview_chars=5,
- persist_threshold_chars=8,
- persist_dir=tmp_path,
- )
-
- result = budget_tool_output(
- tool_name="web_search",
- field_name="results",
- value={"items": ["alpha", "beta"]},
- metadata={"tool_use_id": "search_1"},
- budget=budget,
- )
-
- assert result["results"].startswith("{")
- assert result["persisted"]["mime_type"] == "application/json"
- persisted = json.loads((tmp_path / "search_1.results.json").read_text(encoding="utf-8"))
- assert persisted == {"items": ["alpha", "beta"]}
-
-
-def test_project_model_messages_projects_persisted_tool_result_preview():
- events = [
- SessionEvent(
- id="evt-1",
- session_id="sess-1",
- author="agent",
- event_type="tool_result",
- content={
- "role": "tool",
- "parts": [
- {
- "text": {
- "stdout": "short preview",
- "truncated": True,
- "persisted": {
- "path": "sessions/sess-1/tool-results/call.stdout.txt",
- "mime_type": "text/plain",
- },
- }
- }
- ],
- },
- timestamp="2026-07-02T00:00:00Z",
- seq_id=1,
- )
- ]
-
- projected = project_model_messages(events)
-
- assert projected == [
- {
- "role": "user",
- "content": "[tool_result] short preview\n[persisted-output] sessions/sess-1/tool-results/call.stdout.txt (text/plain)",
- }
- ]
diff --git a/tests/test_tracing_cloud_monitor_e2e.py b/tests/test_tracing_cloud_monitor_e2e.py
deleted file mode 100644
index f9e517bf..00000000
--- a/tests/test_tracing_cloud_monitor_e2e.py
+++ /dev/null
@@ -1,116 +0,0 @@
-import json
-import os
-import subprocess
-import sys
-import textwrap
-from pathlib import Path
-
-
-def test_cloud_monitor_otlp_local_http_e2e():
- repo_root = Path(__file__).resolve().parents[1]
- script = r"""
-import json
-import logging
-import os
-import threading
-import time
-from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
-
-for key in list(os.environ):
- if key.startswith("OTEL_EXPORTER_OTLP") or key.startswith("LANGFUSE_"):
- os.environ.pop(key, None)
-
-received = []
-
-
-class Handler(BaseHTTPRequestHandler):
- def do_POST(self):
- length = int(self.headers.get("Content-Length", "0") or "0")
- body = self.rfile.read(length)
- received.append(
- {
- "path": self.path,
- "app_key": self.headers.get("Ksc-Appkey"),
- "content_type": self.headers.get("Content-Type"),
- "body_len": len(body),
- }
- )
- self.send_response(200)
- self.end_headers()
-
- def log_message(self, *_args):
- return
-
-
-server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
-thread = threading.Thread(target=server.serve_forever, daemon=True)
-thread.start()
-
-os.environ["OTEL_SERVICE_NAME"] = "ar-cloudmonitor-e2e"
-os.environ["CLOUD_MONITOR_APP_KEY"] = "app-key-e2e"
-os.environ["CLOUD_MONITOR_OTLP_ENDPOINT"] = f"http://127.0.0.1:{server.server_port}"
-
-logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s:%(message)s")
-
-from ksadk.tracing import setup_tracing
-from opentelemetry import trace
-
-setup_tracing(enable_inmemory=False, enable_langfuse=False, enable_adk_instrumentation=False)
-tracer = trace.get_tracer("ksadk-cloudmonitor-e2e")
-
-with tracer.start_as_current_span("cloudmonitor-e2e-span") as span:
- span.set_attribute("ksadk.e2e", True)
- span.set_attribute("ksadk.agent_id", "ar-cloudmonitor-e2e")
-
-provider = trace.get_tracer_provider()
-flush_ok = provider.force_flush(timeout_millis=5000) if hasattr(provider, "force_flush") else True
-if hasattr(provider, "shutdown"):
- provider.shutdown()
-
-deadline = time.time() + 5
-while not received and time.time() < deadline:
- time.sleep(0.05)
-
-server.shutdown()
-server.server_close()
-
-print(
- json.dumps(
- {
- "flush_ok": bool(flush_ok),
- "received": len(received),
- "first": received[0] if received else None,
- },
- sort_keys=True,
- )
-)
-"""
- env = os.environ.copy()
- for key in list(env):
- if key.startswith("OTEL_EXPORTER_OTLP") or key.startswith("LANGFUSE_"):
- env.pop(key, None)
- env["PYTHONPATH"] = f"{repo_root}{os.pathsep}{env.get('PYTHONPATH', '')}".rstrip(os.pathsep)
- env["NO_PROXY"] = "127.0.0.1,localhost"
- env["no_proxy"] = "127.0.0.1,localhost"
-
- completed = subprocess.run(
- [sys.executable, "-c", textwrap.dedent(script)],
- cwd=repo_root,
- env=env,
- capture_output=True,
- text=True,
- timeout=30,
- )
-
- assert completed.returncode == 0, completed.stderr
- payload = json.loads(completed.stdout)
- assert payload["flush_ok"] is True
- assert payload["received"] == 1
- assert payload["first"]["path"] == "/v1/traces"
- assert payload["first"]["app_key"] == "app-key-e2e"
- assert payload["first"]["content_type"] == "application/x-protobuf"
- assert payload["first"]["body_len"] > 0
- assert "CloudMonitor OTLP config resolved" in completed.stderr
- assert "CloudMonitor OTLP exporter enabled" in completed.stderr
- assert "CloudMonitor OTLP export started" in completed.stderr
- assert "CloudMonitor OTLP export result" in completed.stderr
diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py
deleted file mode 100644
index f75761cd..00000000
--- a/tests/test_tui_app.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from ksadk.tui.app import AgentTUI
-
-
-class _DummyRunner:
- session_id = "sess-demo"
-
-
-def test_agent_tui_prefers_runner_session_id():
- app = AgentTUI(runner=_DummyRunner(), project_dir=".")
-
- assert app.session_id == "sess-demo"
diff --git a/tests/test_tui_clipboard.py b/tests/test_tui_clipboard.py
deleted file mode 100644
index 329cbf11..00000000
--- a/tests/test_tui_clipboard.py
+++ /dev/null
@@ -1,17 +0,0 @@
-import sys
-from types import SimpleNamespace
-
-from ksadk.tui import clipboard
-
-
-def test_clipboard_copy_methods_skip_osc52_on_windows(monkeypatch):
- fake_pyperclip = SimpleNamespace(copy=lambda _text: None)
- app = SimpleNamespace(copy_to_clipboard=lambda _text: None)
-
- monkeypatch.setattr(clipboard.os, "name", "nt", raising=False)
- monkeypatch.setitem(sys.modules, "pyperclip", fake_pyperclip)
-
- methods = clipboard._clipboard_copy_methods(app)
-
- assert clipboard._copy_osc52 not in methods
- assert methods == [fake_pyperclip.copy, app.copy_to_clipboard]
diff --git a/tests/test_ui_config_resolution.py b/tests/test_ui_config_resolution.py
deleted file mode 100644
index db56ff8f..00000000
--- a/tests/test_ui_config_resolution.py
+++ /dev/null
@@ -1,119 +0,0 @@
-from ksadk.deployment.ui_config import is_same_origin, resolve_ui_config
-
-
-def test_langgraph_defaults_to_chat_ui_path():
- cfg = resolve_ui_config(
- framework="langgraph",
- state={},
- cli_profile=None,
- cli_path=None,
- cli_url=None,
- )
-
- assert cfg.profile == "langchain"
- assert cfg.path == "/chat"
- assert cfg.url is None
-
-
-def test_hermes_defaults_to_chat_ui_path():
- cfg = resolve_ui_config(
- framework="hermes",
- state={},
- cli_profile=None,
- cli_path=None,
- cli_url=None,
- )
-
- assert cfg.profile == "hermes"
- assert cfg.path == "/chat"
- assert cfg.url is None
-
-
-def test_state_ui_config_applies_when_cli_not_set():
- cfg = resolve_ui_config(
- framework="adk",
- state={
- "ui_profile": "custom",
- "ui_path": "/dashboard",
- "ui_url": "https://ui.example.com/dashboard",
- },
- cli_profile=None,
- cli_path=None,
- cli_url=None,
- )
-
- assert cfg.profile == "custom"
- assert cfg.path == "/dashboard"
- assert cfg.url == "https://ui.example.com/dashboard"
-
-
-def test_cli_overrides_state_and_can_clear_ui_url():
- cfg = resolve_ui_config(
- framework="langchain",
- state={
- "ui_profile": "custom",
- "ui_path": "/custom",
- "ui_url": "https://ui.example.com/custom",
- },
- cli_profile="langchain",
- cli_path="/",
- cli_url="",
- )
-
- assert cfg.profile == "langchain"
- assert cfg.path == "/"
- assert cfg.url is None
-
-
-def test_legacy_langchain_state_path_is_migrated_to_chat():
- cfg = resolve_ui_config(
- framework="langgraph",
- state={
- "ui_profile": "langchain",
- "ui_path": "/langchain",
- },
- cli_profile=None,
- cli_path=None,
- cli_url=None,
- )
-
- assert cfg.profile == "langchain"
- assert cfg.path == "/chat"
-
-
-def test_legacy_root_state_path_is_migrated_to_chat_for_managed_profiles():
- cfg = resolve_ui_config(
- framework="langgraph",
- state={
- "ui_profile": "langchain",
- "ui_path": "/",
- },
- cli_profile=None,
- cli_path=None,
- cli_url=None,
- )
-
- assert cfg.profile == "langchain"
- assert cfg.path == "/chat"
-
-
-def test_custom_ui_profile_keeps_root_path_by_default():
- cfg = resolve_ui_config(
- framework="langgraph",
- state={
- "ui_profile": "custom",
- },
- cli_profile=None,
- cli_path=None,
- cli_url=None,
- )
-
- assert cfg.profile == "custom"
- assert cfg.path == "/"
- assert cfg.url is None
-
-
-def test_same_origin_requires_scheme_and_netloc_match():
- assert is_same_origin("https://a.example.com/path", "https://a.example.com/")
- assert not is_same_origin("https://a.example.com/path", "http://a.example.com/")
- assert not is_same_origin("https://a.example.com/path", "https://b.example.com/")
diff --git a/tests/test_unified_agent_ui_local.py b/tests/test_unified_agent_ui_local.py
deleted file mode 100644
index d2c79c6d..00000000
--- a/tests/test_unified_agent_ui_local.py
+++ /dev/null
@@ -1,2254 +0,0 @@
-from __future__ import annotations
-
-import base64
-import importlib
-import json
-import os
-import re
-import subprocess
-import sys
-from pathlib import Path
-from types import SimpleNamespace
-
-import httpx
-import pytest
-from fastapi.testclient import TestClient
-from click.testing import CliRunner
-from opentelemetry import trace
-from opentelemetry.sdk.trace import TracerProvider
-
-from ksadk.runners.base_runner import BaseRunner
-from ksadk.sessions.base import SessionEvent
-from ksadk.sessions.in_memory import InMemorySessionService
-
-
-class _UiRunner(BaseRunner):
- def __init__(self):
- super().__init__(
- detection_result=SimpleNamespace(
- name="demo-agent",
- description="demo agent",
- type=SimpleNamespace(value="langgraph"),
- ),
- project_dir=".",
- )
- self.invocations: list[dict] = []
- self.run_server_calls: list[int] = []
- self.load_agent_calls = 0
-
- def load_agent(self) -> None:
- self.load_agent_calls += 1
- return None
-
- async def invoke(self, input_data: dict) -> dict:
- self.invocations.append(input_data)
- return {"output": "assistant says hi"}
-
- async def stream(self, input_data: dict):
- self.invocations.append(input_data)
- yield {"type": "tool_call", "tool_name": "resume_lookup", "tool_args": {"keyword": "jd"}}
- yield {"type": "tool_result", "tool_name": "resume_lookup", "tool_output": '{"score": 91}'}
- yield {"type": "thinking", "delta": "plan"}
- yield {"type": "text", "delta": "hello"}
- yield {
- "type": "responses_output",
- "response_id": "resp_demo",
- "output": [
- {
- "id": "fc_demo",
- "type": "function_call",
- "name": "resume_lookup",
- "arguments": '{"keyword":"jd"}',
- }
- ],
- }
- yield {"type": "final", "output": "hello world"}
-
- def run_server(self, port: int = 8000) -> None:
- self.run_server_calls.append(port)
-
-
-class _BrokenLoadRunner(_UiRunner):
- def load_agent(self) -> None:
- self.load_agent_calls += 1
- raise RuntimeError("runner load failed")
-
-
-class _InterruptRunner(_UiRunner):
- async def stream(self, input_data: dict):
- self.invocations.append(input_data)
- yield {"type": "text", "delta": "need "}
- yield {
- "type": "interrupt",
- "interrupt_info": {"message": "确认执行?", "tool_name": "delete_file"},
- }
-
-
-class _GenericInterruptRunner(_UiRunner):
- async def stream(self, input_data: dict):
- self.invocations.append(input_data)
- yield {
- "type": "interrupt",
- "interrupt_info": {"message": "需要人工确认"},
- }
-
-
-class _FrameworkUiRunner(_UiRunner):
- def __init__(self, framework: str):
- super().__init__()
- self.detection_result.type = SimpleNamespace(value=framework)
-
-
-class _KeyboardInterruptServerRunner(_UiRunner):
- def run_server(self, port: int = 8000) -> None:
- self.run_server_calls.append(port)
- raise KeyboardInterrupt
-
-
-@pytest.fixture(autouse=True)
-def _block_real_browser_open(monkeypatch):
- import ksadk.cli.cmd_web as cmd_web_module
-
- monkeypatch.setattr(cmd_web_module.webbrowser, "open", lambda _url: None)
-
-
-def _build_transport(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _UiRunner()
- monkeypatch.delenv("KSADK_UI_PROFILE", raising=False)
- monkeypatch.delenv("KSADK_UI_PATH", raising=False)
- monkeypatch.delenv("KSADK_UI_URL", raising=False)
- monkeypatch.delenv("KSADK_UI_BUNDLE_PATH", raising=False)
- monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
- monkeypatch.delenv("OPENAI_API_BASE", raising=False)
- monkeypatch.delenv("OPENAI_API_KEY", raising=False)
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
- transport = httpx.ASGITransport(app=server_app_module.app)
- return server_app_module, runner, service, transport
-
-
-def _build_transport_with_runner(monkeypatch, runner):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
- transport = httpx.ASGITransport(app=server_app_module.app)
- return server_app_module, runner, service, transport
-
-
-@pytest.fixture
-def active_trace_provider():
- provider = TracerProvider()
- trace._TRACER_PROVIDER = None
- trace._TRACER_PROVIDER_SET_ONCE._done = False
- trace._set_tracer_provider(provider, log=False)
- yield
- trace._TRACER_PROVIDER = None
- trace._TRACER_PROVIDER_SET_ONCE._done = False
-
-
-@pytest.mark.asyncio
-async def test_get_agent_ui_bootstrap_matches_local_shape_parity(monkeypatch):
- monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1")
- monkeypatch.delenv("KSADK_TOOL_APPROVAL_MODE", raising=False)
- monkeypatch.delenv("KSADK_SANDBOX_BACKEND", raising=False)
- monkeypatch.delenv("KSADK_SANDBOX_TEMPLATE_ID", raising=False)
- monkeypatch.delenv("KSADK_SKILL_RUNTIME_BACKEND", raising=False)
- monkeypatch.delenv("KSADK_SKILL_RUNTIME_TEMPLATE_ID", raising=False)
- _, runner, _, transport = _build_transport(monkeypatch)
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/GetAgentUiBootstrap",
- json={"AgentId": "demo-agent", "SessionId": "sess-bootstrap"},
- )
-
- assert response.status_code == 200
- payload = response.json()
- assert payload["Code"] == 0
- assert set(payload["Data"].keys()) == {
- "Agent",
- "Modules",
- "Capabilities",
- "WorkspaceFiles",
- "AccessMode",
- "SharePermissions",
- "ApiFormats",
- "Stream",
- "SessionId",
- "SessionBackend",
- "HostedRuntime",
- "Model",
- "CustomUI",
- }
- assert payload["Data"]["Agent"]["AgentId"] == "demo-agent"
- assert payload["Data"]["Agent"]["Framework"] == "langgraph"
- assert payload["Data"]["Modules"] == ["Chat", "Build", "Deploy"]
-
- capabilities = payload["Data"]["Capabilities"]
- assert capabilities["Attachments"] is True
- assert capabilities["WorkspaceFiles"] is True
- assert capabilities["Thinking"] is True
- assert capabilities["Approval"] is True
- assert capabilities["StopRun"] is True
- assert capabilities["ResumeRun"] is True
- assert capabilities["MCP"] is False
- assert capabilities["HostedRuntime"] is False
- assert capabilities["NativeTerminal"] == {
- "Enabled": False,
- "Mode": None,
- "Protocol": "ks-terminal.v1",
- "Path": None,
- }
- assert capabilities["RunLifecycle"] == {
- "Enabled": True,
- "Resume": True,
- "Abort": True,
- "Checkpoints": True,
- "CheckpointResume": True,
- "CheckpointResumePreview": True,
- }
-
- builtin_tools = {tool["name"]: tool for tool in capabilities["BuiltinTools"]}
- assert set(builtin_tools) >= {
- "list_skills",
- "search_skills",
- "load_skill",
- "execute_skills",
- "workspace_status",
- "list_workspace_files",
- "read_workspace_file",
- "write_workspace_file",
- "write_workspace_files",
- "edit_workspace_file",
- "lint_workspace_file",
- "search_workspace_files",
- "delete_workspace_file",
- "component_status",
- "search_knowledge_base",
- "load_memory",
- "save_memory",
- "sandbox_status",
- "run_command",
- "run_code",
- }
- assert builtin_tools["execute_skills"] | {
- "name": "execute_skills",
- "group": "skill",
- "risk_level": "high",
- "requires_approval": False,
- "enabled": False,
- "backend": "disabled",
- "boundary": "isolated_skill_runtime",
- } == builtin_tools["execute_skills"]
- assert builtin_tools["search_knowledge_base"]["args"]["query"]["type"] == "string"
- assert builtin_tools["load_memory"]["args"]["query"]["type"] == "string"
- assert builtin_tools["save_memory"]["args"]["content"]["type"] == "string"
- assert payload["Data"]["WorkspaceFiles"] == {
- "Enabled": True,
- "MaxUploadBytes": 104857600,
- "SupportsDelete": True,
- "RootLabel": "workspace",
- "EntryAction": "ListWorkspaceFiles",
- "UploadAction": "AddWorkspaceFile",
- "ContentPath": "/agentengine/api/v1/GetWorkspaceFileContent",
- }
- assert payload["Data"]["AccessMode"] == "Owner"
- assert payload["Data"]["SharePermissions"] == {
- "Interactive": True,
- "DefaultPath": "/chat",
- "SharePath": "/chat",
- }
- assert payload["Data"]["ApiFormats"] == ["responses", "chat_completions"]
- assert payload["Data"]["Stream"] is True
- assert payload["Data"]["SessionId"] == "sess-bootstrap"
- assert payload["Data"]["HostedRuntime"] is None
- assert payload["Data"]["Model"]["id"] == "glm-5.1"
- assert payload["Data"]["Model"]["source"] == "OPENAI_MODEL_NAME"
- assert runner.load_agent_calls == 0
-
-
-@pytest.mark.asyncio
-@pytest.mark.parametrize("framework", ["hermes", "openclaw"])
-async def test_get_agent_ui_bootstrap_enables_tui_only_for_native_tui_frameworks(
- monkeypatch,
- framework,
-):
- _, _, _, transport = _build_transport_with_runner(
- monkeypatch,
- _FrameworkUiRunner(framework),
- )
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/GetAgentUiBootstrap",
- json={"AgentId": f"{framework}-agent"},
- )
-
- assert response.status_code == 200
- terminal = response.json()["Data"]["Capabilities"]["NativeTerminal"]
- assert terminal == {
- "Enabled": True,
- "Mode": "tui",
- "Protocol": "ks-terminal.v1",
- "Path": "/_ksadk/terminal/ws",
- }
-
-
-@pytest.mark.asyncio
-async def test_get_agent_ui_bootstrap_disables_tui_for_generic_frameworks(monkeypatch):
- _, _, _, transport = _build_transport_with_runner(
- monkeypatch,
- _FrameworkUiRunner("langgraph"),
- )
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/GetAgentUiBootstrap",
- json={"AgentId": "langgraph-agent"},
- )
-
- assert response.status_code == 200
- terminal = response.json()["Data"]["Capabilities"]["NativeTerminal"]
- assert terminal["Enabled"] is False
- assert terminal["Mode"] is None
- assert terminal["Path"] is None
-
-
-@pytest.mark.asyncio
-async def test_list_agent_models_action_uses_real_current_model_without_gemini_fallback(monkeypatch):
- monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1")
- monkeypatch.delenv("MODEL_NAME", raising=False)
- monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
- monkeypatch.delenv("OPENAI_API_BASE", raising=False)
- _, _, _, transport = _build_transport(monkeypatch)
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ListAgentModels",
- json={"AgentId": "demo-agent"},
- )
-
- assert response.status_code == 200
- payload = response.json()
- assert payload["Data"]["Current"] == "glm-5.1"
- assert payload["Data"]["Source"] == "OPENAI_MODEL_NAME"
- assert [item["id"] for item in payload["Data"]["Models"]] == ["glm-5.1"]
-
-
-@pytest.mark.asyncio
-async def test_list_agent_models_action_matches_hosted_shape(monkeypatch):
- monkeypatch.setenv("OPENAI_MODEL_NAME", "glm-5.1")
- _, _, _, transport = _build_transport(monkeypatch)
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/ListAgentModels",
- json={"AgentId": "demo-agent"},
- )
-
- assert response.status_code == 200
- payload = response.json()
- assert payload["Code"] == 0
- assert payload["Data"]["Current"] == "glm-5.1"
- assert payload["Data"]["Source"] == "OPENAI_MODEL_NAME"
- assert [item["id"] for item in payload["Data"]["Models"]] == ["glm-5.1"]
-
-
-@pytest.mark.asyncio
-async def test_run_agent_action_returns_responses_payload_and_persists_session(monkeypatch):
- _, runner, service, transport = _build_transport(monkeypatch)
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": "demo-agent",
- "Messages": [{"role": "user", "content": "hello"}],
- "ApiFormat": "responses",
- "Stream": False,
- },
- )
-
- assert response.status_code == 200
- payload = response.json()
- assert payload["Code"] == 0
- assert payload["Data"]["object"] == "response"
- assert payload["Data"]["status"] == "completed"
- assert payload["Data"]["output_text"] == "assistant says hi"
-
- session_id = payload["Data"]["session_id"]
- session = await service.get_session(session_id)
- assert session is not None
- events = await service.get_events(session_id)
- assert [event.author for event in events] == ["user", "demo-agent", "demo-agent", "demo-agent"]
- assert [event.event_type for event in events] == [
- "user_message",
- "run_status",
- "assistant_message",
- "run_status",
- ]
- assert runner.invocations[-1]["history"] == [{"role": "user", "content": "hello"}]
- assert runner.load_agent_calls == 1
-
-
-@pytest.mark.asyncio
-async def test_run_agent_action_forwards_model_metadata_to_conversation_runtime(monkeypatch):
- server_app_module, _, _, transport = _build_transport(monkeypatch)
- captured: dict[str, object] = {}
-
- async def _fake_invoke_conversation_once(**kwargs):
- captured.update(kwargs)
- return "sess-model-metadata", {"output_text": "assistant says hi", "model": kwargs.get("model")}
-
- monkeypatch.setattr(server_app_module.conversation, "invoke_conversation_once", _fake_invoke_conversation_once)
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": "demo-agent",
- "Messages": [{"role": "user", "content": "hello"}],
- "ApiFormat": "responses",
- "Stream": False,
- "Model": "glm-5.1",
- "ModelMetadata": {
- "id": "glm-5.1",
- "context_length": "64k",
- "max_completion_tokens": "8k",
- },
- },
- )
-
- assert response.status_code == 200
- assert response.json()["Code"] == 0
- assert captured["model"] == "glm-5.1"
- assert captured["model_metadata"] == {
- "id": "glm-5.1",
- "context_length": "64k",
- "max_completion_tokens": "8k",
- }
-
-
-@pytest.mark.asyncio
-async def test_run_agent_action_streaming_responses_uses_responses_lifecycle(monkeypatch):
- _, runner, service, transport = _build_transport(monkeypatch)
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": "demo-agent",
- "SessionId": "sess-runagent-responses",
- "Messages": [{"role": "user", "content": "hello"}],
- "ApiFormat": "responses",
- "Stream": True,
- "Model": "glm-5.1",
- },
- )
-
- assert response.status_code == 200
- lines = [line for line in response.text.splitlines() if line.startswith("event: ")]
- assert "event: response.created" in lines
- assert "event: response.in_progress" in lines
- assert "event: response.output_item.added" in lines
- assert "event: response.function_call_arguments.delta" in lines
- assert "event: response.ksadk.tool_result" in lines
- assert "event: response.completed" in lines
- assert "event: response.tool_call" not in lines
- assert "event: response.tool_result" not in lines
- assert runner.invocations[-1]["model"] == "glm-5.1"
- assert runner.invocations[-1]["session_id"] == "sess-runagent-responses"
- assert runner.invocations[-1]["responses_conversation"] is True
- assert await service.get_session("sess-runagent-responses") is not None
- stored_events = await service.get_events("sess-runagent-responses")
- assistant_events = [event for event in stored_events if event.event_type == "assistant_message"]
- assert assistant_events[-1].metadata["response_id"] == "resp_demo"
- assert assistant_events[-1].metadata["responses_output"][0]["type"] == "function_call"
-
- current_event = ""
- completed_payload = None
- for line in response.text.splitlines():
- if line.startswith("event: "):
- current_event = line.removeprefix("event: ")
- elif line.startswith("data: ") and current_event == "response.completed":
- completed_payload = json.loads(line.removeprefix("data: "))
- assert completed_payload is not None
- assert completed_payload["model"] == "glm-5.1"
- assert completed_payload["session_id"] == "sess-runagent-responses"
-
-
-@pytest.mark.asyncio
-async def test_run_agent_action_normalizes_structured_text_and_inline_attachment(monkeypatch):
- _, runner, service, transport = _build_transport(monkeypatch)
- attachment_bytes = "候选人简历内容".encode("utf-8")
- attachment_b64 = base64.b64encode(attachment_bytes).decode("ascii")
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": "demo-agent",
- "Messages": [
- {
- "role": "user",
- "content": [
- {"type": "input_text", "text": "请总结附件"},
- {
- "type": "input_file",
- "inlineData": {
- "displayName": "resume.txt",
- "mimeType": "text/plain",
- "data": attachment_b64,
- },
- },
- ],
- }
- ],
- "ApiFormat": "responses",
- "Stream": False,
- },
- )
-
- assert response.status_code == 200
- payload = response.json()
- normalized_input = runner.invocations[-1]["input"]
- assert "请总结附件" in normalized_input
- assert "resume.txt" in normalized_input
- assert "候选人简历内容" in normalized_input
- assert runner.invocations[-1]["attachments"] == [
- {
- "display_name": "resume.txt",
- "mime_type": "text/plain",
- "transport": "inline",
- "data": attachment_b64,
- "is_text": True,
- "size_bytes": len(attachment_bytes),
- }
- ]
- assert runner.invocations[-1]["attachment_results"] == [
- {
- "display_name": "resume.txt",
- "mime_type": "text/plain",
- "transport": "inline",
- "file_uri": "",
- "size_bytes": len(attachment_bytes),
- "kind": "text",
- "status": "ok",
- "warnings": [],
- "extraction_method": "text_decode",
- "text_excerpt": "候选人简历内容",
- "text": "候选人简历内容",
- }
- ]
-
- session_id = payload["Data"]["session_id"]
- events = await service.get_events(session_id)
- assert events[0].content["parts"] == [
- {"type": "input_text", "text": "请总结附件"},
- {
- "type": "input_file",
- "filename": "resume.txt",
- "file_data": attachment_b64,
- },
- ]
- assert events[0].metadata["agent_input"] == normalized_input
- assert events[0].event_type == "user_message"
-
-
-@pytest.mark.asyncio
-async def test_run_agent_action_passes_binary_zip_attachment_to_runner(monkeypatch):
- _, runner, _, transport = _build_transport(monkeypatch)
- archive_bytes = b"PK\x03\x04demo-zip"
- archive_b64 = base64.b64encode(archive_bytes).decode("ascii")
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": "demo-agent",
- "Messages": [
- {
- "role": "user",
- "content": [
- {"type": "input_text", "text": "分析这个压缩包"},
- {
- "type": "input_file",
- "inlineData": {
- "displayName": "bundle.zip",
- "mimeType": "application/zip",
- "data": archive_b64,
- },
- },
- ],
- }
- ],
- "ApiFormat": "responses",
- "Stream": False,
- },
- )
-
- assert response.status_code == 200
- normalized_input = runner.invocations[-1]["input"]
- assert "bundle.zip" in normalized_input
- assert "ZIP 压缩包无法打开" in normalized_input
- assert runner.invocations[-1]["attachments"] == [
- {
- "display_name": "bundle.zip",
- "mime_type": "application/zip",
- "transport": "inline",
- "data": archive_b64,
- "is_text": False,
- "size_bytes": len(archive_bytes),
- }
- ]
- assert runner.invocations[-1]["attachment_results"] == [
- {
- "display_name": "bundle.zip",
- "mime_type": "application/zip",
- "transport": "inline",
- "file_uri": "",
- "size_bytes": len(archive_bytes),
- "kind": "archive",
- "status": "failed",
- "warnings": ["ZIP 压缩包无法打开,请确认文件未损坏后重试。"],
- "extraction_method": "zip_enumeration",
- "text_excerpt": "",
- }
- ]
-
-
-@pytest.mark.asyncio
-async def test_upload_file_action_returns_server_handle_and_stores_file(monkeypatch, tmp_path):
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / ".agentengine" / "ui"))
- _, _, _, transport = _build_transport(monkeypatch)
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/UploadFile",
- files={"file": ("resume.txt", b"hello", "text/plain")},
- )
-
- assert response.status_code == 200
- payload = response.json()
- file_data = payload["Data"]["FileData"]
- assert file_data["fileUri"].startswith("ksadk-upload://")
- assert file_data["displayName"] == "resume.txt"
- assert file_data["mimeType"] == "text/plain"
- assert file_data["sizeBytes"] == 5
-
- file_id = file_data["fileUri"].removeprefix("ksadk-upload://")
- stored_files = [
- path
- for path in (tmp_path / ".agentengine" / "ui" / "files").glob(f"{file_id}*")
- if not path.name.endswith(".meta.json")
- ]
- assert len(stored_files) == 1
- assert stored_files[0].read_bytes() == b"hello"
-
-
-@pytest.mark.asyncio
-async def test_run_agent_action_normalizes_uploaded_file_handle_and_persists_compact_metadata(monkeypatch, tmp_path):
- monkeypatch.setenv("AGENTENGINE_UI_DIR", str(tmp_path / ".agentengine" / "ui"))
- _, runner, service, transport = _build_transport(monkeypatch)
- attachment_bytes = "候选人简历内容".encode("utf-8")
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- upload_response = await client.post(
- "/agentengine/api/v1/UploadFile",
- files={"file": ("resume.txt", attachment_bytes, "text/plain")},
- )
- uploaded = upload_response.json()["Data"]["FileData"]
-
- response = await client.post(
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": "demo-agent",
- "Messages": [
- {
- "role": "user",
- "content": [
- {"type": "input_text", "text": "请总结附件"},
- {
- "type": "input_file",
- "fileData": uploaded,
- },
- ],
- }
- ],
- "ApiFormat": "responses",
- "Stream": False,
- },
- )
-
- assert response.status_code == 200
- normalized_input = runner.invocations[-1]["input"]
- assert "请总结附件" in normalized_input
- assert "resume.txt" in normalized_input
- assert "候选人简历内容" in normalized_input
-
- attachment = runner.invocations[-1]["attachments"][0]
- assert attachment["display_name"] == "resume.txt"
- assert attachment["mime_type"] == "text/plain"
- assert attachment["transport"] == "reference"
- assert attachment["file_uri"] == uploaded["fileUri"]
- assert attachment["size_bytes"] == len(attachment_bytes)
- assert attachment["is_text"] is True
- assert attachment["storage_path"].endswith(".txt")
- assert "data" not in attachment
- assert runner.invocations[-1]["attachment_results"] == [
- {
- "display_name": "resume.txt",
- "mime_type": "text/plain",
- "transport": "reference",
- "file_uri": uploaded["fileUri"],
- "size_bytes": len(attachment_bytes),
- "kind": "text",
- "status": "ok",
- "warnings": [],
- "extraction_method": "text_decode",
- "text_excerpt": "候选人简历内容",
- "text": "候选人简历内容",
- }
- ]
-
- session_id = response.json()["Data"]["session_id"]
- events = await service.get_events(session_id)
- assert events[0].content["parts"] == [
- {"type": "input_text", "text": "请总结附件"},
- {
- "type": "input_file",
- "filename": "resume.txt",
- "file_url": uploaded["fileUri"],
- },
- ]
- assert events[0].metadata["attachments"] == [
- {
- "display_name": "resume.txt",
- "mime_type": "text/plain",
- "transport": "reference",
- "size_bytes": len(attachment_bytes),
- "is_text": True,
- "file_uri": uploaded["fileUri"],
- }
- ]
- assert "storage_path" not in events[0].metadata["attachments"][0]
- assert events[0].event_type == "user_message"
-
-
-@pytest.mark.asyncio
-async def test_run_agent_action_uses_responses_input_for_normal_responses_run(monkeypatch):
- _, runner, _, transport = _build_transport(monkeypatch)
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": "demo-agent",
- "ApiFormat": "responses",
- "Messages": [{"role": "user", "content": "SHOULD_NOT_USE"}],
- "ResponsesInput": [
- {
- "role": "user",
- "content": [{"type": "input_text", "text": "hello from responses input"}],
- }
- ],
- "Stream": False,
- },
- )
-
- assert response.status_code == 200
- assert runner.invocations[-1]["input"] == "hello from responses input"
- assert runner.invocations[-1]["input_content"] == [
- {"type": "input_text", "text": "hello from responses input"}
- ]
- assert runner.invocations[-1]["input_messages"] == [
- {
- "role": "user",
- "content": [{"type": "input_text", "text": "hello from responses input"}],
- }
- ]
-
-
-@pytest.mark.asyncio
-async def test_run_agent_action_long_history_generates_semantic_checkpoint(monkeypatch):
- server_app_module, runner, service, transport = _build_transport(monkeypatch)
- conversation_runtime = importlib.import_module("ksadk.conversations.runtime")
- model_context_module = importlib.import_module("ksadk.conversations.model_context")
-
- session = await service.create_session(
- agent_id="demo-agent",
- user_id="user",
- session_id="sess-ui-semantic",
- )
- for turn_index in range(3):
- invocation_id = f"ui-sem-{turn_index}"
- user_text = f"长历史用户消息 {turn_index} " + ("甲方要求很多 " * 10)
- assistant_text = f"长历史助手回复 {turn_index} " + ("当前已经分析过 " * 10)
- await conversation_runtime.append_conversation_event(
- session_id=session.id,
- author="user",
- role="user",
- text=user_text,
- invocation_id=invocation_id,
- event_type="user_message",
- metadata={"agent_input": user_text},
- session_service_provider=lambda: service,
- )
- await conversation_runtime.append_conversation_event(
- session_id=session.id,
- author="demo-agent",
- role="model",
- text=assistant_text,
- invocation_id=invocation_id,
- event_type="assistant_message",
- session_service_provider=lambda: service,
- )
-
- class _SemanticSummaryClient:
- is_available = True
-
- async def summarize(self, *, model, messages, timeout_ms):
- assert model == "glm-5.1"
- assert timeout_ms > 0
- assert any("当前用户目标" in item["content"] for item in messages)
- return (
- "draft当前用户目标\n- 继续处理默认 UI 长会话\n\n关键约束与偏好\n- 摘要质量优先\n\n已完成进展\n- 已为较早轮次生成 checkpoint\n\n重要决策/代码上下文\n- 仍然保留 append-only transcript\n\n未完成事项\n- 继续回答用户追问\n\n下一步工作位置\n- /agentengine/api/v1/RunAgent",
- {"prompt_tokens": 88, "completion_tokens": 22, "total_tokens": 110},
- )
-
- monkeypatch.setattr(
- "ksadk.conversations.semantic_summary.resolve_summary_model_client",
- lambda: _SemanticSummaryClient(),
- )
- monkeypatch.setattr(conversation_runtime, "AUTOCOMPACT_KEEP_TAIL_GROUPS", 1)
- monkeypatch.setattr(model_context_module, "DEFAULT_CONTEXT_WINDOW_TOKENS", 40)
- monkeypatch.setattr(model_context_module, "DEFAULT_MAX_OUTPUT_TOKENS", 0)
- monkeypatch.setattr(model_context_module, "AUTOCOMPACT_SUMMARY_RESERVE_TOKENS", 0)
- monkeypatch.setattr(model_context_module, "AUTOCOMPACT_BUFFER_TOKENS", 2)
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": "demo-agent",
- "Messages": [{"role": "user", "content": "继续基于之前内容给结论"}],
- "SessionId": session.id,
- "Model": "glm-5.1",
- "ApiFormat": "responses",
- "Stream": False,
- },
- )
- events_response = await client.post(
- "/agentengine/api/v1/ListSessionEvents",
- json={"SessionId": session.id},
- )
-
- assert response.status_code == 200
- assert runner.invocations[-1]["history"][0]["role"] == "model"
- assert "当前用户目标" in runner.invocations[-1]["history"][0]["content"]
- event_items = events_response.json()["Data"]["Events"]
- checkpoint = next(item for item in event_items if item["EventType"] == "context_checkpoint")
- assert checkpoint["Metadata"]["summary_strategy"] == "semantic"
- assert checkpoint["Metadata"]["summary_version"] == "v1"
- assert checkpoint["Metadata"]["summary_model"] == "glm-5.1"
- assert checkpoint["Metadata"]["summary_usage"]["total_tokens"] == 110
- assert "当前用户目标" in checkpoint["Content"]["parts"][0]["text"]
-
-
-@pytest.mark.asyncio
-async def test_session_kop_actions_crud_and_event_listing(monkeypatch):
- _, _, _, transport = _build_transport(monkeypatch)
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- created = await client.post(
- "/agentengine/api/v1/CreateSession",
- json={"AgentId": "demo-agent", "UserId": "user-1"},
- )
- session_id = created.json()["Data"]["Session"]["SessionId"]
-
- await client.post(
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": "demo-agent",
- "Messages": [{"role": "user", "content": "hello"}],
- "SessionId": session_id,
- "ApiFormat": "responses",
- },
- )
-
- listed = await client.post(
- "/agentengine/api/v1/ListSessions",
- json={"AgentId": "demo-agent", "UserId": "user-1"},
- )
- fetched = await client.post(
- "/agentengine/api/v1/GetSession",
- json={"SessionId": session_id},
- )
- events = await client.post(
- "/agentengine/api/v1/ListSessionEvents",
- json={"SessionId": session_id},
- )
- deleted = await client.post(
- "/agentengine/api/v1/DeleteSession",
- json={"SessionId": session_id},
- )
-
- assert created.status_code == 200
- assert listed.status_code == 200
- assert fetched.status_code == 200
- assert events.status_code == 200
- assert deleted.status_code == 200
- created_session = created.json()["Data"]["Session"]
- fetched_session = fetched.json()["Data"]["Session"]
- assert created_session["Title"] == ""
- assert created_session["Summary"] == ""
- assert created_session["FirstPrompt"] == ""
- assert created_session["LastPrompt"] == ""
- assert [item["SessionId"] for item in listed.json()["Data"]["Sessions"]] == [session_id]
- assert fetched_session["SessionId"] == session_id
- assert fetched_session["Title"] == "hello"
- assert fetched_session["TitleSource"] == "fallback_first_prompt"
- assert fetched_session["FirstPrompt"] == "hello"
- assert fetched_session["LastPrompt"] == "hello"
- assert fetched_session["Summary"] == "assistant says hi"
- assert [item["Author"] for item in events.json()["Data"]["Events"]] == [
- "user",
- "demo-agent",
- "demo-agent",
- "demo-agent",
- ]
- assert [item["EventType"] for item in events.json()["Data"]["Events"]] == [
- "user_message",
- "run_status",
- "assistant_message",
- "run_status",
- ]
- assert deleted.json()["Data"]["Deleted"] is True
-
-
-@pytest.mark.asyncio
-async def test_responses_endpoint_streams_thinking_and_text_events(monkeypatch):
- _, runner, service, transport = _build_transport(monkeypatch)
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/v1/responses",
- json={
- "input": [{"role": "user", "content": [{"type": "input_text", "text": "hello"}]}],
- "model": "glm-5.1",
- "session_id": "sess-responses-stream",
- "stream": True,
- },
- )
-
- assert response.status_code == 200
- lines = [line for line in response.text.splitlines() if line.startswith("event: ")]
- assert "event: response.created" in lines
- assert "event: response.in_progress" in lines
- assert "event: response.output_item.added" in lines
- assert "event: response.function_call_arguments.delta" in lines
- assert "event: response.function_call_arguments.done" in lines
- assert "event: response.ksadk.tool_result" in lines
- assert "event: response.reasoning.delta" in lines
- assert "event: response.output_text.delta" in lines
- assert "event: response.output_text.done" in lines
- assert "event: response.completed" in lines
- added_indexes = []
- current_event = ""
- for line in response.text.splitlines():
- if line.startswith("event: "):
- current_event = line.removeprefix("event: ")
- elif line.startswith("data: ") and current_event == "response.output_item.added":
- added_indexes.append(json.loads(line.removeprefix("data: "))["output_index"])
- assert added_indexes == [0, 1, 2]
- assert runner.invocations[-1]["model"] == "glm-5.1"
- assert runner.invocations[-1]["session_id"] == "sess-responses-stream"
- assert await service.get_session("sess-responses-stream") is not None
-
- completed_payloads = []
- current_event = ""
- for line in response.text.splitlines():
- if line.startswith("event: "):
- current_event = line.removeprefix("event: ")
- elif line.startswith("data: ") and current_event == "response.completed":
- completed_payloads.append(json.loads(line.removeprefix("data: ")))
- assert completed_payloads[-1]["model"] == "glm-5.1"
- assert completed_payloads[-1]["session_id"] == "sess-responses-stream"
-
-
-@pytest.mark.asyncio
-async def test_responses_endpoint_passes_full_request_history_to_runner(monkeypatch):
- _, runner, _, transport = _build_transport(monkeypatch)
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/v1/responses",
- json={
- "input": [
- {"role": "user", "content": "写一个python快排的示例"},
- {"role": "assistant", "content": "这是 Python 快速排序示例。"},
- {"role": "user", "content": "用go"},
- ],
- "model": "glm-5.1",
- "session_id": "sess-responses-history",
- "stream": True,
- },
- )
-
- assert response.status_code == 200
- assert runner.invocations[-1]["input"] == "用go"
- assert "responses_conversation" not in runner.invocations[-1]
- assert runner.invocations[-1]["history"] == [
- {"role": "user", "content": "写一个python快排的示例"},
- {"role": "model", "content": "这是 Python 快速排序示例。"},
- {"role": "user", "content": "用go"},
- ]
-
-
-@pytest.mark.asyncio
-async def test_responses_endpoint_non_streaming_supports_instructions_and_metadata(
- monkeypatch,
- active_trace_provider,
-):
- _, runner, service, transport = _build_transport(monkeypatch)
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/v1/responses",
- json={
- "input": "hello",
- "instructions": "只用中文回答",
- "metadata": {"trace_label": "demo"},
- "stream": False,
- },
- )
-
- assert response.status_code == 200
- payload = response.json()
- assert payload["object"] == "response"
- assert payload["status"] == "completed"
- assert payload["metadata"]["trace_label"] == "demo"
- assert payload["metadata"]["trace_id"]
- assert payload["metadata"]["root_span_id"]
- assert payload["output_text"] == "assistant says hi"
- assert payload["session_id"]
- assert runner.invocations[-1]["instructions"] == "只用中文回答"
- assert "responses_conversation" not in runner.invocations[-1]
-
- events = await service.get_events(payload["session_id"])
- user_event = next(event for event in events if event.event_type == "user_message")
- assert user_event.content["parts"][0]["text"] == "hello"
- assert user_event.metadata["instructions"] == "只用中文回答"
- assert user_event.metadata["request_metadata"] == {"trace_label": "demo"}
-
-
-@pytest.mark.asyncio
-async def test_responses_endpoint_streaming_interrupt_returns_incomplete(monkeypatch):
- _, _, service, transport = _build_transport_with_runner(monkeypatch, _InterruptRunner())
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/v1/responses",
- json={"input": "delete it", "stream": True},
- )
-
- assert response.status_code == 200
- lines = [line for line in response.text.splitlines() if line.startswith("event: ")]
- assert "event: response.output_item.added" in lines
- assert "event: response.incomplete" in lines
- assert "event: response.completed" not in lines
-
- data_lines = [line.removeprefix("data: ") for line in response.text.splitlines() if line.startswith("data: ")]
- assert any(
- json.loads(line).get("item", {}).get("type") == "mcp_approval_request"
- for line in data_lines
- )
- incomplete_payload = next(
- json.loads(line)
- for line in data_lines
- if json.loads(line).get("status") == "incomplete"
- )
- assert incomplete_payload["incomplete_details"]["reason"] == "approval_required"
- events = await service.get_events(incomplete_payload["session_id"])
- assert any(event.event_type == "approval_request" for event in events)
-
-
-@pytest.mark.asyncio
-async def test_responses_endpoint_streaming_generic_interrupt_uses_ksadk_extension(monkeypatch):
- _, _, _, transport = _build_transport_with_runner(monkeypatch, _GenericInterruptRunner())
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/v1/responses",
- json={"input": "review it", "stream": True},
- )
-
- assert response.status_code == 200
- lines = [line for line in response.text.splitlines() if line.startswith("event: ")]
- assert "event: response.ksadk.approval_request" in lines
- assert "event: response.incomplete" in lines
-
-
-@pytest.mark.asyncio
-async def test_responses_endpoint_accepts_mcp_approval_response_resume(monkeypatch):
- _, runner, service, transport = _build_transport(monkeypatch)
- await service.create_session(agent_id="demo-agent", user_id="user", session_id="sess-approval")
- await service.append_event(
- "sess-approval",
- SessionEvent(
- author="demo-agent",
- event_type="approval_request",
- content={"role": "model", "parts": [{"text": "confirm tool"}]},
- metadata={
- "interrupt_info": {
- "approval_request_id": "appr_123",
- "tool_name": "delete_file",
- "arguments": {"path": "notes.txt"},
- }
- },
- invocation_id="inv-approval",
- ),
- )
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/v1/responses",
- json={
- "session_id": "sess-approval",
- "previous_response_id": "resp_previous",
- "input": [
- {
- "type": "mcp_approval_response",
- "id": "mcprsp_123",
- "approval_request_id": "appr_123",
- "approve": True,
- "reason": "approved",
- }
- ],
- },
- )
-
- assert response.status_code == 200
- payload = response.json()
- assert payload["status"] == "completed"
- assert payload["metadata"]["previous_response_id"] == "resp_previous"
- assert runner.invocations[-1]["resume"] is True
- assert runner.invocations[-1]["input"] == {
- "type": "mcp_approval_response",
- "id": "mcprsp_123",
- "approval_request_id": "appr_123",
- "approve": True,
- "reason": "approved",
- "tool_name": "delete_file",
- "tool_args": {
- "path": "notes.txt",
- "approval": {
- "approved": True,
- "approval_request_id": "appr_123",
- "reason": "approved",
- },
- },
- "approval": {
- "approved": True,
- "approval_request_id": "appr_123",
- "reason": "approved",
- },
- }
- events = await service.get_events("sess-approval")
- assert [event.event_type for event in events[:2]] == ["approval_request", "approval_response"]
-
-
-@pytest.mark.asyncio
-async def test_responses_endpoint_streams_mcp_approval_response_resume(monkeypatch):
- _, runner, service, transport = _build_transport(monkeypatch)
- await service.create_session(
- agent_id="demo-agent", user_id="user", session_id="sess-approval-stream"
- )
- await service.append_event(
- "sess-approval-stream",
- SessionEvent(
- author="demo-agent",
- event_type="approval_request",
- content={"role": "model", "parts": [{"text": "confirm tool"}]},
- metadata={
- "interrupt_info": {
- "approval_request_id": "appr_stream",
- "tool_name": "write_workspace_file",
- "arguments": {"path": "notes.txt", "content": "hello"},
- "run_id": "run_stream",
- }
- },
- invocation_id="inv-approval",
- ),
- )
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/v1/responses",
- json={
- "session_id": "sess-approval-stream",
- "previous_response_id": "resp_previous",
- "input": [
- {
- "type": "mcp_approval_response",
- "approval_request_id": "appr_stream",
- "approve": True,
- }
- ],
- "stream": True,
- },
- )
-
- assert response.status_code == 200
- assert "event: response.completed" in response.text
- assert runner.invocations[-1]["resume"] is True
- assert runner.invocations[-1]["input"] == {
- "type": "function_call_output",
- "call_id": "run_stream",
- "output": {
- "ok": True,
- "path": "notes.txt",
- "absolute_path": runner.invocations[-1]["input"]["output"]["absolute_path"],
- "size": 5,
- },
- }
- assert Path(runner.invocations[-1]["input"]["output"]["absolute_path"]).read_text(
- encoding="utf-8"
- ) == "hello"
-
-
-@pytest.mark.asyncio
-async def test_responses_endpoint_passes_attachment_results_to_runner(monkeypatch):
- _, runner, _, transport = _build_transport(monkeypatch)
- attachment_b64 = base64.b64encode("候选人简历内容".encode("utf-8")).decode("ascii")
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/v1/responses",
- json={
- "input": [
- {
- "role": "user",
- "content": [
- {"type": "input_text", "text": "请分析附件"},
- {
- "type": "input_file",
- "inlineData": {
- "displayName": "resume.txt",
- "mimeType": "text/plain",
- "data": attachment_b64,
- },
- },
- ],
- }
- ],
- "stream": False,
- },
- )
-
- assert response.status_code == 200
- assert runner.invocations[-1]["attachment_results"] == [
- {
- "display_name": "resume.txt",
- "mime_type": "text/plain",
- "transport": "inline",
- "file_uri": "",
- "size_bytes": len("候选人简历内容".encode("utf-8")),
- "kind": "text",
- "status": "ok",
- "warnings": [],
- "extraction_method": "text_decode",
- "text_excerpt": "候选人简历内容",
- "text": "候选人简历内容",
- }
- ]
-
-
-@pytest.mark.asyncio
-async def test_responses_endpoint_maps_openai_input_image_to_current_attachments(monkeypatch):
- _, runner, _, transport = _build_transport(monkeypatch)
- image_b64 = base64.b64encode(b"\x89PNG\r\n").decode("ascii")
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/v1/responses",
- json={
- "input": [
- {
- "role": "user",
- "content": [
- {"type": "input_text", "text": "请分析这张图"},
- {
- "type": "input_image",
- "image_url": f"data:image/png;base64,{image_b64}",
- },
- ],
- }
- ],
- "stream": False,
- },
- )
-
- assert response.status_code == 200
- assert runner.invocations[-1]["has_current_files"] is True
- assert runner.invocations[-1]["current_attachments"] == [
- {
- "display_name": "uploaded_image",
- "mime_type": "image/png",
- "transport": "inline",
- "data": image_b64,
- "is_text": False,
- "size_bytes": len(b"\x89PNG\r\n"),
- }
- ]
- assert runner.invocations[-1]["attachments"] == runner.invocations[-1]["current_attachments"]
- assert runner.invocations[-1]["input_content"] == [
- {"type": "input_text", "text": "请分析这张图"},
- {"type": "input_image", "image_url": f"data:image/png;base64,{image_b64}"},
- ]
- assert runner.invocations[-1]["input_parts"][1] == {
- "inlineData": {
- "data": image_b64,
- "mimeType": "image/png",
- "displayName": "uploaded_image",
- }
- }
-
-
-@pytest.mark.asyncio
-async def test_responses_endpoint_preserves_openai_input_image_remote_url(monkeypatch):
- _, runner, _, transport = _build_transport(monkeypatch)
- image_url = "https://example.com/diagram.png"
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/v1/responses",
- json={
- "input": [
- {
- "role": "user",
- "content": [
- {"type": "input_text", "text": "请分析这张图"},
- {
- "type": "input_image",
- "image_url": image_url,
- },
- ],
- }
- ],
- "stream": False,
- },
- )
-
- assert response.status_code == 200
- assert runner.invocations[-1]["has_current_files"] is True
- assert runner.invocations[-1]["current_attachments"][0]["file_uri"] == image_url
- assert runner.invocations[-1]["current_attachments"][0]["mime_type"] == "image/*"
- assert runner.invocations[-1]["current_attachments"][0]["storage_path"] is None
-
-
-@pytest.mark.asyncio
-async def test_responses_endpoint_maps_openai_input_file_data_to_current_attachments(monkeypatch):
- _, runner, _, transport = _build_transport(monkeypatch)
- file_text = "候选人简历内容"
- file_b64 = base64.b64encode(file_text.encode("utf-8")).decode("ascii")
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/v1/responses",
- json={
- "input": [
- {
- "role": "user",
- "content": [
- {"type": "input_text", "text": "请分析附件"},
- {
- "type": "input_file",
- "filename": "resume.txt",
- "file_data": file_b64,
- },
- ],
- }
- ],
- "stream": False,
- },
- )
-
- assert response.status_code == 200
- assert runner.invocations[-1]["has_current_files"] is True
- assert runner.invocations[-1]["current_attachments"] == [
- {
- "display_name": "resume.txt",
- "mime_type": "text/plain",
- "transport": "inline",
- "data": file_b64,
- "is_text": True,
- "size_bytes": len(file_text.encode("utf-8")),
- }
- ]
- assert runner.invocations[-1]["attachment_results"][0]["text"] == file_text
-
-
-@pytest.mark.asyncio
-async def test_streaming_run_agent_fails_before_starting_sse_when_runner_load_fails(monkeypatch):
- server_app_module = importlib.import_module("ksadk.server.app")
- service = InMemorySessionService()
- runner = _BrokenLoadRunner()
- monkeypatch.setattr(server_app_module, "resolve_session_service", lambda: service)
- server_app_module.set_runner(runner)
- transport = httpx.ASGITransport(app=server_app_module.app, raise_app_exceptions=False)
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- response = await client.post(
- "/agentengine/api/v1/RunAgent",
- json={
- "AgentId": "demo-agent",
- "Messages": [{"role": "user", "content": "hello"}],
- "ApiFormat": "responses",
- "Stream": True,
- },
- )
-
- assert response.status_code == 500
- assert runner.load_agent_calls == 1
-
-
-def test_cmd_web_launches_unified_local_server(monkeypatch, tmp_path):
- runner = CliRunner()
- fake_runner = _UiRunner()
- project_dir = tmp_path / "demo-agent"
- project_dir.mkdir()
- opened = {}
-
- class _Detector:
- def __init__(self, path: str):
- self.path = path
-
- def detect(self):
- return SimpleNamespace(
- type=SimpleNamespace(value="langgraph"),
- name="demo-agent",
- entry_point="agent.py",
- )
-
- import ksadk.cli.cmd_web as cmd_web_module
-
- monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False)
- monkeypatch.setattr(cmd_web_module, "setup_environment", lambda path: None, raising=False)
- monkeypatch.setattr(
- "ksadk.cli.cmd_web.create_runner",
- lambda result, project_dir: fake_runner,
- raising=False,
- )
- monkeypatch.setattr(cmd_web_module.webbrowser, "open", lambda url: opened.setdefault("url", url))
- monkeypatch.chdir(project_dir)
-
- result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"])
-
- assert result.exit_code == 0, result.output
- assert fake_runner.run_server_calls == [8899]
- assert fake_runner.load_agent_calls == 0
- assert opened["url"] == "http://localhost:8899"
-
-
-def test_cmd_web_can_skip_browser_open(monkeypatch, tmp_path):
- runner = CliRunner()
- fake_runner = _UiRunner()
- project_dir = tmp_path / "demo-agent"
- project_dir.mkdir()
- opened = {}
-
- class _Detector:
- def __init__(self, path: str):
- self.path = path
-
- def detect(self):
- return SimpleNamespace(
- type=SimpleNamespace(value="langgraph"),
- name="demo-agent",
- entry_point="agent.py",
- )
-
- import ksadk.cli.cmd_web as cmd_web_module
-
- monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False)
- monkeypatch.setattr(cmd_web_module, "setup_environment", lambda path: None, raising=False)
- monkeypatch.setattr(
- "ksadk.cli.cmd_web.create_runner",
- lambda result, project_dir: fake_runner,
- raising=False,
- )
- monkeypatch.setattr(cmd_web_module.webbrowser, "open", lambda url: opened.setdefault("url", url))
- monkeypatch.chdir(project_dir)
-
- result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899", "--no-open"])
-
- assert result.exit_code == 0, result.output
- assert fake_runner.run_server_calls == [8899]
- assert opened == {}
-
-
-def test_cmd_web_reexecs_with_project_venv_python(monkeypatch, tmp_path):
- runner = CliRunner()
- project_dir = tmp_path / "demo-agent"
- venv_bin = project_dir / ".venv" / "bin"
- venv_bin.mkdir(parents=True)
- venv_python = venv_bin / "python"
- venv_python.write_text("#!/bin/sh\n", encoding="utf-8")
-
- import ksadk.cli.cmd_web as cmd_web_module
-
- captured: dict[str, object] = {}
-
- def _fake_execvpe(file: str, args: list[str], env: dict[str, str]) -> None:
- captured["file"] = file
- captured["args"] = args
- captured["env"] = env
- raise SystemExit(23)
-
- import ksadk.cli.local_runtime as local_runtime
-
- monkeypatch.delenv("AGENTENGINE_WEB_VENV_REEXEC", raising=False)
- monkeypatch.delenv("AGENTENGINE_LOCAL_RUNTIME_VENV_REEXEC", raising=False)
- monkeypatch.setattr(local_runtime.sys, "executable", sys.executable, raising=False)
- monkeypatch.setattr(local_runtime.os, "execvpe", _fake_execvpe, raising=False)
-
- result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"])
-
- assert result.exit_code == 23
- assert captured["file"] == str(venv_python)
- args = captured["args"]
- assert isinstance(args, list)
- assert args[:2] == [str(venv_python), "-c"]
- assert "from ksadk.cli import main; main()" in args[2]
- assert args[3:] == [
- "web",
- str(project_dir.resolve()),
- "--port",
- "8899",
- ]
- env = captured["env"]
- assert isinstance(env, dict)
- assert env["AGENTENGINE_LOCAL_RUNTIME_VENV_REEXEC"] == "1"
- assert str(Path(local_runtime.__file__).resolve().parents[2]) in args[2]
-
-
-def test_cmd_web_does_not_reexec_inside_project_venv(monkeypatch, tmp_path):
- runner = CliRunner()
- fake_runner = _UiRunner()
- project_dir = tmp_path / "demo-agent"
- venv_bin = project_dir / ".venv" / "bin"
- venv_bin.mkdir(parents=True)
- venv_python = venv_bin / "python"
- venv_python.write_text("#!/bin/sh\n", encoding="utf-8")
-
- class _Detector:
- def __init__(self, path: str):
- self.path = path
-
- def detect(self):
- return SimpleNamespace(
- type=SimpleNamespace(value="langgraph"),
- name="demo-agent",
- entry_point="agent.py",
- )
-
- import ksadk.cli.cmd_web as cmd_web_module
- import ksadk.cli.local_runtime as local_runtime
-
- monkeypatch.setattr(local_runtime.sys, "executable", str(venv_python), raising=False)
- monkeypatch.setattr(
- local_runtime.os,
- "execvpe",
- lambda *_args, **_kwargs: pytest.fail("should not re-exec inside project venv"),
- raising=False,
- )
- monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False)
- monkeypatch.setattr(cmd_web_module, "setup_environment", lambda path: None, raising=False)
- monkeypatch.setattr(
- "ksadk.cli.cmd_web.create_runner",
- lambda result, project_dir: fake_runner,
- raising=False,
- )
- monkeypatch.chdir(project_dir)
-
- result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"])
-
- assert result.exit_code == 0, result.output
- assert fake_runner.run_server_calls == [8899]
-
-
-@pytest.mark.parametrize("framework", ["adk", "langgraph", "langchain", "deepagents"])
-def test_cmd_web_defaults_supported_framework_stm_to_persistent_sqlite(
- monkeypatch, tmp_path, framework
-):
- runner = CliRunner()
- fake_runner = _UiRunner()
- project_dir = tmp_path / f"demo-{framework}-agent"
- project_dir.mkdir()
-
- class _Detector:
- def __init__(self, path: str):
- self.path = path
-
- def detect(self):
- return SimpleNamespace(
- type=SimpleNamespace(value=framework),
- name="demo-agent",
- entry_point="agent.py",
- )
-
- import ksadk.cli.cmd_web as cmd_web_module
-
- monkeypatch.delenv("KSADK_STM_BACKEND", raising=False)
- monkeypatch.delenv("KSADK_STM_PATH", raising=False)
- monkeypatch.delenv("KSADK_STM_DB_PATH", raising=False)
- monkeypatch.delenv("KSADK_STM_URL", raising=False)
- monkeypatch.delenv("KSADK_STM_DB_URL", raising=False)
- monkeypatch.delenv("KSADK_SESSION_BACKEND", raising=False)
- monkeypatch.delenv("KSADK_SESSION_PATH", raising=False)
- monkeypatch.delenv("KSADK_SESSION_DSN", raising=False)
- monkeypatch.delenv("KSADK_CHECKPOINT_BACKEND", raising=False)
- monkeypatch.delenv("KSADK_CHECKPOINT_PATH", raising=False)
- monkeypatch.delenv("KSADK_LANGGRAPH_CHECKPOINT_DSN", raising=False)
- monkeypatch.delenv("AGENTENGINE_UI_DIR", raising=False)
- monkeypatch.delenv("KSADK_PROJECT_DIR", raising=False)
- monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False)
- monkeypatch.setattr(cmd_web_module, "setup_environment", lambda path: None, raising=False)
- monkeypatch.setattr(
- "ksadk.cli.cmd_web.create_runner",
- lambda result, project_dir: fake_runner,
- raising=False,
- )
- monkeypatch.chdir(project_dir)
-
- result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"])
-
- assert result.exit_code == 0, result.output
- assert fake_runner.run_server_calls == [8899]
- assert os.environ["KSADK_STM_BACKEND"] == "sqlite"
- assert os.environ["KSADK_STM_PATH"] == str(
- project_dir / ".agentengine" / "ui" / "sessions.sqlite"
- )
- assert os.environ["KSADK_SESSION_BACKEND"] == "local"
- assert os.environ["KSADK_SESSION_PATH"] == str(
- project_dir / ".agentengine" / "ui" / "sessions.sqlite"
- )
- if framework == "langgraph":
- assert os.environ["KSADK_CHECKPOINT_BACKEND"] == "sqlite"
- assert os.environ["KSADK_CHECKPOINT_PATH"] == str(
- project_dir / ".agentengine" / "ui" / "checkpoints.sqlite"
- )
-
-
-def test_cmd_web_overrides_project_dotenv_postgres_session_for_local_debug(
- monkeypatch, tmp_path
-):
- runner = CliRunner()
- fake_runner = _UiRunner()
- project_dir = tmp_path / "demo-langgraph-agent"
- project_dir.mkdir()
-
- class _Detector:
- def __init__(self, path: str):
- self.path = path
-
- def detect(self):
- return SimpleNamespace(
- type=SimpleNamespace(value="langgraph"),
- name="demo-agent",
- entry_point="agent.py",
- )
-
- import ksadk.cli.cmd_web as cmd_web_module
-
- monkeypatch.delenv("KSADK_STM_BACKEND", raising=False)
- monkeypatch.delenv("KSADK_STM_PATH", raising=False)
- monkeypatch.delenv("KSADK_SESSION_BACKEND", raising=False)
- monkeypatch.delenv("KSADK_SESSION_PATH", raising=False)
- monkeypatch.delenv("KSADK_SESSION_DSN", raising=False)
- monkeypatch.delenv("KSADK_CHECKPOINT_BACKEND", raising=False)
- monkeypatch.delenv("KSADK_CHECKPOINT_PATH", raising=False)
- monkeypatch.delenv("KSADK_LANGGRAPH_CHECKPOINT_DSN", raising=False)
- monkeypatch.delenv("AGENTENGINE_UI_DIR", raising=False)
- monkeypatch.delenv("KSADK_PROJECT_DIR", raising=False)
-
- def fake_setup_environment(_path):
- os.environ["KSADK_SESSION_BACKEND"] = "postgres"
- os.environ["KSADK_SESSION_DSN"] = "postgresql://ksadk:secret@db.example.test/session"
- os.environ["KSADK_CHECKPOINT_BACKEND"] = "postgres"
- os.environ["KSADK_LANGGRAPH_CHECKPOINT_DSN"] = "postgresql://ksadk:secret@db.example.test/checkpoints"
-
- monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False)
- monkeypatch.setattr(cmd_web_module, "setup_environment", fake_setup_environment, raising=False)
- monkeypatch.setattr(
- "ksadk.cli.cmd_web.create_runner",
- lambda result, project_dir: fake_runner,
- raising=False,
- )
- monkeypatch.chdir(project_dir)
-
- result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"])
-
- assert result.exit_code == 0, result.output
- assert fake_runner.run_server_calls == [8899]
- assert os.environ["KSADK_SESSION_BACKEND"] == "local"
- assert os.environ["KSADK_SESSION_PATH"] == str(
- project_dir / ".agentengine" / "ui" / "sessions.sqlite"
- )
- assert "KSADK_SESSION_DSN" not in os.environ
- assert os.environ["KSADK_CHECKPOINT_BACKEND"] == "sqlite"
- assert os.environ["KSADK_CHECKPOINT_PATH"] == str(
- project_dir / ".agentengine" / "ui" / "checkpoints.sqlite"
- )
- assert "KSADK_LANGGRAPH_CHECKPOINT_DSN" not in os.environ
-
-
-def test_cmd_web_overrides_dotenv_loaded_before_web_command(monkeypatch, tmp_path):
- runner = CliRunner()
- fake_runner = _UiRunner()
- project_dir = tmp_path / "demo-langgraph-agent"
- project_dir.mkdir()
- dotenv_text = "\n".join(
- [
- "KSADK_SESSION_BACKEND=postgres",
- "KSADK_SESSION_DSN=postgresql://ksadk:secret@db.example.test/session",
- "KSADK_LANGGRAPH_CHECKPOINT_DSN=postgresql://ksadk:secret@db.example.test/checkpoints",
- ]
- )
- (project_dir / ".env").write_text(dotenv_text, encoding="utf-8")
-
- class _Detector:
- def __init__(self, path: str):
- self.path = path
-
- def detect(self):
- return SimpleNamespace(
- type=SimpleNamespace(value="langgraph"),
- name="demo-agent",
- entry_point="agent.py",
- )
-
- import ksadk.cli.cmd_web as cmd_web_module
-
- monkeypatch.setenv("KSADK_SESSION_BACKEND", "postgres")
- monkeypatch.setenv("KSADK_SESSION_DSN", "postgresql://ksadk:secret@db.example.test/session")
- monkeypatch.setenv("KSADK_LANGGRAPH_CHECKPOINT_DSN", "postgresql://ksadk:secret@db.example.test/checkpoints")
- monkeypatch.delenv("KSADK_SESSION_PATH", raising=False)
- monkeypatch.delenv("KSADK_CHECKPOINT_BACKEND", raising=False)
- monkeypatch.delenv("KSADK_CHECKPOINT_PATH", raising=False)
- monkeypatch.delenv("AGENTENGINE_UI_DIR", raising=False)
- monkeypatch.delenv("KSADK_PROJECT_DIR", raising=False)
- monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False)
- monkeypatch.setattr(cmd_web_module, "setup_environment", lambda path: None, raising=False)
- monkeypatch.setattr(
- "ksadk.cli.cmd_web.create_runner",
- lambda result, project_dir: fake_runner,
- raising=False,
- )
- monkeypatch.chdir(project_dir)
-
- result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"])
-
- assert result.exit_code == 0, result.output
- assert fake_runner.run_server_calls == [8899]
- assert os.environ["KSADK_SESSION_BACKEND"] == "local"
- assert os.environ["KSADK_SESSION_PATH"] == str(
- project_dir / ".agentengine" / "ui" / "sessions.sqlite"
- )
- assert "KSADK_SESSION_DSN" not in os.environ
- assert os.environ["KSADK_CHECKPOINT_BACKEND"] == "sqlite"
- assert os.environ["KSADK_CHECKPOINT_PATH"] == str(
- project_dir / ".agentengine" / "ui" / "checkpoints.sqlite"
- )
- assert "KSADK_LANGGRAPH_CHECKPOINT_DSN" not in os.environ
-
-
-def test_cmd_web_overrides_project_dotenv_ui_dir_for_local_debug(monkeypatch, tmp_path):
- runner = CliRunner()
- fake_runner = _UiRunner()
- project_dir = tmp_path / "demo-langgraph-agent"
- project_dir.mkdir()
- (project_dir / ".env").write_text(
- "AGENTENGINE_UI_DIR=/home/node/.agentengine/ui\n",
- encoding="utf-8",
- )
-
- class _Detector:
- def __init__(self, path: str):
- self.path = path
-
- def detect(self):
- return SimpleNamespace(
- type=SimpleNamespace(value="langgraph"),
- name="demo-agent",
- entry_point="agent.py",
- )
-
- import ksadk.cli.cmd_web as cmd_web_module
-
- monkeypatch.setenv("AGENTENGINE_UI_DIR", "/home/node/.agentengine/ui")
- monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False)
- monkeypatch.setattr(cmd_web_module, "setup_environment", lambda path: None, raising=False)
- monkeypatch.setattr(
- "ksadk.cli.cmd_web.create_runner",
- lambda result, project_dir: fake_runner,
- raising=False,
- )
- monkeypatch.chdir(project_dir)
-
- result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"])
-
- assert result.exit_code == 0, result.output
- assert fake_runner.run_server_calls == [8899]
- assert os.environ["AGENTENGINE_UI_DIR"] == str(project_dir / ".agentengine" / "ui")
-
-
-def test_cmd_web_preserves_explicit_ui_dir_for_local_debug(monkeypatch, tmp_path):
- runner = CliRunner()
- fake_runner = _UiRunner()
- project_dir = tmp_path / "demo-langgraph-agent"
- project_dir.mkdir()
- (project_dir / ".env").write_text(
- "AGENTENGINE_UI_DIR=/home/node/.agentengine/ui\n",
- encoding="utf-8",
- )
- explicit_ui_dir = str(tmp_path / "custom-ui-state")
-
- class _Detector:
- def __init__(self, path: str):
- self.path = path
-
- def detect(self):
- return SimpleNamespace(
- type=SimpleNamespace(value="langgraph"),
- name="demo-agent",
- entry_point="agent.py",
- )
-
- import ksadk.cli.cmd_web as cmd_web_module
-
- monkeypatch.setenv("AGENTENGINE_UI_DIR", explicit_ui_dir)
- monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False)
- monkeypatch.setattr(cmd_web_module, "setup_environment", lambda path: None, raising=False)
- monkeypatch.setattr(
- "ksadk.cli.cmd_web.create_runner",
- lambda result, project_dir: fake_runner,
- raising=False,
- )
- monkeypatch.chdir(project_dir)
-
- result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"])
-
- assert result.exit_code == 0, result.output
- assert fake_runner.run_server_calls == [8899]
- assert os.environ["AGENTENGINE_UI_DIR"] == explicit_ui_dir
-
-
-def test_cmd_web_exports_custom_ui_config_and_opens_custom_path(monkeypatch, tmp_path):
- runner = CliRunner()
- fake_runner = _UiRunner()
- project_dir = tmp_path / "demo-langgraph-agent"
- bundle_dir = project_dir / "research-ui" / "dist"
- bundle_dir.mkdir(parents=True)
- (bundle_dir / "index.html").write_text("Custom UI", encoding="utf-8")
- (project_dir / "agentengine.yaml").write_text(
- "\n".join(
- [
- "name: demo-agent",
- "framework: langgraph",
- "entry_point: agent.py",
- "ui_profile: custom",
- "ui_path: /research",
- "ui_bundle_path: research-ui/dist",
- ]
- ),
- encoding="utf-8",
- )
- opened = {}
-
- class _Detector:
- def __init__(self, path: str):
- self.path = path
-
- def detect(self):
- return SimpleNamespace(
- type=SimpleNamespace(value="langgraph"),
- name="demo-agent",
- entry_point="agent.py",
- )
-
- import ksadk.cli.cmd_web as cmd_web_module
-
- monkeypatch.delenv("KSADK_UI_PROFILE", raising=False)
- monkeypatch.delenv("KSADK_UI_PATH", raising=False)
- monkeypatch.delenv("KSADK_UI_BUNDLE_PATH", raising=False)
- monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False)
- monkeypatch.setattr(cmd_web_module, "setup_environment", lambda path: None, raising=False)
- monkeypatch.setattr(
- "ksadk.cli.cmd_web.create_runner",
- lambda result, project_dir: fake_runner,
- raising=False,
- )
- monkeypatch.setattr(cmd_web_module.webbrowser, "open", lambda url: opened.setdefault("url", url))
- monkeypatch.chdir(project_dir)
-
- result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"])
-
- assert result.exit_code == 0, result.output
- assert os.environ["KSADK_UI_PROFILE"] == "custom"
- assert os.environ["KSADK_UI_PATH"] == "/research"
- assert os.environ["KSADK_UI_BUNDLE_PATH"] == "research-ui/dist"
- assert opened["url"] == "http://localhost:8899/research"
- monkeypatch.delenv("KSADK_UI_PROFILE", raising=False)
- monkeypatch.delenv("KSADK_UI_PATH", raising=False)
- monkeypatch.delenv("KSADK_UI_BUNDLE_PATH", raising=False)
-
-
-def test_server_serves_custom_ui_path_and_assets_from_env(monkeypatch, tmp_path):
- server_app_module = importlib.import_module("ksadk.server.app")
- project_dir = tmp_path / "agent"
- bundle_dir = project_dir / "research-ui" / "dist"
- assets_dir = bundle_dir / "assets"
- assets_dir.mkdir(parents=True)
- (bundle_dir / "index.html").write_text(
- 'Custom UI',
- encoding="utf-8",
- )
- (assets_dir / "index.js").write_text("window.customUiLoaded = true;", encoding="utf-8")
- runner = _UiRunner()
- runner.project_dir = str(project_dir)
-
- server_app_module.set_runner(runner)
- monkeypatch.setenv("KSADK_UI_PROFILE", "custom")
- monkeypatch.setenv("KSADK_UI_PATH", "/research")
- monkeypatch.setenv("KSADK_UI_BUNDLE_PATH", "research-ui/dist")
-
- client = TestClient(server_app_module.app)
- shell_response = client.get("/research")
- asset_response = client.get("/research/assets/index.js")
-
- assert shell_response.status_code == 200
- assert "Custom UI" in shell_response.text
- assert asset_response.status_code == 200
- assert "customUiLoaded" in asset_response.text
- monkeypatch.delenv("KSADK_UI_PROFILE", raising=False)
- monkeypatch.delenv("KSADK_UI_PATH", raising=False)
- monkeypatch.delenv("KSADK_UI_BUNDLE_PATH", raising=False)
- server_app_module.set_runner(_UiRunner())
-
-
-def test_server_serves_custom_ui_spa_routes_from_env(monkeypatch, tmp_path):
- server_app_module = importlib.import_module("ksadk.server.app")
- project_dir = tmp_path / "agent"
- bundle_dir = project_dir / "frontend" / "dist"
- assets_dir = bundle_dir / "assets"
- assets_dir.mkdir(parents=True)
- (bundle_dir / "index.html").write_text(
- 'Custom UI',
- encoding="utf-8",
- )
- runner = _UiRunner()
- runner.project_dir = str(project_dir)
-
- server_app_module.set_runner(runner)
- monkeypatch.setenv("KSADK_UI_PROFILE", "custom")
- monkeypatch.setenv("KSADK_UI_PATH", "/luoluo")
- monkeypatch.setenv("KSADK_UI_BUNDLE_PATH", "frontend/dist")
-
- client = TestClient(server_app_module.app)
- shell_response = client.get("/luoluo/chat")
- missing_asset_response = client.get("/luoluo/assets/missing.js")
-
- assert shell_response.status_code == 200
- assert "Custom UI" in shell_response.text
- assert missing_asset_response.status_code == 404
- monkeypatch.delenv("KSADK_UI_PROFILE", raising=False)
- monkeypatch.delenv("KSADK_UI_PATH", raising=False)
- monkeypatch.delenv("KSADK_UI_BUNDLE_PATH", raising=False)
- server_app_module.set_runner(_UiRunner())
-
-
-def test_cmd_web_preserves_explicit_stm_configuration(monkeypatch, tmp_path):
- runner = CliRunner()
- fake_runner = _UiRunner()
- project_dir = tmp_path / "demo-langgraph-agent"
- project_dir.mkdir()
-
- class _Detector:
- def __init__(self, path: str):
- self.path = path
-
- def detect(self):
- return SimpleNamespace(
- type=SimpleNamespace(value="langgraph"),
- name="demo-agent",
- entry_point="agent.py",
- )
-
- import ksadk.cli.cmd_web as cmd_web_module
-
- monkeypatch.setenv("KSADK_STM_BACKEND", "local")
- monkeypatch.setenv("KSADK_STM_PATH", "/tmp/custom-sessions.db")
- monkeypatch.setenv("KSADK_SESSION_BACKEND", "postgres")
- monkeypatch.setenv("KSADK_SESSION_DSN", "postgresql://ksadk:secret@db.example.test/session")
- monkeypatch.setenv("KSADK_CHECKPOINT_BACKEND", "postgres")
- monkeypatch.setenv("KSADK_LANGGRAPH_CHECKPOINT_DSN", "postgresql://ksadk:secret@db.example.test/checkpoints")
- monkeypatch.delenv("AGENTENGINE_UI_DIR", raising=False)
- monkeypatch.delenv("KSADK_PROJECT_DIR", raising=False)
- monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False)
- monkeypatch.setattr(cmd_web_module, "setup_environment", lambda path: None, raising=False)
- monkeypatch.setattr(
- "ksadk.cli.cmd_web.create_runner",
- lambda result, project_dir: fake_runner,
- raising=False,
- )
- monkeypatch.chdir(project_dir)
-
- result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"])
-
- assert result.exit_code == 0, result.output
- assert fake_runner.run_server_calls == [8899]
- assert os.environ["KSADK_STM_BACKEND"] == "local"
- assert os.environ["KSADK_STM_PATH"] == "/tmp/custom-sessions.db"
- assert os.environ["KSADK_SESSION_BACKEND"] == "postgres"
- assert os.environ["KSADK_SESSION_DSN"] == "postgresql://ksadk:secret@db.example.test/session"
- assert os.environ["KSADK_CHECKPOINT_BACKEND"] == "postgres"
- assert os.environ["KSADK_LANGGRAPH_CHECKPOINT_DSN"] == "postgresql://ksadk:secret@db.example.test/checkpoints"
-
-
-def test_cmd_web_treats_explicit_local_checkpoint_backend_as_sqlite(monkeypatch, tmp_path):
- runner = CliRunner()
- fake_runner = _UiRunner()
- project_dir = tmp_path / "demo-langgraph-agent"
- project_dir.mkdir()
-
- class _Detector:
- def __init__(self, path: str):
- self.path = path
-
- def detect(self):
- return SimpleNamespace(
- type=SimpleNamespace(value="langgraph"),
- name="demo-agent",
- entry_point="agent.py",
- )
-
- import ksadk.cli.cmd_web as cmd_web_module
-
- monkeypatch.setenv("KSADK_CHECKPOINT_BACKEND", "local")
- monkeypatch.delenv("KSADK_CHECKPOINT_PATH", raising=False)
- monkeypatch.delenv("KSADK_LANGGRAPH_CHECKPOINT_DSN", raising=False)
- monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False)
- monkeypatch.setattr(cmd_web_module, "setup_environment", lambda path: None, raising=False)
- monkeypatch.setattr(
- "ksadk.cli.cmd_web.create_runner",
- lambda result, project_dir: fake_runner,
- raising=False,
- )
- monkeypatch.chdir(project_dir)
-
- result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"])
-
- assert result.exit_code == 0, result.output
- assert fake_runner.run_server_calls == [8899]
- assert os.environ["KSADK_CHECKPOINT_BACKEND"] == "sqlite"
- assert os.environ["KSADK_CHECKPOINT_PATH"] == str(
- project_dir / ".agentengine" / "ui" / "checkpoints.sqlite"
- )
-
-
-def test_cmd_web_errors_when_langgraph_sqlite_checkpoint_package_missing(
- monkeypatch, tmp_path
-):
- runner = CliRunner()
- fake_runner = _UiRunner()
- project_dir = tmp_path / "demo-langgraph-agent"
- project_dir.mkdir()
-
- class _Detector:
- def __init__(self, path: str):
- self.path = path
-
- def detect(self):
- return SimpleNamespace(
- type=SimpleNamespace(value="langgraph"),
- name="demo-agent",
- entry_point="agent.py",
- )
-
- import builtins
- import ksadk.cli.cmd_web as cmd_web_module
-
- original_import = builtins.__import__
-
- def fake_import(name, globals=None, locals=None, fromlist=(), level=0):
- if name == "langgraph.checkpoint.sqlite.aio":
- raise ImportError("missing sqlite checkpointer")
- return original_import(name, globals, locals, fromlist, level)
-
- monkeypatch.delenv("KSADK_CHECKPOINT_BACKEND", raising=False)
- monkeypatch.delenv("KSADK_CHECKPOINT_PATH", raising=False)
- monkeypatch.delenv("KSADK_LANGGRAPH_CHECKPOINT_DSN", raising=False)
- monkeypatch.setattr(builtins, "__import__", fake_import)
- monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False)
- monkeypatch.setattr(cmd_web_module, "setup_environment", lambda path: None, raising=False)
- monkeypatch.setattr(
- "ksadk.cli.cmd_web.create_runner",
- lambda result, project_dir: fake_runner,
- raising=False,
- )
- monkeypatch.chdir(project_dir)
-
- result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"])
-
- assert result.exit_code == 1
- assert "pip install langgraph-checkpoint-sqlite" in result.output
- assert fake_runner.run_server_calls == []
-
-
-def test_cmd_web_preserves_partial_explicit_stm_configuration(monkeypatch, tmp_path):
- runner = CliRunner()
- fake_runner = _UiRunner()
- project_dir = tmp_path / "demo-langchain-agent"
- project_dir.mkdir()
-
- class _Detector:
- def __init__(self, path: str):
- self.path = path
-
- def detect(self):
- return SimpleNamespace(
- type=SimpleNamespace(value="langchain"),
- name="demo-agent",
- entry_point="agent.py",
- )
-
- import ksadk.cli.cmd_web as cmd_web_module
-
- monkeypatch.delenv("KSADK_STM_BACKEND", raising=False)
- monkeypatch.setenv("KSADK_STM_DB_PATH", "/tmp/legacy-custom-sessions.db")
- monkeypatch.delenv("KSADK_STM_PATH", raising=False)
- monkeypatch.delenv("AGENTENGINE_UI_DIR", raising=False)
- monkeypatch.delenv("KSADK_PROJECT_DIR", raising=False)
- monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False)
- monkeypatch.setattr(cmd_web_module, "setup_environment", lambda path: None, raising=False)
- monkeypatch.setattr(
- "ksadk.cli.cmd_web.create_runner",
- lambda result, project_dir: fake_runner,
- raising=False,
- )
- monkeypatch.chdir(project_dir)
-
- result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"])
-
- assert result.exit_code == 0, result.output
- assert fake_runner.run_server_calls == [8899]
- assert "KSADK_STM_BACKEND" not in os.environ
- assert "KSADK_STM_PATH" not in os.environ
- assert os.environ["KSADK_STM_DB_PATH"] == "/tmp/legacy-custom-sessions.db"
-
-
-def test_cmd_web_exits_quietly_on_keyboard_interrupt(monkeypatch, tmp_path):
- runner = CliRunner()
- fake_runner = _KeyboardInterruptServerRunner()
- project_dir = tmp_path / "demo-agent"
- project_dir.mkdir()
-
- class _Detector:
- def __init__(self, path: str):
- self.path = path
-
- def detect(self):
- return SimpleNamespace(
- type=SimpleNamespace(value="langgraph"),
- name="demo-agent",
- entry_point="agent.py",
- )
-
- import ksadk.cli.cmd_web as cmd_web_module
-
- monkeypatch.setattr(cmd_web_module, "FrameworkDetector", _Detector, raising=False)
- monkeypatch.setattr(cmd_web_module, "setup_environment", lambda path: None, raising=False)
- monkeypatch.setattr(
- "ksadk.cli.cmd_web.create_runner",
- lambda result, project_dir: fake_runner,
- raising=False,
- )
- monkeypatch.chdir(project_dir)
-
- result = runner.invoke(cmd_web_module.web, [str(project_dir), "--port", "8899"])
-
- assert result.exit_code == 0, result.output
- assert fake_runner.run_server_calls == [8899]
- assert "Traceback" not in result.output
- assert "统一 Web UI 启动失败" not in result.output
-
-
-@pytest.mark.asyncio
-async def test_static_routes_serve_unified_agent_ui_shell(monkeypatch):
- _, _, _, transport = _build_transport(monkeypatch)
-
- async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
- root_response = await client.get("/")
- chat_response = await client.get("/chat")
- script_match = re.search(r'src="(\./assets/[^"]+\.js)"', root_response.text)
- style_match = re.search(r'href="(\./assets/[^"]+\.css)"', root_response.text)
- assert script_match is not None
- assert style_match is not None
- js_response = await client.get(script_match.group(1).removeprefix("."))
- css_response = await client.get(style_match.group(1).removeprefix("."))
-
- assert root_response.status_code == 200
- assert chat_response.status_code == 200
- assert js_response.status_code == 200
- assert css_response.status_code == 200
- assert '' in root_response.text
- assert '' in chat_response.text
- assert root_response.text == chat_response.text
- assert 'type="module" crossorigin src="./assets/index-' in root_response.text
- assert 'rel="stylesheet" crossorigin href="./assets/index-' in root_response.text
- assert "/agentengine/api/v1" in js_response.text
- for action_name in (
- "AttachmentContent",
- "UploadFile",
- "ListSessionEvents",
- "ListAgentModels",
- "RunAgent",
- "ListWorkspaceFiles",
- "AddWorkspaceFile",
- "DeleteWorkspaceFile",
- "GetWorkspaceFileContent",
- ):
- assert action_name in js_response.text
- assert "/run_sse" not in js_response.text
- assert "/agentengine/api/v1/models" not in js_response.text
- assert "overflow" in css_response.text
-
-
-def test_source_repository_does_not_track_local_web_ui_source():
- result = subprocess.run(
- ["git", "ls-files", "ksadk/server/web-ui/**"],
- check=True,
- text=True,
- stdout=subprocess.PIPE,
- )
-
- assert result.stdout == ""
-
-
-def test_static_workbench_uses_openai_responses_content_for_inline_attachments():
- index_html = Path("ksadk/server/static/index.html").read_text(encoding="utf-8")
- match = re.search(r'src="\.\/(assets\/index-[^"]+\.js)"', index_html)
- assert match, "static index.html should reference the built Vite entry bundle"
- source = Path("ksadk/server/static", match.group(1)).read_text(encoding="utf-8")
-
- assert "type:`input_image`" in source
- assert "image_url:await this.imageFileToDataUrl" in source
- assert "type:`input_file`" in source
- assert "filename:" in source
- assert "file_url:" in source
- assert "inlineData: {" not in source
diff --git a/tests/test_usage_accumulator.py b/tests/test_usage_accumulator.py
deleted file mode 100644
index 1f3f0b96..00000000
--- a/tests/test_usage_accumulator.py
+++ /dev/null
@@ -1,43 +0,0 @@
-"""usage_accumulator 单测:逐字段累加(input/output/total + details 子键)。"""
-from __future__ import annotations
-
-from ksadk.runners.usage_accumulator import accumulate_usage
-
-
-def test_accumulate_usage_sums_main_fields():
- acc = {}
- acc = accumulate_usage(acc, {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150})
- acc = accumulate_usage(acc, {"input_tokens": 200, "output_tokens": 80, "total_tokens": 280})
- assert acc["input_tokens"] == 300
- assert acc["output_tokens"] == 130
- assert acc["total_tokens"] == 430
-
-
-def test_accumulate_usage_sums_input_token_details():
- """details 键名不统一(cached/cache_read/cache_creation),逐键求和作诊断明细。"""
- acc = {}
- acc = accumulate_usage(acc, {"input_tokens": 100, "input_token_details": {"cached": 50}})
- acc = accumulate_usage(acc, {"input_tokens": 200, "input_token_details": {"cached": 30, "cache_read": 10}})
- assert acc["input_token_details"]["cached"] == 80
- assert acc["input_token_details"]["cache_read"] == 10
-
-
-def test_accumulate_usage_handles_empty_delta():
- acc = {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150}
- acc = accumulate_usage(acc, {})
- assert acc["input_tokens"] == 100 # 不变
-
-
-def test_accumulate_usage_does_not_mutate_input():
- """返回新 dict,不改 acc(避免共享状态)。"""
- acc = {"input_tokens": 100}
- result = accumulate_usage(acc, {"input_tokens": 200})
- assert acc["input_tokens"] == 100 # 原始未变
- assert result["input_tokens"] == 300
-
-
-def test_accumulate_usage_output_token_details():
- acc = {}
- acc = accumulate_usage(acc, {"output_tokens": 50, "output_token_details": {"reasoning": 20}})
- acc = accumulate_usage(acc, {"output_tokens": 30, "output_token_details": {"reasoning": 10}})
- assert acc["output_token_details"]["reasoning"] == 30
diff --git a/tests/test_validate_hosted_long_task_e2e.py b/tests/test_validate_hosted_long_task_e2e.py
deleted file mode 100644
index b9171963..00000000
--- a/tests/test_validate_hosted_long_task_e2e.py
+++ /dev/null
@@ -1,39 +0,0 @@
-from scripts.validate_hosted_long_task_e2e import HostedE2EError, _wait_for_checkpoint
-
-
-class FlakyCheckpointClient:
- agent_id = "ar-test"
-
- def __init__(self):
- self.calls = 0
-
- def action(self, name, payload):
- self.calls += 1
- if name == "ListSessionCheckpoints" and self.calls == 1:
- raise HostedE2EError(
- "ListSessionCheckpoints returned Code=404: {'Code': 404}"
- )
- if name == "ListSessionCheckpoints":
- return {
- "Data": {
- "Checkpoints": [
- {
- "RunId": payload.get("RunId") or "run-1",
- "CheckpointId": "checkpoint-1",
- }
- ]
- }
- }
- raise AssertionError(f"unexpected action: {name}")
-
-
-def test_wait_for_checkpoint_retries_initial_not_found():
- checkpoint = _wait_for_checkpoint(
- FlakyCheckpointClient(),
- session_id="session-1",
- run_id="run-1",
- attempts=2,
- interval=0,
- )
-
- assert checkpoint["CheckpointId"] == "checkpoint-1"
diff --git a/tests/test_web_toolset.py b/tests/test_web_toolset.py
deleted file mode 100644
index 50874992..00000000
--- a/tests/test_web_toolset.py
+++ /dev/null
@@ -1,264 +0,0 @@
-from __future__ import annotations
-
-import httpx
-
-from ksadk.toolsets import describe_agentengine_tools, get_agentengine_tools
-from ksadk.toolsets.web import web_fetch, web_search
-
-
-def test_web_tools_are_registered_in_default_toolset():
- names = {tool.name for tool in get_agentengine_tools()}
-
- assert "web_fetch" in names
- assert "web_search" in names
-
-
-def test_web_tool_descriptors_include_web_group():
- specs = {spec["name"]: spec for spec in describe_agentengine_tools(include=["web"])}
-
- assert specs["web_fetch"]["group"] == "web"
- assert specs["web_search"]["group"] == "web"
-
-
-def test_web_fetch_blocks_loopback_before_request(monkeypatch):
- def _should_not_request(*_args, **_kwargs):
- raise AssertionError("web_fetch must block loopback URLs before issuing requests")
-
- monkeypatch.setattr("ksadk.toolsets.web.httpx.Client.get", _should_not_request)
-
- result = web_fetch("http://127.0.0.1:8000/secret")
-
- assert result["ok"] is False
- assert result["error_type"] == "blocked_by_ssrf_policy"
-
-
-def test_web_fetch_blocks_private_link_local_and_metadata_addresses(monkeypatch):
- def _should_not_request(*_args, **_kwargs):
- raise AssertionError("web_fetch must block non-public URLs before issuing requests")
-
- monkeypatch.setattr("ksadk.toolsets.web.httpx.Client.get", _should_not_request)
-
- for url in (
- "http://10.0.0.2/admin",
- "http://192.168.1.2/admin",
- "http://169.254.169.254/latest/meta-data",
- ):
- result = web_fetch(url)
- assert result["ok"] is False
- assert result["error_type"] == "blocked_by_ssrf_policy"
-
-
-def test_web_fetch_blocks_redirect_to_private_address(monkeypatch):
- calls: list[str] = []
-
- def _fake_get(self, url, **kwargs):
- calls.append(url)
- return httpx.Response(
- 302,
- headers={"location": "http://10.0.0.2/admin"},
- request=httpx.Request("GET", url),
- )
-
- monkeypatch.setattr("ksadk.toolsets.web.httpx.Client.get", _fake_get)
-
- result = web_fetch("https://example.com/redirect")
-
- assert result["ok"] is False
- assert result["error_type"] == "blocked_by_ssrf_policy"
- assert calls == ["https://example.com/redirect"]
-
-
-def test_web_fetch_strips_html_and_budgets_large_content(monkeypatch, tmp_path):
- def _fake_get(self, url, **kwargs):
- return httpx.Response(
- 200,
- headers={"content-type": "text/html"},
- text="Hello
" + ("world " * 20) + "
",
- request=httpx.Request("GET", url),
- )
-
- monkeypatch.setenv("KSADK_WEB_SSRF_POLICY_JSON", '{"allow_private": true}')
- monkeypatch.setenv("KSADK_TOOL_RESULT_DIR", str(tmp_path))
- monkeypatch.setattr("ksadk.toolsets.web.httpx.Client.get", _fake_get)
-
- result = web_fetch("https://example.com/docs", max_chars=25)
-
- assert result["ok"] is True
- assert "bad()" not in result["text"]
- assert "Hello" in result["text"]
- assert result["truncated"] is True
- assert result["persisted"]["mime_type"] == "text/plain"
-
-
-def test_web_search_returns_provider_not_configured_without_provider(monkeypatch):
- monkeypatch.delenv("KSADK_WEB_SEARCH_PROVIDER", raising=False)
- monkeypatch.delenv("OPENCLAW_WEB_SEARCH_PROVIDER", raising=False)
-
- result = web_search("ksadk sandbox")
-
- assert result["ok"] is False
- assert result["error_type"] == "provider_not_configured"
-
-
-def test_web_search_fake_provider_returns_results(monkeypatch):
- monkeypatch.setenv("KSADK_WEB_SEARCH_PROVIDER", "fake")
-
- result = web_search("ksadk sandbox", max_results=2)
-
- assert result["ok"] is True
- assert result["provider"] == "fake"
- assert result["results"] == [
- {
- "title": "Fake result for ksadk sandbox",
- "url": "https://example.com/search?q=ksadk+sandbox",
- "snippet": "Fake search result generated by ksadk test provider.",
- "rank": 1,
- "provider": "fake",
- }
- ]
-
-
-def test_web_search_http_provider_uses_configured_endpoint(monkeypatch):
- captured = {}
-
- def _fake_get(self, url, **kwargs):
- captured["url"] = url
- captured["params"] = kwargs.get("params")
- captured["headers"] = kwargs.get("headers")
- return httpx.Response(
- 200,
- json={
- "results": [
- {"title": "Doc", "url": "https://example.com/doc", "snippet": "Snippet"}
- ]
- },
- request=httpx.Request("GET", url),
- )
-
- monkeypatch.setenv("KSADK_WEB_SEARCH_PROVIDER", "http")
- monkeypatch.setenv("KSADK_WEB_SEARCH_BASE_URL", "https://search.example/api")
- monkeypatch.setenv("KSADK_WEB_SEARCH_API_KEY", "secret")
- monkeypatch.setattr("ksadk.toolsets.web.httpx.Client.get", _fake_get)
-
- result = web_search("ksadk tools", max_results=3, recency_days=7)
-
- assert result["ok"] is True
- assert result["provider"] == "http"
- assert captured["params"] == {"q": "ksadk tools", "max_results": 3, "recency_days": 7}
- assert captured["headers"]["Authorization"] == "Bearer secret"
- assert result["results"][0]["rank"] == 1
-
-
-def test_web_search_ksyun_provider_posts_to_ai_search_endpoint(monkeypatch):
- captured = {}
-
- def _fake_post(self, url, **kwargs):
- captured["url"] = url
- captured["headers"] = kwargs.get("headers")
- captured["json"] = kwargs.get("json")
- return httpx.Response(
- 200,
- json={
- "webpages": [
- {
- "title": "KsADK 文档",
- "link": "https://example.com/ksadk",
- "snippet": "KsADK 是金山云 Agent 开发工具包。",
- "date": "2026年07月01日",
- "position": 1,
- }
- ],
- "credits": 3,
- "total": 1,
- },
- request=httpx.Request("POST", url),
- )
-
- monkeypatch.setenv("KSADK_WEB_SEARCH_PROVIDER", "ksyun")
- monkeypatch.setenv("KSADK_WEB_SEARCH_API_KEY", "ksyun-token")
- monkeypatch.delenv("KSADK_WEB_SEARCH_BASE_URL", raising=False)
- monkeypatch.setattr("ksadk.toolsets.web.httpx.Client.post", _fake_post)
-
- result = web_search("ksadk", max_results=5)
-
- assert result["ok"] is True
- assert result["provider"] == "ksyun"
- # 金山云 AI搜索用 POST + JSON body,不是 GET + params。
- assert captured["url"] == "https://search.aipro.ksyun.com/v1/aisearch/search"
- assert captured["headers"]["Authorization"] == "Bearer ksyun-token"
- assert captured["json"] == {"q": "ksadk", "scope": "webpage", "size": 5}
- assert len(result["results"]) == 1
- first = result["results"][0]
- assert first["title"] == "KsADK 文档"
- assert first["url"] == "https://example.com/ksadk"
- assert first["snippet"].startswith("KsADK 是金山云")
- assert first["date"] == "2026年07月01日"
- assert first["rank"] == 1
- assert first["provider"] == "ksyun"
-
-
-def test_web_search_ksyun_provider_reuses_metaso_credentials(monkeypatch):
- # 不设 KSADK_WEB_SEARCH_API_KEY,应回退到 KSADK_MCP_KEY / KSC_AIPRO_API_KEY。
- monkeypatch.setenv("KSADK_WEB_SEARCH_PROVIDER", "ksyun")
- monkeypatch.delenv("KSADK_WEB_SEARCH_API_KEY", raising=False)
- monkeypatch.delenv("OPENCLAW_WEB_SEARCH_API_KEY", raising=False)
- monkeypatch.setenv("KSADK_MCP_KEY", "shared-metaso-token")
-
- captured = {}
-
- def _fake_post(self, url, **kwargs):
- captured["headers"] = kwargs.get("headers")
- return httpx.Response(
- 200,
- json={"webpages": []},
- request=httpx.Request("POST", url),
- )
-
- monkeypatch.setattr("ksadk.toolsets.web.httpx.Client.post", _fake_post)
-
- result = web_search("ksadk")
-
- assert result["ok"] is True
- assert captured["headers"]["Authorization"] == "Bearer shared-metaso-token"
-
-
-def test_web_search_ksyun_provider_returns_not_configured_without_api_key(monkeypatch):
- monkeypatch.setenv("KSADK_WEB_SEARCH_PROVIDER", "ksyun")
- for key in (
- "KSADK_WEB_SEARCH_API_KEY",
- "OPENCLAW_WEB_SEARCH_API_KEY",
- "KSADK_MCP_KEY",
- "KSC_AIPRO_API_KEY",
- "KSC_AIPRO_APIKEY",
- "AIPRO_API_KEY",
- ):
- monkeypatch.delenv(key, raising=False)
-
- result = web_search("ksadk")
-
- assert result["ok"] is False
- assert result["error_type"] == "provider_not_configured"
- assert "api key" in result["error_message"]
-
-
-def test_web_search_ksyun_provider_respects_custom_scope(monkeypatch):
- captured = {}
-
- def _fake_post(self, url, **kwargs):
- captured["json"] = kwargs.get("json")
- return httpx.Response(
- 200,
- json={"webpages": []},
- request=httpx.Request("POST", url),
- )
-
- monkeypatch.setenv("KSADK_WEB_SEARCH_PROVIDER", "ksyun")
- monkeypatch.setenv("KSADK_WEB_SEARCH_API_KEY", "token")
- monkeypatch.setenv("KSADK_WEB_SEARCH_SCOPE", "scholar")
- monkeypatch.setattr("ksadk.toolsets.web.httpx.Client.post", _fake_post)
-
- result = web_search("transformer architecture", max_results=10)
-
- assert result["ok"] is True
- assert captured["json"]["scope"] == "scholar"
- assert captured["json"]["size"] == 10
diff --git a/tests/test_workflow_common.py b/tests/test_workflow_common.py
deleted file mode 100644
index c3471591..00000000
--- a/tests/test_workflow_common.py
+++ /dev/null
@@ -1,221 +0,0 @@
-from pathlib import Path
-import json
-
-from ksadk.cli.workflow_common import (
- build_workflow_local_plan,
- clear_build_metadata,
- load_cached_artifact_reference,
- plan_artifact_build,
- resolve_artifact_build_plan,
- should_build_artifact,
-)
-
-
-def test_should_build_artifact_serverless_code_and_container():
- assert should_build_artifact(
- target="serverless",
- artifact_type="Code",
- ks3_path=None,
- image=None,
- ) is True
- assert should_build_artifact(
- target="serverless",
- artifact_type="Code",
- ks3_path="ks3://bucket/object.zip",
- image=None,
- ) is False
- assert should_build_artifact(
- target="serverless",
- artifact_type="Container",
- ks3_path=None,
- image=None,
- ) is True
- assert should_build_artifact(
- target="serverless",
- artifact_type="Container",
- ks3_path=None,
- image="hub.kce.ksyun.com/demo:image",
- ) is False
-
-
-def test_should_build_artifact_non_serverless_never_builds():
- assert should_build_artifact(
- target="kce",
- artifact_type="Code",
- ks3_path=None,
- image=None,
- ) is False
-
-
-def test_plan_artifact_build_no_cache_behaviors():
- plan_rebuild = plan_artifact_build(
- target="serverless",
- artifact_type="Code",
- ks3_path=None,
- image=None,
- no_cache=True,
- )
- assert plan_rebuild.should_build is True
- assert plan_rebuild.should_clear_metadata is True
- assert plan_rebuild.explicit_ref_option is None
-
- plan_external = plan_artifact_build(
- target="serverless",
- artifact_type="Code",
- ks3_path="ks3://bucket/object.zip",
- image=None,
- no_cache=True,
- )
- assert plan_external.should_build is False
- assert plan_external.should_clear_metadata is False
- assert plan_external.explicit_ref_option == "--ks3-path"
-
-
-def test_plan_artifact_build_repackage_rebuilds_without_clearing_dependency_cache():
- plan = plan_artifact_build(
- target="serverless",
- artifact_type="Code",
- ks3_path=None,
- image=None,
- no_cache=False,
- repackage=True,
- )
-
- assert plan.should_build is True
- assert plan.should_clear_metadata is True
- assert plan.explicit_ref_option is None
-
-
-def test_clear_build_metadata(tmp_path: Path):
- metadata_file = tmp_path / ".agentengine" / "build-metadata.json"
- metadata_file.parent.mkdir(parents=True, exist_ok=True)
- metadata_file.write_text("{}", encoding="utf-8")
-
- assert clear_build_metadata(tmp_path) is True
- assert metadata_file.exists() is False
- assert clear_build_metadata(tmp_path) is False
-
-
-def test_load_cached_artifact_reference_reads_code_and_container_metadata(tmp_path: Path):
- metadata_file = tmp_path / ".agentengine" / "build-metadata.json"
- metadata_file.parent.mkdir(parents=True, exist_ok=True)
- metadata_file.write_text(
- json.dumps(
- {
- "image": "hub.kce.ksyun.com/demo/demo-agent:latest",
- "metadata": {
- "ks3_path": "ks3://bucket/agents/demo-agent/code.zip",
- "image": "hub.kce.ksyun.com/demo/demo-agent:latest",
- },
- }
- ),
- encoding="utf-8",
- )
-
- assert load_cached_artifact_reference(tmp_path, "Code") == "ks3://bucket/agents/demo-agent/code.zip"
- assert load_cached_artifact_reference(tmp_path, "Container") == "hub.kce.ksyun.com/demo/demo-agent:latest"
-
-
-def test_resolve_artifact_build_plan_prefers_cached_then_predicted_dry_run():
- base_plan = plan_artifact_build(
- target="serverless",
- artifact_type="Code",
- ks3_path=None,
- image=None,
- no_cache=False,
- )
-
- cached = resolve_artifact_build_plan(
- plan=base_plan,
- target="serverless",
- artifact_type="Code",
- dry_run=False,
- deploy_name="demo-agent",
- region="cn-beijing-6",
- account_id="2000003485",
- ks3_bucket=None,
- registry=None,
- explicit_reference=None,
- cached_reference="ks3://bucket/agents/demo-agent/cached.zip",
- )
- assert cached.should_build is False
- assert cached.will_build is False
- assert cached.should_publish is False
- assert cached.will_publish is False
- assert cached.source == "cached"
- assert cached.reference == "ks3://bucket/agents/demo-agent/cached.zip"
-
- predicted = resolve_artifact_build_plan(
- plan=base_plan,
- target="serverless",
- artifact_type="Code",
- dry_run=True,
- deploy_name="demo-agent",
- region="cn-beijing-6",
- account_id="2000003485",
- ks3_bucket=None,
- registry=None,
- explicit_reference=None,
- cached_reference=None,
- )
- assert predicted.should_build is True
- assert predicted.will_build is False
- assert predicted.should_publish is True
- assert predicted.will_publish is False
- assert predicted.source == "planned_build"
- assert predicted.reference_is_predicted is True
- assert predicted.reference == "ks3://agentengine-2000003485-cn-beijing-6/agents/demo-agent/code_.zip"
-
-
-def test_build_workflow_local_plan_splits_local_build_and_artifact_publish_steps():
- base_plan = plan_artifact_build(
- target="serverless",
- artifact_type="Code",
- ks3_path=None,
- image=None,
- no_cache=False,
- )
- predicted = resolve_artifact_build_plan(
- plan=base_plan,
- target="serverless",
- artifact_type="Code",
- dry_run=True,
- deploy_name="demo-agent",
- region="cn-beijing-6",
- account_id="2000003485",
- ks3_bucket=None,
- registry=None,
- explicit_reference=None,
- cached_reference=None,
- )
-
- plan = build_workflow_local_plan(
- project_dir=Path("/tmp/demo-agent"),
- framework="langgraph",
- target="serverless",
- region="cn-beijing-6",
- deploy_name="demo-agent",
- artifact_type="Code",
- artifact_plan=predicted,
- build_dir="/tmp/demo-agent/.agentengine/build",
- artifact_reference=predicted.reference,
- no_cache=False,
- )
-
- assert [step["name"] for step in plan["steps"]] == [
- "validate_config",
- "package",
- "local_build",
- "artifact_publish",
- "deploy_request",
- ]
- assert plan["steps"][2]["kind"] == "local"
- assert plan["steps"][2]["planned"] is True
- assert plan["steps"][2]["reason"] == "dry_run_prediction"
- assert plan["steps"][3]["kind"] == "remote"
- assert plan["steps"][3]["planned"] is True
- assert plan["steps"][3]["reason"] == "dry_run_prediction"
- assert plan["artifact"]["should_local_build"] is True
- assert plan["artifact"]["will_local_build"] is False
- assert plan["artifact"]["should_publish"] is True
- assert plan["artifact"]["will_publish"] is False
diff --git a/tests/test_workflow_help_snapshots.py b/tests/test_workflow_help_snapshots.py
deleted file mode 100644
index 06b59d2a..00000000
--- a/tests/test_workflow_help_snapshots.py
+++ /dev/null
@@ -1,51 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from click.testing import CliRunner
-
-from ksadk.cli.cmd_build import build
-from ksadk.cli.cmd_deploy import deploy
-from ksadk.cli.cmd_launch import launch
-
-
-SNAPSHOT_FILE = Path(__file__).parent / "snapshots" / "workflow_help_snapshots.txt"
-
-
-def load_section_snapshots(path: Path) -> dict[str, str]:
- sections: dict[str, str] = {}
- current_name: str | None = None
- current_lines: list[str] = []
-
- for line in path.read_text(encoding="utf-8").splitlines():
- if line.startswith("=== ") and line.endswith(" ==="):
- if current_name is not None:
- sections[current_name] = "\n".join(current_lines).rstrip() + "\n"
- current_name = line[4:-4]
- current_lines = []
- continue
- current_lines.append(line)
-
- if current_name is not None:
- sections[current_name] = "\n".join(current_lines).rstrip() + "\n"
-
- return sections
-
-
-def _normalize_help(text: str) -> str:
- return text.rstrip() + "\n"
-
-
-def test_workflow_help_snapshots_match():
- runner = CliRunner()
- snapshots = load_section_snapshots(SNAPSHOT_FILE)
- commands = {
- "build_help": (build, ["--help"]),
- "deploy_help": (deploy, ["--help"]),
- "launch_help": (launch, ["--help"]),
- }
-
- for section_name, (command, argv) in commands.items():
- result = runner.invoke(command, argv)
- assert result.exit_code == 0, result.output
- assert _normalize_help(result.output) == snapshots[section_name]
diff --git a/tests/unit/knowledge_base/test_client_env.py b/tests/unit/knowledge_base/test_client_env.py
deleted file mode 100644
index f83540d9..00000000
--- a/tests/unit/knowledge_base/test_client_env.py
+++ /dev/null
@@ -1,57 +0,0 @@
-import os
-
-
-def _clear_kb_env(monkeypatch):
- for key in list(os.environ):
- if key.startswith("KSADK_KB_") or key.startswith("KSYUN_"):
- monkeypatch.delenv(key, raising=False)
-
-
-def test_from_env_prefers_explicit_region_over_ksyun_region(monkeypatch):
- from ksadk.knowledge_base.client import KnowledgeBaseClient
-
- _clear_kb_env(monkeypatch)
- monkeypatch.setenv("KSADK_KB_DATASET_ID", "dataset-test")
- monkeypatch.setenv("KSYUN_REGION", "pre-online")
- monkeypatch.setenv("KSADK_KB_REGION", "cn-beijing-6")
-
- client = KnowledgeBaseClient.from_env()
-
- assert client.region == "cn-beijing-6"
-
-
-def test_from_env_falls_back_to_ksyun_region(monkeypatch):
- from ksadk.knowledge_base.client import KnowledgeBaseClient
-
- _clear_kb_env(monkeypatch)
- monkeypatch.setenv("KSADK_KB_DATASET_ID", "dataset-test")
- monkeypatch.setenv("KSYUN_REGION", "pre-online")
-
- client = KnowledgeBaseClient.from_env()
-
- assert client.region == "pre-online"
-
-
-def test_from_env_uses_http_for_inner_endpoint_when_scheme_unset(monkeypatch):
- from ksadk.knowledge_base.client import KnowledgeBaseClient
-
- _clear_kb_env(monkeypatch)
- monkeypatch.setenv("KSADK_KB_DATASET_ID", "dataset-test")
- monkeypatch.setenv("KSADK_KB_ENDPOINT", "aicp.inner.api.ksyun.com")
-
- client = KnowledgeBaseClient.from_env()
-
- assert client.scheme == "http"
-
-
-def test_from_env_keeps_explicit_scheme_for_inner_endpoint(monkeypatch):
- from ksadk.knowledge_base.client import KnowledgeBaseClient
-
- _clear_kb_env(monkeypatch)
- monkeypatch.setenv("KSADK_KB_DATASET_ID", "dataset-test")
- monkeypatch.setenv("KSADK_KB_ENDPOINT", "aicp.inner.api.ksyun.com")
- monkeypatch.setenv("KSADK_KB_SCHEME", "https")
-
- client = KnowledgeBaseClient.from_env()
-
- assert client.scheme == "https"
diff --git a/tests/unit/memory/test_adk_memory_comprehensive.py b/tests/unit/memory/test_adk_memory_comprehensive.py
deleted file mode 100644
index f5eb5ae9..00000000
--- a/tests/unit/memory/test_adk_memory_comprehensive.py
+++ /dev/null
@@ -1,1169 +0,0 @@
-"""KsADK 记忆库 ADK 模块综合单元测试
-
-覆盖 ADK 记忆模块的全部接口和使用流程,分 8 个测试类:
-
- A. TestInMemoryLTMBackendExtended - InMemoryLTMBackend 边界测试
- B. TestHttpLTMBackend - HttpLTMBackend Mock HTTP 测试
- C. TestSdkLTMBackend - SdkLTMBackend Mock SDK 测试
- D. TestLongTermMemoryInit - LongTermMemory 构造和工厂方法
- E. TestLongTermMemoryEventFiltering - 事件过滤逻辑
- F. TestLongTermMemorySearchMemory - 检索和响应解析
- G. TestShortTermMemory - ShortTermMemory 会话管理
- H. TestADKRunnerMemoryIntegration - ADKRunner 记忆集成
-
-所有测试纯本地运行,不依赖 LLM / 远程 API。
-
-运行方式:
- .venv/bin/python -m pytest tests/unit/memory/test_adk_memory_comprehensive.py -v
-"""
-
-import json
-import os
-import time
-from unittest.mock import MagicMock, patch, AsyncMock
-
-import pytest
-
-# ============================================================
-# A. TestInMemoryLTMBackendExtended
-# ============================================================
-
-
-class TestInMemoryLTMBackendExtended:
- """InMemoryLTMBackend 边界场景和详细行为测试"""
-
- def _make_backend(self, index="test_app"):
- from ksadk.memory.adk.backends.inmemory_ltm_backend import InMemoryLTMBackend
- return InMemoryLTMBackend(index=index)
-
- def test_index_property(self):
- backend = self._make_backend(index="my_custom_index")
- assert backend.index == "my_custom_index"
-
- def test_save_empty_list_returns_true(self):
- backend = self._make_backend()
- assert backend.save_memory("user_1", []) is True
- assert backend.search_memory("user_1", "anything") == []
-
- def test_unicode_special_characters(self):
- backend = self._make_backend()
- events = [
- json.dumps({"text": "我喜欢🎉派对和日本語テスト"}, ensure_ascii=False),
- json.dumps({"text": "特殊字符: <>&\"'\\n\\t"}, ensure_ascii=False),
- ]
- assert backend.save_memory("u1", events) is True
- results = backend.search_memory("u1", "派对", top_k=5)
- assert len(results) >= 1
- assert any("派对" in r for r in results)
-
- def test_large_volume_memory(self):
- backend = self._make_backend()
- events = [f"memory_item_{i}: topic_{i % 10}" for i in range(200)]
- assert backend.save_memory("u1", events) is True
- results = backend.search_memory("u1", "topic_5", top_k=10)
- assert len(results) == 10
-
- def test_top_k_limits_results(self):
- backend = self._make_backend()
- events = [f"event_{i}" for i in range(10)]
- backend.save_memory("u1", events)
-
- results_3 = backend.search_memory("u1", "event", top_k=3)
- assert len(results_3) == 3
-
- results_1 = backend.search_memory("u1", "event", top_k=1)
- assert len(results_1) == 1
-
- # top_k > total: returns all
- results_100 = backend.search_memory("u1", "event", top_k=100)
- assert len(results_100) == 10
-
- def test_no_match_returns_recent(self):
- """查询无匹配时,返回最近 top_k 条记忆"""
- backend = self._make_backend()
- events = [f"event_{i}" for i in range(5)]
- backend.save_memory("u1", events)
-
- results = backend.search_memory("u1", "completely_unrelated_xyz", top_k=3)
- assert len(results) == 3
- # 应该是最后 3 条
- assert results == events[-3:]
-
- def test_multiple_users_isolation(self):
- backend = self._make_backend()
- for i in range(5):
- backend.save_memory(f"user_{i}", [f"secret_data_for_user_{i}"])
-
- for i in range(5):
- results = backend.search_memory(f"user_{i}", f"secret_data_for_user_{i}")
- assert len(results) == 1
- assert f"user_{i}" in results[0]
- # 不应搜到其他用户的精确数据
- for j in range(5):
- if j != i:
- other_results = backend.search_memory(
- f"user_{i}", f"secret_data_for_user_{j}"
- )
- assert not any(f"user_{j}" in r for r in other_results)
-
- def test_full_match_scores_higher(self):
- """完整匹配得分 (+10) 高于部分关键词匹配 (+1)"""
- backend = self._make_backend()
- backend.save_memory("u1", [
- "I love Python programming", # 完整匹配 "Python programming"
- "Python is good", # 仅部分匹配 "Python"
- ])
- results = backend.search_memory("u1", "Python programming", top_k=2)
- assert len(results) == 2
- # 完整匹配的应该排在前面
- assert "Python programming" in results[0]
-
-
-# ============================================================
-# B. TestHttpLTMBackend
-# ============================================================
-
-
-class TestHttpLTMBackend:
- """HttpLTMBackend Mock HTTP 测试"""
-
- def _make_backend(self, base_url="http://test.local", token="test-token"):
- from ksadk.memory.adk.backends.http_ltm_backend import HttpLTMBackend
- return HttpLTMBackend(index="test", base_url=base_url, token=token)
-
- def test_empty_base_url_save_returns_false(self):
- backend = self._make_backend(base_url="")
- assert backend.save_memory("u1", ["event"]) is False
-
- def test_empty_base_url_search_returns_empty(self):
- backend = self._make_backend(base_url="")
- assert backend.search_memory("u1", "query") == []
-
- def test_save_memory_success(self):
- backend = self._make_backend()
- mock_response = MagicMock()
- mock_response.raise_for_status = MagicMock()
- mock_client = MagicMock()
- mock_client.post.return_value = mock_response
- backend._client = mock_client
-
- result = backend.save_memory("u1", ["event_1", "event_2"])
- assert result is True
- mock_client.post.assert_called_once()
- call_args = mock_client.post.call_args
- payload = call_args[1]["json"]
- assert payload["user_id"] == "u1"
- assert payload["events"] == ["event_1", "event_2"]
-
- def test_save_memory_http_error(self):
- import httpx
- backend = self._make_backend()
- mock_client = MagicMock()
- mock_response = MagicMock()
- mock_response.status_code = 500
- mock_response.text = "Internal Server Error"
- mock_client.post.side_effect = httpx.HTTPStatusError(
- "error", request=MagicMock(), response=mock_response
- )
- backend._client = mock_client
-
- assert backend.save_memory("u1", ["event"]) is False
-
- def test_search_memory_success(self):
- backend = self._make_backend()
- mock_client = MagicMock()
- mock_response = MagicMock()
- mock_response.raise_for_status = MagicMock()
- mock_response.json.return_value = {"memories": ["mem_1", "mem_2"]}
- mock_client.post.return_value = mock_response
- backend._client = mock_client
-
- results = backend.search_memory("u1", "query", top_k=5)
- assert results == ["mem_1", "mem_2"]
-
- def test_search_memory_http_error(self):
- import httpx
- backend = self._make_backend()
- mock_client = MagicMock()
- mock_response = MagicMock()
- mock_response.status_code = 404
- mock_response.text = "Not Found"
- mock_client.post.side_effect = httpx.HTTPStatusError(
- "error", request=MagicMock(), response=mock_response
- )
- backend._client = mock_client
-
- assert backend.search_memory("u1", "query") == []
-
- def test_client_lazy_init(self):
- backend = self._make_backend()
- assert backend._client is None
- client1 = backend.client
- assert backend._client is not None
- client2 = backend.client
- assert client1 is client2
-
- def test_token_in_headers(self):
- backend = self._make_backend(token="my-secret-token")
- client = backend.client
- assert "Authorization" in client.headers
- assert client.headers["Authorization"] == "Bearer my-secret-token"
-
- def test_close_resets_client(self):
- backend = self._make_backend()
- _ = backend.client # trigger lazy init
- assert backend._client is not None
- backend.close()
- assert backend._client is None
-
-
-# ============================================================
-# C. TestSdkLTMBackend
-# ============================================================
-
-
-class TestSdkLTMBackend:
- """SdkLTMBackend Mock SDK 测试"""
-
- def _make_backend(self, **kwargs):
- from ksadk.memory.adk.backends.sdk_ltm_backend import SdkLTMBackend
- defaults = {
- "index": "test_idx",
- "access_key": "test_ak",
- "secret_key": "test_sk",
- "namespace": "test_ns",
- }
- defaults.update(kwargs)
- return SdkLTMBackend(**defaults)
-
- def test_init_no_credentials_warning(self, caplog):
- import logging
- with caplog.at_level(logging.WARNING):
- from ksadk.memory.adk.backends.sdk_ltm_backend import SdkLTMBackend
- backend = SdkLTMBackend(index="test", access_key="", secret_key="")
- assert "AK/SK not provided" in caplog.text
-
- def test_save_empty_events_returns_true(self):
- backend = self._make_backend()
- assert backend.save_memory("u1", []) is True
-
- def test_save_calls_create_memory_sdk(self):
- backend = self._make_backend()
- mock_client = MagicMock()
- mock_client.call.return_value = '{"RequestId": "123"}'
-
- with patch.object(backend, '_get_client', return_value=mock_client):
- event = json.dumps(
- {"role": "user", "parts": [{"text": "hello"}]},
- ensure_ascii=False,
- )
- result = backend.save_memory(
- "u1",
- [event],
- metadata={"agent_id": "agent-1", "session_id": "sess-1"},
- )
-
- assert result is True
- mock_client.call.assert_called_once()
- call_args = mock_client.call.call_args
- assert call_args[0][0] == "CreateMemorySdk"
- params = call_args[0][1]
- assert params["MemoryCollectionId"] == "test_ns"
- assert params["AgentUserId"] == "u1"
- assert params["AgentId"] == "agent-1"
- assert params["SessionId"] == "sess-1"
- assert params["SceneId"] == "_sys_general"
- assert params["DataType"] == "conversation"
- assert "Namespace" not in params
- assert "UserId" not in params
-
- def test_save_data_conversation_format(self):
- """验证 Data 字段为 {"Conversation": [...]} 结构"""
- backend = self._make_backend()
- mock_client = MagicMock()
- mock_client.call.return_value = "{}"
-
- with patch.object(backend, '_get_client', return_value=mock_client):
- event = json.dumps(
- {"role": "user", "parts": [{"text": "test msg"}]},
- ensure_ascii=False,
- )
- backend.save_memory("u1", [event])
-
- params = mock_client.call.call_args[0][1]
- assert "Data" in params
- assert "Conversation" in params["Data"]
- assert isinstance(params["Data"]["Conversation"], list)
- assert len(params["Data"]["Conversation"]) == 1
-
- def test_save_conversation_item_fields(self):
- """每个 Conversation 项必须有 Role/CreatedAt/MessageId/Content"""
- backend = self._make_backend()
- mock_client = MagicMock()
- mock_client.call.return_value = "{}"
-
- with patch.object(backend, '_get_client', return_value=mock_client):
- event = json.dumps(
- {"role": "user", "parts": [{"text": "hello world"}]},
- ensure_ascii=False,
- )
- backend.save_memory("u1", [event])
-
- params = mock_client.call.call_args[0][1]
- item = params["Data"]["Conversation"][0]
- assert item["Role"] == "user"
- assert isinstance(item["CreatedAt"], int)
- assert item["CreatedAt"] > 0
- assert len(item["MessageId"]) > 0
- assert item["Content"] == [{"Type": "input_text", "Text": "hello world"}]
-
- def test_save_parses_event_json(self):
- """从 ADK event JSON 正确提取 role 和 text"""
- backend = self._make_backend()
- mock_client = MagicMock()
- mock_client.call.return_value = "{}"
-
- with patch.object(backend, '_get_client', return_value=mock_client):
- events = [
- json.dumps({"role": "user", "parts": [{"text": "msg_1"}]}),
- json.dumps({"role": "user", "parts": [{"text": "msg_2"}]}),
- ]
- backend.save_memory("u1", events)
-
- params = mock_client.call.call_args[0][1]
- conv = params["Data"]["Conversation"]
- assert len(conv) == 2
- assert conv[0]["Content"][0]["Text"] == "msg_1"
- assert conv[1]["Content"][0]["Text"] == "msg_2"
-
- def test_save_plain_text_fallback(self):
- """非 JSON 格式的事件字符串按纯文本处理"""
- backend = self._make_backend()
- mock_client = MagicMock()
- mock_client.call.return_value = "{}"
-
- with patch.object(backend, '_get_client', return_value=mock_client):
- backend.save_memory("u1", ["plain text message"])
-
- params = mock_client.call.call_args[0][1]
- item = params["Data"]["Conversation"][0]
- assert item["Role"] == "user"
- assert item["Content"][0]["Text"] == "plain text message"
-
- def test_save_exception_returns_false(self):
- backend = self._make_backend()
- mock_client = MagicMock()
- mock_client.call.side_effect = Exception("SDK error")
-
- with patch.object(backend, '_get_client', return_value=mock_client):
- assert backend.save_memory("u1", ["event"]) is False
-
- def test_search_calls_query_memory_sdk(self):
- backend = self._make_backend()
- mock_client = MagicMock()
- mock_client.call.return_value = json.dumps({"Memories": ["result1"]})
-
- with patch.object(backend, '_get_client', return_value=mock_client):
- results = backend.search_memory("u1", "query", top_k=3)
-
- assert results == ["result1"]
- call_args = mock_client.call.call_args
- assert call_args[0][0] == "QueryMemorySdk"
- params = call_args[0][1]
- assert params["MemoryCollectionId"] == "test_ns"
- assert params["AgentUserId"] == "u1"
- assert params["SceneId"] == "_sys_general"
- assert params["Query"] == "query"
- assert params["Limit"] == 3
- assert "Namespace" not in params
- assert "UserId" not in params
-
- def test_search_exception_returns_empty(self):
- backend = self._make_backend()
- mock_client = MagicMock()
- mock_client.call.side_effect = Exception("SDK error")
-
- with patch.object(backend, '_get_client', return_value=mock_client):
- assert backend.search_memory("u1", "query") == []
-
- def test_get_session_status_calls_list_sessions(self):
- backend = self._make_backend()
- mock_client = MagicMock()
- mock_client.call.return_value = json.dumps({
- "Code": 200,
- "Message": "success",
- "Data": {
- "Total": 1,
- "Items": [
- {"SessionId": "sess-1", "State": 0, "DataType": "conversation"},
- ],
- },
- })
-
- with patch.object(backend, '_get_client', return_value=mock_client):
- status = backend.get_session_status(user_id="u1", session_id="sess-1")
-
- assert status == {"SessionId": "sess-1", "State": 0, "DataType": "conversation"}
- call_args = mock_client.call.call_args
- assert call_args[0][0] == "ListSessions"
- assert call_args[0][1] == {
- "MemoryCollectionId": "test_ns",
- "AgentUserId": "u1",
- "Page": 1,
- "PageSize": 20,
- }
-
- def test_namespace_fallback_to_index(self):
- backend = self._make_backend(namespace="", index="fallback_idx")
- mock_client = MagicMock()
- mock_client.call.return_value = json.dumps({"Memories": []})
-
- with patch.object(backend, '_get_client', return_value=mock_client):
- backend.search_memory("u1", "query")
-
- params = mock_client.call.call_args[0][1]
- assert params["MemoryCollectionId"] == "fallback_idx"
-
- def test_optional_search_params(self):
- backend = self._make_backend(scene_id="scene_1")
- mock_client = MagicMock()
- mock_client.call.return_value = json.dumps({"Memories": []})
-
- with patch.object(backend, '_get_client', return_value=mock_client):
- backend.search_memory(
- "u1", "query",
- occurred_after=1000,
- occurred_before=2000,
- mode="semantic",
- )
-
- params = mock_client.call.call_args[0][1]
- assert params["SceneId"] == "scene_1"
- assert params["OccurredAfter"] == 1000
- assert params["OccurredBefore"] == 2000
- assert params["Mode"] == "semantic"
-
- # --- _parse_query_response tests ---
-
- def test_parse_response_memories_format(self):
- backend = self._make_backend()
- result = backend._parse_query_response({"Memories": ["text1", "text2"]})
- assert result == ["text1", "text2"]
-
- def test_parse_response_data_dict_format(self):
- backend = self._make_backend()
- result = backend._parse_query_response({
- "Data": [{"Content": "content_1"}, {"Text": "text_1"}]
- })
- assert result == ["content_1", "text_1"]
-
- def test_parse_response_data_nested_empty_memories_is_empty(self):
- backend = self._make_backend()
- result = backend._parse_query_response({
- "Code": 200,
- "Message": "success",
- "Data": [{"Memories": []}],
- })
- assert result == []
-
- def test_parse_response_data_nested_aicp_memory_field(self):
- backend = self._make_backend()
- result = backend._parse_query_response({
- "Code": 200,
- "Message": "success",
- "Data": [{
- "Memories": [
- {
- "MemoryId": "mem-1",
- "Memory": "用户张三喜欢喝桃汁。",
- "Score": 0.99,
- },
- {
- "MemoryId": "mem-2",
- "Memory": "用户张三不喜欢喝咖啡。",
- "Score": 0.98,
- },
- ],
- }],
- })
- assert result == ["用户张三喜欢喝桃汁。", "用户张三不喜欢喝咖啡。"]
-
- def test_parse_response_results_format(self):
- backend = self._make_backend()
- result = backend._parse_query_response({
- "Results": [{"Text": "r1"}, {"Content": "r2"}]
- })
- assert result == ["r1", "r2"]
-
- def test_parse_response_content_priority(self):
- """Content 字段优先于 Text 和 Data"""
- backend = self._make_backend()
- result = backend._parse_query_response({
- "Memories": [{"Content": "preferred", "Text": "fallback", "Data": "last"}]
- })
- assert result == ["preferred"]
-
- def test_parse_response_unknown_format(self, caplog):
- import logging
- backend = self._make_backend()
- with caplog.at_level(logging.WARNING):
- result = backend._parse_query_response({"UnknownKey": "value"})
- assert result == []
- assert "Unknown QueryMemorySdk response format" in caplog.text
-
- def test_parse_response_invalid_json(self):
- backend = self._make_backend()
- result = backend._parse_query_response("not valid json {{{")
- assert result == []
-
-
-# ============================================================
-# D. TestLongTermMemoryInit
-# ============================================================
-
-
-class TestLongTermMemoryInit:
- """LongTermMemory 构造和 from_env() 工厂方法"""
-
- def test_init_local_string(self):
- from ksadk.memory.adk.long_term_memory import LongTermMemory
- from ksadk.memory.adk.backends.inmemory_ltm_backend import InMemoryLTMBackend
-
- ltm = LongTermMemory(backend="local", app_name="test_app")
- assert isinstance(ltm._backend, InMemoryLTMBackend)
-
- def test_init_backend_instance(self):
- from ksadk.memory.adk.long_term_memory import LongTermMemory
- from ksadk.memory.adk.backends.inmemory_ltm_backend import InMemoryLTMBackend
-
- custom_backend = InMemoryLTMBackend(index="custom_index")
- ltm = LongTermMemory(backend=custom_backend)
- assert ltm._backend is custom_backend
- assert ltm.index == "custom_index"
-
- def test_init_with_backend_config(self):
- from ksadk.memory.adk.long_term_memory import LongTermMemory
-
- ltm = LongTermMemory(
- backend="local",
- backend_config={"index": "config_index"},
- app_name="test",
- )
- assert ltm._backend.index == "config_index"
-
- def test_init_default_index(self):
- from ksadk.memory.adk.long_term_memory import LongTermMemory
-
- ltm = LongTermMemory(backend="local")
- assert ltm.index == "default_app"
-
- def test_init_app_name_as_index(self):
- from ksadk.memory.adk.long_term_memory import LongTermMemory
-
- ltm = LongTermMemory(backend="local", app_name="my_app")
- assert ltm.index == "my_app"
-
- def test_init_invalid_backend_raises(self):
- from ksadk.memory.adk.long_term_memory import LongTermMemory
- from pydantic import ValidationError
-
- with pytest.raises(ValidationError):
- LongTermMemory(backend="unknown_backend", app_name="test")
-
- def test_from_env_default(self, monkeypatch):
- from ksadk.memory.adk.long_term_memory import LongTermMemory
-
- # Clear all LTM env vars
- for key in list(os.environ.keys()):
- if key.startswith("KSADK_LTM_"):
- monkeypatch.delenv(key, raising=False)
-
- ltm = LongTermMemory.from_env()
- assert ltm.backend == "local"
-
- def test_from_env_http(self, monkeypatch):
- from ksadk.memory.adk.long_term_memory import LongTermMemory
-
- monkeypatch.setenv("KSADK_LTM_BACKEND", "http")
- monkeypatch.setenv("KSADK_LTM_HTTP_URL", "http://test.local")
- monkeypatch.setenv("KSADK_LTM_HTTP_TOKEN", "tok123")
-
- ltm = LongTermMemory.from_env()
- assert ltm.backend == "http"
- assert ltm.backend_config["base_url"] == "http://test.local"
- assert ltm.backend_config["token"] == "tok123"
-
- def test_from_env_sdk(self, monkeypatch):
- from ksadk.memory.adk.long_term_memory import LongTermMemory
-
- monkeypatch.setenv("KSADK_LTM_BACKEND", "sdk")
- monkeypatch.setenv("KSADK_LTM_ACCESS_KEY", "ak_test")
- monkeypatch.setenv("KSADK_LTM_SECRET_KEY", "sk_test")
- monkeypatch.setenv("KSADK_LTM_NAMESPACE", "ns_test")
-
- ltm = LongTermMemory.from_env()
- assert ltm.backend == "sdk"
- assert ltm.backend_config["access_key"] == "ak_test"
- assert ltm.backend_config["secret_key"] == "sk_test"
- assert ltm.backend_config["namespace"] == "ns_test"
- assert ltm.backend_config["scene_id"] == "_sys_general"
-
- def test_from_env_sdk_ak_fallback(self, monkeypatch):
- from ksadk.memory.adk.long_term_memory import LongTermMemory
-
- monkeypatch.setenv("KSADK_LTM_BACKEND", "sdk")
- monkeypatch.delenv("KSADK_LTM_ACCESS_KEY", raising=False)
- monkeypatch.delenv("KSADK_LTM_SECRET_KEY", raising=False)
- monkeypatch.setenv("KSYUN_ACCESS_KEY", "fallback_ak")
- monkeypatch.setenv("KSYUN_SECRET_KEY", "fallback_sk")
-
- ltm = LongTermMemory.from_env()
- assert ltm.backend_config["access_key"] == "fallback_ak"
- assert ltm.backend_config["secret_key"] == "fallback_sk"
-
- def test_from_env_sdk_prefers_explicit_region_over_ksyun_region(
- self, monkeypatch
- ):
- from ksadk.memory.adk.long_term_memory import LongTermMemory
-
- monkeypatch.setenv("KSADK_LTM_BACKEND", "sdk")
- monkeypatch.setenv("KSADK_LTM_REGION", "cn-beijing-6")
- monkeypatch.setenv("KSYUN_REGION", "pre-online")
-
- ltm = LongTermMemory.from_env()
- assert ltm.backend_config["region"] == "cn-beijing-6"
-
- def test_from_env_sdk_falls_back_to_ksyun_region(self, monkeypatch):
- from ksadk.memory.adk.long_term_memory import LongTermMemory
-
- monkeypatch.setenv("KSADK_LTM_BACKEND", "sdk")
- monkeypatch.delenv("KSADK_LTM_REGION", raising=False)
- monkeypatch.setenv("KSYUN_REGION", "pre-online")
-
- ltm = LongTermMemory.from_env()
- assert ltm.backend_config["region"] == "pre-online"
-
- def test_from_env_sdk_uses_http_for_inner_endpoint_when_scheme_unset(
- self, monkeypatch
- ):
- from ksadk.memory.adk.long_term_memory import LongTermMemory
-
- monkeypatch.setenv("KSADK_LTM_BACKEND", "sdk")
- monkeypatch.delenv("KSADK_LTM_SCHEME", raising=False)
- monkeypatch.setenv("KSADK_LTM_ENDPOINT", "aicp.inner.api.ksyun.com")
-
- ltm = LongTermMemory.from_env()
- assert ltm.backend_config["scheme"] == "http"
-
- def test_from_env_top_k(self, monkeypatch):
- from ksadk.memory.adk.long_term_memory import LongTermMemory
-
- monkeypatch.setenv("KSADK_LTM_TOP_K", "10")
- ltm = LongTermMemory.from_env()
- assert ltm.top_k == 10
-
-
-# ============================================================
-# E. TestLongTermMemoryEventFiltering
-# ============================================================
-
-
-class TestLongTermMemoryEventFiltering:
- """LongTermMemory._filter_and_convert_events() 详细测试"""
-
- def _make_ltm(self):
- from ksadk.memory.adk.long_term_memory import LongTermMemory
- return LongTermMemory(backend="local", app_name="filter_test")
-
- def _make_event(self, author="user", text=None, function_call=None):
- from google.adk.events.event import Event
- from google.genai import types
-
- parts = []
- if text is not None:
- parts.append(types.Part(text=text))
- if function_call is not None:
- parts.append(types.Part(function_call=function_call))
-
- content = types.Content(role=author, parts=parts) if parts else None
- return Event(invocation_id="inv1", author=author, content=content)
-
- def test_only_user_events_pass(self):
- ltm = self._make_ltm()
- events = [
- self._make_event(author="user", text="user message"),
- self._make_event(author="model", text="model reply"),
- ]
- result = ltm._filter_and_convert_events(events)
- assert len(result) == 1
- assert "user message" in result[0]
-
- def test_function_call_filtered(self):
- from google.genai import types
- ltm = self._make_ltm()
- events = [
- self._make_event(
- author="user",
- function_call=types.FunctionCall(name="search", args={"q": "test"}),
- ),
- ]
- result = ltm._filter_and_convert_events(events)
- assert len(result) == 0
-
- def test_empty_content_filtered(self):
- from google.adk.events.event import Event
- ltm = self._make_ltm()
- event = Event(invocation_id="inv1", author="user", content=None)
- result = ltm._filter_and_convert_events([event])
- assert len(result) == 0
-
- def test_empty_parts_filtered(self):
- from google.adk.events.event import Event
- from google.genai import types
- ltm = self._make_ltm()
- event = Event(
- invocation_id="inv1",
- author="user",
- content=types.Content(role="user", parts=[]),
- )
- result = ltm._filter_and_convert_events([event])
- assert len(result) == 0
-
- def test_event_serialization_json(self):
- ltm = self._make_ltm()
- events = [self._make_event(author="user", text="hello world")]
- result = ltm._filter_and_convert_events(events)
- assert len(result) == 1
-
- parsed = json.loads(result[0])
- assert "role" in parsed
- assert "parts" in parsed
- assert parsed["parts"][0]["text"] == "hello world"
-
- async def test_empty_session_no_save(self):
- from google.adk.sessions import InMemorySessionService
- ltm = self._make_ltm()
-
- svc = InMemorySessionService()
- session = await svc.create_session(app_name="test", user_id="u1")
- # Session has no events
- await ltm.add_session_to_memory(session)
- # No error, nothing saved
-
- async def test_all_filtered_no_save(self):
- from google.adk.sessions import InMemorySessionService
- ltm = self._make_ltm()
-
- svc = InMemorySessionService()
- session = await svc.create_session(app_name="test", user_id="u1")
- # Only model events
- session.events = [self._make_event(author="model", text="model only")]
- await ltm.add_session_to_memory(session)
- # Nothing saved to backend
-
- def test_mixed_events_only_user_text_saved(self):
- from google.genai import types
- ltm = self._make_ltm()
- events = [
- self._make_event(author="user", text="keep this"),
- self._make_event(author="model", text="discard model"),
- self._make_event(
- author="user",
- function_call=types.FunctionCall(name="fn", args={}),
- ),
- self._make_event(author="user", text="keep this too"),
- ]
- result = ltm._filter_and_convert_events(events)
- assert len(result) == 2
- assert "keep this" in result[0]
- assert "keep this too" in result[1]
-
-
-# ============================================================
-# F. TestLongTermMemorySearchMemory
-# ============================================================
-
-
-class TestLongTermMemorySearchMemory:
- """LongTermMemory.search_memory() 返回格式和解析"""
-
- def _make_ltm(self):
- from ksadk.memory.adk.long_term_memory import LongTermMemory
- return LongTermMemory(backend="local", app_name="search_test")
-
- async def test_returns_search_memory_response(self):
- from google.adk.memory.base_memory_service import SearchMemoryResponse
- ltm = self._make_ltm()
- result = await ltm.search_memory(
- app_name="search_test", user_id="u1", query="anything"
- )
- assert isinstance(result, SearchMemoryResponse)
-
- async def test_memory_entry_structure(self):
- ltm = self._make_ltm()
- # Pre-populate backend
- ltm._backend.save_memory("u1", [
- json.dumps({"role": "user", "parts": [{"text": "test memory"}]})
- ])
-
- result = await ltm.search_memory(
- app_name="search_test", user_id="u1", query="test"
- )
- assert len(result.memories) == 1
- entry = result.memories[0]
- assert hasattr(entry, "author")
- assert hasattr(entry, "content")
- assert entry.content.parts[0].text == "test memory"
-
- async def test_json_format_parsing(self):
- ltm = self._make_ltm()
- ltm._backend.save_memory("u1", [
- json.dumps({"role": "user", "parts": [{"text": "parsed correctly"}]})
- ])
-
- result = await ltm.search_memory(
- app_name="test", user_id="u1", query="parsed"
- )
- assert len(result.memories) == 1
- assert result.memories[0].content.parts[0].text == "parsed correctly"
- assert result.memories[0].content.role == "user"
-
- async def test_plain_text_fallback(self):
- ltm = self._make_ltm()
- ltm._backend.save_memory("u1", ["just plain text, not json"])
-
- result = await ltm.search_memory(
- app_name="test", user_id="u1", query="plain text"
- )
- assert len(result.memories) == 1
- assert result.memories[0].content.parts[0].text == "just plain text, not json"
- assert result.memories[0].content.role == "user"
-
- async def test_non_standard_json_skipped(self):
- ltm = self._make_ltm()
- ltm._backend.save_memory("u1", [
- json.dumps({"invalid": "no parts key"})
- ])
-
- result = await ltm.search_memory(
- app_name="test", user_id="u1", query="invalid"
- )
- # Non-standard format is skipped
- assert len(result.memories) == 0
-
- async def test_empty_results(self):
- from google.adk.memory.base_memory_service import SearchMemoryResponse
- ltm = self._make_ltm()
- result = await ltm.search_memory(
- app_name="test", user_id="nonexistent", query="anything"
- )
- assert isinstance(result, SearchMemoryResponse)
- assert len(result.memories) == 0
-
- async def test_backend_error_returns_empty(self):
- ltm = self._make_ltm()
- # Replace the private _backend with a mock after construction
- mock_backend = MagicMock()
- mock_backend.search_memory.side_effect = Exception("boom")
- object.__setattr__(ltm, '_backend', mock_backend)
-
- result = await ltm.search_memory(
- app_name="test", user_id="u1", query="query"
- )
- assert len(result.memories) == 0
-
- async def test_top_k_passed_to_backend(self):
- ltm = self._make_ltm()
- ltm.top_k = 3
- mock_backend = MagicMock()
- mock_backend.search_memory.return_value = []
- object.__setattr__(ltm, '_backend', mock_backend)
-
- await ltm.search_memory(app_name="test", user_id="u1", query="q")
- mock_backend.search_memory.assert_called_once_with(
- query="q", top_k=3, user_id="u1"
- )
-
-
-# ============================================================
-# G. TestShortTermMemory
-# ============================================================
-
-
-class TestShortTermMemory:
- """ShortTermMemory 会话管理测试"""
-
- def test_init_local(self):
- from ksadk.memory.adk.short_term_memory import ShortTermMemory
- from google.adk.sessions import InMemorySessionService
-
- stm = ShortTermMemory(backend="local")
- assert isinstance(stm.session_service, InMemorySessionService)
-
- def test_init_database_no_url_raises(self):
- from ksadk.memory.adk.short_term_memory import ShortTermMemory
-
- with pytest.raises(ValueError, match="KSADK_SESSION_DSN"):
- ShortTermMemory(backend="database", db_url="")
-
- def test_init_unknown_backend_raises(self):
- """Pydantic Literal validation rejects unknown backends"""
- from ksadk.memory.adk.short_term_memory import ShortTermMemory
- from pydantic import ValidationError
-
- with pytest.raises(ValidationError):
- ShortTermMemory(backend="xyz_unknown")
-
- def test_session_service_property(self):
- from ksadk.memory.adk.short_term_memory import ShortTermMemory
- from google.adk.sessions import BaseSessionService
-
- stm = ShortTermMemory(backend="local")
- assert isinstance(stm.session_service, BaseSessionService)
-
- async def test_create_session_auto_id(self):
- from ksadk.memory.adk.short_term_memory import ShortTermMemory
-
- stm = ShortTermMemory(backend="local")
- session = await stm.create_session(app_name="app", user_id="u1")
- assert session is not None
- assert session.id # auto-generated, non-empty
-
- async def test_create_session_with_id(self):
- from ksadk.memory.adk.short_term_memory import ShortTermMemory
-
- stm = ShortTermMemory(backend="local")
- session = await stm.create_session(
- app_name="app", user_id="u1", session_id="custom_session_123"
- )
- assert session is not None
- assert session.id == "custom_session_123"
-
- async def test_create_session_retrieves_existing(self):
- from ksadk.memory.adk.short_term_memory import ShortTermMemory
-
- stm = ShortTermMemory(backend="local")
- s1 = await stm.create_session(
- app_name="app", user_id="u1", session_id="shared_id"
- )
- s2 = await stm.create_session(
- app_name="app", user_id="u1", session_id="shared_id"
- )
- assert s1.id == s2.id
-
- def test_from_env_default(self, monkeypatch):
- from ksadk.memory.adk.short_term_memory import ShortTermMemory
-
- monkeypatch.delenv("KSADK_STM_BACKEND", raising=False)
- monkeypatch.delenv("KSADK_STM_PATH", raising=False)
- monkeypatch.delenv("KSADK_STM_DB_URL", raising=False)
- monkeypatch.delenv("KSADK_STM_DB_PATH", raising=False)
- monkeypatch.delenv("KSADK_STM_URL", raising=False)
- monkeypatch.delenv("KSADK_ADK_SESSION_BACKEND", raising=False)
- monkeypatch.delenv("KSADK_ADK_SESSION_PATH", raising=False)
- monkeypatch.delenv("KSADK_ADK_SESSION_URL", raising=False)
-
- stm = ShortTermMemory.from_env()
- assert stm.backend == "local"
-
- def test_from_env_backend(self, monkeypatch):
- from ksadk.memory.adk.short_term_memory import ShortTermMemory
-
- monkeypatch.setenv("KSADK_STM_BACKEND", "sqlite")
- stm = ShortTermMemory.from_env()
- assert stm.backend == "sqlite"
-
- def test_from_env_db_path(self, monkeypatch):
- from ksadk.memory.adk.short_term_memory import ShortTermMemory
-
- monkeypatch.delenv("KSADK_STM_PATH", raising=False)
- monkeypatch.delenv("KSADK_ADK_SESSION_PATH", raising=False)
- monkeypatch.setenv("KSADK_STM_DB_PATH", "/custom/path.db")
- stm = ShortTermMemory.from_env()
- assert stm.local_database_path == "/custom/path.db"
-
-
-# ============================================================
-# H. TestADKRunnerMemoryIntegration
-# ============================================================
-
-
-class TestADKRunnerMemoryIntegration:
- """ADKRunner 记忆初始化和工具注入测试"""
-
- def _make_runner(self):
- from ksadk.runners.adk_runner import ADKRunner
-
- mock_detection = MagicMock()
- mock_detection.entry_point = "agent.py"
- mock_detection.agent_variable = "root_agent"
- runner = ADKRunner(mock_detection, "/tmp/test_project")
- return runner
-
- def test_init_stm_no_env(self, monkeypatch):
- monkeypatch.delenv("KSADK_SESSION_BACKEND", raising=False)
- monkeypatch.delenv("KSADK_SESSION_PATH", raising=False)
- monkeypatch.delenv("KSADK_SESSION_DSN", raising=False)
- monkeypatch.delenv("KSADK_STM_BACKEND", raising=False)
- monkeypatch.delenv("KSADK_STM_PATH", raising=False)
- monkeypatch.delenv("KSADK_STM_URL", raising=False)
- monkeypatch.delenv("KSADK_STM_DB_PATH", raising=False)
- monkeypatch.delenv("KSADK_STM_DB_URL", raising=False)
- monkeypatch.delenv("KSADK_ADK_SESSION_BACKEND", raising=False)
- monkeypatch.delenv("KSADK_ADK_SESSION_PATH", raising=False)
- monkeypatch.delenv("KSADK_ADK_SESSION_URL", raising=False)
- runner = self._make_runner()
- result = runner._init_short_term_memory()
- assert result is None
-
- def test_init_stm_local(self, monkeypatch):
- monkeypatch.setenv("KSADK_STM_BACKEND", "local")
- runner = self._make_runner()
- result = runner._init_short_term_memory()
- assert result is not None
-
- def test_init_ltm_no_env(self, monkeypatch):
- monkeypatch.delenv("KSADK_LTM_BACKEND", raising=False)
- runner = self._make_runner()
- result = runner._init_long_term_memory()
- assert result is None
-
- def test_init_ltm_local(self, monkeypatch):
- monkeypatch.setenv("KSADK_LTM_BACKEND", "local")
- runner = self._make_runner()
- mock_agent = MagicMock()
- mock_agent.name = "test_agent" # set as attribute, not MagicMock constructor param
- runner._agent = mock_agent
- result = runner._init_long_term_memory()
- assert result is not None
-
- def test_init_ltm_sdk_env(self, monkeypatch):
- monkeypatch.setenv("KSADK_LTM_BACKEND", "sdk")
- monkeypatch.setenv("KSADK_LTM_ACCESS_KEY", "ak")
- monkeypatch.setenv("KSADK_LTM_SECRET_KEY", "sk")
- monkeypatch.setenv("KSADK_LTM_NAMESPACE", "ns")
-
- runner = self._make_runner()
- mock_agent = MagicMock()
- mock_agent.name = "test_agent"
- runner._agent = mock_agent
- result = runner._init_long_term_memory()
- assert result is not None
-
- def test_init_ltm_sdk_uses_ksyun_region_and_inner_http(self, monkeypatch):
- monkeypatch.setenv("KSADK_LTM_BACKEND", "sdk")
- monkeypatch.setenv("KSADK_LTM_ACCESS_KEY", "ak")
- monkeypatch.setenv("KSADK_LTM_SECRET_KEY", "sk")
- monkeypatch.setenv("KSADK_LTM_NAMESPACE", "ns")
- monkeypatch.delenv("KSADK_LTM_REGION", raising=False)
- monkeypatch.delenv("KSADK_LTM_SCHEME", raising=False)
- monkeypatch.setenv("KSYUN_REGION", "pre-online")
- monkeypatch.setenv("KSADK_LTM_ENDPOINT", "aicp.inner.api.ksyun.com")
-
- runner = self._make_runner()
- mock_agent = MagicMock()
- mock_agent.name = "test_agent"
- runner._agent = mock_agent
- result = runner._init_long_term_memory()
-
- assert result is not None
- assert result.backend_config["region"] == "pre-online"
- assert result.backend_config["scheme"] == "http"
-
- def test_inject_tool_into_empty(self):
- runner = self._make_runner()
- runner._agent = MagicMock()
- runner._agent.tools = []
-
- runner._inject_load_memory_tool()
- tool_names = [
- getattr(t, "name", None) or getattr(t, "__name__", "")
- for t in runner._agent.tools
- ]
- assert "load_memory" in tool_names
-
- def test_inject_tool_skips_duplicate(self):
- from google.adk.tools import load_memory
-
- runner = self._make_runner()
- runner._agent = MagicMock()
- runner._agent.tools = [load_memory]
-
- runner._inject_load_memory_tool()
- # Should still be just 1
- assert len(runner._agent.tools) == 1
-
- def test_inject_tool_no_tools_attr(self):
- runner = self._make_runner()
- runner._agent = MagicMock(spec=[]) # no 'tools' attribute
- del runner._agent.tools # ensure it's truly missing
-
- # Should not crash
- runner._inject_load_memory_tool()
-
- def test_inject_save_memory_tool_into_empty(self):
- runner = self._make_runner()
- runner._agent = MagicMock()
- runner._agent.tools = []
-
- runner._inject_save_memory_tool()
- tool_names = [
- getattr(t, "name", None) or getattr(t, "__name__", "")
- for t in runner._agent.tools
- ]
- assert "save_memory" in tool_names
-
- async def test_ensure_session_new_external(self):
- from google.adk.sessions import InMemorySessionService
-
- runner = self._make_runner()
- runner._agent = MagicMock()
- runner._agent.name = "test_agent"
- runner._session_service = InMemorySessionService()
-
- session_id = await runner._ensure_session("external_123")
- assert session_id is not None
- assert "external_123" in runner._session_map
-
- async def test_ensure_session_cached(self):
- from google.adk.sessions import InMemorySessionService
-
- runner = self._make_runner()
- runner._agent = MagicMock()
- runner._agent.name = "test_agent"
- runner._session_service = InMemorySessionService()
-
- id1 = await runner._ensure_session("ext_1")
- id2 = await runner._ensure_session("ext_1")
- assert id1 == id2
-
- async def test_ensure_session_default(self):
- from google.adk.sessions import InMemorySessionService
-
- runner = self._make_runner()
- runner._agent = MagicMock()
- runner._agent.name = "test_agent"
- runner._session_service = InMemorySessionService()
-
- id1 = await runner._ensure_session()
- id2 = await runner._ensure_session()
- assert id1 == id2
- assert runner._default_session_id == id1
-
- async def test_save_to_ltm_no_ltm(self):
- runner = self._make_runner()
- runner._long_term_memory = None
- result = await runner.save_session_to_long_term_memory("session_1")
- assert result is False
-
- async def test_save_to_ltm_session_not_found(self):
- from google.adk.sessions import InMemorySessionService
-
- runner = self._make_runner()
- runner._agent = MagicMock()
- runner._agent.name = "test_agent"
- runner._session_service = InMemorySessionService()
- runner._long_term_memory = MagicMock()
-
- result = await runner.save_session_to_long_term_memory("nonexistent_session")
- assert result is False
From 2e50f6a82ad7591407b521a5fa4d7f5bf4b4a9d5 Mon Sep 17 00:00:00 2001
From: xiayu
Date: Wed, 8 Jul 2026 12:07:01 +0800
Subject: [PATCH 4/5] fix(release): refresh public 0.6.9 candidate gates
---
.gitleaks.toml | 45 +++++++++++++++++++
AGENTS.md | 2 +-
docs/maintainer-approval-record.md | 4 +-
...30\351\207\217\345\217\202\350\200\203.md" | 2 +-
export-manifest.json | 8 ++--
scripts/prepare_ksadk_python_export.py | 2 +
tests/test_public_release_positioning.py | 19 ++++++++
7 files changed, 74 insertions(+), 8 deletions(-)
create mode 100644 .gitleaks.toml
diff --git a/.gitleaks.toml b/.gitleaks.toml
new file mode 100644
index 00000000..e77adb8b
--- /dev/null
+++ b/.gitleaks.toml
@@ -0,0 +1,45 @@
+# .gitleaks.toml — ksadk-python
+# 继承 gitleaks 默认规则 (useDefault = true), 仅追加项目级 allowlist。
+# workflow 用法 (见 .github/workflows/secret-patterns.yml):
+# gitleaks detect --source . --config .gitleaks.toml --no-banner --redact --verbose
+# detect 子命令 + fetch-depth:0 扫全 history; allowlist 同时覆盖历史与当前 tree。
+
+title = "ksadk-python gitleaks config"
+
+[extend]
+useDefault = true
+
+# ---- allowlist 统一用 [[allowlists]] 新语法 (gitleaks 8.21+), 不能与 [allowlist] 旧语法混用 ----
+
+# 测试 fixture 假 key (命中当前 tree 与历史 commit)
+[[allowlists]]
+description = "Allowlist test-fixture placeholders in hermes CLI tests"
+regexes = [
+ # tests/test_cmd_hermes.py 的假 Bearer token (curl-auth-header 规则命中)
+ '''sk-live-secret''',
+ # tests/test_cmd_hermes.py 的假 secret value (generic-api-key 兜底)
+ '''sk-test-secret''',
+]
+paths = [
+ # 精确限定到测试文件, 避免误 allowlist 其它路径的真 key
+ '''tests/test_cmd_hermes\.py''',
+]
+
+# ---- 历史已删文档中的 OPENAI_API_KEY (疑似真 kspmas key) ----
+# !!! 安全前置条件 !!!
+# 启用本 allowlist 之前, 必须先在金山云 kspmas 控制台 rotate (吊销并重发) 该 key,
+# 确认 4fd210b0-eee5-4c64-a23c-dc7fb3f86717 已失效。rotate 之前不要启用——
+# allowlist 只让 CI 不报, 不消除 "真 key 已暴露在 public history" 这个事实。
+#
+# 已 rotate 后: 历史 commit 里的该 UUID 已是废值, allowlist 让 CI 对此噪声免疫,
+# 同时保留 git scan 抓未来真新增泄漏的能力 (优于改 --no-git 的掩耳盗铃方案)。
+# 已删文档的历史 commit 路径: gitleaks 历史扫描时仍按该 commit 当时的路径报出,
+# 故 allowlist 的 paths 规则能命中历史版本, 无需特殊语法。
+[[allowlists]]
+description = "Historical rotated OPENAI_API_KEY in deleted hermes doc (safe only AFTER rotation)"
+regexes = [
+ '''4fd210b0-eee5-4c64-a23c-dc7fb3f86717''',
+]
+paths = [
+ '''docs/hermes-agent-v2026\.4\.13_本地安装配置与ksadk接入流程\.md''',
+]
diff --git a/AGENTS.md b/AGENTS.md
index 4e37ca04..74fa3c70 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -37,7 +37,7 @@
- `ksadk.skills` / `ksadk.skills.runtime` 是 Skill Runtime 上层应用,负责 Skill Center 消费、包校验、安全解压、loader、工具定义和 `execute_skills` 编排。
- E2B backend 是当前优先实现路径;后续可扩展 KOP / 平台私有 backend,但业务逻辑不要写死到 E2B 特定对象。
- ADK Runner 可做自动工具注入;LangGraph / DeepAgents 等已编译 graph 默认提供 helper 或显式接入,不强行魔改用户 graph。
-- 沙箱镜像内最小 agent 交付物以 `deploy/skill-runtime/` 为准。
+- 沙箱镜像内最小 agent 交付物以 `ksadk/skills/runtime/agent.py` 为准;顶层 `deploy/` 已迁出本仓。
- Skill Service 管注册、CRUD、版本治理;KsADK 只消费运行时必要接口,例如 `ListSkillsBySpaceId`、`GetSkillDownloadUrl`。
## 5. 跨仓边界
diff --git a/docs/maintainer-approval-record.md b/docs/maintainer-approval-record.md
index fad17358..6d6d718b 100644
--- a/docs/maintainer-approval-record.md
+++ b/docs/maintainer-approval-record.md
@@ -30,8 +30,8 @@ Record exactly one approved source publication strategy.
The approved strategy must name the reviewed commit, tag, pull request, or
export archive used for:
-- `ksadk-python`: clean export candidate from reviewed internal commit `1f3c0d844dc4df2630c8e1d410bcd9f7c9b595cc`; local candidate directory `/tmp/ksadk-python-export-candidate-0.6.9`; verified on 2026-07-08 with public source audit, registry-bundled `make public-preflight` in the public candidate worktree, Fumadocs static build, wheel/sdist build, twine check, and source/dist package audits.
-- `ksadk-web`: npm package `@kingsoftcloud/ksadk-web@0.2.18` from commit `24551d0f290e5a4efc5b5d60d02fa298cccd2efa`; Python candidate commit `1f3c0d844dc4df2630c8e1d410bcd9f7c9b595cc`; published by the trusted GitHub npm workflow on 2026-07-08 and consumed from the npm registry during `make public-preflight`.
+- `ksadk-python`: clean export candidate from reviewed internal commit `b90bbba9a8eff17ede3fd96d300427f0538a9ff9`; local candidate directory `/tmp/ksadk-python-export-candidate-0.6.9`; verified on 2026-07-08 with public source audit, registry-bundled `make public-preflight` in the public candidate worktree, Fumadocs static build, wheel/sdist build, twine check, and source/dist package audits.
+- `ksadk-web`: npm package `@kingsoftcloud/ksadk-web@0.2.18` from commit `24551d0f290e5a4efc5b5d60d02fa298cccd2efa`; Python candidate commit `b90bbba9a8eff17ede3fd96d300427f0538a9ff9`; published by the trusted GitHub npm workflow on 2026-07-08 and consumed from the npm registry during `make public-preflight`.
Both approved source references must include the current commit SHA at approval
time. This prevents a stale approval record from passing after candidate
diff --git "a/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md" "b/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md"
index 8228fe4e..93dae208 100644
--- "a/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md"
+++ "b/docs/reference/ksadk\347\216\257\345\242\203\345\217\230\351\207\217\345\217\202\350\200\203.md"
@@ -2,7 +2,7 @@
本文档面向部署、运行、运维和 SDK 集成排障。它不是业务代码 `.env` 模板;业务方自己的变量,例如 `APP_ENV`、`DB_URL`、`CUSTOM_API_KEY`,只要不是 KsADK / 平台运行时读取的变量,都属于业务自定义变量,不在本文逐项维护。
-本文档覆盖 `ksadk/`、`deploy/`、`tests/` 中已经注册或常见可配置的运行时变量,由 `tests/test_config_env_registry.py` 保证 `ENV_VAR_REGISTRY` 注册项与文档一致。测试专用变量、PID/marker/cache 等进程内部临时变量、镜像构建脚本内部常量不会逐项列入表格;如果要排查这些高级项,以对应脚本源码和模板 README 为准。
+本文档覆盖 `ksadk/` 中已经注册或常见可配置的运行时变量,由 `tests/test_config_env_registry.py` 保证 `ENV_VAR_REGISTRY` 注册项与文档一致。测试专用变量、PID/marker/cache 等进程内部临时变量、镜像构建脚本内部常量不会逐项列入表格;如果要排查这些高级项,以对应脚本源码和模板 README 为准。
## 1. 阅读规则
diff --git a/export-manifest.json b/export-manifest.json
index dd7da282..dd641065 100644
--- a/export-manifest.json
+++ b/export-manifest.json
@@ -1,11 +1,10 @@
{
- "generatedAt": "2026-07-08T03:54:48.013854+00:00",
+ "generatedAt": "2026-07-08T04:05:37.590232+00:00",
"targetRepository": "https://github.com/kingsoftcloud/ksadk-python",
"documentation": "https://kingsoftcloud.github.io/ksadk-python/",
- "exportPathCount": 554,
- "excludedPathCount": 202,
+ "exportPathCount": 555,
+ "excludedPathCount": 201,
"excludedPaths": [
- ".gitleaks.toml",
"docs/Agent 开发者上下文接入指南.md",
"docs/DeepAgents说明.md",
"docs/archive/kb-memory/knowledge_base_integration_plan.md",
@@ -224,6 +223,7 @@
".github/workflows/release-check.yml",
".github/workflows/secret-patterns.yml",
".gitignore",
+ ".gitleaks.toml",
"AGENTS.md",
"CHANGELOG.md",
"CLAUDE.md",
diff --git a/scripts/prepare_ksadk_python_export.py b/scripts/prepare_ksadk_python_export.py
index 5d46f6ff..ae6232c3 100644
--- a/scripts/prepare_ksadk_python_export.py
+++ b/scripts/prepare_ksadk_python_export.py
@@ -33,6 +33,7 @@
ROOT_EXPORT_FILES = {
".dockerignore",
".gitattributes",
+ ".gitleaks.toml",
".github/BRANCH_PROTECTION.md",
".github/ISSUE_TEMPLATE/bug_report.md",
".github/ISSUE_TEMPLATE/feature_request.md",
@@ -73,6 +74,7 @@
"README.md",
"README.en.md",
"README.zh-CN.md",
+ ".gitleaks.toml",
"docs-site/package.json",
"docs-site/pnpm-lock.yaml",
"pyproject.toml",
diff --git a/tests/test_public_release_positioning.py b/tests/test_public_release_positioning.py
index 074b2d7d..0d70937e 100644
--- a/tests/test_public_release_positioning.py
+++ b/tests/test_public_release_positioning.py
@@ -115,6 +115,25 @@ def test_public_readme_docs_links_match_fumadocs_routes():
assert candidate.exists() or index_candidate.exists(), url
+def test_legacy_deploy_and_examples_are_not_tracked_in_source_repo():
+ if not (ROOT / ".git").exists():
+ return
+
+ tracked = subprocess.run(
+ ["git", "ls-files", "deploy", "examples"],
+ cwd=ROOT,
+ check=True,
+ text=True,
+ stdout=subprocess.PIPE,
+ ).stdout
+ makefile = _read("Makefile")
+
+ assert tracked == ""
+ assert "-f deploy/hermes/Dockerfile" not in makefile
+ assert "-f deploy/openclaw/Dockerfile" not in makefile
+ assert "-f deploy/openclaw-user-template/Dockerfile" not in makefile
+
+
def test_public_metadata_uses_runtime_platform_positioning():
pyproject = tomllib.loads(_read("pyproject.toml"))
init_text = _read("ksadk/__init__.py")
From 5a08c48b3d55ba62f349702c9c09f980f7a74a68 Mon Sep 17 00:00:00 2001
From: xiayu
Date: Wed, 8 Jul 2026 12:13:54 +0800
Subject: [PATCH 5/5] fix(release): align public ci with exported tests
---
.github/workflows/ci.yml | 13 +------------
docs/maintainer-approval-record.md | 4 ++--
export-manifest.json | 2 +-
tests/test_public_release_positioning.py | 3 +++
4 files changed, 7 insertions(+), 15 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 21c1488e..5176454b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -46,18 +46,7 @@ jobs:
run: uv build
- name: Run public release gate tests
- run: |
- uv run --extra dev pytest \
- tests/test_open_source_audit.py \
- tests/test_runtime_common_packaging.py \
- tests/test_public_release_positioning.py \
- tests/test_tracing_setup_otlp.py \
- tests/test_check_publication_state.py \
- tests/test_check_approval_record.py \
- tests/test_markdown_repair.py \
- tests/test_conversation_runtime.py \
- tests/test_server_session_app.py \
- -q
+ run: make public-test
- name: Audit public repository candidate
run: uv run --extra dev python scripts/open_source_audit.py --target public-repo
diff --git a/docs/maintainer-approval-record.md b/docs/maintainer-approval-record.md
index 6d6d718b..18261e23 100644
--- a/docs/maintainer-approval-record.md
+++ b/docs/maintainer-approval-record.md
@@ -30,8 +30,8 @@ Record exactly one approved source publication strategy.
The approved strategy must name the reviewed commit, tag, pull request, or
export archive used for:
-- `ksadk-python`: clean export candidate from reviewed internal commit `b90bbba9a8eff17ede3fd96d300427f0538a9ff9`; local candidate directory `/tmp/ksadk-python-export-candidate-0.6.9`; verified on 2026-07-08 with public source audit, registry-bundled `make public-preflight` in the public candidate worktree, Fumadocs static build, wheel/sdist build, twine check, and source/dist package audits.
-- `ksadk-web`: npm package `@kingsoftcloud/ksadk-web@0.2.18` from commit `24551d0f290e5a4efc5b5d60d02fa298cccd2efa`; Python candidate commit `b90bbba9a8eff17ede3fd96d300427f0538a9ff9`; published by the trusted GitHub npm workflow on 2026-07-08 and consumed from the npm registry during `make public-preflight`.
+- `ksadk-python`: clean export candidate from reviewed internal commit `eb76b17d7d3c176f7cf6126ceb001ab00f5d651d`; local candidate directory `/tmp/ksadk-python-export-candidate-0.6.9`; verified on 2026-07-08 with public source audit, registry-bundled `make public-preflight` in the public candidate worktree, Fumadocs static build, wheel/sdist build, twine check, and source/dist package audits.
+- `ksadk-web`: npm package `@kingsoftcloud/ksadk-web@0.2.18` from commit `24551d0f290e5a4efc5b5d60d02fa298cccd2efa`; Python candidate commit `eb76b17d7d3c176f7cf6126ceb001ab00f5d651d`; published by the trusted GitHub npm workflow on 2026-07-08 and consumed from the npm registry during `make public-preflight`.
Both approved source references must include the current commit SHA at approval
time. This prevents a stale approval record from passing after candidate
diff --git a/export-manifest.json b/export-manifest.json
index dd641065..b96167dd 100644
--- a/export-manifest.json
+++ b/export-manifest.json
@@ -1,5 +1,5 @@
{
- "generatedAt": "2026-07-08T04:05:37.590232+00:00",
+ "generatedAt": "2026-07-08T04:10:29.360629+00:00",
"targetRepository": "https://github.com/kingsoftcloud/ksadk-python",
"documentation": "https://kingsoftcloud.github.io/ksadk-python/",
"exportPathCount": 555,
diff --git a/tests/test_public_release_positioning.py b/tests/test_public_release_positioning.py
index 0d70937e..a6f6c074 100644
--- a/tests/test_public_release_positioning.py
+++ b/tests/test_public_release_positioning.py
@@ -181,6 +181,9 @@ def test_pypi_publish_workflow_uses_trusted_publishing_and_bundles_ksadk_web():
assert "make public-preflight" in workflow
assert "make public-publish-gate" in workflow
assert "make open-source-audit-dist" in ci_workflow
+ assert "make public-test" in ci_workflow
+ assert "tests/test_conversation_runtime.py" not in ci_workflow
+ assert "tests/test_server_session_app.py" not in ci_workflow
assert 'KSADK_WEB_VERSION: "0.2.18"' in ci_workflow
assert "PUBLIC_KSADK_WEB_VERSION" not in ci_workflow
assert "KSADK_WEB_VERSION ?= latest" in makefile