feat(swads): add daily image report workflow
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: swads-daily-report
|
||||
description: Generate a read-only TikTok GMV Max daily report with Markdown analysis and a PNG long image. Invoke explicitly with /swads-daily-report.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# SW Ads daily report
|
||||
|
||||
Use only `swads_cli` to read real SW Ads data and `render_swads_daily_report` to create artifacts. Never substitute mock data, execute platform writes, request tokens in chat, or run shell commands.
|
||||
|
||||
## Preflight and scope
|
||||
|
||||
1. Call `swads_cli` with command `capabilities`, arguments `{}`. Require read-only `swads_whoami`, `metrics_catalog`, `metrics_semantic_query` and TikTok GMV Max support. Inspect returned input schemas; do not infer online capabilities from another installation. If missing, incompatible, or non-read-only, stop before rendering and explain the capability error.
|
||||
2. Call `whoami` with `{}` to verify identity and authorization. Use its current account, reporting timezone, currency, and business date (use capabilities.server_time converted to that timezone if needed). Default to the most recent complete business day, with the preceding seven complete days as context. Honor account/date overrides from the text after the Slash command without changing the remote current account. Resolve overrides against visible accounts. If identity, timezone, account or platform cannot be established, ask for the missing scope and stop. First version supports TikTok GMV Max only.
|
||||
3. Call `metrics.catalog` with `{"platform":"tiktok"}`. Require supported TikTok metrics/dimensions and query shapes. Missing capabilities, auth/permission errors, invalid results or unavailable data must stop generation; never render an empty or fabricated report. A missing server token is SWADS_NOT_CONFIGURED; tell the user to configure it on the server, never paste it into chat.
|
||||
|
||||
## Collect and analyze
|
||||
|
||||
- Use `metrics.query` with a nested `query` following the catalog, explicit account/timezone/date scope, and platform tiktok. Query report-day summary (spend, impressions, clicks, CTR, CPI, GMV, orders, ROAS, cost per order), the preceding seven complete daily rows, nonzero-spend campaigns and creatives (ID, title, creator, spend, GMV, orders, product impressions/clicks/CTR). Map every row using returned `columns[].name`; never assume positional order.
|
||||
- Read available read-only `runtime.account-overview`, `runtime.proposals`, `runtime.config`, and `campaign.list`, passing the same account and platform. Paginate campaign.list only until all queried campaign IDs are mapped or results exhausted. Disclose unavailable optional context. Never call stateful runtime.findings: derive findings from semantic metrics if unavailable/non-read-only.
|
||||
- Account status includes Spend, CTR, CPI, ROAS (GMV/spend), and simplified advertising ROI ((GMV-spend)/spend, percentage points). Missing inputs/denominators are null/N/A, never zero. Exclude product, commission, refund and fulfillment costs explicitly.
|
||||
- Rank winners and anomalies by both efficiency and sample size; identify low-sample limitations in reason. Prefer active campaigns and note material disabled spend separately. Creative attribution is correlation, not causality; distinguish account-owned posts from other creators and disclose source/coverage.
|
||||
- Produce exactly three prioritized, evidence-backed recommendations. List every proposed pause/resume, budget/target, pin/unpin or publishing write for manual confirmation. No platform writes are executed. A partial current day is a labeled monitoring note only.
|
||||
|
||||
## Deliver
|
||||
|
||||
Read [references/report-data-schema.md](references/report-data-schema.md) and normalize the observed data strictly, including ISO report_date. First output concise Markdown: account conclusion, key anomalies, three recommendations, dates/timezone and attribution caveats. Then call `render_swads_daily_report` with `{"data": <normalized report>}` as the final action. The server owns storage paths and rendering. PNG is the main delivery; HTML/JSON downloads provide traceability. If PNG is unavailable, surface the structured warning and available downloads; never claim an image was generated. Do not output another analysis after the render tool.
|
||||
@@ -0,0 +1,141 @@
|
||||
|
||||
:root {
|
||||
--primary: #9fe870;
|
||||
--primary-pale: #e2f6d5;
|
||||
--canvas: #ffffff;
|
||||
--canvas-soft: #e8ebe6;
|
||||
--ink: #0e0f0c;
|
||||
--ink-deep: #163300;
|
||||
--body: #454745;
|
||||
--mute: #868685;
|
||||
--line: #d4dad1;
|
||||
--positive: #2ead4b;
|
||||
--positive-deep: #054d28;
|
||||
--warning: #ffd11a;
|
||||
--warning-deep: #b86700;
|
||||
--warning-bg: #fff4c2;
|
||||
--warning-content: #4a3b1c;
|
||||
--negative: #d03238;
|
||||
--negative-deep: #a72027;
|
||||
--negative-bg: #320707;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--canvas-soft); }
|
||||
body {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
background: var(--canvas-soft);
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
font-feature-settings: "calt";
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.report { width: 100%; max-width: 1320px; margin: 0 auto; padding: 56px 44px; }
|
||||
.hero { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 48px; align-items: center; }
|
||||
.eyebrow { display: inline-block; padding: 6px 14px; border-radius: 9999px; color: var(--ink-deep); background: var(--primary); font-size: 12px; font-weight: 600; letter-spacing: .08em; }
|
||||
h1 { margin: 24px 0 16px; font-size: 72px; font-weight: 900; line-height: .88; letter-spacing: -.055em; }
|
||||
.subtitle { margin: 0; color: var(--body); }
|
||||
.status { padding: 28px; border-radius: 24px; color: var(--primary); background: var(--ink); }
|
||||
.status strong { display: block; font-size: 24px; font-weight: 600; }
|
||||
.status span { display: block; margin-top: 10px; color: var(--canvas-soft); font-size: 14px; }
|
||||
.kpis { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-top: 48px; }
|
||||
.kpi { min-width: 0; min-height: 168px; padding: 24px; border-radius: 24px; background: var(--canvas); }
|
||||
.kpi.accent { background: var(--primary-pale); }
|
||||
.kpi-label { color: var(--body); font-size: 14px; font-weight: 600; }
|
||||
.kpi-value { display: block; margin-top: 22px; font-size: 39px; font-weight: 900; line-height: .98; letter-spacing: -.04em; white-space: nowrap; }
|
||||
.kpi-note { display: block; margin-top: 10px; color: var(--body); font-size: 14px; }
|
||||
.good { color: var(--positive-deep); }
|
||||
.bad { color: var(--negative-deep); }
|
||||
.warn { color: var(--warning-deep); }
|
||||
.callout { display: grid; grid-template-columns: 24px 1fr; gap: 12px; margin-top: 16px; padding: 22px 24px; border-radius: 24px; color: var(--warning-content); background: var(--warning-bg); }
|
||||
.callout strong { font-weight: 600; }
|
||||
.callout p { margin: 3px 0 0; font-size: 14px; }
|
||||
section { margin-top: 56px; }
|
||||
.section-head { display: flex; justify-content: space-between; gap: 32px; align-items: end; margin-bottom: 24px; }
|
||||
h2 { margin: 0; font-size: 40px; font-weight: 900; line-height: .95; letter-spacing: -.04em; }
|
||||
.section-head p { max-width: 560px; margin: 0; color: var(--body); font-size: 14px; text-align: right; }
|
||||
.panel-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; }
|
||||
.panel { overflow: hidden; border-radius: 24px; background: var(--canvas); }
|
||||
.panel-head { display: flex; justify-content: space-between; gap: 16px; align-items: center; padding: 22px 24px; border-bottom: 1px solid var(--line); }
|
||||
.panel-title { font-weight: 600; }
|
||||
.badge { padding: 4px 12px; border-radius: 9999px; font-size: 12px; font-weight: 600; white-space: nowrap; }
|
||||
.badge-good { color: var(--positive-deep); background: var(--primary-pale); }
|
||||
.badge-bad { color: var(--canvas); background: var(--negative-bg); }
|
||||
.badge-warn { color: var(--warning-content); background: var(--warning); }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
th { padding: 12px 16px; color: var(--body); font-size: 12px; font-weight: 600; text-align: right; }
|
||||
th:first-child, td:first-child { text-align: left; }
|
||||
td { padding: 15px 16px; border-top: 1px solid var(--line); text-align: right; vertical-align: top; }
|
||||
td:first-child { max-width: 250px; font-weight: 600; }
|
||||
.row-note { display: block; margin-top: 2px; color: var(--mute); font-size: 12px; font-weight: 400; }
|
||||
.trend-panel { padding: 24px; }
|
||||
.trend { display: grid; grid-template-columns: repeat(7, 1fr); gap: 12px; min-height: 190px; align-items: end; border-bottom: 1px solid var(--ink); }
|
||||
.trend-item { display: grid; grid-template-rows: 1fr auto auto; gap: 5px; height: 100%; align-items: end; text-align: center; }
|
||||
.bar { width: 38px; max-width: 78%; min-height: 20px; margin: 0 auto; border-radius: 12px 12px 0 0; background: var(--primary); }
|
||||
.bar.partial { background: var(--warning); }
|
||||
.trend-value { font-size: 12px; font-weight: 600; }
|
||||
.trend-label { color: var(--mute); font-size: 12px; }
|
||||
.source-list { padding: 24px; }
|
||||
.source-row { display: grid; grid-template-columns: 180px 1fr 80px; gap: 12px; align-items: center; margin: 16px 0; font-size: 14px; }
|
||||
.meter { height: 12px; overflow: hidden; border-radius: 9999px; background: var(--canvas-soft); }
|
||||
.meter span { display: block; height: 100%; border-radius: inherit; background: var(--primary); }
|
||||
.source-row:nth-child(2n) .meter span { background: var(--warning); }
|
||||
.source-value { text-align: right; font-weight: 600; }
|
||||
.insights { padding: 12px 24px; }
|
||||
.insight { display: grid; grid-template-columns: 32px 1fr; gap: 12px; padding: 15px 0; border-top: 1px solid var(--line); }
|
||||
.insight:first-child { border-top: 0; }
|
||||
.insight-num { display: grid; width: 32px; height: 32px; place-items: center; border-radius: 9999px; color: var(--ink-deep); background: var(--primary); font-size: 14px; font-weight: 600; }
|
||||
.insight p { margin: 4px 0 0; color: var(--body); font-size: 14px; }
|
||||
.recommendations { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
||||
.recommendation { min-height: 250px; padding: 24px; border-radius: 24px; background: var(--canvas); }
|
||||
.recommendation-top { display: flex; justify-content: space-between; gap: 12px; align-items: center; }
|
||||
.recommendation-num { font-size: 40px; font-weight: 900; line-height: 1; }
|
||||
.recommendation h3 { margin: 24px 0 10px; font-size: 24px; font-weight: 600; line-height: 1.3; }
|
||||
.recommendation p { margin: 0; color: var(--body); }
|
||||
.confirmations { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.confirmation { display: grid; grid-template-columns: 28px 1fr; gap: 12px; padding: 22px 24px; border-radius: 24px; background: var(--canvas); }
|
||||
.confirmation strong { display: block; font-weight: 600; }
|
||||
.confirmation span { display: block; margin-top: 3px; color: var(--body); font-size: 14px; }
|
||||
.notes { margin: 48px 0 0; padding: 24px; border-radius: 24px; color: var(--canvas-soft); background: var(--ink); font-size: 13px; }
|
||||
.notes strong { color: var(--primary); }
|
||||
.notes ul { margin: 8px 0 0; padding-left: 20px; }
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.report { padding: 40px 24px; }
|
||||
.hero { grid-template-columns: 1fr; gap: 24px; }
|
||||
.kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.panel-grid { grid-template-columns: 1fr; }
|
||||
.recommendations { grid-template-columns: 1fr; }
|
||||
.recommendation { min-height: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.report { padding: 32px 16px; }
|
||||
h1 { font-size: 48px; }
|
||||
h2 { font-size: 32px; line-height: 1.15; }
|
||||
.kpis { grid-template-columns: 1fr; }
|
||||
.kpi { min-height: 0; }
|
||||
.kpi-value { font-size: 32px; white-space: normal; }
|
||||
.section-head { display: block; }
|
||||
.section-head p { margin-top: 8px; text-align: left; }
|
||||
.confirmations { grid-template-columns: 1fr; }
|
||||
.source-row { grid-template-columns: 120px 1fr 60px; }
|
||||
}
|
||||
|
||||
@media print {
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
}
|
||||
/* Wrap identifiers without hiding content. */
|
||||
h1, h3, td, p, span, strong { overflow-wrap: anywhere; }
|
||||
.report { padding-bottom: 100px; }
|
||||
|
||||
/* Reserve readable evidence columns even for the maximum row length. */
|
||||
table { table-layout: fixed; }
|
||||
th, td { padding-left: 10px; padding-right: 10px; }
|
||||
th:first-child, td:first-child { width: 52%; max-width: none; }
|
||||
.source-card { padding: 16px 24px; border-bottom: 1px solid var(--line); }
|
||||
.source-card p { margin: 8px 0 0; color: var(--body); font-size: 14px; }
|
||||
.kpi-value { white-space: normal; }
|
||||
@@ -0,0 +1,74 @@
|
||||
# Normalized report data
|
||||
|
||||
The renderer consumes normalized report data, not raw MCP responses. Build each semantic row by zipping `columns[].name` with the row values, then populate this schema. Ratios such as ROAS use multiplier units (`6.31` means `6.31×`); percentage fields use percentage points (`4.18` means `4.18%`). Money values use the account currency.
|
||||
|
||||
## Required shape
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"report_date": "2026-08-19",
|
||||
"account_name": "Demo Commerce",
|
||||
"account_id": "demo-account-001",
|
||||
"currency": "MYR",
|
||||
"reporting_timezone": "Asia/Kuala_Lumpur",
|
||||
"period_label": "2026-08-19",
|
||||
"comparison_label": "Previous 7 complete days",
|
||||
"freshness_label": "2026-08-20 14:48 MYT",
|
||||
"attribution_label": "TikTok GMV Max native attribution",
|
||||
"status_label": "Cautiously positive"
|
||||
},
|
||||
"summary": {
|
||||
"spend": 1837.84,
|
||||
"revenue": 11593.35,
|
||||
"orders": 663,
|
||||
"ctr_percent": null,
|
||||
"cpi": null,
|
||||
"roas": 6.31,
|
||||
"simple_roi_percent": 530.8,
|
||||
"cost_per_order": 2.77
|
||||
},
|
||||
"monitoring_note": "Optional partial-day or data-quality note.",
|
||||
"trend": [
|
||||
{"label": "08/14", "roas": 6.45, "partial": false}
|
||||
],
|
||||
"campaigns": {
|
||||
"winners": [
|
||||
{"name": "Campaign name", "id": "external id", "status": "ENABLE", "spend": 10, "revenue": 80, "orders": 5, "roas": 8, "reason": "Balanced scale and efficiency"}
|
||||
],
|
||||
"anomalies": []
|
||||
},
|
||||
"creative": {
|
||||
"row_count": 692,
|
||||
"coverage_percent": 75.2,
|
||||
"source_groups": [
|
||||
{"label": "Affiliate / creator", "spend": 1232.55, "revenue": 7535.94, "orders": 463, "roas": 6.11},
|
||||
{"label": "Account-owned", "spend": 150.2, "revenue": 121, "orders": 8, "roas": 0.81}
|
||||
],
|
||||
"winners": [
|
||||
{"name": "Creative title", "id": "creative id", "creator": "creator", "spend": 20, "revenue": 350, "orders": 20, "roas": 17.5, "clicks": 89, "product_ctr_percent": 7.0}
|
||||
],
|
||||
"anomalies": [],
|
||||
"insights": ["Short evidence-backed attribution clue"]
|
||||
},
|
||||
"recommendations": [
|
||||
{"title": "Action title", "detail": "Observed evidence and bounded next step.", "confirmation": "required"}
|
||||
],
|
||||
"manual_confirmations": [
|
||||
{"action": "Pause or change budget", "required": true, "reason": "Changes live delivery"}
|
||||
],
|
||||
"notes": ["Attribution and unavailable-data caveats"]
|
||||
}
|
||||
```
|
||||
|
||||
## Completion rules
|
||||
|
||||
- `recommendations` contains exactly three items. `confirmation` is `required`, `partial`, or `none`.
|
||||
- Winner and anomaly tables should each contain three to five rows when sufficient data exists. Keep low-sample rows but identify the sample limitation in `reason`.
|
||||
- Missing CTR/CPI/ROI inputs are JSON `null`. Never encode missing data as numeric zero.
|
||||
- `creative.source_groups` may be empty when creator identity is unavailable. Keep `creative.insights` evidence-based and avoid causal language.
|
||||
- `manual_confirmations` covers every recommended platform write. Read-only checks may use `required: false`.
|
||||
|
||||
## Runtime bounds
|
||||
|
||||
All objects are strict. report_date must be a real ISO YYYY-MM-DD date; timezone an IANA timezone; currency a three-letter code. Text fields: maximum 500 characters (names/IDs/labels 160). Arrays: trend 7, each winner/anomaly table 5, source_groups 6, insights 4, notes and manual_confirmations 10. Metrics are finite numbers or null; counts are nonnegative integers. Campaign reason is required; creative reason is optional for sample caveats. Required/partial recommendations need a required manual confirmation entry. Reports require non-null spend and at least one other observed summary metric.
|
||||
@@ -14,3 +14,9 @@ SESSION_RUN_TIMEOUT_MS=120000
|
||||
APPROVAL_TIMEOUT_MS=60000
|
||||
MAX_IMAGE_BYTES=5242880
|
||||
MAX_IMAGES_PER_MESSAGE=4
|
||||
# Server-only read-only SW Ads MCP Gateway. Missing token only disables SW Ads tools.
|
||||
SWADS_MCP_URL=https://ads.mincode.cn/mcp
|
||||
SWADS_MCP_TOKEN=
|
||||
SWADS_MCP_TIMEOUT_MS=30000
|
||||
# Optional absolute Chrome/Chromium executable; otherwise fixed system paths are tried.
|
||||
CHROME_BIN=
|
||||
|
||||
@@ -7,3 +7,5 @@ workspace/sessions/
|
||||
workspace/model-credentials.enc.json
|
||||
*.log
|
||||
.DS_Store
|
||||
workspace/reports/
|
||||
workspace/.pi-catalog/
|
||||
|
||||
@@ -195,3 +195,15 @@ Composer 支持选择、拖拽、粘贴、上传状态、移除与对话框预
|
||||
课堂版服务端仍是单进程:重启会丢 Session/Event/Approval,但模型 API Key 可通过本地 AES-256-GCM 密文恢复;浏览器只保留非敏感模型元数据。当前文件 vault 没有多用户/租户隔离、远程 KMS、密钥轮换或分布式锁。SSE 无跨实例协调;没有用户身份、CSRF、分布式 rate limit、持久审计、对象存储/KMS 或 OS Sandbox。自定义 provider 的 Vision 声明会被服务器注册成精确 Pi Model 并按该模型检查,但课堂版不会主动发远端 capability probe。
|
||||
|
||||
生产化路线见 [docs/production-roadmap.md](docs/production-roadmap.md):Redis/Postgres、对象存储、KMS、身份鉴权与 CSRF、速率/配额、容器/VM Sandbox、出口代理、不可变审计日志、队列/worker、分布式 SSE,以及完整可观测性。
|
||||
|
||||
## SW Ads 图片日报
|
||||
|
||||
在服务器 `.env` 配置 `SWADS_MCP_URL`(默认 `https://ads.mincode.cn/mcp`)、`SWADS_MCP_TOKEN` 和可选 `SWADS_MCP_TIMEOUT_MS`(默认 30000,范围 100–120000)。Token 只在服务端保存,勿粘贴到聊天或提交 Git。URL 启动时校验;缺少 Token 不影响现有 mock ads、附件和报告草稿功能,首次 SW Ads 调用返回 `SWADS_NOT_CONFIGURED`。
|
||||
|
||||
发送 `/swads-daily-report`,或 `/swads-daily-report 账户 demo-account,日期 2026-09-06`。该内置 Skill 仅显式调用,保留原始用户消息。首版仅支持 TikTok GMV Max:默认当前账户最近完整业务日,之前七个完整业务日作比较;账户时区、币种和账户范围来自真实 Gateway。查询前探测线上 `tools/list`,要求只读 whoami、metrics catalog、semantic query 及实际 TikTok 指标能力;能力缺失、鉴权失败或空数据时停止生成。
|
||||
|
||||
Agent 先输出简洁 Markdown 分析,再生成 PNG 长图,图片可点击预览;Tool 卡提供 PNG、HTML、JSON 下载。服务器通过固定 Chrome CLI 参数和 Sharp 截图检查生成图片,不需要 Playwright/Puppeteer。可用 `CHROME_BIN` 指定绝对可执行路径,否则尝试固定系统 Chrome/Chromium 路径。浏览器缺失、超时或达到画布上限仍不完整时保留 HTML/JSON,并显示 `CHROME_UNAVAILABLE`、`PNG_TIMEOUT`、`PNG_RENDER_FAILED` 或 `PNG_TRUNCATED`。
|
||||
|
||||
产物原子保存于 `workspace/reports/<accountKey>/<YYYY-MM-DD>/<reportId>/`,不随 Session reset/delete 清理,不进入 Git。报告 URL 没有目录列表,PNG inline,HTML/JSON attachment。当前版本没有用户认证,随机 ID 只是内部/本地部署下的非枚举地址;持有 URL 即可访问。公网多用户部署需要另行实现认证、授权与租户隔离。存储保留与容量清理由部署方管理。
|
||||
|
||||
`swads_cli` 是服务端受控 CLI facade:Agent 不接收 Gateway 的逐项 MCP Tools,也不能执行 shell,但 facade 仍使用官方 MCP Streamable HTTP client 连接 MCP Gateway。该功能不执行任何平台写操作;所有预算、状态、目标和发布建议都列入人工确认。
|
||||
|
||||
+14
-3
@@ -13,6 +13,9 @@ import {
|
||||
InMemorySessionRegistry,
|
||||
PiAgentSessionFactory,
|
||||
SkillCatalog,
|
||||
SwadsGateway,
|
||||
ReportStore,
|
||||
REPORT_CSP,
|
||||
safeErrorMessage,
|
||||
type AgentSessionFactory,
|
||||
} from "@agent-studio/harness";
|
||||
@@ -25,7 +28,7 @@ const eventQuery = z.object({ afterSequence: z.coerce.number().int().nonnegative
|
||||
|
||||
function parse<T>(schema: z.ZodType<T>, value: unknown): T { return schema.parse(value); }
|
||||
|
||||
export interface AppServices { sessions: InMemorySessionRegistry; events: InMemoryEventStore; attachments: FileAttachmentStore; models: InMemoryModelConnectionStore; skills: SkillCatalog }
|
||||
export interface AppServices { sessions: InMemorySessionRegistry; events: InMemoryEventStore; attachments: FileAttachmentStore; models: InMemoryModelConnectionStore; skills: SkillCatalog; reports: ReportStore }
|
||||
export interface AppOptions { projectRoot?: string; runTimeoutMs?: number; approvalTimeoutMs?: number; heartbeatMs?: number; agentFactory?: AgentSessionFactory; onReady?: (services: AppServices) => void }
|
||||
|
||||
export async function buildApp(options: AppOptions = {}): Promise<FastifyInstance> {
|
||||
@@ -35,6 +38,8 @@ export async function buildApp(options: AppOptions = {}): Promise<FastifyInstanc
|
||||
await mkdir(sessionsRoot, { recursive: true, mode: 0o700 });
|
||||
await mkdir(agentDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
const gateway = new SwadsGateway();
|
||||
const reports = new ReportStore(path.join(projectRoot, "workspace/reports"), path.join(projectRoot, ".agents/skills/swads-daily-report/assets/report.css"));
|
||||
const events = new InMemoryEventStore(1_000);
|
||||
const credentialVault = process.env.MODEL_CREDENTIAL_ENCRYPTION_KEY
|
||||
? new EncryptedFileCredentialVault(path.join(projectRoot, "workspace", "model-credentials.enc.json"), process.env.MODEL_CREDENTIAL_ENCRYPTION_KEY)
|
||||
@@ -46,8 +51,8 @@ export async function buildApp(options: AppOptions = {}): Promise<FastifyInstanc
|
||||
const skills = new SkillCatalog(projectRoot, agentDir);
|
||||
await skills.reload();
|
||||
const sessions = new InMemorySessionRegistry(sessionsRoot, events, attachments, models, skills, undefined, options.approvalTimeoutMs ?? Number(process.env.APPROVAL_TIMEOUT_MS ?? 60_000), options.runTimeoutMs ?? Number(process.env.SESSION_RUN_TIMEOUT_MS ?? 120_000));
|
||||
sessions.setFactory(options.agentFactory ?? new PiAgentSessionFactory(path.join(projectRoot, "fixtures", "ads-account.json"), skills, models, sessions.approvals));
|
||||
options.onReady?.({ sessions, events, attachments, models, skills });
|
||||
sessions.setFactory(options.agentFactory ?? new PiAgentSessionFactory(path.join(projectRoot, "fixtures", "ads-account.json"), skills, models, sessions.approvals, gateway, reports));
|
||||
options.onReady?.({ sessions, events, attachments, models, skills, reports });
|
||||
|
||||
const app = Fastify({
|
||||
genReqId: () => `req_${randomUUID()}`,
|
||||
@@ -65,6 +70,12 @@ export async function buildApp(options: AppOptions = {}): Promise<FastifyInstanc
|
||||
return sendError(reply, request, 500, "INTERNAL_ERROR", "Internal server error");
|
||||
});
|
||||
|
||||
app.get("/api/reports/:accountKey/:reportDate/:reportId/:format", async (request, reply) => {
|
||||
const params = request.params as Record<string, string>;
|
||||
const result = await reports.read({ accountKey: params.accountKey, reportDate: params.reportDate, reportId: params.reportId }, params.format);
|
||||
const mime = { png: "image/png", html: "text/html; charset=utf-8", json: "application/json; charset=utf-8" };
|
||||
return reply.header("content-type", mime[result.format]).header("content-disposition", `${result.format === "png" ? "inline" : "attachment"}; filename="swads-daily-report.${result.format}"`).header("x-content-type-options", "nosniff").header("content-security-policy", REPORT_CSP).header("cache-control", "private, no-store").send(result.bytes);
|
||||
});
|
||||
app.get("/health", async (request) => ({ ok: true, requestId: request.id }));
|
||||
app.get("/api/skills", async (request) => ({ skills: skills.list(), requestId: request.id }));
|
||||
app.post("/api/sessions", async (request, reply) => { const session = await sessions.create(); return reply.code(201).send({ sessionId: session.sessionId, requestId: request.id }); });
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { mkdtemp, mkdir } from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, copyFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { buildApp, type AppServices } from "../src/app.js";
|
||||
import type { AgentSessionFactory, AgentSessionPort } from "@agent-studio/harness";
|
||||
import { sampleReport } from "../../../packages/harness/tests/report-fixture.js";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
class ServerFakePort implements AgentSessionPort {
|
||||
@@ -20,7 +21,7 @@ afterEach(async () => { while (apps.length) await apps.pop()?.close(); });
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "studio-server-")); await mkdir(path.join(root, ".agents/skills"), { recursive: true }); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); await mkdir(path.join(root, "fixtures"));
|
||||
const factory = new ServerFakeFactory(); let services: AppServices | undefined; const app = await buildApp({ projectRoot: root, agentFactory: factory, heartbeatMs: 20, onReady: (value) => { services = value; } }); apps.push(app); return { app, factory, services: services! };
|
||||
const factory = new ServerFakeFactory(); let services: AppServices | undefined; const app = await buildApp({ projectRoot: root, agentFactory: factory, heartbeatMs: 20, onReady: (value) => { services = value; } }); apps.push(app); return { app, factory, root, services: services! };
|
||||
}
|
||||
async function createSession(app: FastifyInstance) { const response = await app.inject({ method: "POST", url: "/api/sessions" }); return (response.json() as { sessionId: string }).sessionId; }
|
||||
async function configure(app: FastifyInstance, sessionId: string, supportsImages = true) { return app.inject({ method: "PUT", url: `/api/sessions/${sessionId}/model-config`, payload: { providerId: "fake", api: "openai-responses", modelId: "fake-model", apiKey: "sk-test-never-return", supportsImages } }); }
|
||||
@@ -36,6 +37,14 @@ describe("Fastify API", () => {
|
||||
it("supports approve and deny endpoints with conflict detection", async () => { const { app, services } = await fixture(); const sessionId = await createSession(app); const controller = new AbortController(); const pending = services.sessions.approvals.request({ sessionId, runId: "run_test", toolCallId: "tool_test", toolName: "save_report_draft", arguments: {}, riskLevel: "medium" }, controller.signal); const event = services.events.listAfter(sessionId).events.find((item) => item.type === "approval.required"); if (!event || event.type !== "approval.required") throw new Error("missing approval"); const approved = await app.inject({ method: "POST", url: `/api/approvals/${event.payload.approvalId}/approve` }); expect(approved.statusCode).toBe(200); expect((await pending).status).toBe("approved"); const conflict = await app.inject({ method: "POST", url: `/api/approvals/${event.payload.approvalId}/deny` }); expect(conflict.statusCode).toBe(409); });
|
||||
it("accepts a valid multipart PNG and rejects SVG", async () => { const { app } = await fixture(); const sessionId = await createSession(app); const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"); const accepted = await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/attachments`, headers: { "content-type": "multipart/form-data; boundary=studio" }, payload: multipart("studio", "image.png", "image/png", png) }); expect(accepted.statusCode).toBe(201); expect(accepted.json()).toMatchObject({ mediaType: "image/png", width: 1, status: "ready" }); const rejected = await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/attachments`, headers: { "content-type": "multipart/form-data; boundary=studio" }, payload: multipart("studio", "x.svg", "image/svg+xml", Buffer.from("<svg/>")) }); expect(rejected.statusCode).toBe(415); });
|
||||
it("serves SSE events and heartbeat comments", async () => { const { app } = await fixture(); const sessionId = await createSession(app); const address = await app.listen({ host: "127.0.0.1", port: 0 }); const controller = new AbortController(); const response = await fetch(`${address}/api/sessions/${sessionId}/events`, { signal: controller.signal }); expect(response.headers.get("content-type")).toContain("text/event-stream"); const reader = response.body!.getReader(); let text = ""; for (let count = 0; count < 4 && !text.includes(": heartbeat"); count++) { const chunk = await reader.read(); text += new TextDecoder().decode(chunk.value); } controller.abort(); expect(text).toContain("event: session.started"); expect(text).toContain(": heartbeat"); });
|
||||
it("keeps reports downloadable after session deletion and guards artifact routes", async () => {
|
||||
const { app, root, services } = await fixture(); const assets = path.join(root, ".agents/skills/swads-daily-report/assets"); await mkdir(assets, { recursive: true }); await copyFile(path.resolve(import.meta.dirname, "../../../.agents/skills/swads-daily-report/assets/report.css"), path.join(assets, "report.css"));
|
||||
const artifact = await services.reports.create(sampleReport(), new AbortController().signal); const session = await createSession(app); await app.inject({ method: "DELETE", url: `/api/sessions/${session}` });
|
||||
for (const [format, url] of Object.entries(artifact.downloads)) { const response = await app.inject({ method: "GET", url }); expect(response.statusCode).toBe(200); expect(response.headers["content-disposition"]).toContain(format === "png" ? "inline" : "attachment"); expect(response.headers["x-content-type-options"]).toBe("nosniff"); expect(response.headers["content-security-policy"]).toContain("default-src 'none'"); expect(response.body).not.toContain(root); expect(response.headers["content-type"]).toContain(format === "png" ? "image/png" : format === "json" ? "application/json" : "text/html"); }
|
||||
const base = artifact.downloads.json.slice(0, -4);
|
||||
for (const url of [base + "secret", base.replace(artifact.reportId, "00000000-0000-4000-8000-000000000000") + "json", "/api/reports/%2e%2e/x/y/json", "/api/reports/", base + "%2e%2e%2fsecret"]) { const response = await app.inject({ method: "GET", url }); expect(response.statusCode).toBe(404); expect(response.body).not.toContain(root); }
|
||||
}, 60_000);
|
||||
|
||||
});
|
||||
|
||||
function multipart(boundary: string, filename: string, mime: string, data: Buffer): Buffer { return Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${mime}\r\n\r\n`), data, Buffer.from(`\r\n--${boundary}--\r\n`)]); }
|
||||
|
||||
+13
-2
@@ -3,15 +3,26 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": { "dev": "vite --host 127.0.0.1", "typecheck": "tsc --noEmit", "build": "vite build" },
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agent-studio/shared": "workspace:*",
|
||||
"@assistant-ui/react": "0.15.17",
|
||||
"@assistant-ui/react-markdown": "^0.14.14",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"lucide-react": "^0.555.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": { "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", "typescript": "^5.9.3", "vite": "^7.2.6" }
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface ClientState {
|
||||
|
||||
interface LocalAttachment extends ImageAttachment { objectUrl: string }
|
||||
type Listener = () => void;
|
||||
const eventTypes: HarnessEvent["type"][] = ["session.started", "session.ended", "user.message", "assistant.delta", "assistant.completed", "skill.requested", "skill.loaded", "tool.requested", "tool.completed", "tool.failed", "approval.required", "approval.resolved", "attachment.uploaded", "attachment.rejected", "model.configured", "model.validation_failed", "run.started", "run.cancelled", "run.completed", "run.failed"];
|
||||
const eventTypes: HarnessEvent["type"][] = ["session.started", "session.ended", "user.message", "assistant.delta", "assistant.completed", "report.generated", "skill.requested", "skill.loaded", "tool.requested", "tool.completed", "tool.failed", "approval.required", "approval.resolved", "attachment.uploaded", "attachment.rejected", "model.configured", "model.validation_failed", "run.started", "run.cancelled", "run.completed", "run.failed"];
|
||||
const modelStorageKey = "agent-studio-mini:model-connection:v1";
|
||||
|
||||
export class HarnessClientStore {
|
||||
@@ -23,6 +23,8 @@ export class HarnessClientStore {
|
||||
private readonly listeners = new Set<Listener>();
|
||||
private readonly seen = new Set<string>();
|
||||
private readonly attachments = new Map<string, LocalAttachment>();
|
||||
private readonly reportImages = new Map<string, string | undefined>();
|
||||
private generation = 0;
|
||||
private source?: EventSource;
|
||||
private lastSequence = 0;
|
||||
private initializing: Promise<void> | undefined;
|
||||
@@ -95,7 +97,13 @@ export class HarnessClientStore {
|
||||
} else if (event.type === "assistant.delta") {
|
||||
messages = updateAssistant(messages, event.payload.messageId, event.timestamp, (parts) => appendText(parts, event.payload.delta), { type: "running" }, event.runId);
|
||||
} else if (event.type === "assistant.completed") {
|
||||
messages = updateAssistant(messages, event.payload.messageId, event.timestamp, (parts) => parts, { type: "complete", reason: "stop" });
|
||||
messages = updateAssistant(messages, event.payload.messageId, event.timestamp, (parts) => parts, { type: "complete", reason: "stop" }, event.runId);
|
||||
} else if (event.type === "report.generated") {
|
||||
const { artifact, assistantMessageId } = event.payload;
|
||||
if (artifact.previewUrl) {
|
||||
messages = updateAssistant(messages, assistantMessageId, event.timestamp, (parts) => parts, { type: "running" }, event.runId);
|
||||
void this.loadReportImage(event);
|
||||
}
|
||||
} else if (event.type === "tool.requested") {
|
||||
const id = assistantIdForRun(messages, event.runId);
|
||||
messages = updateAssistant(messages, id, event.timestamp, (parts) => upsertToolCall(parts, event.payload.toolCallId, event.payload.toolName, asArgs(event.payload.arguments)), { type: "running" }, event.runId);
|
||||
@@ -113,6 +121,28 @@ export class HarnessClientStore {
|
||||
this.update({ events, messages, isRunning });
|
||||
}
|
||||
|
||||
private async loadReportImage(event: Extract<HarnessEvent, { type: "report.generated" }>): Promise<void> {
|
||||
const { artifact, assistantMessageId } = event.payload;
|
||||
const url = artifact.previewUrl;
|
||||
if (!url || this.reportImages.has(url)) return;
|
||||
this.reportImages.set(url, undefined);
|
||||
const generation = this.generation;
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok || !response.headers.get("content-type")?.startsWith("image/png")) throw new Error("图片加载失败,可通过下载链接重试。");
|
||||
const blob = await response.blob();
|
||||
if (generation !== this.generation) return;
|
||||
const image = URL.createObjectURL(blob);
|
||||
this.reportImages.set(url, image);
|
||||
const existing = this.state.messages.find((message) => message.id === assistantMessageId);
|
||||
const status = this.state.isRunning ? existing?.status ?? { type: "running" as const } : { type: "complete" as const, reason: "stop" as const };
|
||||
const messages = updateAssistant(this.state.messages, assistantMessageId, event.timestamp, (parts) => [...parts, { type: "image", image, filename: `SW Ads ${artifact.reportDate}` }], status, event.runId);
|
||||
this.update({ messages });
|
||||
} catch {
|
||||
if (generation === this.generation) this.update({ error: "日报图片预览加载失败,可通过下载链接重试。" });
|
||||
}
|
||||
}
|
||||
|
||||
onNew = async (message: AppendMessage): Promise<void> => {
|
||||
const sessionId = await this.requireSession();
|
||||
const text = message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
|
||||
@@ -125,6 +155,9 @@ export class HarnessClientStore {
|
||||
|
||||
async reset(): Promise<void> {
|
||||
const old = this.state.sessionId;
|
||||
this.generation++;
|
||||
for (const image of this.reportImages.values()) if (image) URL.revokeObjectURL(image);
|
||||
this.reportImages.clear();
|
||||
this.source?.close();
|
||||
if (old) await api(`/api/sessions/${old}`, { method: "DELETE" });
|
||||
for (const item of this.attachments.values()) URL.revokeObjectURL(item.objectUrl);
|
||||
@@ -177,9 +210,9 @@ export class HarnessClientStore {
|
||||
|
||||
type ContentParts = Exclude<ThreadMessageLike["content"], string>;
|
||||
function updateAssistant(messages: ThreadMessageLike[], id: string, timestamp: string, change: (parts: ContentParts) => ContentParts, status: ThreadMessageLike["status"], runId?: string): ThreadMessageLike[] {
|
||||
const index = messages.findIndex((item) => item.id === id);
|
||||
const index = messages.findIndex((item) => item.id === id || (runId && item.role === "assistant" && item.metadata?.custom?.runId === runId));
|
||||
if (index < 0) return [...messages, { id, role: "assistant", createdAt: new Date(timestamp), content: change([]), status, metadata: { custom: runId ? { runId } : {} } }];
|
||||
return messages.map((item, current) => current === index ? { ...item, content: change(typeof item.content === "string" ? [{ type: "text", text: item.content }] : item.content), status } : item);
|
||||
return messages.map((item, current) => current === index ? { ...item, id, content: change(typeof item.content === "string" ? [{ type: "text", text: item.content }] : item.content), status } : item);
|
||||
}
|
||||
function appendText(parts: ContentParts, delta: string): ContentParts { const last = parts.at(-1); return last?.type === "text" ? [...parts.slice(0, -1), { ...last, text: last.text + delta }] : [...parts, { type: "text", text: delta }]; }
|
||||
function upsertToolCall(parts: ContentParts, toolCallId: string, toolName: string, args: Record<string, string | number | boolean | null>): ContentParts {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MarkdownTextPrimitive } from "@assistant-ui/react-markdown";
|
||||
import { useState } from "react";
|
||||
import { AttachmentPrimitive, ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useAui, useAuiState } from "@assistant-ui/react";
|
||||
import { ImagePlus, Send, Square, X } from "lucide-react";
|
||||
@@ -7,14 +8,14 @@ import { useHarnessState } from "../assistant/HarnessContext";
|
||||
|
||||
const demoPrompt = "请使用 ads-analysis Skill,分析账户 demo-account 最近 7 天数据,找出异常 Campaign,并输出账户总结、关键指标和 3 条优化建议。最后使用 report-writer Skill 生成一份报告草稿。";
|
||||
|
||||
function TextPart({ text }: { text: string }) { return <div className="message-text">{text}</div>; }
|
||||
function ImagePart({ image, filename }: { image: string; filename?: string }) { return <ImagePreview src={image} name={filename ?? "图片"}/>; }
|
||||
function TextPart() { return <MarkdownTextPrimitive className="message-text markdown" smooth={false} disallowedElements={["img"]}/>; }
|
||||
function ImagePart({ image, filename }: { image: string; filename?: string }) { return <ImagePreview src={image} name={filename ?? "图片"} report={filename?.startsWith("SW Ads ") ?? false}/>; }
|
||||
const partComponents = { Text: TextPart, Image: ImagePart, tools: { Fallback: ToolFallback } };
|
||||
function UserMessage() { return <MessagePrimitive.Root className="message user"><div className="role">You</div><MessagePrimitive.Content components={partComponents}/><MessagePrimitive.Attachments>{() => null}</MessagePrimitive.Attachments></MessagePrimitive.Root>; }
|
||||
function AssistantMessage() { return <MessagePrimitive.Root className="message assistant"><div className="role">Agent</div><MessagePrimitive.Content components={partComponents}/><MessagePrimitive.Error/></MessagePrimitive.Root>; }
|
||||
|
||||
function ImagePreview({ src, name }: { src: string; name: string }) {
|
||||
return <Dialog.Root><Dialog.Trigger asChild><button className="image-preview" aria-label={`预览 ${name}`}><img src={src} alt={name}/></button></Dialog.Trigger><Dialog.Portal><Dialog.Overlay className="dialog-overlay"/><Dialog.Content className="image-dialog" aria-describedby={undefined}><Dialog.Title>{name}</Dialog.Title><img src={src} alt={name}/><Dialog.Close className="icon-button" aria-label="关闭预览"><X/></Dialog.Close></Dialog.Content></Dialog.Portal></Dialog.Root>;
|
||||
function ImagePreview({ src, name, report = false }: { src: string; name: string; report?: boolean }) {
|
||||
return <Dialog.Root><Dialog.Trigger asChild><button className={`image-preview${report ? " report-image-preview" : ""}`} aria-label={`预览 ${name}`}><img src={src} alt={name}/></button></Dialog.Trigger><Dialog.Portal><Dialog.Overlay className="dialog-overlay"/><Dialog.Content className={`image-dialog${report ? " report-image-dialog" : ""}`} aria-describedby={undefined}><Dialog.Title>{name}</Dialog.Title><img src={src} alt={name}/><Dialog.Close className="icon-button" aria-label="关闭预览"><X/></Dialog.Close></Dialog.Content></Dialog.Portal></Dialog.Root>;
|
||||
}
|
||||
function ComposerAttachment() {
|
||||
const preview = useAuiState((state) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { AuiConfig, AuiProvider, defineToolkit, Tools, useAui, type Toolkit, type ToolCallMessagePartProps } from "@assistant-ui/react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { z } from "zod";
|
||||
import { reportArtifactSchema } from "@agent-studio/shared";
|
||||
import { useHarnessStore } from "../assistant/HarnessContext";
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
@@ -32,11 +33,18 @@ export function SaveReportDraftUI(props: ToolCallMessagePartProps<JsonObject, un
|
||||
const content = typeof props.args?.content === "string" ? props.args.content : "";
|
||||
return <section className="tool-card risk" data-tool="save_report_draft"><header><span>save_report_draft</span><span className="status">{stateLabel(props)}</span></header><p>将写入当前 Session 的固定文件 <code>report.md</code></p><p className="summary">{content.slice(0, 180)}{content.length > 180 ? "…" : ""}</p>{props.approval && <ApprovalActions approval={props.approval}/>}</section>;
|
||||
}
|
||||
export function SwadsReportUI(props: ToolCallMessagePartProps<JsonObject, unknown>) {
|
||||
const parsed = reportArtifactSchema.safeParse(props.result);
|
||||
return <section className="tool-card" data-tool="render_swads_daily_report"><header><span>SW Ads 图片日报</span><span className="status">{stateLabel(props)}</span></header>{parsed.success && <>{parsed.data.warning && <p role="status">{parsed.data.warning.message}</p>}<div className="report-downloads">{Object.entries(parsed.data.downloads).map(([format, url]) => <a key={format} href={url} download>下载 {format.toUpperCase()}</a>)}</div></>}</section>;
|
||||
}
|
||||
export function SwadsCliUI(props: ToolCallMessagePartProps<JsonObject, unknown>) { return <section className="tool-card"><header><span>SW Ads · {String(props.args.command ?? "read")}</span><span className="status">{stateLabel(props)}</span></header></section>; }
|
||||
export function ToolFallback(props: ToolCallMessagePartProps) { return <section className="tool-card"><header><span>{props.toolName}</span><span className="status">{stateLabel(props)}</span></header><pre>{JSON.stringify(props.args ?? {}, null, 2)}</pre></section>; }
|
||||
function Metric({ label, value, suffix = "" }: { label: string; value: unknown; suffix?: string }) { return <div><span>{label}</span><strong>{typeof value === "number" ? value.toLocaleString() : "—"}{suffix}</strong></div>; }
|
||||
function asRecord(value: unknown): JsonObject { return value && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : {}; }
|
||||
|
||||
const authoredToolkit = defineToolkit({
|
||||
swads_cli: { description: "显示 SW Ads 只读查询状态", parameters: z.object({ command: z.string() }), render: SwadsCliUI },
|
||||
render_swads_daily_report: { description: "日报状态与下载", parameters: z.object({}), render: SwadsReportUI },
|
||||
mock_ads_metrics: { description: "显示服务端广告指标工具结果", parameters: z.object({ accountId: z.string(), days: z.number() }), render: MockAdsMetricsUI },
|
||||
save_report_draft: { description: "显示服务端报告保存审批与结果", parameters: z.object({ content: z.string(), title: z.string().optional() }), render: SaveReportDraftUI },
|
||||
});
|
||||
|
||||
@@ -104,3 +104,14 @@ button:disabled { opacity: .46; cursor: not-allowed; }
|
||||
html, body, #root { overflow: hidden; }.app-shell { grid-template-rows: 58px 44px 1fr; }.brand small, .model-badge, .vision-badge, .session-summary > .run-dot { display: none; }.topbar { padding: 0 10px; }.settings-button { width: 42px; padding: 0; font-size: 0; }.settings-button svg { width: 18px; }.mobile-tabs { display: grid; grid-template-columns: repeat(3, 1fr); border-bottom: 1px solid var(--border); background: var(--surface-1); }.mobile-tabs button { border: 0; background: transparent; color: var(--muted); text-transform: capitalize; }.mobile-tabs button.active { color: var(--cyan); box-shadow: inset 0 -2px var(--cyan); }.workspace-grid { display: block; min-height: 0; }.mobile-panel { display: none !important; height: 100%; }.mobile-panel.shown { display: block !important; }.side-panel { border: 0; }.chat-toolbar { padding: 6px 10px; }.text-button { padding: 0 8px; }.composer-footer { padding: 10px; }.message { width: calc(100% - 20px); }.metrics { grid-template-columns: repeat(2, 1fr); }.form-grid { grid-template-columns: 1fr; }.span-2 { grid-column: span 1 !important; }.presets { overflow-x: auto; }.presets button { flex: 0 0 auto; }.dialog-actions { flex-wrap: wrap; }.dialog-actions button { flex: 1; }.dialog-actions .danger { margin: 0; }.composer-hint { display: none; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; } }
|
||||
|
||||
.report-downloads { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 12px; }
|
||||
.message-text.markdown { white-space: normal; overflow-wrap: anywhere; }
|
||||
.markdown p { margin: 0.5em 0; }
|
||||
.markdown pre { overflow-x: auto; white-space: pre; }
|
||||
.markdown table { display: block; overflow-x: auto; border-collapse: collapse; }
|
||||
.markdown th, .markdown td { padding: 6px 10px; border: 1px solid var(--line, #ddd); }
|
||||
|
||||
.report-image-preview { width: min(640px, 100%); }
|
||||
.report-image-preview img { width: 100%; height: auto; object-fit: contain; }
|
||||
.report-image-dialog img { width: 100%; max-height: none; }
|
||||
|
||||
@@ -46,3 +46,21 @@ describe("HarnessClientStore reducer", () => {
|
||||
await expect(store.saveModel({ providerId: "openai", api: "openai-responses", apiKey: "top-secret", modelId: "gpt", supportsImages: true })).rejects.toThrow("Session 初始化失败:Failed to fetch");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generated report events", () => {
|
||||
it("deduplicates images and merges provisional tool messages with the correct assistant", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response(new Blob(["png"], { type: "image/png" }), { headers: { "content-type": "image/png" } })));
|
||||
Object.defineProperty(URL, "createObjectURL", { configurable: true, value: vi.fn(() => "blob:report-image") });
|
||||
const store = new HarnessClientStore(); const base = { sessionId: "ses_report", runId: "run_report", timestamp: new Date().toISOString() };
|
||||
const accountKey = "acct_0123456789abcdef01234567", reportDate = "2026-09-06", reportId = "12345678-1234-4234-8234-123456789abc";
|
||||
const url = `/api/reports/${accountKey}/${reportDate}/${reportId}`;
|
||||
const artifact = { accountKey, reportDate, reportId, previewUrl: `${url}/png`, downloads: { json: `${url}/json`, html: `${url}/html`, png: `${url}/png` } };
|
||||
store.reduce({ ...base, eventId: "report_1", sequence: 1, type: "tool.requested", payload: { toolCallId: "render", toolName: "render_swads_daily_report", arguments: {} } });
|
||||
store.reduce({ ...base, eventId: "report_2", sequence: 2, type: "assistant.delta", payload: { messageId: "real_message", delta: "分析" } });
|
||||
const event: HarnessEvent = { ...base, eventId: "report_3", sequence: 3, type: "report.generated", payload: { assistantMessageId: "real_message", artifact } };
|
||||
store.reduce(event); store.reduce(event); store.reduce({ ...event, eventId: "report_4", sequence: 4 });
|
||||
await vi.waitFor(() => expect(store.getSnapshot().messages[0]?.content).toEqual(expect.arrayContaining([expect.objectContaining({ type: "image" })])));
|
||||
const messages = store.getSnapshot().messages; expect(messages).toHaveLength(1); expect(messages[0]?.id).toBe("real_message"); expect(messages[0]?.content).toEqual(expect.arrayContaining([{ type: "image", image: "blob:report-image", filename: "SW Ads 2026-09-06" }]));
|
||||
expect(Array.isArray(messages[0]?.content) && messages[0].content.filter((part) => part.type === "image")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// @vitest-environment jsdom
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HarnessEvent } from "@agent-studio/shared";
|
||||
import { App } from "../src/App";
|
||||
import { HarnessRuntime } from "../src/assistant/HarnessRuntime";
|
||||
import { HarnessClientStore } from "../src/assistant/store";
|
||||
import { MockAdsMetricsUI, SaveReportDraftUI } from "../src/components/ToolUIs";
|
||||
import { MockAdsMetricsUI, SaveReportDraftUI, SwadsReportUI } from "../src/components/ToolUIs";
|
||||
import { AppErrorBoundary } from "../src/components/AppErrorBoundary";
|
||||
|
||||
afterEach(() => { localStorage.clear(); sessionStorage.clear(); vi.restoreAllMocks(); });
|
||||
afterEach(() => { localStorage.clear(); sessionStorage.clear(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
|
||||
describe("web UI", () => {
|
||||
it("renders the Skills, Chat, and Timeline operating areas", () => { const store = new HarnessClientStore(); render(<HarnessRuntime store={store}><App/></HarnessRuntime>); expect(screen.getByLabelText("Skills Catalog")).toBeInTheDocument(); expect(screen.getByLabelText("Streaming Chat")).toBeInTheDocument(); expect(screen.getByLabelText("Event Timeline")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /尚未配置模型/ })).toBeInTheDocument(); });
|
||||
it("renders the mock metrics Tool UI", () => { render(<MockAdsMetricsUI {...toolProps("mock_ads_metrics", { accountId: "demo-account", days: 7 }, { details: { spend: 100, ctr: 2, cpa: 4, roas: 5 } })}/>); expect(screen.getByText("demo-account", { exact: false })).toBeInTheDocument(); expect(screen.getByText("ROAS")).toBeInTheDocument(); });
|
||||
@@ -36,7 +36,34 @@ describe("web UI", () => {
|
||||
expect(screen.getByText("ROAS")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "允许保存报告草稿?" })).toBeInTheDocument();
|
||||
});
|
||||
it("shows Markdown, generated PNG preview and downloads for a text-only model", async () => {
|
||||
const store = new HarnessClientStore(); store.getSnapshot().model.supportsImages = false;
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response(new Blob(["png"], { type: "image/png" }), { headers: { "content-type": "image/png" } })));
|
||||
Object.defineProperty(URL, "createObjectURL", { configurable: true, value: vi.fn(() => "blob:report-image") });
|
||||
const artifact = reportArtifact();
|
||||
store.reduce(harnessEvent(1, "tool.requested", { toolCallId: "render", toolName: "render_swads_daily_report", arguments: {} }));
|
||||
store.reduce(harnessEvent(2, "assistant.delta", { messageId: "assistant-real", delta: "**账户结论**\n\n- 建议一\n- 建议二\n- 建议三" }));
|
||||
store.reduce(harnessEvent(3, "tool.completed", { toolCallId: "render", toolName: "render_swads_daily_report", result: artifact }));
|
||||
store.reduce(harnessEvent(4, "report.generated", { assistantMessageId: "assistant-real", artifact }));
|
||||
store.reduce(harnessEvent(5, "run.completed", {}));
|
||||
await waitFor(() => expect(store.getSnapshot().messages[0]?.content).toEqual(expect.arrayContaining([expect.objectContaining({ type: "image" })])));
|
||||
render(<HarnessRuntime store={store}><App/></HarnessRuntime>);
|
||||
expect(screen.getByText("账户结论").tagName).toBe("STRONG"); expect(screen.getAllByRole("listitem")).toHaveLength(3); expect(screen.getByRole("link", { name: "下载 JSON" })).toHaveAttribute("href", artifact.downloads.json);
|
||||
await userEvent.setup().click(screen.getByRole("button", { name: "预览 SW Ads 2026-09-06" })); expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(store.getSnapshot().messages.filter((message) => message.role === "assistant")).toHaveLength(1);
|
||||
});
|
||||
it("shows a no-PNG warning and only available downloads", () => {
|
||||
const artifact = reportArtifact(); const result = { ...artifact, previewUrl: undefined, downloads: { json: artifact.downloads.json, html: artifact.downloads.html }, warning: { code: "CHROME_UNAVAILABLE", message: "Chrome unavailable; HTML/JSON available" } };
|
||||
render(<SwadsReportUI {...toolProps("render_swads_daily_report", {}, result)}/>); expect(screen.getByRole("status")).toHaveTextContent("Chrome unavailable"); expect(screen.queryByRole("link", { name: "下载 PNG" })).not.toBeInTheDocument(); expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
function toolProps(toolName: string, args: Record<string, unknown>, result?: unknown, approval?: { id: string }) { return { type: "tool-call" as const, toolCallId: "t1", toolName, args, argsText: JSON.stringify(args), result, isError: false, status: { type: "complete" as const }, addResult: vi.fn(), resume: vi.fn(), respondToApproval: vi.fn(), ...(approval ? { approval } : {}) }; }
|
||||
function harnessEvent<T extends HarnessEvent["type"]>(sequence: number, type: T, payload: Extract<HarnessEvent, { type: T }>["payload"]): HarnessEvent { return { eventId: `evt_${sequence}`, sessionId: "ses_1", runId: "run_1", timestamp: new Date().toISOString(), sequence, type, payload } as HarnessEvent; }
|
||||
|
||||
function reportArtifact() {
|
||||
const accountKey = "acct_0123456789abcdef01234567", reportDate = "2026-09-06", reportId = "12345678-1234-4234-8234-123456789abc";
|
||||
const base = `/api/reports/${accountKey}/${reportDate}/${reportId}`;
|
||||
return { accountKey, reportDate, reportId, previewUrl: `${base}/png`, downloads: { png: `${base}/png`, html: `${base}/html`, json: `${base}/json` } };
|
||||
}
|
||||
|
||||
@@ -53,3 +53,13 @@ flowchart TB
|
||||
## Failure and shutdown
|
||||
|
||||
Run timeout 和用户 cancel 使用不同 reason;Approval wait 监听同一 AbortSignal。删除先 abort,再 cancel pending approval,关闭订阅与 SSE,调用 Pi `dispose()`,清凭证/附件,最后在 canonical sessions root 校验后删除 cwd。单个 SSE 断开不取消 Run。
|
||||
|
||||
## SW Ads daily report
|
||||
|
||||
`SkillCatalog.expandCommand` 将精确 `/swads-daily-report` 按需转换为 Pi `<skill>` block,自由文本参数保留,`user.message` 保持原文。Catalog/模型自动匹配不加载该 Skill 正文;`disable-model-invocation` 排除自动发现提示。`skill.requested/loaded` 的 `source` 区分 command 和 read,command 不伪造 toolCallId。只读 Skill resources 在已发现根目录内校验 realpath、普通文件和 symlink 边界。
|
||||
|
||||
Pi tools 增加 `swads_cli`、`render_swads_daily_report`,原有工具和审批不变。`SwadsGateway` 在服务启动时读取并校验环境配置,每个命令创建独立 MCP client/transport,初始化后使用短时能力缓存(60 秒,capabilities 强制刷新),调用结束关闭连接。命令 enum 映射固定远端名,参数先经过本地 strict schema/64 KiB/深度 10 检查,再经远端 inputSchema 验证;需要显式 readOnlyHint,拒绝 destructiveHint。单次调用 30 秒默认超时,响应 2 MiB 上限,AbortSignal 来自当前 Run。Gateway 工具不会注册到 Agent。
|
||||
|
||||
每个 Session 的 `SwadsReportTools` 记录当前 Run 的 whoami/catalog/query 成功证据,缺失或必需查询失败禁止 render。Skill 根据返回 columns 名称归一化真实指标,日期、账户、归因和样本限制写入 strict `ReportData`。ReportStore 拥有 accountKey/hash、reportId/UUID 和输出路径,临时目录完整写入后 rename。Chrome 从环境或固定系统路径发现,通过 execFile 启动,5200/9000/24000 三档画布,检查底边与结尾标记后裁切;不完整则降级。
|
||||
|
||||
render tool completion 由 Pi adapter 映射到 `report.generated { assistantMessageId, artifact }`。事件只含相对 URL/安全标识/warning,既没有文件正文也没有绝对路径。Web reducer 合并同 Run 的临时 assistant ID,从同源接口获取 PNG 并创建 blob URL,再追加到指定消息(兼容本地 HTTP 部署下 assistant-ui 的图片 URL 校验);Markdown、图片 Dialog 与下载 Tool UI 分工呈现。生成图片是展示产物,不进入模型输入,不受 vision capability 限制。报告 Store 位于 Session Store 之外,重置和删除会话不会删除报告。
|
||||
|
||||
@@ -39,3 +39,11 @@ multipart 流有字节上限;扩展名、MIME、magic bytes 三重匹配。Sha
|
||||
## Required production isolation
|
||||
|
||||
不可信 Skill、仓库、脚本、附件解析或无人值守 Agent 必须在 Docker(配合 seccomp/AppArmor 和无特权用户)、VM、micro-VM 或远程 Sandbox 运行,并施加 CPU/内存/磁盘/时间/网络配额。Project Trust 不能替代这些措施。
|
||||
|
||||
## SW Ads read-only facade and artifacts
|
||||
|
||||
- `SWADS_MCP_TOKEN` 仅存在服务端配置和出站 Authorization Header。URL 不允许凭据/query/fragment;fetch 禁止 redirect,避免 Header 转发。Gateway 原始错误正文、Headers 和 Token 不进入模型/SSE/日志/下载,返回固定 `SWADS_*` 错误分类。响应中的 Token 精确值及敏感键在模型交付前脱敏。
|
||||
- `swads_cli` 只允许约定九个 command。参数拒绝任意 URL/Header/Token/tool name 和未知顶层字段;语义 query 允许有界嵌套 JSON,但不能提供传输控制项。Tool 必须存在并声明只读;非只读 `runtime_findings` 禁止执行。缺少所需 TikTok 能力时 fail closed,不用 mock 数据兜底。
|
||||
- `render_swads_daily_report` 不接收路径、命令或 Chrome 参数。strict schema 限制字符串/数组长度、真实 ISO 日期、有限数值/null、倍率/百分比与恰好三条建议。动态 HTML 字段全部转义,CSS 无外部字体;CSP 禁止脚本/网络资源,Chrome 使用独立临时 profile、固定参数和进程超时。
|
||||
- Artifact URL 仅按 accountKey、ISO 日期、UUID 和 png/html/json enum 定位;逐段拒绝 symlink,无目录枚举。PNG inline、HTML/JSON attachment,准确 MIME、nosniff、限制性 CSP、private no-store。随机 ID 不是身份授权,现有单进程无认证应用仅适合内部/本地边界;公网多租户部署需要独立授权设计。
|
||||
- JSON/HTML 即使 PNG 失败仍保留。PNG 超时、缺浏览器、截断分别返回 warning;取消 Run 则清理未发布临时目录。报告不随 Session 删除,部署方需管理持久目录容量。
|
||||
|
||||
@@ -3,15 +3,29 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": { "types": "./src/index.ts", "development": "./src/index.ts", "import": "./dist/index.js", "default": "./dist/index.js" } },
|
||||
"scripts": { "typecheck": "tsc --noEmit", "build": "tsc -p tsconfig.json" },
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agent-studio/shared": "workspace:*",
|
||||
"@earendil-works/pi-ai": "0.84.3",
|
||||
"@earendil-works/pi-coding-agent": "0.84.3",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"sharp": "^0.34.5",
|
||||
"typebox": "1.3.7",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": { "@types/node": "^24.10.1", "typescript": "^5.9.3" }
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ export class HarnessError extends Error {
|
||||
|
||||
export function safeErrorMessage(error: unknown): string {
|
||||
if (!(error instanceof Error)) return "Unexpected error";
|
||||
return error.message
|
||||
const message = process.env.SWADS_MCP_TOKEN ? error.message.split(process.env.SWADS_MCP_TOKEN).join("[REDACTED]") : error.message;
|
||||
return message
|
||||
.replace(/(?:sk|pk|key)-[A-Za-z0-9_-]{8,}/g, "[REDACTED]")
|
||||
.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]")
|
||||
.replace(/[A-Za-z0-9+/]{200,}={0,2}/g, "[REDACTED_DATA]");
|
||||
@@ -29,3 +30,16 @@ export function redactValue(value: unknown, depth = 0): unknown {
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Remove the configured server secret without truncating event or report data. */
|
||||
export function redactServerSecret<T>(value: T): T {
|
||||
const token = process.env.SWADS_MCP_TOKEN;
|
||||
if (!token) return value;
|
||||
const scrub = (item: unknown): unknown => {
|
||||
if (typeof item === "string") return item.split(token).join("[REDACTED]");
|
||||
if (Array.isArray(item)) return item.map(scrub);
|
||||
if (item && typeof item === "object") return Object.fromEntries(Object.entries(item).map(([key, val]) => [String(scrub(key)), scrub(val)]));
|
||||
return item;
|
||||
};
|
||||
return scrub(value) as T;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { redactServerSecret } from "./errors.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { EventInput, HarnessEvent, HarnessEventType } from "@agent-studio/shared";
|
||||
|
||||
@@ -29,7 +30,7 @@ export class InMemoryEventStore implements EventStore {
|
||||
append<T extends HarnessEventType>(sessionId: string, input: EventInput<T>): Extract<HarnessEvent, { type: T }> {
|
||||
const bucket = this.bucket(sessionId);
|
||||
const value = {
|
||||
...input,
|
||||
...redactServerSecret(input),
|
||||
eventId: `evt_${randomUUID()}`,
|
||||
sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
|
||||
@@ -9,3 +9,9 @@ export * from "./safe-workspace.js";
|
||||
export * from "./session.js";
|
||||
export * from "./skills.js";
|
||||
export * from "./tools.js";
|
||||
|
||||
export * from "./swads-gateway.js";
|
||||
export * from "./swads-tools.js";
|
||||
export * from "./report-schema.js";
|
||||
export * from "./report-renderer.js";
|
||||
export * from "./report-store.js";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
||||
import type { EventInput, HarnessEventType } from "@agent-studio/shared";
|
||||
import { reportArtifactSchema } from "@agent-studio/shared";
|
||||
import { redactValue } from "./errors.js";
|
||||
|
||||
export interface PiAdapterContext { runId: string; assistantMessageId: string }
|
||||
@@ -14,11 +15,20 @@ export function mapPiEvent(event: AgentSessionEvent, context: PiAdapterContext):
|
||||
if (event.message.role === "assistant") return [{ type: "assistant.completed", runId: context.runId, payload: { messageId: context.assistantMessageId } }];
|
||||
return [];
|
||||
case "tool_execution_start":
|
||||
return [{ type: "tool.requested", runId: context.runId, payload: { toolCallId: event.toolCallId, toolName: event.toolName, arguments: redactValue(event.args) } }];
|
||||
case "tool_execution_end":
|
||||
return [{ type: "tool.requested", runId: context.runId, payload: { toolCallId: event.toolCallId, toolName: event.toolName, arguments: event.toolName === "render_swads_daily_report" ? { status: "requested" } : redactValue(event.args) } }];
|
||||
case "tool_execution_end": {
|
||||
if (!event.isError && event.toolName === "render_swads_daily_report") {
|
||||
const artifact = reportArtifactSchema.safeParse((event.result as { details?: unknown })?.details);
|
||||
if (artifact.success) return [
|
||||
{ type: "tool.completed", runId: context.runId, payload: { toolCallId: event.toolCallId, toolName: event.toolName, result: artifact.data } },
|
||||
{ type: "report.generated", runId: context.runId, payload: { assistantMessageId: context.assistantMessageId, artifact: artifact.data } },
|
||||
];
|
||||
return [{ type: "tool.failed", runId: context.runId, payload: { toolCallId: event.toolCallId, toolName: event.toolName, error: "SWADS_INVALID_REPORT" } }];
|
||||
}
|
||||
return event.isError
|
||||
? [{ type: "tool.failed", runId: context.runId, payload: { toolCallId: event.toolCallId, toolName: event.toolName, error: "Tool execution failed" } }]
|
||||
? [{ type: "tool.failed", runId: context.runId, payload: { toolCallId: event.toolCallId, toolName: event.toolName, error: toolFailure(event.result) } }]
|
||||
: [{ type: "tool.completed", runId: context.runId, payload: { toolCallId: event.toolCallId, toolName: event.toolName, result: summarizeToolResult(event.toolName, event.result) } }];
|
||||
}
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -26,5 +36,13 @@ export function mapPiEvent(event: AgentSessionEvent, context: PiAdapterContext):
|
||||
|
||||
function summarizeToolResult(toolName: string, result: unknown): unknown {
|
||||
if (["read", "grep", "find", "ls"].includes(toolName)) return { status: "success" };
|
||||
if (toolName === "swads_cli") return redactValue((result as { details?: unknown })?.details ?? { status: "success" });
|
||||
return redactValue(result);
|
||||
}
|
||||
|
||||
function toolFailure(result: unknown): string {
|
||||
const content = (result as { content?: Array<{ type?: string; text?: string }> } | null)?.content;
|
||||
const text = content?.filter((item) => item.type === "text").map((item) => item.text ?? "").join(" ") ?? "";
|
||||
const codes = ["NO_DATA", "NOT_CONFIGURED", "INVALID_CONFIG", "INVALID_ARGUMENTS", "AUTH_FAILED", "FORBIDDEN", "TOOL_MISSING", "TOOL_DRIFT", "NOT_READ_ONLY", "CAPABILITY_MISSING", "INVALID_RESPONSE", "UPSTREAM_ERROR", "RESPONSE_TOO_LARGE", "TIMEOUT", "ABORTED", "UNAVAILABLE", "PREFLIGHT_REQUIRED", "INVALID_REPORT", "REPORT_FAILED"];
|
||||
return codes.map((code) => `SWADS_${code}`).find((code) => text === code || text === `Error: ${code}`) ?? "Tool execution failed";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { access, mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import sharp from "sharp";
|
||||
import type { ReportArtifact } from "@agent-studio/shared";
|
||||
import type { ReportData } from "./report-schema.js";
|
||||
|
||||
const escape = (value: unknown) => String(value ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
||||
const number = (value: number | null, suffix = "") => value === null ? "N/A" : `${value.toLocaleString("en-US", { maximumFractionDigits: 2 })}${suffix}`;
|
||||
export const REPORT_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src 'none'; font-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; sandbox";
|
||||
export function renderReportHtml(data: ReportData, css: string): string {
|
||||
const { meta, summary, creative } = data;
|
||||
if (/@import|url\s*\(|<\/style/i.test(css)) throw new Error("Report CSS must be local and self-contained");
|
||||
const money = (value: number | null) => value === null ? "N/A" : `${meta.currency} ${number(value)}`;
|
||||
const kpi = (label: string, value: string, note: string, accent = false) => `<div class="kpi${accent ? " accent" : ""}"><span class="kpi-label">${escape(label)}</span><span class="kpi-value">${escape(value)}</span><span class="kpi-note">${escape(note)}</span></div>`;
|
||||
type Row = ReportData["campaigns"]["winners"][number] | ReportData["creative"]["winners"][number];
|
||||
const table = (rows: Row[], creativeTable = false) => `<div class="table-wrap"><table><thead><tr><th>${creativeTable ? "素材 / 发布账号" : "Campaign"}</th><th>Spend</th><th>GMV</th><th>订单</th><th>ROAS</th></tr></thead><tbody>${rows.length ? rows.map((row) => `<tr><td>${escape(row.name)}<span class="row-note">${escape([row.id, "creator" in row ? row.creator : row.status, row.reason].filter(Boolean).join(" · "))}</span>${"clicks" in row ? `<span class="row-note">点击 ${number(row.clicks)} · Product CTR ${number(row.product_ctr_percent, "%")}</span>` : ""}</td><td>${number(row.spend)}</td><td>${number(row.revenue)}</td><td>${number(row.orders)}</td><td><strong>${number(row.roas, "×")}</strong></td></tr>`).join("") : '<tr><td colspan="5">暂无足够样本</td></tr>'}</tbody></table></div>`;
|
||||
const panel = (title: string, content: string) => `<div class="panel"><div class="panel-head"><span class="panel-title">${escape(title)}</span></div>${content}</div>`;
|
||||
const section = (title: string, subtitle: string, content: string) => `<section><div class="section-head"><h2>${escape(title)}</h2><p>${escape(subtitle)}</p></div>${content}</section>`;
|
||||
const maxRoas = Math.max(1, ...data.trend.map((item) => item.roas ?? 0));
|
||||
const trend = data.trend.map((item) => `<div class="trend-item"><div class="bar${item.partial ? " partial" : ""}" style="height:${item.roas === null ? 0 : 24 + 126 * item.roas / maxRoas}px;min-height:0"></div><span class="trend-value">${number(item.roas, "×")}</span><span class="trend-label">${escape(item.label)}${item.partial ? " (partial)" : ""}</span></div>`).join("");
|
||||
const labels = { required: "需人工确认", partial: "部分需确认", none: "只读检查" };
|
||||
const recommendations = data.recommendations.map((item, index) => `<article class="recommendation"><div class="recommendation-top"><span class="recommendation-num">0${index + 1}</span><span class="badge badge-warn">${labels[item.confirmation]}</span></div><h3>${escape(item.title)}</h3><p>${escape(item.detail)}</p></article>`).join("");
|
||||
const confirmations = data.manual_confirmations.map((item) => `<div class="confirmation"><span>${item.required ? "✓" : "○"}</span><div><strong>${escape(item.action)} · ${item.required ? "需确认" : "只读"}</strong><span>${escape(item.reason)}</span></div></div>`).join("");
|
||||
const sources = creative.source_groups.map((item) => `<div class="source-card"><div><strong>${escape(item.label)} · ${number(item.roas, "×")}</strong><p>Spend ${escape(money(item.spend))} · GMV ${escape(money(item.revenue))} · ${number(item.orders)} 单</p></div></div>`).join("") || "发布账号归因信息不可用。";
|
||||
const insights = creative.insights.map((item, index) => `<div class="insight"><span class="insight-num">${index + 1}</span><div><strong>归因线索</strong><p>${escape(item)}</p></div></div>`).join("") || "样本不足以形成稳定判断。";
|
||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${escape(REPORT_CSP)}"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(meta.account_name)} 每日投放报告</title><style>${css}</style></head><body><main class="report">
|
||||
<header class="hero"><div><span class="eyebrow">SW ADS · DELIVERY INTELLIGENCE</span><h1>${escape(meta.account_name)}<br>每日投放报告</h1><p class="subtitle">${escape(meta.report_date)} · ${escape(meta.period_label)} · ${escape(meta.reporting_timezone)} · ${escape(meta.attribution_label)}</p></div><div class="status"><strong>整体状态:${escape(meta.status_label)}</strong><span>${escape(meta.comparison_label)} · ${escape(meta.freshness_label)} · ${escape(meta.account_id)}</span></div></header>
|
||||
<div class="kpis">${kpi("Spend", money(summary.spend), "报告期总消耗")}${kpi("GMV", money(summary.revenue), `${number(summary.orders)} 单 · Cost/order ${money(summary.cost_per_order)}`)}${kpi("ROAS", number(summary.roas, "×"), "GMV ÷ Spend", true)}${kpi("简化广告 ROI", number(summary.simple_roi_percent, "%"), "未扣商品、佣金、退款与履约成本", true)}${kpi("CTR", number(summary.ctr_percent, "%"), "缺失分母时 N/A")}${kpi("CPI", money(summary.cpi), "缺失指标时 N/A")}</div>
|
||||
${data.monitoring_note ? `<div class="callout"><span>⚠</span><div><strong>监控提示</strong><p>${escape(data.monitoring_note)}</p></div></div>` : ""}
|
||||
${section("七日 ROAS 趋势", meta.comparison_label, `<div class="panel trend-panel"><div class="trend">${trend}</div></div>`)}
|
||||
${section("Campaign 分层", `效率、消耗与订单样本共同判断;金额单位 ${meta.currency}。`, `<div class="panel-grid">${panel("相对优质 Campaign", table(data.campaigns.winners))}${panel("异常 / 低效 Campaign", table(data.campaigns.anomalies))}</div>`)}
|
||||
${section("素材表现与归因线索", `${number(creative.row_count)} 条有消耗素材;消耗覆盖 ${number(creative.coverage_percent, "%")};相关性不代表因果。`, `<div class="panel-grid">${panel("发布账号贡献对比", sources)}${panel("归因线索", insights)}</div><div class="panel-grid" style="margin-top:24px">${panel("高价值素材", table(creative.winners, true))}${panel("高消耗低转化素材", table(creative.anomalies, true))}</div>`)}
|
||||
${section("三条优化建议", "本报告不执行平台写入。", `<div class="recommendations">${recommendations}</div>`)}
|
||||
${section("人工确认清单", "预算、目标、状态、授权与发布均需人工控制。", `<div class="confirmations">${confirmations}</div>`)}
|
||||
<footer class="notes"><strong>数据与口径</strong><ul>${[meta.attribution_label, ...data.notes].map((note) => `<li>${escape(note)}</li>`).join("")}</ul></footer></main><div id="report-end" style="height:8px;background:#1234ab"></div></body></html>`;
|
||||
}
|
||||
|
||||
export interface PngResult { png?: Buffer; warning?: ReportArtifact["warning"] }
|
||||
export interface ChromeRunner { (executable: string, args: string[], signal: AbortSignal): Promise<void> }
|
||||
export const runChrome: ChromeRunner = (executable, args, signal) => new Promise((resolve, reject) => {
|
||||
const screenshot = args.find((arg) => arg.startsWith("--screenshot="))!.slice("--screenshot=".length);
|
||||
let captured = false;
|
||||
let inspecting = false;
|
||||
const stop = () => { child.kill("SIGKILL"); };
|
||||
const env = Object.fromEntries(["PATH", "LANG", "LC_ALL", "TMPDIR", "SYSTEMROOT", "DISPLAY", "XDG_RUNTIME_DIR"].flatMap((key) => process.env[key] ? [[key, process.env[key]!]] : []));
|
||||
const child = execFile(executable, args, { signal, env, timeout: 30_000, killSignal: "SIGKILL", maxBuffer: 1024 * 1024 }, (error) => {
|
||||
clearInterval(poll); signal.removeEventListener("abort", stop); stop();
|
||||
if (signal.aborted) reject(signal.reason);
|
||||
else if (error && !captured) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
signal.addEventListener("abort", stop, { once: true });
|
||||
// Some desktop Chrome builds keep background children alive after writing the
|
||||
// screenshot. Once the entire PNG decodes, stop our isolated Chrome process.
|
||||
// Content completeness is checked separately by renderReportPng.
|
||||
const poll = setInterval(() => {
|
||||
if (inspecting) return;
|
||||
inspecting = true;
|
||||
void readFile(screenshot).then((bytes) => sharp(bytes).raw().toBuffer()).then(() => { captured = true; stop(); }).catch(() => undefined).finally(() => { inspecting = false; });
|
||||
}, 200);
|
||||
});
|
||||
export async function discoverChrome(explicit = process.env.CHROME_BIN): Promise<string | undefined> {
|
||||
const candidates = explicit ? [explicit] : ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"];
|
||||
for (const candidate of candidates) { if (!path.isAbsolute(candidate)) continue; try { await access(candidate, constants.X_OK); if ((await stat(candidate)).isFile()) return candidate; } catch { /* Try the next fixed system path. */ } }
|
||||
return undefined;
|
||||
}
|
||||
const warning = (code: NonNullable<ReportArtifact["warning"]>["code"]): PngResult => ({ warning: { code, message: `${code}: PNG unavailable; HTML and JSON are available.` } });
|
||||
export async function renderReportPng(htmlPath: string, signal: AbortSignal, options: { discover?: () => Promise<string | undefined>; runner?: ChromeRunner } = {}): Promise<PngResult> {
|
||||
signal.throwIfAborted();
|
||||
const executable = await (options.discover ?? discoverChrome)();
|
||||
if (!executable) return warning("CHROME_UNAVAILABLE");
|
||||
const temp = await mkdtemp(path.join(tmpdir(), "swads-chrome-"));
|
||||
try {
|
||||
for (const height of [5200, 9000, 24000]) {
|
||||
signal.throwIfAborted();
|
||||
const pngPath = path.join(temp, "capture.png");
|
||||
await rm(pngPath, { force: true });
|
||||
await (options.runner ?? runChrome)(executable, ["--headless=new", "--disable-gpu", "--password-store=basic", "--use-mock-keychain", "--disable-breakpad", "--hide-scrollbars", "--no-first-run", "--disable-extensions", "--disable-background-networking", "--disable-sync", "--no-default-browser-check", "--host-resolver-rules=MAP * ~NOTFOUND", "--force-device-scale-factor=1", `--user-data-dir=${path.join(temp, "profile")}`, `--window-size=1440,${height}`, `--screenshot=${pngPath}`, pathToFileURL(htmlPath).href], signal);
|
||||
const input = await readFile(pngPath);
|
||||
const { data, info } = await sharp(input, { limitInputPixels: 1440 * 24000 }).removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
||||
if (info.width !== 1440 || info.height !== height) return warning("PNG_RENDER_FAILED");
|
||||
// Require the full-width end marker and a background-only bottom edge.
|
||||
// This also detects a blank/error page and clipping through whitespace.
|
||||
let end = -1;
|
||||
for (let y = 0; y < height; y++) {
|
||||
const at = (y * info.width + 20) * info.channels;
|
||||
if (data[at] === 18 && data[at + 1] === 52 && data[at + 2] === 171) { end = y; break; }
|
||||
}
|
||||
let bottomClear = true;
|
||||
for (let x = 0; x < info.width; x++) {
|
||||
const at = ((height - 1) * info.width + x) * info.channels;
|
||||
if (Math.abs(data[at]! - 232) > 3 || Math.abs(data[at + 1]! - 235) > 3 || Math.abs(data[at + 2]! - 230) > 3) { bottomClear = false; break; }
|
||||
}
|
||||
if (end < 1 || !bottomClear || end + 16 >= height) continue;
|
||||
const cropped = await sharp(input).extract({ left: 0, top: 0, width: info.width, height: end }).toBuffer();
|
||||
const png = await sharp(cropped).trim({ background: "#e8ebe6", threshold: 4 }).extend({ top: 24, bottom: 24, left: 24, right: 24, background: "#e8ebe6" }).png().toBuffer();
|
||||
return { png };
|
||||
}
|
||||
return warning("PNG_TRUNCATED");
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
return warning((error as { killed?: boolean; code?: string }).killed || (error as { code?: string }).code === "ETIMEDOUT" ? "PNG_TIMEOUT" : "PNG_RENDER_FAILED");
|
||||
} finally { await rm(temp, { recursive: true, force: true }); }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const reportDateSchema = z.iso.date();
|
||||
const label = z.string().min(1).max(160);
|
||||
const text = z.string().min(1).max(500);
|
||||
const metric = z.number().finite().nonnegative().nullable();
|
||||
const count = z.number().int().nonnegative().nullable();
|
||||
const percent = z.number().min(0).max(100).nullable();
|
||||
const performance = { spend: metric, revenue: metric, orders: count, roas: metric };
|
||||
const campaign = z.object({ name: label, id: label, status: label, ...performance, reason: text }).strict();
|
||||
const creative = z.object({ name: label, id: label, creator: label, ...performance, clicks: count, product_ctr_percent: percent, reason: text.optional() }).strict();
|
||||
export const reportDataSchema = z.object({
|
||||
meta: z.object({ report_date: reportDateSchema, account_name: label, account_id: label, currency: z.string().regex(/^[A-Z]{3}$/), reporting_timezone: label.refine((value) => { try { new Intl.DateTimeFormat("en", { timeZone: value }); return true; } catch { return false; } }, "Invalid reporting timezone"), period_label: label, comparison_label: label, freshness_label: label, attribution_label: label, status_label: label }).strict(),
|
||||
summary: z.object({ ...performance, ctr_percent: percent, cpi: metric, simple_roi_percent: z.number().finite().min(-100).nullable(), cost_per_order: metric }).strict(),
|
||||
monitoring_note: text.optional(),
|
||||
trend: z.array(z.object({ label, roas: metric, partial: z.boolean() }).strict()).max(7),
|
||||
campaigns: z.object({ winners: z.array(campaign).max(5), anomalies: z.array(campaign).max(5) }).strict(),
|
||||
creative: z.object({ row_count: count, coverage_percent: percent, source_groups: z.array(z.object({ label, ...performance }).strict()).max(6), winners: z.array(creative).max(5), anomalies: z.array(creative).max(5), insights: z.array(text).max(4) }).strict(),
|
||||
recommendations: z.array(z.object({ title: label, detail: text, confirmation: z.enum(["required", "partial", "none"]) }).strict()).length(3),
|
||||
manual_confirmations: z.array(z.object({ action: label, required: z.boolean(), reason: text }).strict()).max(10),
|
||||
notes: z.array(text).max(10),
|
||||
}).strict().superRefine((data, ctx) => {
|
||||
if (data.summary.spend === null || Object.entries(data.summary).every(([key, value]) => key === "spend" || value === null)) ctx.addIssue({ code: "custom", message: "Observed summary data is required", path: ["summary"] });
|
||||
if (data.recommendations.some((r) => r.confirmation !== "none") && !data.manual_confirmations.some((r) => r.required)) ctx.addIssue({ code: "custom", message: "Platform changes require manual confirmation", path: ["manual_confirmations"] });
|
||||
if ((data.summary.spend === 0 || data.summary.revenue === null) && (data.summary.roas !== null || data.summary.simple_roi_percent !== null)) ctx.addIssue({ code: "custom", message: "Missing revenue or zero spend requires null ROAS and ROI", path: ["summary"] });
|
||||
if ((data.summary.orders === 0 || data.summary.orders === null) && data.summary.cost_per_order !== null) ctx.addIssue({ code: "custom", message: "Missing order denominator requires null cost per order", path: ["summary"] });
|
||||
});
|
||||
export type ReportData = z.infer<typeof reportDataSchema>;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { reportArtifactSchema, reportFormatSchema, reportSegmentsSchema, type ReportArtifact } from "@agent-studio/shared";
|
||||
import { HarnessError, redactServerSecret } from "./errors.js";
|
||||
import { reportDataSchema, type ReportData } from "./report-schema.js";
|
||||
import { renderReportHtml, renderReportPng, type PngResult } from "./report-renderer.js";
|
||||
|
||||
type PngRenderer = (htmlPath: string, signal: AbortSignal) => Promise<PngResult>;
|
||||
const files = { json: "report-data.json", html: "report.html", png: "report.png" } as const;
|
||||
export class ReportStore {
|
||||
constructor(private readonly root: string, private readonly cssPath: string, private readonly pngRenderer: PngRenderer = renderReportPng) {}
|
||||
async create(input: ReportData, signal: AbortSignal): Promise<ReportArtifact> {
|
||||
const data = reportDataSchema.parse(redactServerSecret(input));
|
||||
const accountKey = `acct_${createHash("sha256").update(`tiktok:${data.meta.account_id}`).digest("hex").slice(0, 24)}`;
|
||||
const reportDate = data.meta.report_date; const reportId = randomUUID();
|
||||
const root = await this.ensureRoot();
|
||||
const parent = await this.ensureDirectory(root, [accountKey, reportDate]);
|
||||
const temp = path.join(parent, `.tmp-${reportId}`); const final = path.join(parent, reportId);
|
||||
await mkdir(temp, { mode: 0o700 });
|
||||
try {
|
||||
signal.throwIfAborted();
|
||||
const html = renderReportHtml(data, await readFile(this.cssPath, "utf8"));
|
||||
await writeFile(path.join(temp, files.json), JSON.stringify(data, null, 2), { mode: 0o600, flag: "wx" });
|
||||
await writeFile(path.join(temp, files.html), html, { mode: 0o600, flag: "wx" });
|
||||
const rendered = await this.pngRenderer(path.join(temp, files.html), signal);
|
||||
signal.throwIfAborted();
|
||||
if (rendered.png) await writeFile(path.join(temp, files.png), rendered.png, { mode: 0o600, flag: "wx" });
|
||||
signal.throwIfAborted();
|
||||
await rename(temp, final);
|
||||
const base = `/api/reports/${accountKey}/${reportDate}/${reportId}`;
|
||||
return reportArtifactSchema.parse({ accountKey, reportDate, reportId, downloads: { json: `${base}/json`, html: `${base}/html`, ...(rendered.png ? { png: `${base}/png` } : {}) }, ...(rendered.png ? { previewUrl: `${base}/png` } : {}), ...(rendered.warning ? { warning: rendered.warning } : {}) });
|
||||
} finally { await rm(temp, { recursive: true, force: true }); }
|
||||
}
|
||||
async read(segments: unknown, formatInput: unknown): Promise<{ bytes: Buffer; format: keyof typeof files }> {
|
||||
const parsed = reportSegmentsSchema.safeParse(segments); const format = reportFormatSchema.safeParse(formatInput);
|
||||
if (!parsed.success || !format.success) throw this.notFound();
|
||||
const { accountKey, reportDate, reportId } = parsed.data;
|
||||
try {
|
||||
const root = await realpath(this.root);
|
||||
if ((await lstat(this.root)).isSymbolicLink()) throw this.notFound();
|
||||
let current = root;
|
||||
for (const segment of [accountKey, reportDate, reportId, files[format.data]]) { current = path.join(current, segment); if ((await lstat(current)).isSymbolicLink()) throw this.notFound(); }
|
||||
if (!(await lstat(current)).isFile() || !inside(root, await realpath(current))) throw this.notFound();
|
||||
return { bytes: await readFile(current), format: format.data };
|
||||
} catch { throw this.notFound(); }
|
||||
}
|
||||
private notFound() { return new HarnessError("REPORT_NOT_FOUND", "Report artifact not found", 404); }
|
||||
private async ensureRoot() { await mkdir(this.root, { recursive: true, mode: 0o700 }); if ((await lstat(this.root)).isSymbolicLink()) throw new HarnessError("REPORT_STORE_UNSAFE", "Report storage is unavailable", 500); return realpath(this.root); }
|
||||
private async ensureDirectory(root: string, segments: string[]) {
|
||||
let current = root;
|
||||
for (const segment of segments) { current = path.join(current, segment); await mkdir(current, { mode: 0o700 }).catch((error: NodeJS.ErrnoException) => { if (error.code !== "EEXIST") throw error; }); if ((await lstat(current)).isSymbolicLink() || !inside(root, await realpath(current))) throw new HarnessError("REPORT_STORE_UNSAFE", "Report storage is unavailable", 500); }
|
||||
return current;
|
||||
}
|
||||
}
|
||||
function inside(root: string, target: string) { const relative = path.relative(root, target); return relative && !relative.startsWith("..") && !path.isAbsolute(relative); }
|
||||
@@ -14,13 +14,16 @@ import {
|
||||
import type { ModelConnectionInput, SendMessageInput } from "@agent-studio/shared";
|
||||
import { InMemoryApprovalBroker, type ApprovalBroker } from "./approval.js";
|
||||
import type { FileAttachmentStore } from "./attachments.js";
|
||||
import { HarnessError, redactValue, safeErrorMessage } from "./errors.js";
|
||||
import { HarnessError, redactValue, redactServerSecret, safeErrorMessage } from "./errors.js";
|
||||
import type { EventStore } from "./event-store.js";
|
||||
import type { ModelConnectionStore } from "./model-store.js";
|
||||
import { mapPiEvent } from "./pi-event-adapter.js";
|
||||
import { SafeWorkspace } from "./safe-workspace.js";
|
||||
import type { SkillCatalog } from "./skills.js";
|
||||
import type { SkillIndexEntry } from "./skills.js";
|
||||
import { SwadsGateway } from "./swads-gateway.js";
|
||||
import { SwadsReportTools } from "./swads-tools.js";
|
||||
import { ReportStore } from "./report-store.js";
|
||||
import { createMockAdsMetricsTool, createSaveReportDraftTool } from "./tools.js";
|
||||
|
||||
export interface AgentSessionPort {
|
||||
@@ -66,16 +69,19 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
||||
private readonly catalog: SkillCatalog,
|
||||
private readonly models: ModelConnectionStore,
|
||||
private readonly approvals: ApprovalBroker,
|
||||
private readonly gateway = new SwadsGateway(),
|
||||
private readonly reports = new ReportStore(path.resolve(fixturePath, "../../workspace/reports"), path.resolve(fixturePath, "../../.agents/skills/swads-daily-report/assets/report.css")),
|
||||
) {}
|
||||
|
||||
async create(input: AgentSessionFactoryInput): Promise<AgentSessionPort> {
|
||||
const resolved = await this.models.createRuntime(input.sessionId);
|
||||
const swads = new SwadsReportTools(this.gateway, this.reports, input.getRun);
|
||||
const extension: InlineExtension = { name: "agent-studio-safety", hidden: true, factory: (pi) => {
|
||||
const pendingSkills = new Map<string, SkillIndexEntry>();
|
||||
pi.on("tool_call", async (event) => {
|
||||
const run = input.getRun();
|
||||
if (!run) return { block: true, reason: "No active Harness run" };
|
||||
const known = new Set(["read", "grep", "find", "ls", "mock_ads_metrics", "save_report_draft"]);
|
||||
const known = new Set(["read", "grep", "find", "ls", "mock_ads_metrics", "save_report_draft", "swads_cli", "render_swads_daily_report"]);
|
||||
if (!known.has(event.toolName)) {
|
||||
input.toolRequested(event.toolCallId, event.toolName, publicToolArguments(event.input, input.cwd));
|
||||
return { block: true, reason: "Tool is not allowed by the Harness policy", terminate: true };
|
||||
@@ -92,7 +98,7 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
||||
const maybePath = (event.input as Record<string, unknown>).path;
|
||||
if (typeof maybePath === "string") await input.workspace.assertReadPath(maybePath);
|
||||
}
|
||||
input.toolRequested(event.toolCallId, event.toolName, publicToolArguments(event.input, input.cwd, matchedSkill));
|
||||
input.toolRequested(event.toolCallId, event.toolName, event.toolName === "render_swads_daily_report" ? { status: "requested" } : publicToolArguments(event.input, input.cwd, matchedSkill));
|
||||
if (event.toolName === "save_report_draft") {
|
||||
const decision = await this.approvals.request({ sessionId: input.sessionId, runId: run.runId, toolCallId: event.toolCallId, toolName: event.toolName, arguments: event.input, riskLevel: "medium" }, run.signal);
|
||||
if (decision.status !== "approved") return { block: true, reason: `Human approval ${decision.status}` };
|
||||
@@ -114,8 +120,8 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
||||
agentDir: path.join(input.cwd, ".pi-agent"),
|
||||
modelRuntime: resolved.runtime,
|
||||
model: resolved.model,
|
||||
tools: ["read", "grep", "find", "ls", "mock_ads_metrics", "save_report_draft"],
|
||||
customTools: [createMockAdsMetricsTool(this.fixturePath), createSaveReportDraftTool(input.workspace)],
|
||||
tools: ["read", "grep", "find", "ls", "mock_ads_metrics", "save_report_draft", "swads_cli", "render_swads_daily_report"],
|
||||
customTools: [createMockAdsMetricsTool(this.fixturePath), createSaveReportDraftTool(input.workspace), ...swads.definitions()],
|
||||
resourceLoader: loader,
|
||||
sessionManager: SessionManager.inMemory(input.cwd),
|
||||
settingsManager: SettingsManager.inMemory(),
|
||||
@@ -197,6 +203,7 @@ export class InMemorySessionRegistry implements SessionRegistry {
|
||||
if (!item) throw new HarnessError("ATTACHMENT_NOT_FOUND", "Attachment not found for this session", 404);
|
||||
return item;
|
||||
});
|
||||
const expanded = await this.catalog.expandCommand(message.text);
|
||||
const runId = `run_${randomUUID()}`;
|
||||
const assistantMessageId = `msg_${randomUUID()}`;
|
||||
const controller = new AbortController();
|
||||
@@ -214,7 +221,10 @@ export class InMemorySessionRegistry implements SessionRegistry {
|
||||
this.events.append(id, { type: "run.started", runId, payload: { modelId: agent.modelId } });
|
||||
const images: ImageContent[] = await Promise.all(selected.map(async (item) => ({ type: "image" as const, mimeType: item.mediaType, data: await this.attachments.readBase64(id, item.attachmentId) })));
|
||||
session.timeout = setTimeout(() => void this.cancel(id, "timeout"), this.runTimeoutMs);
|
||||
void this.executeRun(session, message.text, images);
|
||||
if (expanded.skill) {
|
||||
for (const type of ["skill.requested", "skill.loaded"] as const) this.events.append(id, { type, runId, payload: { name: expanded.skill.name, filePath: expanded.skill.filePath, source: "command" } });
|
||||
}
|
||||
void this.executeRun(session, redactServerSecret(expanded.text), images);
|
||||
return { runId };
|
||||
}
|
||||
|
||||
@@ -226,8 +236,8 @@ export class InMemorySessionRegistry implements SessionRegistry {
|
||||
cwd: session.cwd,
|
||||
workspace: session.workspace,
|
||||
getRun: () => session.currentRun ? { runId: session.currentRun.runId, signal: session.currentRun.controller.signal, assistantMessageId: session.currentRun.assistantMessageId } : undefined,
|
||||
skillRequested: (skill, toolCallId) => { const run = session.currentRun; if (run) this.events.append(session.sessionId, { type: "skill.requested", runId: run.runId, payload: { name: skill.name, filePath: skill.filePath, toolCallId } }); },
|
||||
skillLoaded: (skill, toolCallId) => { const run = session.currentRun; if (run) this.events.append(session.sessionId, { type: "skill.loaded", runId: run.runId, payload: { name: skill.name, filePath: skill.filePath, toolCallId } }); },
|
||||
skillRequested: (skill, toolCallId) => { const run = session.currentRun; if (run) this.events.append(session.sessionId, { type: "skill.requested", runId: run.runId, payload: { name: skill.name, filePath: skill.filePath, toolCallId, source: "read" } }); },
|
||||
skillLoaded: (skill, toolCallId) => { const run = session.currentRun; if (run) this.events.append(session.sessionId, { type: "skill.loaded", runId: run.runId, payload: { name: skill.name, filePath: skill.filePath, toolCallId, source: "read" } }); },
|
||||
toolRequested: (toolCallId, toolName, argumentsValue) => { const run = session.currentRun; if (run && !session.requestedToolCalls.has(toolCallId)) { session.requestedToolCalls.add(toolCallId); this.events.append(session.sessionId, { type: "tool.requested", runId: run.runId, payload: { toolCallId, toolName, arguments: redactValue(argumentsValue) } }); } },
|
||||
});
|
||||
session.unsubscribe = session.agent.subscribe((event) => this.onPiEvent(session, event as AgentSessionEvent));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { access, realpath, stat } from "node:fs/promises";
|
||||
import { access, lstat, readFile, realpath, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { DefaultResourceLoader, SettingsManager, type InlineExtension, type ResourceDiagnostic, type Skill } from "@earendil-works/pi-coding-agent";
|
||||
import type { SkillCatalogItem } from "@agent-studio/shared";
|
||||
@@ -41,6 +41,7 @@ export class SkillCatalog {
|
||||
const discovered = this.loader.getSkills();
|
||||
const byPath = new Map<string, SkillIndexEntry>();
|
||||
for (const skill of discovered.skills) {
|
||||
if ((await lstat(skill.filePath)).isSymbolicLink() || (await lstat(path.dirname(skill.filePath))).isSymbolicLink()) continue;
|
||||
const skillPath = await realpath(skill.filePath).catch(() => path.resolve(skill.filePath));
|
||||
const related = discovered.diagnostics.filter((item) => item.path && path.resolve(item.path) === path.resolve(skill.filePath));
|
||||
byPath.set(skillPath, this.toItem(skill, skillPath, related));
|
||||
@@ -77,8 +78,32 @@ export class SkillCatalog {
|
||||
list(): SkillCatalogItem[] { return this.items.map((item) => ({ name: item.name, description: item.description, source: item.source, filePath: item.filePath, diagnostics: item.diagnostics })); }
|
||||
findCanonical(filePath: string): SkillIndexEntry | undefined { return this.items.find((item) => item.canonicalPath === filePath); }
|
||||
async matchReadPath(inputPath: string, cwd: string): Promise<SkillIndexEntry | undefined> {
|
||||
const canonical = await realpath(path.resolve(cwd, inputPath)).catch(() => undefined);
|
||||
return canonical ? this.findCanonical(canonical) : undefined;
|
||||
if (inputPath.split(/[\\/]/).includes("..")) return undefined;
|
||||
let target = path.resolve(cwd, inputPath);
|
||||
const projectRelative = path.relative(path.resolve(this.projectRoot), target);
|
||||
if (projectRelative && !projectRelative.startsWith("..") && !path.isAbsolute(projectRelative)) target = path.resolve(await realpath(this.projectRoot), projectRelative);
|
||||
const skill = this.items.find((item) => !item.canonicalPath.startsWith("diagnostic:") && inside(path.dirname(item.canonicalPath), target));
|
||||
if (!skill) return undefined;
|
||||
const root = path.dirname(skill.canonicalPath);
|
||||
try {
|
||||
let current = root;
|
||||
if ((await lstat(root)).isSymbolicLink()) return undefined;
|
||||
for (const segment of path.relative(root, target).split(path.sep).filter(Boolean)) {
|
||||
current = path.join(current, segment);
|
||||
if ((await lstat(current)).isSymbolicLink()) return undefined;
|
||||
}
|
||||
if (!(await stat(target)).isFile() || !inside(root, await realpath(target))) return undefined;
|
||||
return skill;
|
||||
} catch { return undefined; }
|
||||
}
|
||||
async expandCommand(text: string): Promise<{ text: string; skill?: SkillIndexEntry }> {
|
||||
const match = /^\/swads-daily-report(?:\s+([\s\S]*))?$/.exec(text);
|
||||
if (!match) return { text };
|
||||
const skill = this.items.find((item) => item.name === "swads-daily-report" && !item.canonicalPath.startsWith("diagnostic:"));
|
||||
if (!skill || !await this.matchReadPath(skill.canonicalPath, this.projectRoot)) throw new Error("Built-in swads-daily-report Skill is unavailable");
|
||||
const body = (await readFile(skill.canonicalPath, "utf8")).replace(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/, "").trim();
|
||||
const escape = (value: string) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">");
|
||||
return { skill, text: `<skill name="${escape(skill.name)}" location="${escape(skill.canonicalPath)}">\nReferences are relative to ${path.dirname(skill.canonicalPath)}.\n\n${body}\n</skill>${match[1] ? `\n\n${match[1]}` : ""}` };
|
||||
}
|
||||
private toItem(skill: Skill, canonicalPath: string, diagnostics: ResourceDiagnostic[]): SkillIndexEntry {
|
||||
return { name: skill.name, description: skill.description, source: skill.sourceInfo.source, filePath: this.displayPath(canonicalPath), canonicalPath, diagnostics: diagnostics.map((item) => ({ severity: item.type === "warning" ? "warning" : "error", message: item.message })) };
|
||||
@@ -89,3 +114,5 @@ export class SkillCatalog {
|
||||
return relative && !relative.startsWith("..") && !path.isAbsolute(relative) ? relative : `<external>/${path.basename(absolute)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function inside(root: string, target: string): boolean { const relative = path.relative(root, target); return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative); }
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { HarnessError } from "./errors.js";
|
||||
|
||||
export interface GatewayConnection {
|
||||
list(signal: AbortSignal): Promise<Tool[]>;
|
||||
call(name: string, args: Record<string, unknown>, signal: AbortSignal): Promise<unknown>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
export type GatewayConnector = (signal: AbortSignal) => Promise<GatewayConnection>;
|
||||
export interface GatewayConfig { url: URL; token: string | undefined; timeoutMs: number }
|
||||
export const MAX_GATEWAY_BYTES = 2 * 1024 * 1024;
|
||||
export function gatewayConfig(env: NodeJS.ProcessEnv = process.env): GatewayConfig {
|
||||
let url: URL;
|
||||
try { url = new URL(env.SWADS_MCP_URL || "https://ads.mincode.cn/mcp"); } catch { throw new HarnessError("SWADS_INVALID_CONFIG", "SWADS_MCP_URL must be a valid HTTP(S) URL"); }
|
||||
if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new HarnessError("SWADS_INVALID_CONFIG", "SWADS_MCP_URL must not contain credentials, query or fragment");
|
||||
const timeoutMs = Number(env.SWADS_MCP_TIMEOUT_MS ?? 30_000);
|
||||
if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 120_000) throw new HarnessError("SWADS_INVALID_CONFIG", "SWADS_MCP_TIMEOUT_MS must be between 100 and 120000");
|
||||
return { url, token: env.SWADS_MCP_TOKEN || undefined, timeoutMs };
|
||||
}
|
||||
|
||||
// Read the wire body with a byte bound before handing it to the official SDK.
|
||||
// A connection belongs to one command, so cancellation never affects another run.
|
||||
export function mcpConnector(config: GatewayConfig, fetcher: typeof fetch = fetch): GatewayConnector {
|
||||
return async (signal) => {
|
||||
const boundedFetch: typeof fetch = async (input, init) => {
|
||||
const response = await fetcher(input, { ...init, redirect: "error", signal: AbortSignal.any([signal, ...(init?.signal ? [init.signal] : [])]) });
|
||||
if (!response.ok) { await response.body?.cancel(); throw new GatewayHttpError(response.status); }
|
||||
if (!response.body) return response;
|
||||
if (Number(response.headers.get("content-length")) > MAX_GATEWAY_BYTES) { await response.body.cancel(); throw failure("SWADS_RESPONSE_TOO_LARGE"); }
|
||||
const reader = response.body.getReader(); let total = 0;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) { controller.close(); return; }
|
||||
total += value.byteLength;
|
||||
if (total > MAX_GATEWAY_BYTES) { await reader.cancel(); controller.error(failure("SWADS_RESPONSE_TOO_LARGE")); return; }
|
||||
controller.enqueue(value);
|
||||
} catch (error) { controller.error(error); }
|
||||
},
|
||||
cancel: () => reader.cancel(),
|
||||
});
|
||||
return new Response(body, { status: response.status, headers: response.headers });
|
||||
};
|
||||
const client = new Client({ name: "agent-studio-swads-cli", version: "1.0.0" });
|
||||
const transport = new StreamableHTTPClientTransport(config.url, { fetch: boundedFetch, requestInit: { headers: { Authorization: `Bearer ${config.token}` } }, reconnectionOptions: { maxRetries: 0, initialReconnectionDelay: 1000, maxReconnectionDelay: 1000, reconnectionDelayGrowFactor: 1 } });
|
||||
const close = async () => { await client.close().catch(() => undefined); };
|
||||
const abort = () => { void close(); };
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
try { await client.connect(transport as Parameters<Client["connect"]>[0], { signal, timeout: config.timeoutMs }); } catch (error) { signal.removeEventListener("abort", abort); await close(); throw error; }
|
||||
return {
|
||||
async list(requestSignal) {
|
||||
const all: Tool[] = []; let cursor: string | undefined;
|
||||
do {
|
||||
const page = await client.listTools(cursor ? { cursor } : {}, { signal: requestSignal, timeout: config.timeoutMs });
|
||||
all.push(...page.tools); cursor = page.nextCursor;
|
||||
if (all.length > 500 || Buffer.byteLength(JSON.stringify(all)) > MAX_GATEWAY_BYTES) throw failure("SWADS_RESPONSE_TOO_LARGE");
|
||||
} while (cursor);
|
||||
return all;
|
||||
},
|
||||
call: (name, args, requestSignal) => client.callTool({ name, arguments: args }, undefined, { signal: requestSignal, timeout: config.timeoutMs }),
|
||||
async close() { signal.removeEventListener("abort", abort); await close(); },
|
||||
};
|
||||
};
|
||||
}
|
||||
class GatewayHttpError extends Error { constructor(readonly status: number) { super("Gateway HTTP request failed"); } }
|
||||
export function failure(code: string): HarnessError { return new HarnessError(code, code, 422); }
|
||||
export function gatewayError(error: unknown, signal?: AbortSignal): HarnessError {
|
||||
if (error instanceof HarnessError && error.code.startsWith("SWADS_")) return failure(error.code);
|
||||
if (signal?.aborted) return failure(signal.reason?.name === "TimeoutError" ? "SWADS_TIMEOUT" : "SWADS_ABORTED");
|
||||
const status = error instanceof GatewayHttpError ? error.status : (error as { code?: number; status?: number } | null)?.status ?? (error as { code?: number } | null)?.code;
|
||||
return failure(status === 401 ? "SWADS_AUTH_FAILED" : status === 403 ? "SWADS_FORBIDDEN" : status === -32601 ? "SWADS_TOOL_MISSING" : status === -32602 ? "SWADS_TOOL_DRIFT" : status === -32001 ? "SWADS_TIMEOUT" : "SWADS_UNAVAILABLE");
|
||||
}
|
||||
export class SwadsGateway {
|
||||
private capabilities: { expires: number; tools: Tool[] } | undefined;
|
||||
private readonly connect: GatewayConnector;
|
||||
constructor(private readonly config = gatewayConfig(), connector?: GatewayConnector) { this.connect = connector ?? mcpConnector(config); }
|
||||
sanitize<T>(value: T): T {
|
||||
if (!this.config.token) return value;
|
||||
const scrub = (item: unknown): unknown => typeof item === "string" ? item.split(this.config.token!).join("[REDACTED]") : Array.isArray(item) ? item.map(scrub) : item && typeof item === "object" ? Object.fromEntries(Object.entries(item).map(([key, val]) => [String(scrub(key)), /token|authorization|secret|password/i.test(key) ? "[REDACTED]" : scrub(val)])) : item;
|
||||
return scrub(value) as T;
|
||||
}
|
||||
async execute<T>(operation: (connection: GatewayConnection, signal: AbortSignal) => Promise<T>, runSignal: AbortSignal): Promise<T> {
|
||||
if (!this.config.token) throw failure("SWADS_NOT_CONFIGURED");
|
||||
const signal = AbortSignal.any([runSignal, AbortSignal.timeout(this.config.timeoutMs)]);
|
||||
let connection: GatewayConnection | undefined;
|
||||
let abort: (() => void) | undefined;
|
||||
try {
|
||||
signal.throwIfAborted();
|
||||
const cancelled = new Promise<never>((_, reject) => { abort = () => reject(gatewayError(undefined, signal)); signal.addEventListener("abort", abort, { once: true }); });
|
||||
const work = (async () => {
|
||||
connection = await this.connect(signal);
|
||||
if (signal.aborted) { await connection.close(); throw gatewayError(undefined, signal); }
|
||||
return operation(connection, signal);
|
||||
})();
|
||||
return this.sanitize(await Promise.race([work, cancelled]));
|
||||
} catch (error) { throw gatewayError(error, signal); }
|
||||
finally { if (abort) signal.removeEventListener("abort", abort); await connection?.close().catch(() => undefined); }
|
||||
}
|
||||
async list(connection: GatewayConnection, signal: AbortSignal, refresh = false): Promise<Tool[]> {
|
||||
if (!refresh && this.capabilities && this.capabilities.expires > Date.now()) return this.capabilities.tools;
|
||||
const tools = await connection.list(signal);
|
||||
if (Buffer.byteLength(JSON.stringify(tools)) > MAX_GATEWAY_BYTES) throw failure("SWADS_RESPONSE_TOO_LARGE");
|
||||
this.capabilities = { tools, expires: Date.now() + 60_000 }; return tools;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { defineTool } from "@earendil-works/pi-coding-agent";
|
||||
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
|
||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { Type } from "typebox";
|
||||
import { z } from "zod";
|
||||
import { MAX_GATEWAY_BYTES, SwadsGateway, failure } from "./swads-gateway.js";
|
||||
import { reportDataSchema } from "./report-schema.js";
|
||||
import type { ReportStore } from "./report-store.js";
|
||||
|
||||
export const commands = {
|
||||
capabilities: "tools/list", whoami: "swads_whoami", "metrics.catalog": "metrics_catalog", "metrics.query": "metrics_semantic_query",
|
||||
"runtime.account-overview": "runtime_account_overview", "runtime.proposals": "runtime_list_proposals", "runtime.config": "runtime_get_config", "runtime.findings": "runtime_findings", "campaign.list": "campaign_list",
|
||||
} as const;
|
||||
const id = z.string().min(1).max(160);
|
||||
const scope = { external_account_id: id.optional(), platform: z.literal("tiktok").optional(), tenant_id: id.optional() };
|
||||
const schemas = {
|
||||
capabilities: z.object({}).strict(), whoami: z.object({}).strict(),
|
||||
"metrics.catalog": z.object({ platform: z.literal("tiktok") }).strict(),
|
||||
"metrics.query": z.object({ ...scope, platform: z.literal("tiktok"), query: z.record(z.string(), z.unknown()) }).strict(),
|
||||
"runtime.account-overview": z.object({ external_account_id: id.optional(), platform: z.literal("tiktok") }).strict(),
|
||||
"runtime.proposals": z.object({ ...scope, platform: z.literal("tiktok"), external_account_id: id, status: id.optional() }).strict(),
|
||||
"runtime.config": z.object({ ...scope, platform: z.literal("tiktok"), external_account_id: id }).strict(),
|
||||
"runtime.findings": z.object({ ...scope, platform: z.literal("tiktok"), external_account_id: id, metric: id.optional(), window: id.optional(), min_confidence: z.number().min(0).max(1).optional(), include_entities: z.boolean().optional() }).strict(),
|
||||
"campaign.list": z.object({ ...scope, platform: z.literal("tiktok"), page: z.number().int().min(1).max(1000).optional(), page_size: z.number().int().min(1).max(100).optional(), cursor: z.string().max(1000).optional(), status: id.optional() }).strict(),
|
||||
};
|
||||
export const swadsCliSchema = z.object({ command: z.enum(Object.keys(commands) as [keyof typeof commands, ...Array<keyof typeof commands>]), arguments: z.record(z.string(), z.unknown()) }).strict();
|
||||
export function validateCli(input: unknown) {
|
||||
assertBounds(input);
|
||||
const parsed = swadsCliSchema.safeParse(input);
|
||||
if (!parsed.success) throw failure("SWADS_INVALID_ARGUMENTS");
|
||||
const args = schemas[parsed.data.command].safeParse(parsed.data.arguments);
|
||||
if (!args.success) throw failure("SWADS_INVALID_ARGUMENTS");
|
||||
if (parsed.data.command === "metrics.query") {
|
||||
const query = parsed.data.arguments.query as Record<string, unknown>;
|
||||
if (query.platform !== undefined && query.platform !== "tiktok") throw failure("SWADS_INVALID_ARGUMENTS");
|
||||
}
|
||||
return { command: parsed.data.command, arguments: args.data };
|
||||
}
|
||||
function assertBounds(input: unknown): void {
|
||||
let nodes = 0;
|
||||
const visit = (value: unknown, depth: number) => {
|
||||
if (++nodes > 4000 || depth > 10) throw failure("SWADS_INVALID_ARGUMENTS");
|
||||
if (value && typeof value === "object") for (const [key, child] of Object.entries(value)) {
|
||||
if (/^(?:url|headers?|token|authorization|tool_?name|__proto__|constructor|prototype)$/i.test(key)) throw failure("SWADS_INVALID_ARGUMENTS");
|
||||
visit(child, depth + 1);
|
||||
}
|
||||
};
|
||||
visit(input, 0);
|
||||
if (Buffer.byteLength(JSON.stringify(input) ?? "") > 64 * 1024) throw failure("SWADS_INVALID_ARGUMENTS");
|
||||
}
|
||||
export function isReadOnly(tool: Tool): boolean { return tool.annotations?.readOnlyHint === true && tool.annotations.destructiveHint !== true; }
|
||||
const required = ["swads_whoami", "metrics_catalog", "metrics_semantic_query"];
|
||||
function assertCapabilities(tools: Tool[]): void {
|
||||
for (const name of required) {
|
||||
const tool = tools.find((item) => item.name === name);
|
||||
if (!tool) throw failure("SWADS_TOOL_MISSING");
|
||||
if (!isReadOnly(tool)) throw failure("SWADS_NOT_READ_ONLY");
|
||||
}
|
||||
}
|
||||
function unwrap(result: unknown): unknown {
|
||||
if (Buffer.byteLength(JSON.stringify(result)) > MAX_GATEWAY_BYTES) throw failure("SWADS_RESPONSE_TOO_LARGE");
|
||||
const envelope = result as { isError?: boolean; structuredContent?: unknown; content?: Array<{ type: string; text?: string }> };
|
||||
let data = envelope.structuredContent;
|
||||
if (data === undefined) {
|
||||
const content = envelope.content?.filter((part) => part.type === "text");
|
||||
if (content?.length !== 1 || !content[0]?.text) throw failure("SWADS_INVALID_RESPONSE");
|
||||
try { data = JSON.parse(content[0].text); } catch { throw failure(envelope.isError ? "SWADS_UPSTREAM_ERROR" : "SWADS_INVALID_RESPONSE"); }
|
||||
}
|
||||
const error = data as { error?: unknown; error_code?: string; upstream_status?: number; status?: number } | null;
|
||||
if (envelope.isError || error?.error || error?.error_code) {
|
||||
const status = error?.upstream_status ?? error?.status;
|
||||
throw failure(status === 401 ? "SWADS_AUTH_FAILED" : status === 403 ? "SWADS_FORBIDDEN" : error?.error_code === "platform_not_supported" ? "SWADS_CAPABILITY_MISSING" : "SWADS_UPSTREAM_ERROR");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// Per-session, run-scoped evidence prevents rendering after failed/missing preflight.
|
||||
export class SwadsReportTools {
|
||||
private evidence: { runId: string; whoami: boolean; catalog: boolean; query: boolean } | undefined;
|
||||
constructor(private readonly gateway: SwadsGateway, private readonly reports: ReportStore, private readonly getRun: () => { runId: string; signal: AbortSignal } | undefined) {}
|
||||
async cli(input: unknown, signal: AbortSignal): Promise<unknown> {
|
||||
let params: ReturnType<typeof validateCli>;
|
||||
try { params = validateCli(input); } catch (error) { this.evidence = undefined; throw error; }
|
||||
const run = this.getRun(); if (!run) throw failure("SWADS_NO_ACTIVE_RUN");
|
||||
if (this.evidence?.runId !== run.runId) this.evidence = { runId: run.runId, whoami: false, catalog: false, query: false };
|
||||
try {
|
||||
return await this.gateway.execute(async (connection, requestSignal) => {
|
||||
const tools = await this.gateway.list(connection, requestSignal, params.command === "capabilities");
|
||||
assertCapabilities(tools);
|
||||
if (params.command === "capabilities") return { server_time: new Date().toISOString(), tools: tools.filter((tool) => Object.values(commands).includes(tool.name as typeof commands[keyof typeof commands])) };
|
||||
if (params.command === "metrics.query" && (!this.evidence?.whoami || !this.evidence.catalog)) throw failure("SWADS_PREFLIGHT_REQUIRED");
|
||||
const tool = tools.find((item) => item.name === commands[params.command]);
|
||||
if (!tool) throw failure("SWADS_TOOL_MISSING");
|
||||
if (!isReadOnly(tool)) throw failure("SWADS_NOT_READ_ONLY");
|
||||
try { if (!new AjvJsonSchemaValidator().getValidator(tool.inputSchema as Parameters<AjvJsonSchemaValidator["getValidator"]>[0])(params.arguments).valid) throw failure("SWADS_TOOL_DRIFT"); } catch { throw failure("SWADS_TOOL_DRIFT"); }
|
||||
const data = unwrap(await connection.call(tool.name, params.arguments, requestSignal));
|
||||
if (data === null || typeof data !== "object") throw failure("SWADS_INVALID_RESPONSE");
|
||||
if (params.command === "metrics.query") {
|
||||
const rows = data as { columns?: Array<{ name?: unknown }>; rows?: unknown[] };
|
||||
if (!Array.isArray(rows.columns) || !rows.columns.length || !rows.columns.every((column) => typeof column.name === "string") || !Array.isArray(rows.rows)) throw failure("SWADS_INVALID_RESPONSE");
|
||||
if (!rows.rows.length) throw failure("SWADS_NO_DATA");
|
||||
}
|
||||
if (params.command === "metrics.catalog" && Object.keys(data).length === 0) throw failure("SWADS_CAPABILITY_MISSING");
|
||||
if (params.command === "whoami" && Object.keys(data).length === 0) throw failure("SWADS_INVALID_RESPONSE");
|
||||
if (params.command === "whoami") this.evidence!.whoami = true;
|
||||
if (params.command === "metrics.catalog") this.evidence!.catalog = true;
|
||||
if (params.command === "metrics.query") this.evidence!.query = true;
|
||||
return data;
|
||||
}, AbortSignal.any([signal, run.signal]));
|
||||
} catch (error) {
|
||||
if (["capabilities", "whoami", "metrics.catalog", "metrics.query"].includes(params.command)) this.evidence = undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async render(input: unknown, signal: AbortSignal) {
|
||||
const run = this.getRun();
|
||||
if (!run || this.evidence?.runId !== run.runId || !this.evidence.whoami || !this.evidence.catalog || !this.evidence.query) throw failure("SWADS_PREFLIGHT_REQUIRED");
|
||||
const parsed = reportDataSchema.safeParse(this.gateway.sanitize(input));
|
||||
if (!parsed.success) throw failure("SWADS_INVALID_REPORT");
|
||||
try { return await this.reports.create(parsed.data, AbortSignal.any([signal, run.signal])); } catch { throw failure(signal.aborted || run.signal.aborted ? "SWADS_ABORTED" : "SWADS_REPORT_FAILED"); }
|
||||
}
|
||||
definitions() {
|
||||
return [defineTool({
|
||||
name: "swads_cli", label: "SW Ads read-only CLI", description: "Read SW Ads via fixed commands. First capabilities then whoami and metrics.catalog. Arguments must follow the remote input schema and the local command schema; only platform tiktok is supported.",
|
||||
parameters: Type.Unsafe<z.infer<typeof swadsCliSchema>>({ type: "object", properties: { command: { type: "string", enum: Object.keys(commands) }, arguments: { type: "object" } }, required: ["command", "arguments"], additionalProperties: false, oneOf: Object.entries(schemas).map(([command, schema]) => ({ type: "object", properties: { command: { const: command }, arguments: z.toJSONSchema(schema) }, required: ["command", "arguments"], additionalProperties: false })) }),
|
||||
executionMode: "sequential",
|
||||
execute: async (_id, params, signal) => { const data = await this.cli(params, signal ?? new AbortController().signal); return { content: [{ type: "text" as const, text: JSON.stringify(data) }], details: { status: "success", command: params.command } }; },
|
||||
}), defineTool({
|
||||
name: "render_swads_daily_report", label: "SW Ads daily report", description: "After real SW Ads preflight and queries, render normalized data to persistent PNG/HTML/JSON. Output Markdown analysis first, then call this as the final action. No model-selected paths or executable arguments.",
|
||||
parameters: Type.Object({ data: Type.Unsafe<z.infer<typeof reportDataSchema>>(z.toJSONSchema(reportDataSchema)) }, { additionalProperties: false }), executionMode: "sequential",
|
||||
execute: async (_id, params, signal) => { const artifact = await this.render(params.data, signal ?? new AbortController().signal); return { content: [{ type: "text" as const, text: JSON.stringify(artifact) }], details: artifact }; },
|
||||
})];
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,12 @@ describe("InMemoryEventStore", () => {
|
||||
it("unsubscribes listeners", () => {
|
||||
const store = new InMemoryEventStore(); const listener = vi.fn(); const unsubscribe = store.subscribe("s", listener); unsubscribe(); store.append("s", { type: "run.completed", payload: {} }); expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
it("removes the configured server token from SSE payloads", () => {
|
||||
vi.stubEnv("SWADS_MCP_TOKEN", "server-sentinel-secret");
|
||||
try { const store = new InMemoryEventStore(); store.append("s", { type: "assistant.delta", payload: { messageId: "m", delta: "Value server-sentinel-secret" } }); expect(JSON.stringify(store.listAfter("s"))).not.toContain("server-sentinel-secret"); }
|
||||
finally { vi.unstubAllEnvs(); }
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("ApprovalBroker", () => {
|
||||
@@ -106,6 +112,22 @@ describe("SkillCatalog", () => {
|
||||
it("matches requested skill reads by canonical path", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "studio-skills-")); await createSkill(root, ".agents/skills/one", "one", "First skill"); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); const catalog = new SkillCatalog(root, path.join(root, "agent-dir")); await catalog.reload(); expect((await catalog.matchReadPath(path.join(root, ".agents/skills/one/SKILL.md"), root))?.name).toBe("one"); expect(await catalog.matchReadPath("SKILL.md", root)).toBeUndefined();
|
||||
});
|
||||
it("expands only the exact Slash command on demand and guards reference paths", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "studio-slash-")); const dir = path.join(root, ".agents/skills/swads-daily-report");
|
||||
await createSkill(root, ".agents/skills/swads-daily-report", "swads-daily-report", "Daily report"); await mkdir(path.join(dir, "references")); await writeFile(path.join(dir, "references/schema.md"), "schema");
|
||||
await mkdir(path.join(root, ".pi/skills"), { recursive: true }); const catalog = new SkillCatalog(root, path.join(root, "agent")); await catalog.reload();
|
||||
expect(JSON.stringify(catalog.list())).not.toContain("SECRET BODY");
|
||||
await writeFile(path.join(dir, "SKILL.md"), "---\nname: swads-daily-report\ndescription: Daily report\n---\nLATEST BODY");
|
||||
const command = await catalog.expandCommand("/swads-daily-report account=abc date=2026-09-06"); expect(command.text).toContain("LATEST BODY"); expect(command.text).not.toContain("description:"); expect(command.text).toContain("account=abc date=2026-09-06");
|
||||
expect((await catalog.expandCommand("/swads-daily-report-other")).skill).toBeUndefined(); expect((await catalog.expandCommand("mention /swads-daily-report")).skill).toBeUndefined();
|
||||
expect((await catalog.matchReadPath(path.join(dir, "references/schema.md"), root))?.name).toBe("swads-daily-report");
|
||||
expect(await catalog.matchReadPath(path.join(dir, "references"), root)).toBeUndefined();
|
||||
expect(await catalog.matchReadPath(`${dir}/references/../SKILL.md`, root)).toBeUndefined();
|
||||
await writeFile(path.join(root, ".agents/skills/outside.md"), "outside"); expect(await catalog.matchReadPath(path.join(root, ".agents/skills/outside.md"), root)).toBeUndefined();
|
||||
await symlink(path.join(dir, "references/schema.md"), path.join(dir, "references/link.md")); expect(await catalog.matchReadPath(path.join(dir, "references/link.md"), root)).toBeUndefined();
|
||||
await symlink(path.join(root, ".agents/skills/outside.md"), path.join(dir, "references/escape.md")); expect(await catalog.matchReadPath(path.join(dir, "references/escape.md"), root)).toBeUndefined();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("Base URL safety", () => {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ReportData } from "../src/report-schema.js";
|
||||
export function sampleReport(): ReportData {
|
||||
const campaign = { id: "campaign-1", name: "Demo campaign", status: "ENABLE", spend: 100, revenue: 600, orders: 30, roas: 6, reason: "30 orders; compare sample size before scaling." };
|
||||
const creative = { id: "creative-1", name: "Demo product", creator: "demo-creator", spend: 20, revenue: 120, orders: 6, roas: 6, clicks: 50, product_ctr_percent: 2, reason: "Only six orders; small sample." };
|
||||
return {
|
||||
meta: { report_date: "2026-09-06", account_name: "Demo Commerce", account_id: "demo-account", currency: "MYR", reporting_timezone: "Asia/Kuala_Lumpur", period_label: "2026-09-06", comparison_label: "2026-08-30 to 2026-09-05 · seven complete business days", freshness_label: "2026-09-07 08:00 MYT", attribution_label: "TikTok GMV Max native attribution", status_label: "稳健观察" },
|
||||
summary: { spend: 100, revenue: 600, orders: 30, ctr_percent: null, cpi: null, roas: 6, simple_roi_percent: 500, cost_per_order: 3.33 },
|
||||
monitoring_note: "示例数据仅用于测试,不代表真实账户。",
|
||||
trend: Array.from({ length: 7 }, (_, i) => ({ label: `Day ${i + 1}`, roas: i === 2 ? null : 3 + i, partial: false })),
|
||||
campaigns: { winners: [campaign], anomalies: [{ ...campaign, name: "Low sample campaign", orders: 2, roas: 1, reason: "Only two orders; uncertainty remains." }] },
|
||||
creative: { row_count: 100, coverage_percent: 80, source_groups: [{ label: "Creator", spend: 20, revenue: 120, orders: 6, roas: 6 }], winners: [creative], anomalies: [], insights: ["Creator attribution is correlated with efficiency; not a causal claim."] },
|
||||
recommendations: [{ title: "观察高效 Campaign", detail: "以 30 单样本为依据,先检查下一完整业务日。", confirmation: "none" }, { title: "检查异常素材", detail: "低样本不足以支持立即暂停,核对归因。", confirmation: "none" }, { title: "预算调整候选", detail: "预算调整前人工复核订单与成本。", confirmation: "required" }],
|
||||
manual_confirmations: [{ action: "预算调整", required: true, reason: "Changes live delivery" }],
|
||||
notes: ["简化广告 ROI 未扣商品、佣金、退款与履约成本。", "CTR 与 CPI 缺失为 N/A。"],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { mkdtemp, readFile, readdir, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import sharp from "sharp";
|
||||
import { reportDataSchema } from "../src/report-schema.js";
|
||||
import { ReportStore } from "../src/report-store.js";
|
||||
import { discoverChrome, renderReportHtml, renderReportPng, type ChromeRunner } from "../src/report-renderer.js";
|
||||
import { sampleReport } from "./report-fixture.js";
|
||||
|
||||
const cssPath = path.resolve(import.meta.dirname, "../../../.agents/skills/swads-daily-report/assets/report.css");
|
||||
const signal = () => new AbortController().signal;
|
||||
const root = () => mkdtemp(path.join(tmpdir(), "swads-report-test-"));
|
||||
describe("normalized report", () => {
|
||||
it("keeps null, multiplier and percentage units and exactly three recommendations", () => {
|
||||
const data = reportDataSchema.parse(sampleReport()); expect(data.summary).toMatchObject({ ctr_percent: null, roas: 6, simple_roi_percent: 500 });
|
||||
for (const mutate of [(d: ReturnType<typeof sampleReport>) => { d.recommendations.pop(); }, (d: ReturnType<typeof sampleReport>) => { d.meta.report_date = "2026-02-30"; }, (d: ReturnType<typeof sampleReport>) => { d.summary.spend = 0; }, (d: ReturnType<typeof sampleReport>) => { d.manual_confirmations = []; }]) { const invalid = sampleReport(); mutate(invalid); expect(reportDataSchema.safeParse(invalid).success).toBe(false); }
|
||||
expect(reportDataSchema.safeParse({ ...data, outputPath: "/tmp/x" }).success).toBe(false);
|
||||
expect(reportDataSchema.safeParse({ ...data, summary: { ...data.summary, roas: "6x" } }).success).toBe(false);
|
||||
});
|
||||
it("escapes all dynamic HTML and excludes external resources", async () => {
|
||||
const data = sampleReport(); data.meta.account_name = '<script src="https://evil">&'; data.creative.insights = ["</style><img src=x onerror=alert(1)>"];
|
||||
const html = renderReportHtml(data, await readFile(cssPath, "utf8")); expect(html).toContain("<script"); expect(html).not.toContain('<script'); expect(html).not.toContain('<img'); expect(html).not.toContain("fonts.googleapis"); expect(html).toContain("N/A"); expect(html).toContain("500%"); expect(html).toContain("6×");
|
||||
});
|
||||
});
|
||||
describe("PNG renderer", () => {
|
||||
it("degrades when Chrome is missing without invoking a process", async () => { const runner = vi.fn(); expect((await renderReportPng("/tmp/report.html", signal(), { discover: async () => undefined, runner })).warning?.code).toBe("CHROME_UNAVAILABLE"); expect(runner).not.toHaveBeenCalled(); });
|
||||
it("uses fixed args, retries truncation, crops only a verified complete image", async () => {
|
||||
const heights: number[] = [];
|
||||
const runner: ChromeRunner = async (exe, args) => {
|
||||
expect(exe).toBe("/trusted/chrome"); expect(args).toContain("--host-resolver-rules=MAP * ~NOTFOUND"); expect(args.at(-1)).toBe("file:///tmp/report%20$(x).html");
|
||||
const height = Number(args.find((s) => s.startsWith("--window-size="))!.split(",")[1]); heights.push(height);
|
||||
const output = args.find((s) => s.startsWith("--screenshot="))!.slice(13);
|
||||
const overlays = height > 5200 ? [{ input: await sharp({ create: { width: 1440, height: 8, channels: 3, background: "#1234ab" } }).png().toBuffer(), top: 6000, left: 0 }] : [];
|
||||
await sharp({ create: { width: 1440, height, channels: 3, background: "#e8ebe6" } }).composite([{ input: await sharp({ create: { width: 1200, height: 2000, channels: 3, background: "white" } }).png().toBuffer(), top: 24, left: 120 }, ...overlays]).png().toFile(output);
|
||||
};
|
||||
const result = await renderReportPng('/tmp/report $(x).html', signal(), { discover: async () => "/trusted/chrome", runner });
|
||||
expect(heights).toEqual([5200, 9000]); expect(result.warning).toBeUndefined(); expect((await sharp(result.png!).metadata()).height).toBe(2048);
|
||||
});
|
||||
it("returns safe timeout/failure warnings and honors abort", async () => {
|
||||
const options = { discover: async () => "/trusted/chrome", runner: async () => { throw Object.assign(new Error("/private/token-secret"), { killed: true }); } };
|
||||
expect(await renderReportPng("/tmp/x", signal(), options)).toMatchObject({ warning: { code: "PNG_TIMEOUT" } });
|
||||
const controller = new AbortController(); controller.abort(); await expect(renderReportPng("/tmp/x", controller.signal, options)).rejects.toThrow();
|
||||
});
|
||||
it("renders a real sample and longest allowed report when Chrome is installed", async () => {
|
||||
const chrome = await discoverChrome(); if (!chrome) return;
|
||||
const folder = await root(); const css = await readFile(cssPath, "utf8");
|
||||
const longest = sampleReport(); const long = "最长允许内容,检查换行与画布完整性。".repeat(30).slice(0, 500);
|
||||
longest.campaigns.winners = Array.from({ length: 5 }, () => ({ ...longest.campaigns.winners[0]!, name: long.slice(0, 160), id: long.slice(0, 160), reason: long })); longest.campaigns.anomalies = structuredClone(longest.campaigns.winners);
|
||||
longest.creative.winners = Array.from({ length: 5 }, () => ({ ...longest.creative.winners[0]!, name: long.slice(0, 160), id: long.slice(0, 160), reason: long })); longest.creative.anomalies = structuredClone(longest.creative.winners);
|
||||
longest.creative.winners.forEach((row) => { row.creator = long.slice(0, 160); }); longest.creative.anomalies = structuredClone(longest.creative.winners);
|
||||
longest.creative.source_groups = Array.from({ length: 6 }, () => ({ label: long.slice(0, 160), spend: 100, revenue: 600, orders: 30, roas: 6 }));
|
||||
longest.meta.account_name = long.slice(0, 160); longest.monitoring_note = long;
|
||||
longest.creative.insights = Array(4).fill(long); longest.notes = Array(10).fill(long); longest.manual_confirmations = Array.from({ length: 10 }, () => ({ action: long.slice(0, 160), required: true, reason: long })); longest.recommendations.forEach((r) => { r.detail = long; r.title = long.slice(0, 160); });
|
||||
for (const [name, data] of [["sample", sampleReport()], ["longest", longest]] as const) {
|
||||
const htmlPath = path.join(folder, `${name}.html`); await writeFile(htmlPath, renderReportHtml(data, css)); const result = await renderReportPng(htmlPath, signal());
|
||||
expect(result.warning, JSON.stringify(result.warning)).toBeUndefined(); expect(result.png).toBeDefined(); const metadata = await sharp(result.png!).metadata(); expect(metadata.width).toBeGreaterThan(1200); expect(metadata.height).toBeGreaterThan(1500); expect(metadata.height).toBeLessThan(24000); await writeFile(path.join(folder, `${name}.png`), result.png!);
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
describe("ReportStore", () => {
|
||||
it("atomically persists independent reports and allows only strict artifact paths", async () => {
|
||||
const folder = await root(); const store = new ReportStore(folder, cssPath, async () => ({ warning: { code: "CHROME_UNAVAILABLE", message: "HTML/JSON available" } }));
|
||||
const reports = await Promise.all([store.create(sampleReport(), signal()), store.create(sampleReport(), signal())]); expect(reports[0]!.reportId).not.toBe(reports[1]!.reportId);
|
||||
const { accountKey, reportDate, reportId } = reports[0]!; const segments = { accountKey, reportDate, reportId };
|
||||
expect(JSON.stringify(reports)).not.toContain(folder); expect((await store.read(segments, "json")).bytes.toString()).toContain('"roas": 6'); await expect(store.read(segments, "png")).rejects.toMatchObject({ code: "REPORT_NOT_FOUND" });
|
||||
await expect(store.read({ ...segments, accountKey: ".." }, "json")).rejects.toMatchObject({ code: "REPORT_NOT_FOUND" }); await expect(store.read(segments, "../../x")).rejects.toThrow();
|
||||
const external = path.join(await root(), "secret"); await writeFile(external, "secret"); await symlink(external, path.join(folder, accountKey, reportDate, reportId, "report.png")); await expect(store.read(segments, "png")).rejects.toThrow();
|
||||
expect((await readdir(path.join(folder, accountKey, reportDate))).some((name) => name.startsWith(".tmp"))).toBe(false);
|
||||
});
|
||||
it("does not persist the configured server token in JSON or HTML", async () => {
|
||||
vi.stubEnv("SWADS_MCP_TOKEN", "server-sentinel-secret");
|
||||
try {
|
||||
const store = new ReportStore(await root(), cssPath, async () => ({ warning: { code: "CHROME_UNAVAILABLE", message: "HTML/JSON available" } }));
|
||||
const data = sampleReport(); data.notes.push("server-sentinel-secret"); const { accountKey, reportDate, reportId } = await store.create(data, signal());
|
||||
for (const format of ["html", "json"]) expect((await store.read({ accountKey, reportDate, reportId }, format)).bytes.toString()).not.toContain("server-sentinel-secret");
|
||||
} finally { vi.unstubAllEnvs(); }
|
||||
});
|
||||
it("cleans temporary files on failure and blocks symlink storage", async () => {
|
||||
const folder = await root(); const store = new ReportStore(folder, cssPath, async () => { throw new Error("failed"); }); await expect(store.create(sampleReport(), signal())).rejects.toThrow();
|
||||
const account = (await readdir(folder))[0]!; const date = path.join(folder, account, "2026-09-06"); expect(await readdir(date)).toEqual([]);
|
||||
const linked = path.join(await root(), "reports"); await symlink(folder, linked); await expect(new ReportStore(linked, cssPath).create(sampleReport(), signal())).rejects.toMatchObject({ code: "REPORT_STORE_UNSAFE" });
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, mkdir, readFile, stat } from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
@@ -45,7 +45,7 @@ async function setup(supportsImages = true) {
|
||||
const events = new InMemoryEventStore(); const attachments = new FileAttachmentStore(sessionsRoot); const models = new InMemoryModelConnectionStore({}); const factory = new FakeFactory(supportsImages);
|
||||
const registry = new InMemorySessionRegistry(sessionsRoot, events, attachments, models, catalog, factory, 500, 5_000);
|
||||
const session = await registry.create(); await registry.configureModel(session.sessionId, { providerId: "fake", api: "openai-responses", apiKey: "test-secret", modelId: "fake-model", supportsImages });
|
||||
return { root, sessionsRoot, events, attachments, models, factory, registry, session };
|
||||
return { root, sessionsRoot, events, attachments, models, catalog, factory, registry, session };
|
||||
}
|
||||
|
||||
describe("session lifecycle", () => {
|
||||
@@ -80,6 +80,26 @@ describe("session lifecycle", () => {
|
||||
it("derives skill requested/loaded callbacks with the same toolCallId", async () => {
|
||||
const context = await setup(); await context.registry.send(context.session.sessionId, { text: "hello", attachmentIds: [] }); const skill = { name: "ads-analysis", description: "x", source: "project", filePath: ".agents/skills/ads-analysis/SKILL.md", canonicalPath: "/tmp/SKILL.md", diagnostics: [] }; context.factory.input?.skillRequested(skill, "read_1"); context.factory.input?.skillLoaded(skill, "read_1"); const skillEvents = context.events.listAfter(context.session.sessionId).events.filter((event) => event.type.startsWith("skill.")); expect(skillEvents.map((event) => event.type)).toEqual(["skill.requested", "skill.loaded"]); context.factory.port.complete();
|
||||
});
|
||||
it("preserves Slash user text and emits command activation without a fake toolCallId", async () => {
|
||||
const context = await setup(); const dir = path.join(context.root, ".agents/skills/swads-daily-report"); await mkdir(dir); await writeFile(path.join(dir, "SKILL.md"), "---\nname: swads-daily-report\ndescription: report\ndisable-model-invocation: true\n---\nINVOKED BODY");
|
||||
await context.catalog.reload();
|
||||
const prompt = vi.spyOn(context.factory.port, "prompt"); const original = "/swads-daily-report account=demo date=2026-09-06";
|
||||
await context.registry.send(context.session.sessionId, { text: original, attachmentIds: [] });
|
||||
await vi.waitFor(() => expect(prompt).toHaveBeenCalled()); expect(prompt.mock.calls[0]![0]).toContain("<skill name="); expect(prompt.mock.calls[0]![0]).toContain("account=demo date=2026-09-06");
|
||||
const events = context.events.listAfter(context.session.sessionId).events; expect(events.find((e) => e.type === "user.message")?.payload).toMatchObject({ text: original });
|
||||
const activations = events.filter((e) => e.type === "skill.loaded" || e.type === "skill.requested"); expect(activations).toHaveLength(2); for (const e of activations) { expect(e.payload.source).toBe("command"); expect(e.payload.toolCallId).toBeUndefined(); }
|
||||
context.factory.port.complete();
|
||||
});
|
||||
|
||||
it("emits only report metadata on completion and preserves classified tool failures", async () => {
|
||||
const context = await setup(false); await context.registry.send(context.session.sessionId, { text: "report", attachmentIds: [] });
|
||||
const accountKey = "acct_0123456789abcdef01234567", reportDate = "2026-09-06", reportId = "12345678-1234-4234-8234-123456789abc"; const url = `/api/reports/${accountKey}/${reportDate}/${reportId}`;
|
||||
const artifact = { accountKey, reportDate, reportId, previewUrl: `${url}/png`, downloads: { png: `${url}/png`, html: `${url}/html`, json: `${url}/json` } };
|
||||
context.factory.port.emit({ type: "tool_execution_end", toolCallId: "render", toolName: "render_swads_daily_report", isError: false, result: { content: [{ type: "text", text: "/private/SHOULD_NOT_LEAK full report body" }], details: artifact } });
|
||||
context.factory.port.emit({ type: "tool_execution_end", toolCallId: "whoami", toolName: "swads_cli", isError: true, result: { content: [{ type: "text", text: "SWADS_AUTH_FAILED" }] } });
|
||||
const events = context.events.listAfter(context.session.sessionId).events; const generated = events.find((e) => e.type === "report.generated"); expect(generated?.payload).toMatchObject({ assistantMessageId: context.session.currentRun!.assistantMessageId, artifact }); expect(JSON.stringify(events)).not.toContain("SHOULD_NOT_LEAK"); expect(events.find((e) => e.type === "tool.failed")?.payload).toMatchObject({ error: "SWADS_AUTH_FAILED" }); context.factory.port.complete();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("multimodal session path", () => {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { SwadsGateway, gatewayConfig, mcpConnector, MAX_GATEWAY_BYTES, type GatewayConnection } from "../src/swads-gateway.js";
|
||||
import { SwadsReportTools, commands, validateCli } from "../src/swads-tools.js";
|
||||
import type { ReportStore } from "../src/report-store.js";
|
||||
import { sampleReport } from "./report-fixture.js";
|
||||
const signal = () => new AbortController().signal;
|
||||
const token = "private-sentinel-swads-value";
|
||||
const config = () => gatewayConfig({ SWADS_MCP_TOKEN: token, SWADS_MCP_TIMEOUT_MS: "1000" });
|
||||
const capabilities = (): Tool[] => Object.values(commands).filter((name) => name !== "tools/list").map((name) => ({ name, inputSchema: { type: "object" }, annotations: { readOnlyHint: true, destructiveHint: false } }));
|
||||
function fixture(tools = capabilities()) {
|
||||
const connection: GatewayConnection = { list: vi.fn(async () => tools), call: vi.fn(async (name) => ({ content: [{ type: "text", text: JSON.stringify(name === "metrics_semantic_query" ? { columns: [{ name: "spend" }], rows: [[100]] } : { platform: "tiktok", account_id: "demo", metrics: ["spend"], echo: token }) }] })), close: vi.fn(async () => undefined) };
|
||||
const gateway = new SwadsGateway(config(), async () => connection); const create = vi.fn(async () => ({ ok: true }));
|
||||
let runId = "run_1";
|
||||
const toolsFacade = new SwadsReportTools(gateway, { create } as unknown as ReportStore, () => ({ runId, signal: signal() }));
|
||||
return { connection, gateway, tools: toolsFacade, create, nextRun: () => { runId = "run_2"; } };
|
||||
}
|
||||
async function preflight(tools: SwadsReportTools) { await tools.cli({ command: "capabilities", arguments: {} }, signal()); await tools.cli({ command: "whoami", arguments: {} }, signal()); await tools.cli({ command: "metrics.catalog", arguments: { platform: "tiktok" } }, signal()); await tools.cli({ command: "metrics.query", arguments: { platform: "tiktok", query: { metrics: ["spend"] } } }, signal()); }
|
||||
describe("swads CLI boundary", () => {
|
||||
it("rejects unknown commands, arbitrary control parameters and oversized/deep inputs", () => {
|
||||
for (const input of [{ command: "tools/call", arguments: {} }, { command: "whoami", arguments: { url: "https://evil" } }, { command: "metrics.catalog", arguments: { platform: "vk" } }, { command: "metrics.query", arguments: { platform: "tiktok", query: { platform: "vk" } } }, { command: "metrics.query", arguments: { platform: "tiktok", query: { headers: { x: "y" } } } }, { command: "whoami", arguments: { name: "x" } }]) expect(() => validateCli(input)).toThrow();
|
||||
let nested: unknown = {}; for (let i = 0; i < 12; i++) nested = { child: nested }; expect(() => validateCli({ command: "metrics.query", arguments: { platform: "tiktok", query: nested } })).toThrow();
|
||||
expect(() => validateCli({ command: "metrics.query", arguments: { platform: "tiktok", query: { large: "x".repeat(70_000) } } })).toThrow();
|
||||
});
|
||||
it("maps only fixed tools, unwraps JSON, caches capabilities and redacts token before model delivery", async () => {
|
||||
const f = fixture(); const result = await f.tools.cli({ command: "whoami", arguments: {} }, signal()); expect(result).toMatchObject({ echo: "[REDACTED]" });
|
||||
await f.tools.cli({ command: "metrics.catalog", arguments: { platform: "tiktok" } }, signal()); expect(f.connection.list).toHaveBeenCalledTimes(1); expect(f.connection.call).toHaveBeenCalledWith("metrics_catalog", { platform: "tiktok" }, expect.any(AbortSignal));
|
||||
expect(JSON.stringify(f.tools.definitions())).not.toContain(token);
|
||||
});
|
||||
it("refuses missing tools, non-read-only annotations and remote input drift", async () => {
|
||||
const f = fixture(capabilities().filter((tool) => tool.name !== "metrics_semantic_query")); await expect(f.tools.cli({ command: "capabilities", arguments: {} }, signal())).rejects.toMatchObject({ code: "SWADS_TOOL_MISSING" }); await expect(f.tools.render(sampleReport(), signal())).rejects.toMatchObject({ code: "SWADS_PREFLIGHT_REQUIRED" }); expect(f.create).not.toHaveBeenCalled();
|
||||
const list = capabilities(); list.find((tool) => tool.name === "runtime_findings")!.annotations = { readOnlyHint: false, destructiveHint: true }; const unsafe = fixture(list); await expect(unsafe.tools.cli({ command: "runtime.findings", arguments: { platform: "tiktok", external_account_id: "demo" } }, signal())).rejects.toMatchObject({ code: "SWADS_NOT_READ_ONLY" }); expect(unsafe.connection.call).not.toHaveBeenCalled();
|
||||
const drift = capabilities(); drift.find((tool) => tool.name === "metrics_catalog")!.inputSchema = { type: "object", properties: { platform: { enum: ["vk", "yandex"] } } }; await expect(fixture(drift).tools.cli({ command: "metrics.catalog", arguments: { platform: "tiktok" } }, signal())).rejects.toMatchObject({ code: "SWADS_TOOL_DRIFT" });
|
||||
});
|
||||
it("requires successful real preflight and rows in this run to render", async () => {
|
||||
const f = fixture(); await expect(f.tools.render(sampleReport(), signal())).rejects.toThrow(); await preflight(f.tools); const report = sampleReport(); report.notes.push(token); await f.tools.render(report, signal()); expect(JSON.stringify(vi.mocked(f.create).mock.calls)).not.toContain(token); f.nextRun(); await expect(f.tools.render(report, signal())).rejects.toThrow();
|
||||
});
|
||||
it("invalidates render evidence after query failure and rejects empty data", async () => {
|
||||
const f = fixture(); await preflight(f.tools); vi.mocked(f.connection.call).mockResolvedValue({ structuredContent: { columns: [{ name: "spend" }], rows: [] } }); await expect(f.tools.cli({ command: "metrics.query", arguments: { platform: "tiktok", query: {} } }, signal())).rejects.toMatchObject({ code: "SWADS_NO_DATA" }); await expect(f.tools.render(sampleReport(), signal())).rejects.toThrow();
|
||||
});
|
||||
it("classifies upstream permission envelopes and response limits without their contents", async () => {
|
||||
for (const [status, code] of [[401, "SWADS_AUTH_FAILED"], [403, "SWADS_FORBIDDEN"]] as const) { const f = fixture(); vi.mocked(f.connection.call).mockResolvedValue({ structuredContent: { error_code: "upstream", upstream_status: status, detail: token } }); await expect(f.tools.cli({ command: "whoami", arguments: {} }, signal())).rejects.toMatchObject({ code, message: code }); }
|
||||
const f = fixture(); vi.mocked(f.connection.call).mockResolvedValue({ structuredContent: { data: "x".repeat(MAX_GATEWAY_BYTES + 1) } }); await expect(f.tools.cli({ command: "whoami", arguments: {} }, signal())).rejects.toMatchObject({ code: "SWADS_RESPONSE_TOO_LARGE" });
|
||||
});
|
||||
it("validates startup URL but defers missing credentials to first call", async () => {
|
||||
expect(() => gatewayConfig({ SWADS_MCP_URL: "file:///tmp/x" })).toThrow(); expect(() => gatewayConfig({ SWADS_MCP_URL: "https://u:p@example.test" })).toThrow(); const gateway = new SwadsGateway(gatewayConfig({})); await expect(gateway.execute(async () => true, signal())).rejects.toMatchObject({ code: "SWADS_NOT_CONFIGURED" });
|
||||
});
|
||||
it("cancels and times out blocked transport calls, closing connections", async () => {
|
||||
const connection: GatewayConnection = { list: async () => [], call: async () => new Promise(() => undefined), close: vi.fn(async () => undefined) };
|
||||
const gateway = new SwadsGateway({ ...config(), timeoutMs: 100 }, async () => connection); await expect(gateway.execute((conn, s) => conn.call("x", {}, s), signal())).rejects.toMatchObject({ code: "SWADS_TIMEOUT" });
|
||||
const controller = new AbortController(); const pending = gateway.execute((conn, s) => conn.call("x", {}, s), controller.signal); controller.abort(); await expect(pending).rejects.toMatchObject({ code: "SWADS_ABORTED" }); expect(connection.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe("official MCP Streamable HTTP transport with fake fetch", () => {
|
||||
it("initializes, lists and calls over MCP with server-only Authorization", async () => {
|
||||
const methods: string[] = [];
|
||||
const fetcher: typeof fetch = async (_url, init) => {
|
||||
expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${token}`);
|
||||
if (init?.method === "GET") return new Response(null, { status: 405 });
|
||||
const message = JSON.parse(String(init?.body)); methods.push(message.method);
|
||||
if (message.id === undefined) return new Response(null, { status: 202 });
|
||||
const result = message.method === "initialize" ? { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "fake", version: "1" } } : message.method === "tools/list" ? { tools: capabilities() } : { content: [{ type: "text", text: '{"ok":true}' }] };
|
||||
return Response.json({ jsonrpc: "2.0", id: message.id, result });
|
||||
};
|
||||
const gateway = new SwadsGateway(config(), mcpConnector(config(), fetcher));
|
||||
await gateway.execute(async (conn, s) => { expect(await conn.list(s)).toHaveLength(8); await conn.call("swads_whoami", {}, s); }, signal());
|
||||
expect(methods).toEqual(expect.arrayContaining(["initialize", "notifications/initialized", "tools/list", "tools/call"]));
|
||||
});
|
||||
it.each([[401, "SWADS_AUTH_FAILED"], [403, "SWADS_FORBIDDEN"], [503, "SWADS_UNAVAILABLE"]])("classifies HTTP %s without leaking body", async (status, code) => {
|
||||
const fetcher: typeof fetch = async () => new Response(token, { status: Number(status) }); const gateway = new SwadsGateway(config(), mcpConnector(config(), fetcher)); await expect(gateway.execute(async () => true, signal())).rejects.toMatchObject({ code, message: code });
|
||||
});
|
||||
it("bounds response bytes before JSON parsing", async () => { const fetcher: typeof fetch = async () => new Response("x".repeat(MAX_GATEWAY_BYTES + 1), { headers: { "content-type": "application/json", "content-length": String(MAX_GATEWAY_BYTES + 1) } }); const gateway = new SwadsGateway(config(), mcpConnector(config(), fetcher)); await expect(gateway.execute(async () => true, signal())).rejects.toMatchObject({ code: "SWADS_RESPONSE_TOO_LARGE" }); });
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { approvalRequestSchema, imageAttachmentSchema, llmApiSchema } from "./schemas.js";
|
||||
import { approvalRequestSchema, imageAttachmentSchema, llmApiSchema, reportArtifactSchema } from "./schemas.js";
|
||||
|
||||
const baseShape = {
|
||||
eventId: z.string(),
|
||||
@@ -22,8 +22,9 @@ export const harnessEventSchema = z.discriminatedUnion("type", [
|
||||
event("user.message", z.object({ messageId: z.string(), text: z.string(), attachmentIds: z.array(z.string()) }).strict()),
|
||||
event("assistant.delta", z.object({ messageId: z.string(), delta: z.string() }).strict()),
|
||||
event("assistant.completed", z.object({ messageId: z.string() }).strict()),
|
||||
event("skill.requested", z.object({ name: z.string(), filePath: z.string(), toolCallId: z.string() }).strict()),
|
||||
event("skill.loaded", z.object({ name: z.string(), filePath: z.string(), toolCallId: z.string() }).strict()),
|
||||
event("skill.requested", z.object({ name: z.string(), filePath: z.string(), toolCallId: z.string().optional(), source: z.enum(["read", "command"]) }).strict()),
|
||||
event("skill.loaded", z.object({ name: z.string(), filePath: z.string(), toolCallId: z.string().optional(), source: z.enum(["read", "command"]) }).strict()),
|
||||
event("report.generated", z.object({ assistantMessageId: z.string(), artifact: reportArtifactSchema }).strict()),
|
||||
event("tool.requested", toolBase),
|
||||
event("tool.completed", z.object({ toolCallId: z.string(), toolName: z.string(), result: z.unknown().optional() }).strict()),
|
||||
event("tool.failed", z.object({ toolCallId: z.string(), toolName: z.string(), error: z.string() }).strict()),
|
||||
|
||||
@@ -89,3 +89,17 @@ export const skillCatalogItemSchema = z.object({
|
||||
diagnostics: z.array(skillDiagnosticSchema),
|
||||
}).strict();
|
||||
export type SkillCatalogItem = z.infer<typeof skillCatalogItemSchema>;
|
||||
|
||||
export const reportSegmentsSchema = z.object({
|
||||
accountKey: z.string().regex(/^acct_[a-f0-9]{24}$/),
|
||||
reportDate: z.iso.date(),
|
||||
reportId: z.string().uuid(),
|
||||
}).strict();
|
||||
export const reportFormatSchema = z.enum(["png", "html", "json"]);
|
||||
const reportUrl = z.string().regex(/^\/api\/reports\/acct_[a-f0-9]{24}\/\d{4}-\d{2}-\d{2}\/[a-f0-9-]{36}\/(png|html|json)$/);
|
||||
export const reportArtifactSchema = reportSegmentsSchema.extend({
|
||||
previewUrl: reportUrl.optional(),
|
||||
downloads: z.object({ json: reportUrl, html: reportUrl, png: reportUrl.optional() }).strict(),
|
||||
warning: z.object({ code: z.enum(["CHROME_UNAVAILABLE", "PNG_RENDER_FAILED", "PNG_TIMEOUT", "PNG_TRUNCATED"]), message: z.string().max(300) }).strict().optional(),
|
||||
}).strict();
|
||||
export type ReportArtifact = z.infer<typeof reportArtifactSchema>;
|
||||
|
||||
@@ -31,3 +31,13 @@ describe("shared boundary schemas", () => {
|
||||
expect(apiErrorSchema.parse({ error: { code: "SESSION_NOT_FOUND", message: "Session not found", requestId: "req_1", details: {} } }).error.code).toBe("SESSION_NOT_FOUND");
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts report metadata and rejects paths and malformed URL formats", () => {
|
||||
const base = { eventId: "report", sessionId: "s", timestamp: new Date().toISOString(), sequence: 1, type: "report.generated" };
|
||||
const accountKey = "acct_0123456789abcdef01234567", reportDate = "2026-09-06", reportId = "12345678-1234-4234-8234-123456789abc";
|
||||
const url = `/api/reports/${accountKey}/${reportDate}/${reportId}`;
|
||||
const artifact = { accountKey, reportDate, reportId, downloads: { json: `${url}/json`, html: `${url}/html` } };
|
||||
expect(harnessEventSchema.safeParse({ ...base, payload: { assistantMessageId: "m", artifact } }).success).toBe(true);
|
||||
expect(harnessEventSchema.safeParse({ ...base, payload: { assistantMessageId: "m", artifact: { ...artifact, absolutePath: "/private/report" } } }).success).toBe(false);
|
||||
expect(harnessEventSchema.safeParse({ ...base, payload: { assistantMessageId: "m", artifact: { ...artifact, previewUrl: "https://evil.test/report.png" } } }).success).toBe(false);
|
||||
});
|
||||
|
||||
Generated
+1322
-10
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user