add skill activity stream and job runtime foundation (v1.1.0)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 17s
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 17s
Introduce SAS v1 emit/finish/journal API, sibling CLI bridge, RPA video session integration, and tests for the host Job Runner activity timeline. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
67
README.md
67
README.md
@@ -12,7 +12,7 @@ pip install jiangchang-platform-kit --index-url https://git.jc2009.com/api/packa
|
||||
|
||||
## 3. 包含的模块
|
||||
|
||||
- **jiangchang_skill_core**:权益 HTTP 客户端、`enforce_entitlement`、统一文件日志、`JIANGCHANG_*` 数据根与技能根路径解析、共享媒体资源(`media_assets`)等,供 Skill CLI / 服务进程使用。
|
||||
- **jiangchang_skill_core**:权益 HTTP 客户端、`enforce_entitlement`、统一文件日志、`JIANGCHANG_*` 数据根与技能根路径解析、**Skill Activity Stream / Job 运行时**(`emit` / `finish` / Run Journal)、兄弟技能 CLI 桥(`sibling_bridge`)、共享媒体资源(`media_assets`)等,供 Skill CLI / 服务进程使用。
|
||||
- **jiangchang_desktop_sdk**:匠厂桌面应用自动化客户端(基于 CDP + Playwright 同步 API)。**本包不声明 Playwright 依赖**;请在运行环境中自行安装 Playwright,以便 `import playwright` 可用。
|
||||
|
||||
## 4. 快速开始
|
||||
@@ -78,6 +78,71 @@ assert_skill_env_file(ctx, ["API_KEY"])
|
||||
send_prompt_via_composer(page, "第一行\n第二行")
|
||||
```
|
||||
|
||||
## Skill Activity Stream / Job 运行时
|
||||
|
||||
长任务(批量生成、RPA)需要把**逐步进度**推到匠厂宿主 UI,而不能依赖 Agent 轮询或 OpenClaw stdout patch。`jiangchang_skill_core.activity` 提供 **Skill Activity Stream (SAS) v1** 契约:
|
||||
|
||||
| API | 作用 |
|
||||
|-----|------|
|
||||
| `emit(text, ...)` | 写 Run Journal(`activity` / `progress` / `warn` / `debug`) |
|
||||
| `step(text)` | `emit(type='activity')` 别名 |
|
||||
| `finish(status=..., **fields)` | 写终态事件 + **唯一**向 stdout 输出单行 result JSON |
|
||||
| `rpa_step(name)` | 装饰器:进入/成功/失败自动 emit |
|
||||
| `job_context(...)` | 可选包裹 main;异常时自动 `finish(failed)` |
|
||||
|
||||
### Run Journal 路径(宿主 tail 此文件)
|
||||
|
||||
```
|
||||
{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.jsonl
|
||||
```
|
||||
|
||||
- `job_id`:优先 `JIANGCHANG_JOB_ID`,否则进程内 `uuid4().hex`(首次 emit 时生成并缓存)
|
||||
- `JIANGCHANG_RUN_MODE`:`job` | `cli`(默认 `cli`),首行 meta 事件记录
|
||||
- NDJSON,UTF-8,append-only,每行 flush
|
||||
- `debug` 类型仅当 `JIANGCHANG_ACTIVITY_DEBUG=1` 时写入
|
||||
|
||||
### 与 Agent / OpenClaw 的分工
|
||||
|
||||
- **`emit()` / journal**:永不 print 到 stdout,避免 bash tool 结果淹没 Agent context
|
||||
- **`finish()`**:唯一允许向 stdout 写业务结果的 API;单行 compact JSON,`status` + 摘要字段,默认 ≤4096 字符
|
||||
- 技能里手写 `print("OK")`、pretty JSON 应逐步改为 `finish()`;RPA 步骤用 `emit("打开浏览器")` 或 `@rpa_step`
|
||||
|
||||
### GEO batch 示例
|
||||
|
||||
```python
|
||||
from jiangchang_skill_core.activity import emit, finish
|
||||
|
||||
for i, item in enumerate(items, 1):
|
||||
emit(f"生成 {item['title']}", type="progress", current=i, total=len(items), skill="geo-batch")
|
||||
# ... business logic ...
|
||||
|
||||
finish(status="success", skill="geo-batch", generated=len(items))
|
||||
```
|
||||
|
||||
### RPA 步骤示例
|
||||
|
||||
```python
|
||||
from jiangchang_skill_core.activity import emit, rpa_step
|
||||
|
||||
@rpa_step("打开浏览器")
|
||||
def open_browser(page):
|
||||
page.goto("https://example.com")
|
||||
|
||||
emit("手动步骤说明", skill="publish-toutiao", stage="rpa")
|
||||
```
|
||||
|
||||
`RpaVideoSession.add_step()` 在录屏字幕之外默认同步 `emit(..., stage="rpa.video")`;可通过 `emit_activity=False` 关闭。
|
||||
|
||||
### 兄弟技能 CLI 桥
|
||||
|
||||
```python
|
||||
from jiangchang_skill_core import call_sibling_json, get_sibling_main_path
|
||||
|
||||
accounts = call_sibling_json("account-manager", ["list", "all"])
|
||||
```
|
||||
|
||||
同步 `subprocess.run`,`capture_output=True`,自动带上 `subprocess_env_with_trace()`。
|
||||
|
||||
## 共享媒体资源
|
||||
|
||||
`jiangchang_skill_core.media_assets` 提供共享媒体资源解析能力。默认会从:
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jiangchang-platform-kit"
|
||||
version = "1.0.17"
|
||||
version = "1.1.0"
|
||||
description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
from .activity import (
|
||||
emit,
|
||||
finish,
|
||||
get_job_id,
|
||||
get_run_mode,
|
||||
job_context,
|
||||
rpa_step,
|
||||
step,
|
||||
)
|
||||
from .sibling_bridge import (
|
||||
call_sibling_json,
|
||||
call_sibling_json_array,
|
||||
get_sibling_main_path,
|
||||
)
|
||||
from .client import EntitlementClient
|
||||
from .config import ensure_env_file, get, get_bool, get_float, get_int
|
||||
from .errors import EntitlementDeniedError, EntitlementError, EntitlementServiceError
|
||||
@@ -5,8 +19,10 @@ from .guard import enforce_entitlement
|
||||
from .models import EntitlementResult
|
||||
from .runtime_env import (
|
||||
apply_cli_local_defaults,
|
||||
ensure_runs_dir,
|
||||
find_chrome_executable,
|
||||
get_data_root,
|
||||
get_runs_dir,
|
||||
get_sibling_skills_root,
|
||||
get_skills_root,
|
||||
get_user_id,
|
||||
@@ -52,6 +68,18 @@ except ImportError:
|
||||
rpa = None # type: ignore[assignment,misc]
|
||||
|
||||
__all__ = [
|
||||
"call_sibling_json",
|
||||
"call_sibling_json_array",
|
||||
"emit",
|
||||
"finish",
|
||||
"get_job_id",
|
||||
"get_run_mode",
|
||||
"get_runs_dir",
|
||||
"ensure_runs_dir",
|
||||
"job_context",
|
||||
"rpa_step",
|
||||
"step",
|
||||
"get_sibling_main_path",
|
||||
"EntitlementClient",
|
||||
"EntitlementDeniedError",
|
||||
"EntitlementError",
|
||||
@@ -88,6 +116,7 @@ __all__ = [
|
||||
"get_data_root",
|
||||
"get_float",
|
||||
"get_int",
|
||||
"get_runs_dir",
|
||||
"get_sibling_skills_root",
|
||||
"get_skills_root",
|
||||
"get_skill_log_file_path",
|
||||
|
||||
343
src/jiangchang_skill_core/activity.py
Normal file
343
src/jiangchang_skill_core/activity.py
Normal file
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
Skill Activity Stream (SAS) v1 — Run Journal + stdout result contract.
|
||||
|
||||
emit() / journal events never touch stdout (Agent/bash tool result stays clean).
|
||||
finish() is the only API that writes a single-line result JSON to stdout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Callable, Iterator, Optional, TypeVar
|
||||
|
||||
from .runtime_env import ensure_runs_dir, get_runs_dir
|
||||
|
||||
_SCHEMA_VERSION = 1
|
||||
_VALID_EMIT_TYPES = frozenset({"activity", "progress", "warn", "debug"})
|
||||
_RESULT_MAX_CHARS = 4096
|
||||
|
||||
_cached_job_id: Optional[str] = None
|
||||
_journal_path: Optional[str] = None
|
||||
_meta_written: bool = False
|
||||
_journal_file: Optional[Any] = None
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
|
||||
def _activity_debug_enabled() -> bool:
|
||||
v = (os.getenv("JIANGCHANG_ACTIVITY_DEBUG") or "").strip().lower()
|
||||
return v in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def get_run_mode() -> str:
|
||||
mode = (os.getenv("JIANGCHANG_RUN_MODE") or "").strip().lower()
|
||||
if mode in ("job", "cli"):
|
||||
return mode
|
||||
return "cli"
|
||||
|
||||
|
||||
def get_job_id() -> str:
|
||||
global _cached_job_id
|
||||
env_job = (os.getenv("JIANGCHANG_JOB_ID") or "").strip()
|
||||
if env_job:
|
||||
return env_job
|
||||
if _cached_job_id is None:
|
||||
_cached_job_id = uuid.uuid4().hex
|
||||
return _cached_job_id
|
||||
|
||||
|
||||
def _get_journal_path() -> str:
|
||||
global _journal_path
|
||||
if _journal_path is None:
|
||||
ensure_runs_dir()
|
||||
_journal_path = os.path.join(get_runs_dir(), f"{get_job_id()}.jsonl")
|
||||
return _journal_path
|
||||
|
||||
|
||||
def _open_journal() -> Any:
|
||||
global _journal_file
|
||||
if _journal_file is None:
|
||||
path = _get_journal_path()
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
_journal_file = open(path, "a", encoding="utf-8")
|
||||
return _journal_file
|
||||
|
||||
|
||||
def _write_journal_line(event: dict[str, Any]) -> None:
|
||||
global _meta_written
|
||||
f = _open_journal()
|
||||
if not _meta_written:
|
||||
meta = {
|
||||
"type": "meta",
|
||||
"schema_version": _SCHEMA_VERSION,
|
||||
"job_id": get_job_id(),
|
||||
"run_mode": get_run_mode(),
|
||||
"ts": _now_ms(),
|
||||
}
|
||||
f.write(json.dumps(meta, ensure_ascii=False, separators=(",", ":")) + "\n")
|
||||
f.flush()
|
||||
_meta_written = True
|
||||
f.write(json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n")
|
||||
f.flush()
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def _build_event(
|
||||
type_: str,
|
||||
text: str,
|
||||
*,
|
||||
skill: str | None = None,
|
||||
stage: str | None = None,
|
||||
current: int | None = None,
|
||||
total: int | None = None,
|
||||
item_id: str | None = None,
|
||||
ts: int | None = None,
|
||||
**extra: Any,
|
||||
) -> dict[str, Any]:
|
||||
cleaned = (text or "").strip()
|
||||
if not cleaned:
|
||||
raise ValueError("text must be non-empty")
|
||||
event: dict[str, Any] = {
|
||||
"type": type_,
|
||||
"text": cleaned,
|
||||
"schema_version": _SCHEMA_VERSION,
|
||||
"ts": ts if ts is not None else _now_ms(),
|
||||
}
|
||||
if skill is not None:
|
||||
event["skill"] = skill
|
||||
if stage is not None:
|
||||
event["stage"] = stage
|
||||
if current is not None:
|
||||
event["current"] = current
|
||||
if total is not None:
|
||||
event["total"] = total
|
||||
if item_id is not None:
|
||||
event["item_id"] = item_id
|
||||
for key, value in extra.items():
|
||||
if key not in event:
|
||||
event[key] = value
|
||||
return event
|
||||
|
||||
|
||||
def emit(
|
||||
text: str,
|
||||
*,
|
||||
type: str = "activity",
|
||||
skill: str | None = None,
|
||||
stage: str | None = None,
|
||||
current: int | None = None,
|
||||
total: int | None = None,
|
||||
item_id: str | None = None,
|
||||
**extra: Any,
|
||||
) -> None:
|
||||
"""Append one SAS event to the Run Journal. Never writes to stdout."""
|
||||
if type == "debug" and not _activity_debug_enabled():
|
||||
return
|
||||
if type not in _VALID_EMIT_TYPES:
|
||||
raise ValueError(f"invalid emit type: {type!r}")
|
||||
event = _build_event(
|
||||
type,
|
||||
text,
|
||||
skill=skill,
|
||||
stage=stage,
|
||||
current=current,
|
||||
total=total,
|
||||
item_id=item_id,
|
||||
**extra,
|
||||
)
|
||||
_write_journal_line(event)
|
||||
|
||||
|
||||
def step(text: str, **kwargs: Any) -> None:
|
||||
"""Shorthand for emit(type='activity')."""
|
||||
emit(text, type="activity", **kwargs)
|
||||
|
||||
|
||||
def _serialize_result(result: dict[str, Any]) -> str:
|
||||
line = json.dumps(result, ensure_ascii=False, separators=(",", ":"))
|
||||
if len(line) <= _RESULT_MAX_CHARS:
|
||||
return line
|
||||
|
||||
core_keys = ("type", "schema_version", "status", "skill", "message")
|
||||
truncated: dict[str, Any] = {
|
||||
k: result[k] for k in core_keys if k in result
|
||||
}
|
||||
truncated["truncated"] = True
|
||||
|
||||
for key, value in result.items():
|
||||
if key in truncated or key == "truncated":
|
||||
continue
|
||||
candidate = {**truncated, key: value}
|
||||
candidate_line = json.dumps(candidate, ensure_ascii=False, separators=(",", ":"))
|
||||
if len(candidate_line) <= _RESULT_MAX_CHARS:
|
||||
truncated[key] = value
|
||||
|
||||
line = json.dumps(truncated, ensure_ascii=False, separators=(",", ":"))
|
||||
if len(line) > _RESULT_MAX_CHARS:
|
||||
truncated.pop("message", None)
|
||||
line = json.dumps(truncated, ensure_ascii=False, separators=(",", ":"))
|
||||
if len(line) > _RESULT_MAX_CHARS:
|
||||
return line[:_RESULT_MAX_CHARS]
|
||||
return line
|
||||
|
||||
|
||||
def finish(
|
||||
*,
|
||||
status: str = "success",
|
||||
message: str | None = None,
|
||||
skill: str | None = None,
|
||||
**fields: Any,
|
||||
) -> None:
|
||||
"""
|
||||
1) Write terminal done|error event to Run Journal.
|
||||
2) Print a single compact result JSON line to stdout (Agent/bash path).
|
||||
"""
|
||||
if status not in ("success", "partial", "failed"):
|
||||
raise ValueError(f"invalid finish status: {status!r}")
|
||||
|
||||
journal_type = "error" if status == "failed" else "done"
|
||||
terminal_text = message or ("任务失败" if status == "failed" else "任务完成")
|
||||
|
||||
journal_event = _build_event(
|
||||
journal_type,
|
||||
terminal_text,
|
||||
skill=skill,
|
||||
status=status,
|
||||
**fields,
|
||||
)
|
||||
if message is not None:
|
||||
journal_event["message"] = message
|
||||
_write_journal_line(journal_event)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"type": "result",
|
||||
"schema_version": _SCHEMA_VERSION,
|
||||
"status": status,
|
||||
}
|
||||
if skill is not None:
|
||||
result["skill"] = skill
|
||||
if message is not None:
|
||||
result["message"] = message
|
||||
result.update(fields)
|
||||
|
||||
line = _serialize_result(result)
|
||||
sys.stdout.write(line + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def job_context(
|
||||
*,
|
||||
job_id: str | None = None,
|
||||
skill: str | None = None,
|
||||
run_mode: str | None = None,
|
||||
) -> Iterator[None]:
|
||||
"""Optional wrapper: sets job context; auto finish(failed) on unhandled exception."""
|
||||
global _cached_job_id, _journal_path, _meta_written, _journal_file
|
||||
|
||||
saved_job_id = _cached_job_id
|
||||
saved_journal_path = _journal_path
|
||||
saved_meta = _meta_written
|
||||
saved_file = _journal_file
|
||||
saved_env_job = os.environ.get("JIANGCHANG_JOB_ID")
|
||||
saved_env_mode = os.environ.get("JIANGCHANG_RUN_MODE")
|
||||
|
||||
if job_id:
|
||||
os.environ["JIANGCHANG_JOB_ID"] = job_id
|
||||
_cached_job_id = None
|
||||
_journal_path = None
|
||||
_meta_written = False
|
||||
if _journal_file is not None:
|
||||
try:
|
||||
_journal_file.close()
|
||||
except Exception:
|
||||
pass
|
||||
_journal_file = None
|
||||
if run_mode:
|
||||
os.environ["JIANGCHANG_RUN_MODE"] = run_mode
|
||||
|
||||
try:
|
||||
yield
|
||||
except Exception as exc:
|
||||
finish(status="failed", message=str(exc), skill=skill)
|
||||
raise
|
||||
finally:
|
||||
_cached_job_id = saved_job_id
|
||||
_journal_path = saved_journal_path
|
||||
_meta_written = saved_meta
|
||||
if _journal_file is not None and _journal_file is not saved_file:
|
||||
try:
|
||||
_journal_file.close()
|
||||
except Exception:
|
||||
pass
|
||||
_journal_file = saved_file
|
||||
if saved_env_job is None:
|
||||
os.environ.pop("JIANGCHANG_JOB_ID", None)
|
||||
else:
|
||||
os.environ["JIANGCHANG_JOB_ID"] = saved_env_job
|
||||
if saved_env_mode is None:
|
||||
os.environ.pop("JIANGCHANG_RUN_MODE", None)
|
||||
else:
|
||||
os.environ["JIANGCHANG_RUN_MODE"] = saved_env_mode
|
||||
|
||||
|
||||
def rpa_step(name: str | None = None) -> Callable[[F], F]:
|
||||
"""Decorator for sync/async RPA steps: emit enter/success/warn on failure."""
|
||||
|
||||
def decorator(fn: F) -> F:
|
||||
step_name = name or fn.__name__
|
||||
|
||||
if inspect.iscoroutinefunction(fn):
|
||||
|
||||
@functools.wraps(fn)
|
||||
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
emit(f"▶ {step_name}")
|
||||
try:
|
||||
result = await fn(*args, **kwargs)
|
||||
emit(f"✓ {step_name}")
|
||||
return result
|
||||
except Exception as exc:
|
||||
emit(f"✗ {step_name}: {exc}", type="warn")
|
||||
raise
|
||||
|
||||
return async_wrapper # type: ignore[return-value]
|
||||
|
||||
@functools.wraps(fn)
|
||||
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
emit(f"▶ {step_name}")
|
||||
try:
|
||||
result = fn(*args, **kwargs)
|
||||
emit(f"✓ {step_name}")
|
||||
return result
|
||||
except Exception as exc:
|
||||
emit(f"✗ {step_name}: {exc}", type="warn")
|
||||
raise
|
||||
|
||||
return sync_wrapper # type: ignore[return-value]
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def _reset_for_tests() -> None:
|
||||
"""Clear module-level journal state (tests only)."""
|
||||
global _cached_job_id, _journal_path, _meta_written, _journal_file
|
||||
if _journal_file is not None:
|
||||
try:
|
||||
_journal_file.close()
|
||||
except Exception:
|
||||
pass
|
||||
_cached_job_id = None
|
||||
_journal_path = None
|
||||
_meta_written = False
|
||||
_journal_file = None
|
||||
@@ -568,12 +568,14 @@ class RpaVideoSession:
|
||||
batch_id: str,
|
||||
title: str = "",
|
||||
closing_title: str = "",
|
||||
emit_activity: bool = True,
|
||||
) -> None:
|
||||
self.skill_slug = skill_slug
|
||||
self.skill_data_dir = os.path.abspath(skill_data_dir)
|
||||
self.batch_id = batch_id
|
||||
self.title = title
|
||||
self.closing_title = closing_title
|
||||
self.emit_activity = emit_activity
|
||||
self.enabled = _record_video_enabled()
|
||||
self.warnings: List[str] = []
|
||||
self.output_video_path: Optional[str] = None
|
||||
@@ -631,13 +633,25 @@ class RpaVideoSession:
|
||||
self.add_step(text, duration=duration)
|
||||
|
||||
def add_step(self, text: str, *, duration: float = 4.0) -> None:
|
||||
"""记录一步骤字幕(相对录屏开始时间)。"""
|
||||
"""记录一步骤字幕(相对录屏开始时间);可选同步写入 Activity Stream。"""
|
||||
cleaned = text.strip()
|
||||
if self.emit_activity and cleaned:
|
||||
try:
|
||||
from .. import activity
|
||||
|
||||
activity.emit(
|
||||
cleaned,
|
||||
skill=self.skill_slug,
|
||||
stage="rpa.video",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if not self.enabled:
|
||||
return
|
||||
if self._t0 is None:
|
||||
self._t0 = time.monotonic()
|
||||
elapsed = time.monotonic() - self._t0
|
||||
self._steps.append(_StepEntry(start=elapsed, text=text.strip(), duration=duration))
|
||||
self._steps.append(_StepEntry(start=elapsed, text=cleaned, duration=duration))
|
||||
|
||||
def _close_ffmpeg_record_log(self) -> None:
|
||||
fh = self._ffmpeg_record_log_file
|
||||
|
||||
@@ -44,6 +44,20 @@ def get_user_id() -> str:
|
||||
return (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "_anon"
|
||||
|
||||
|
||||
def get_runs_dir() -> str:
|
||||
"""Run Journal 目录:{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/"""
|
||||
return os.path.join(get_data_root(), ".jiangchang", "runs")
|
||||
|
||||
|
||||
def ensure_runs_dir() -> str:
|
||||
path = get_runs_dir()
|
||||
try:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return path
|
||||
|
||||
|
||||
def _looks_like_skills_root(path: str) -> bool:
|
||||
if not path or not os.path.isdir(path):
|
||||
return False
|
||||
|
||||
71
src/jiangchang_skill_core/sibling_bridge.py
Normal file
71
src/jiangchang_skill_core/sibling_bridge.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
兄弟技能 CLI 桥:同步 subprocess 调用并列技能 scripts/main.py。
|
||||
|
||||
account-manager 等兄弟技能为同步 CLI,非 async、非线程池。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, List, Optional, Sequence
|
||||
|
||||
from .runtime_env import get_skills_root
|
||||
from .unified_logging import subprocess_env_with_trace
|
||||
|
||||
|
||||
def get_sibling_main_path(skill_slug: str) -> str:
|
||||
return os.path.join(get_skills_root(), skill_slug, "scripts", "main.py")
|
||||
|
||||
|
||||
def _python_exe() -> str:
|
||||
return sys.executable
|
||||
|
||||
|
||||
def _run_sibling(skill_slug: str, argv: Sequence[str]) -> subprocess.CompletedProcess[str]:
|
||||
main_path = get_sibling_main_path(skill_slug)
|
||||
cmd = [_python_exe(), main_path, *argv]
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
env=subprocess_env_with_trace(),
|
||||
)
|
||||
|
||||
|
||||
def _parse_json_from_stdout(stdout: str) -> Any:
|
||||
for line in reversed((stdout or "").splitlines()):
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("ERROR:"):
|
||||
continue
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def call_sibling_json(skill_slug: str, argv: Sequence[str]) -> dict | None:
|
||||
"""Run sibling CLI; return last JSON object line from stdout, or None."""
|
||||
result = _run_sibling(skill_slug, argv)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
parsed = _parse_json_from_stdout(result.stdout or "")
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
return None
|
||||
|
||||
|
||||
def call_sibling_json_array(skill_slug: str, argv: Sequence[str]) -> list | None:
|
||||
"""Run sibling CLI; return last JSON array line from stdout, or None."""
|
||||
result = _run_sibling(skill_slug, argv)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
parsed = _parse_json_from_stdout(result.stdout or "")
|
||||
if isinstance(parsed, list):
|
||||
return parsed
|
||||
return None
|
||||
209
tests/test_activity_emit_finish.py
Normal file
209
tests/test_activity_emit_finish.py
Normal file
@@ -0,0 +1,209 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Skill Activity Stream: journal, flush, job_id, finish stdout, truncation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from jiangchang_skill_core import activity
|
||||
from jiangchang_skill_core.runtime_env import get_runs_dir
|
||||
|
||||
|
||||
class _ActivityEnvIsolation(unittest.TestCase):
|
||||
_KEYS = (
|
||||
"JIANGCHANG_DATA_ROOT",
|
||||
"JIANGCHANG_USER_ID",
|
||||
"JIANGCHANG_JOB_ID",
|
||||
"JIANGCHANG_RUN_MODE",
|
||||
"JIANGCHANG_ACTIVITY_DEBUG",
|
||||
)
|
||||
|
||||
def setUp(self) -> None:
|
||||
self._saved = {k: os.environ.get(k) for k in self._KEYS}
|
||||
self._tmps: list[str] = []
|
||||
for k in self._KEYS:
|
||||
os.environ.pop(k, None)
|
||||
activity._reset_for_tests()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
activity._reset_for_tests()
|
||||
for k, v in self._saved.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
for tmp in self._tmps:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
def _tmp_data_root(self) -> str:
|
||||
tmp = tempfile.mkdtemp()
|
||||
self._tmps.append(tmp)
|
||||
return tmp
|
||||
|
||||
|
||||
class TestActivityJournal(_ActivityEnvIsolation):
|
||||
def test_emit_writes_ndjson_with_meta(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "job-test-1"
|
||||
activity._reset_for_tests()
|
||||
|
||||
activity.emit("步骤一", type="activity", skill="geo-batch")
|
||||
journal = Path(get_runs_dir()) / "job-test-1.jsonl"
|
||||
self.assertTrue(journal.is_file())
|
||||
lines = journal.read_text(encoding="utf-8").strip().splitlines()
|
||||
self.assertGreaterEqual(len(lines), 2)
|
||||
meta = json.loads(lines[0])
|
||||
self.assertEqual(meta["type"], "meta")
|
||||
self.assertEqual(meta["job_id"], "job-test-1")
|
||||
event = json.loads(lines[1])
|
||||
self.assertEqual(event["type"], "activity")
|
||||
self.assertEqual(event["text"], "步骤一")
|
||||
self.assertEqual(event["schema_version"], 1)
|
||||
self.assertEqual(event["skill"], "geo-batch")
|
||||
self.assertIn("ts", event)
|
||||
|
||||
def test_emit_flushes_immediately(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "job-flush"
|
||||
activity._reset_for_tests()
|
||||
activity.emit("flush test")
|
||||
journal = Path(get_runs_dir()) / "job-flush.jsonl"
|
||||
self.assertGreater(journal.stat().st_size, 0)
|
||||
|
||||
def test_job_id_from_env(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "env-job-42"
|
||||
activity._reset_for_tests()
|
||||
self.assertEqual(activity.get_job_id(), "env-job-42")
|
||||
|
||||
def test_job_id_generated_and_cached(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
activity._reset_for_tests()
|
||||
jid1 = activity.get_job_id()
|
||||
jid2 = activity.get_job_id()
|
||||
self.assertEqual(jid1, jid2)
|
||||
self.assertEqual(len(jid1), 32)
|
||||
|
||||
def test_debug_skipped_without_env(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "job-no-debug"
|
||||
activity._reset_for_tests()
|
||||
activity.emit("hidden", type="debug")
|
||||
journal = Path(get_runs_dir()) / "job-no-debug.jsonl"
|
||||
self.assertFalse(journal.exists())
|
||||
|
||||
def test_debug_written_when_enabled(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "job-debug"
|
||||
os.environ["JIANGCHANG_ACTIVITY_DEBUG"] = "1"
|
||||
activity._reset_for_tests()
|
||||
activity.emit("dbg", type="debug")
|
||||
journal = Path(get_runs_dir()) / "job-debug.jsonl"
|
||||
content = journal.read_text(encoding="utf-8")
|
||||
self.assertIn('"type":"debug"', content.replace(" ", ""))
|
||||
|
||||
def test_emit_never_prints_stdout(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "job-quiet"
|
||||
activity._reset_for_tests()
|
||||
buf = io.StringIO()
|
||||
with patch("sys.stdout", buf):
|
||||
activity.emit("silent step")
|
||||
self.assertEqual(buf.getvalue(), "")
|
||||
|
||||
def test_get_run_mode_default_cli(self) -> None:
|
||||
self.assertEqual(activity.get_run_mode(), "cli")
|
||||
|
||||
def test_get_run_mode_job(self) -> None:
|
||||
os.environ["JIANGCHANG_RUN_MODE"] = "job"
|
||||
self.assertEqual(activity.get_run_mode(), "job")
|
||||
|
||||
|
||||
class TestActivityFinish(_ActivityEnvIsolation):
|
||||
def test_finish_stdout_single_line_result(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "job-finish"
|
||||
activity._reset_for_tests()
|
||||
|
||||
buf = io.StringIO()
|
||||
with patch("sys.stdout", buf):
|
||||
activity.finish(status="success", message="ok", skill="geo", generated=3)
|
||||
|
||||
lines = [ln for ln in buf.getvalue().splitlines() if ln.strip()]
|
||||
self.assertEqual(len(lines), 1)
|
||||
result = json.loads(lines[0])
|
||||
self.assertEqual(result["type"], "result")
|
||||
self.assertEqual(result["schema_version"], 1)
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(result["skill"], "geo")
|
||||
self.assertEqual(result["generated"], 3)
|
||||
|
||||
journal = Path(get_runs_dir()) / "job-finish.jsonl"
|
||||
journal_lines = journal.read_text(encoding="utf-8").strip().splitlines()
|
||||
last = json.loads(journal_lines[-1])
|
||||
self.assertEqual(last["type"], "done")
|
||||
self.assertEqual(last["status"], "success")
|
||||
|
||||
def test_finish_failed_writes_error_event(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "job-fail"
|
||||
activity._reset_for_tests()
|
||||
|
||||
with patch("sys.stdout", io.StringIO()):
|
||||
activity.finish(status="failed", message="boom")
|
||||
|
||||
journal = Path(get_runs_dir()) / "job-fail.jsonl"
|
||||
last = json.loads(journal.read_text(encoding="utf-8").strip().splitlines()[-1])
|
||||
self.assertEqual(last["type"], "error")
|
||||
|
||||
def test_finish_truncates_large_result(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "job-trunc"
|
||||
activity._reset_for_tests()
|
||||
|
||||
big = {f"field_{i}": "x" * 200 for i in range(50)}
|
||||
buf = io.StringIO()
|
||||
with patch("sys.stdout", buf):
|
||||
activity.finish(status="success", skill="big", **big)
|
||||
|
||||
line = buf.getvalue().strip()
|
||||
self.assertLessEqual(len(line), 4096)
|
||||
result = json.loads(line)
|
||||
self.assertTrue(result.get("truncated"))
|
||||
|
||||
|
||||
class TestJobContext(_ActivityEnvIsolation):
|
||||
def test_job_context_auto_finish_on_exception(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
activity._reset_for_tests()
|
||||
|
||||
with patch("sys.stdout", io.StringIO()):
|
||||
with self.assertRaises(ValueError):
|
||||
with activity.job_context(job_id="ctx-err", skill="test-skill"):
|
||||
raise ValueError("broken")
|
||||
|
||||
journal = Path(get_runs_dir()) / "ctx-err.jsonl"
|
||||
last = json.loads(journal.read_text(encoding="utf-8").strip().splitlines()[-1])
|
||||
self.assertEqual(last["type"], "error")
|
||||
self.assertIn("broken", last["message"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
120
tests/test_activity_rpa_step_decorator.py
Normal file
120
tests/test_activity_rpa_step_decorator.py
Normal file
@@ -0,0 +1,120 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""rpa_step decorator: sync + async."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from jiangchang_skill_core import activity
|
||||
from jiangchang_skill_core.runtime_env import get_runs_dir
|
||||
|
||||
|
||||
class _DecoratorEnvIsolation(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._saved_root = os.environ.get("JIANGCHANG_DATA_ROOT")
|
||||
self._saved_job = os.environ.get("JIANGCHANG_JOB_ID")
|
||||
self._tmps: list[str] = []
|
||||
os.environ.pop("JIANGCHANG_JOB_ID", None)
|
||||
activity._reset_for_tests()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
activity._reset_for_tests()
|
||||
if self._saved_root is None:
|
||||
os.environ.pop("JIANGCHANG_DATA_ROOT", None)
|
||||
else:
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = self._saved_root
|
||||
if self._saved_job is None:
|
||||
os.environ.pop("JIANGCHANG_JOB_ID", None)
|
||||
else:
|
||||
os.environ["JIANGCHANG_JOB_ID"] = self._saved_job
|
||||
for tmp in self._tmps:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
def _tmp_data_root(self) -> str:
|
||||
tmp = tempfile.mkdtemp()
|
||||
self._tmps.append(tmp)
|
||||
return tmp
|
||||
|
||||
|
||||
class TestRpaStepDecorator(_DecoratorEnvIsolation):
|
||||
def _journal_texts(self, job_id: str) -> list[str]:
|
||||
journal = Path(get_runs_dir()) / f"{job_id}.jsonl"
|
||||
lines = journal.read_text(encoding="utf-8").strip().splitlines()
|
||||
events = [json.loads(ln) for ln in lines if json.loads(ln).get("type") != "meta"]
|
||||
return [e["text"] for e in events]
|
||||
|
||||
def test_sync_success(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "rpa-sync-ok"
|
||||
activity._reset_for_tests()
|
||||
|
||||
@activity.rpa_step("打开浏览器")
|
||||
def open_browser() -> str:
|
||||
return "ok"
|
||||
|
||||
self.assertEqual(open_browser(), "ok")
|
||||
texts = self._journal_texts("rpa-sync-ok")
|
||||
self.assertIn("▶ 打开浏览器", texts)
|
||||
self.assertIn("✓ 打开浏览器", texts)
|
||||
|
||||
def test_sync_failure_emits_warn_and_reraises(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "rpa-sync-fail"
|
||||
activity._reset_for_tests()
|
||||
|
||||
@activity.rpa_step("提交表单")
|
||||
def submit() -> None:
|
||||
raise RuntimeError("timeout")
|
||||
|
||||
with self.assertRaises(RuntimeError):
|
||||
submit()
|
||||
|
||||
journal = Path(get_runs_dir()) / "rpa-sync-fail.jsonl"
|
||||
content = journal.read_text(encoding="utf-8")
|
||||
self.assertIn("timeout", content)
|
||||
self.assertIn('"type":"warn"', content.replace(" ", ""))
|
||||
|
||||
def test_async_success(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "rpa-async-ok"
|
||||
activity._reset_for_tests()
|
||||
|
||||
@activity.rpa_step("异步步骤")
|
||||
async def async_step() -> int:
|
||||
await asyncio.sleep(0)
|
||||
return 42
|
||||
|
||||
result = asyncio.run(async_step())
|
||||
self.assertEqual(result, 42)
|
||||
texts = self._journal_texts("rpa-async-ok")
|
||||
self.assertIn("▶ 异步步骤", texts)
|
||||
self.assertIn("✓ 异步步骤", texts)
|
||||
|
||||
def test_async_failure(self) -> None:
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "rpa-async-fail"
|
||||
activity._reset_for_tests()
|
||||
|
||||
@activity.rpa_step()
|
||||
async def fail_step() -> None:
|
||||
await asyncio.sleep(0)
|
||||
raise ValueError("async err")
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
asyncio.run(fail_step())
|
||||
|
||||
journal = Path(get_runs_dir()) / "rpa-async-fail.jsonl"
|
||||
self.assertIn("async err", journal.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
72
tests/test_sibling_bridge.py
Normal file
72
tests/test_sibling_bridge.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""sibling_bridge: mock subprocess sibling CLI calls."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from jiangchang_skill_core.sibling_bridge import (
|
||||
call_sibling_json,
|
||||
call_sibling_json_array,
|
||||
get_sibling_main_path,
|
||||
)
|
||||
|
||||
|
||||
class TestSiblingBridge(unittest.TestCase):
|
||||
def test_get_sibling_main_path(self) -> None:
|
||||
with patch.dict(os.environ, {"JIANGCHANG_SKILLS_ROOT": r"D:\skills"}):
|
||||
path = get_sibling_main_path("account-manager")
|
||||
self.assertTrue(path.replace("\\", "/").endswith("account-manager/scripts/main.py"))
|
||||
|
||||
@patch("jiangchang_skill_core.sibling_bridge.subprocess.run")
|
||||
def test_call_sibling_json_success(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = subprocess.CompletedProcess(
|
||||
args=[],
|
||||
returncode=0,
|
||||
stdout='log line\n{"id": 1, "name": "test"}\n',
|
||||
stderr="",
|
||||
)
|
||||
result = call_sibling_json("account-manager", ["list", "all"])
|
||||
self.assertEqual(result, {"id": 1, "name": "test"})
|
||||
cmd = mock_run.call_args[0][0]
|
||||
self.assertIn("account-manager", cmd[1])
|
||||
self.assertIn("list", cmd)
|
||||
self.assertTrue(mock_run.call_args[1]["capture_output"])
|
||||
|
||||
@patch("jiangchang_skill_core.sibling_bridge.subprocess.run")
|
||||
def test_call_sibling_json_nonzero_exit(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = subprocess.CompletedProcess(
|
||||
args=[], returncode=1, stdout="", stderr="ERROR:fail"
|
||||
)
|
||||
self.assertIsNone(call_sibling_json("content-manager", ["get", "x"]))
|
||||
|
||||
@patch("jiangchang_skill_core.sibling_bridge.subprocess.run")
|
||||
def test_call_sibling_json_array(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = subprocess.CompletedProcess(
|
||||
args=[],
|
||||
returncode=0,
|
||||
stdout=json.dumps([{"a": 1}, {"b": 2}]),
|
||||
stderr="",
|
||||
)
|
||||
result = call_sibling_json_array("account-manager", ["list", "all"])
|
||||
self.assertEqual(result, [{"a": 1}, {"b": 2}])
|
||||
|
||||
@patch("jiangchang_skill_core.sibling_bridge.subprocess.run")
|
||||
def test_uses_subprocess_env_with_trace(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout='{"ok":true}', stderr=""
|
||||
)
|
||||
with patch(
|
||||
"jiangchang_skill_core.sibling_bridge.subprocess_env_with_trace",
|
||||
return_value={"JIANGCHANG_TRACE_ID": "abc123"},
|
||||
) as mock_env:
|
||||
call_sibling_json("api-key-vault", ["get", "key"])
|
||||
self.assertEqual(mock_run.call_args[1]["env"], {"JIANGCHANG_TRACE_ID": "abc123"})
|
||||
mock_env.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
113
tests/test_video_session_activity_integration.py
Normal file
113
tests/test_video_session_activity_integration.py
Normal file
@@ -0,0 +1,113 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""RpaVideoSession.add_step integrates with activity.emit."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from jiangchang_skill_core import activity, config
|
||||
from jiangchang_skill_core.rpa.video_session import RpaVideoSession
|
||||
from jiangchang_skill_core.runtime_env import get_runs_dir
|
||||
|
||||
|
||||
class TestVideoSessionActivityIntegration(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
config.reset_cache()
|
||||
self._saved_root = os.environ.get("JIANGCHANG_DATA_ROOT")
|
||||
self._saved_job = os.environ.get("JIANGCHANG_JOB_ID")
|
||||
self._saved_video = os.environ.get("OPENCLAW_RECORD_VIDEO")
|
||||
self._tmps: list[str] = []
|
||||
activity._reset_for_tests()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
activity._reset_for_tests()
|
||||
if self._saved_root is None:
|
||||
os.environ.pop("JIANGCHANG_DATA_ROOT", None)
|
||||
else:
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = self._saved_root
|
||||
if self._saved_job is None:
|
||||
os.environ.pop("JIANGCHANG_JOB_ID", None)
|
||||
else:
|
||||
os.environ["JIANGCHANG_JOB_ID"] = self._saved_job
|
||||
if self._saved_video is None:
|
||||
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
|
||||
else:
|
||||
os.environ["OPENCLAW_RECORD_VIDEO"] = self._saved_video
|
||||
config.reset_cache()
|
||||
for tmp in self._tmps:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
def _tmp_data_root(self) -> str:
|
||||
tmp = tempfile.mkdtemp()
|
||||
self._tmps.append(tmp)
|
||||
return tmp
|
||||
|
||||
def test_add_step_emits_activity_when_disabled(self) -> None:
|
||||
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "video-act-1"
|
||||
activity._reset_for_tests()
|
||||
config.reset_cache()
|
||||
|
||||
session = RpaVideoSession(
|
||||
skill_slug="publish-toutiao",
|
||||
skill_data_dir=tmp,
|
||||
batch_id="batch1",
|
||||
)
|
||||
self.assertFalse(session.enabled)
|
||||
session.add_step("打开浏览器")
|
||||
|
||||
journal = Path(get_runs_dir()) / "video-act-1.jsonl"
|
||||
self.assertTrue(journal.is_file())
|
||||
content = journal.read_text(encoding="utf-8")
|
||||
self.assertIn("打开浏览器", content)
|
||||
self.assertIn("rpa.video", content)
|
||||
self.assertIn("publish-toutiao", content)
|
||||
|
||||
def test_emit_activity_false_skips_journal(self) -> None:
|
||||
os.environ.pop("OPENCLAW_RECORD_VIDEO", None)
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "video-act-off"
|
||||
activity._reset_for_tests()
|
||||
|
||||
session = RpaVideoSession(
|
||||
skill_slug="publish-toutiao",
|
||||
skill_data_dir=tmp,
|
||||
batch_id="batch2",
|
||||
emit_activity=False,
|
||||
)
|
||||
session.add_step("不应写入")
|
||||
journal = Path(get_runs_dir()) / "video-act-off.jsonl"
|
||||
self.assertFalse(journal.exists())
|
||||
|
||||
def test_add_step_writes_both_journal_and_subtitle_steps(self) -> None:
|
||||
os.environ["OPENCLAW_RECORD_VIDEO"] = "1"
|
||||
tmp = self._tmp_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = tmp
|
||||
os.environ["JIANGCHANG_JOB_ID"] = "video-act-both"
|
||||
activity._reset_for_tests()
|
||||
config.reset_cache()
|
||||
|
||||
session = RpaVideoSession(
|
||||
skill_slug="receive-order",
|
||||
skill_data_dir=tmp,
|
||||
batch_id="batch3",
|
||||
)
|
||||
session.add_step("录制步骤")
|
||||
self.assertEqual(len(session._steps), 1)
|
||||
|
||||
journal = Path(get_runs_dir()) / "video-act-both.jsonl"
|
||||
lines = [json.loads(ln) for ln in journal.read_text(encoding="utf-8").splitlines()]
|
||||
activity_events = [e for e in lines if e.get("type") == "activity"]
|
||||
self.assertEqual(len(activity_events), 1)
|
||||
self.assertEqual(activity_events[0]["text"], "录制步骤")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user