fix(swads): make daily report generation resilient
This commit is contained in:
@@ -18,10 +18,13 @@ export function mapPiEvent(event: AgentSessionEvent, context: PiAdapterContext):
|
||||
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);
|
||||
const details = (event.result as { details?: unknown })?.details;
|
||||
const wrapped = details && typeof details === "object" && !Array.isArray(details) ? details as Record<string, unknown> : {};
|
||||
const artifact = reportArtifactSchema.safeParse(wrapped.artifact ?? details);
|
||||
const markdown = typeof wrapped.markdown === "string" ? wrapped.markdown.slice(0, 6_000) : undefined;
|
||||
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 } },
|
||||
{ type: "report.generated", runId: context.runId, payload: { assistantMessageId: context.assistantMessageId, artifact: artifact.data, ...(markdown ? { markdown } : {}) } },
|
||||
];
|
||||
return [{ type: "tool.failed", runId: context.runId, payload: { toolCallId: event.toolCallId, toolName: event.toolName, error: "SWADS_INVALID_REPORT" } }];
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type AgentSessionEvent,
|
||||
type InlineExtension,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { ModelConnectionInput, SendMessageInput } from "@agent-studio/shared";
|
||||
import type { ModelConnectionInput, ReportArtifact, SendMessageInput } from "@agent-studio/shared";
|
||||
import { InMemoryApprovalBroker, type ApprovalBroker } from "./approval.js";
|
||||
import type { FileAttachmentStore } from "./attachments.js";
|
||||
import { HarnessError, redactValue, redactServerSecret, safeErrorMessage } from "./errors.js";
|
||||
@@ -43,13 +43,15 @@ export interface AgentSessionFactoryInput {
|
||||
skillRequested: (skill: SkillIndexEntry, toolCallId: string) => void;
|
||||
skillLoaded: (skill: SkillIndexEntry, toolCallId: string) => void;
|
||||
toolRequested: (toolCallId: string, toolName: string, argumentsValue: unknown) => void;
|
||||
reportGenerated: (toolCallId: string, artifact: ReportArtifact, markdown: string) => void;
|
||||
toolFailed: (toolCallId: string, toolName: string, error: string) => void;
|
||||
}
|
||||
export interface AgentSessionFactory { create(input: AgentSessionFactoryInput): Promise<AgentSessionPort> }
|
||||
|
||||
class PiAgentSessionPort implements AgentSessionPort {
|
||||
readonly modelId: string;
|
||||
readonly supportsImages: boolean;
|
||||
constructor(private readonly session: AgentSession) {
|
||||
constructor(private readonly session: AgentSession, private readonly completeRun?: () => Promise<string | undefined> | string | undefined) {
|
||||
if (!session.model) throw new Error("Pi AgentSession was created without an explicit model");
|
||||
this.modelId = session.model.id;
|
||||
this.supportsImages = session.model.input.includes("image");
|
||||
@@ -57,6 +59,11 @@ class PiAgentSessionPort implements AgentSessionPort {
|
||||
async prompt(text: string, options?: { images?: unknown[] }): Promise<void> {
|
||||
const images = options?.images as ImageContent[] | undefined;
|
||||
await this.session.prompt(text, images ? { images } : undefined);
|
||||
// Pi reports provider failures through its message stream and resolves prompt().
|
||||
// Convert a terminal model error into Harness run.failed instead of run.completed.
|
||||
if (this.session.agent.state.errorMessage) throw new Error("Model provider run failed");
|
||||
const completionError = await this.completeRun?.();
|
||||
if (completionError) throw new Error(completionError);
|
||||
}
|
||||
abort(): Promise<void> { return this.session.abort(); }
|
||||
subscribe(listener: (event: unknown) => void): () => void { return this.session.subscribe(listener); }
|
||||
@@ -76,8 +83,23 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
||||
async create(input: AgentSessionFactoryInput): Promise<AgentSessionPort> {
|
||||
const resolved = await this.models.createRuntime(input.sessionId);
|
||||
const swads = new SwadsReportTools(this.gateway, this.reports, input.getRun);
|
||||
let swadsWorkflow: { runId: string; rendered: boolean; queryCount: number; finalTextSeen: boolean; fatal: boolean; explained: boolean } | undefined;
|
||||
const extension: InlineExtension = { name: "agent-studio-safety", hidden: true, factory: (pi) => {
|
||||
const pendingSkills = new Map<string, SkillIndexEntry>();
|
||||
pi.on("before_agent_start", (event) => {
|
||||
const run = input.getRun();
|
||||
swadsWorkflow = run && event.prompt.includes('<skill name="swads-daily-report"') ? { runId: run.runId, rendered: false, queryCount: 0, finalTextSeen: false, fatal: false, explained: false } : undefined;
|
||||
});
|
||||
pi.on("message_end", (event) => {
|
||||
const run = input.getRun();
|
||||
if (!run || swadsWorkflow?.runId !== run.runId || event.message.role !== "assistant") return;
|
||||
if (swadsWorkflow.queryCount > 0 && assistantText(event.message).trim()) swadsWorkflow.finalTextSeen = true;
|
||||
});
|
||||
pi.on("turn_end", (event) => {
|
||||
const run = input.getRun(); const workflow = swadsWorkflow;
|
||||
if (!run || workflow?.runId !== run.runId || workflow.rendered || event.message.role !== "assistant") return;
|
||||
if (workflow.fatal && assistantText(event.message).trim()) workflow.explained = true;
|
||||
});
|
||||
pi.on("tool_call", async (event) => {
|
||||
const run = input.getRun();
|
||||
if (!run) return { block: true, reason: "No active Harness run" };
|
||||
@@ -99,6 +121,7 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
||||
if (typeof maybePath === "string") await input.workspace.assertReadPath(maybePath);
|
||||
}
|
||||
input.toolRequested(event.toolCallId, event.toolName, event.toolName === "render_swads_daily_report" ? { status: "requested" } : publicToolArguments(event.input, input.cwd, matchedSkill));
|
||||
if (event.toolName === "render_swads_daily_report" && swadsWorkflow?.runId === run.runId && !swadsWorkflow.finalTextSeen) return { block: true, reason: "Final Markdown analysis is required before rendering the SW Ads report" };
|
||||
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}` };
|
||||
@@ -107,6 +130,15 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
||||
});
|
||||
pi.on("tool_result", async (event) => {
|
||||
const run = input.getRun();
|
||||
if (run && swadsWorkflow?.runId === run.runId) {
|
||||
const toolInput = objectValue(event.input);
|
||||
if (event.toolName === "swads_cli" && !event.isError && toolInput.command === "metrics.query") swadsWorkflow.queryCount++;
|
||||
if (event.toolName === "swads_cli" && event.isError && ["capabilities", "whoami", "metrics.catalog", "metrics.query"].includes(String(toolInput.command))) swadsWorkflow.fatal = true;
|
||||
if (event.toolName === "render_swads_daily_report") {
|
||||
if (event.isError) swadsWorkflow.fatal = true;
|
||||
else swadsWorkflow.rendered = true;
|
||||
}
|
||||
}
|
||||
const skill = pendingSkills.get(event.toolCallId);
|
||||
if (run && skill && !event.isError && event.toolName === "read") {
|
||||
input.skillLoaded(skill, event.toolCallId);
|
||||
@@ -126,7 +158,23 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
||||
sessionManager: SessionManager.inMemory(input.cwd),
|
||||
settingsManager: SettingsManager.inMemory(),
|
||||
});
|
||||
return new PiAgentSessionPort(session);
|
||||
return new PiAgentSessionPort(session, async () => {
|
||||
const run = input.getRun(); const workflow = swadsWorkflow;
|
||||
if (!run || workflow?.runId !== run.runId || workflow.rendered || workflow.explained) return undefined;
|
||||
if (!workflow.fatal && workflow.queryCount >= 4) {
|
||||
const toolCallId = `swads-fallback-${randomUUID()}`;
|
||||
input.toolRequested(toolCallId, "render_swads_daily_report", { status: "server-fallback" });
|
||||
try {
|
||||
const result = await swads.renderCollected(run.signal);
|
||||
workflow.rendered = true;
|
||||
input.reportGenerated(toolCallId, result.artifact, result.markdown);
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
input.toolFailed(toolCallId, "render_swads_daily_report", error instanceof HarnessError ? error.code : "SWADS_REPORT_FAILED");
|
||||
}
|
||||
}
|
||||
return "SWADS_REPORT_INCOMPLETE";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +205,7 @@ export class InMemorySessionRegistry implements SessionRegistry {
|
||||
private readonly catalog: SkillCatalog,
|
||||
private factory: AgentSessionFactory | undefined,
|
||||
approvalTimeoutMs = 60_000,
|
||||
private readonly runTimeoutMs = 120_000,
|
||||
private readonly runTimeoutMs = 300_000,
|
||||
) {
|
||||
this.approvals = new InMemoryApprovalBroker(approvalTimeoutMs, (resolution) => {
|
||||
this.events.append(resolution.request.sessionId, { type: "approval.resolved", runId: resolution.request.runId, payload: { approvalId: resolution.request.approvalId, toolCallId: resolution.request.toolCallId, status: resolution.decision.status, resolvedAt: resolution.decision.resolvedAt } });
|
||||
@@ -220,7 +268,7 @@ export class InMemorySessionRegistry implements SessionRegistry {
|
||||
this.events.append(id, { type: "user.message", runId, payload: { messageId: `msg_${randomUUID()}`, text: message.text, attachmentIds: message.attachmentIds } });
|
||||
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);
|
||||
this.touchRunTimeout(session);
|
||||
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" } });
|
||||
}
|
||||
@@ -239,12 +287,15 @@ export class InMemorySessionRegistry implements SessionRegistry {
|
||||
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) } }); } },
|
||||
reportGenerated: (toolCallId, artifact, markdown) => { const run = session.currentRun; if (run) { this.events.append(session.sessionId, { type: "tool.completed", runId: run.runId, payload: { toolCallId, toolName: "render_swads_daily_report", result: artifact } }); this.events.append(session.sessionId, { type: "report.generated", runId: run.runId, payload: { assistantMessageId: run.assistantMessageId, artifact, markdown } }); } },
|
||||
toolFailed: (toolCallId, toolName, error) => { const run = session.currentRun; if (run) this.events.append(session.sessionId, { type: "tool.failed", runId: run.runId, payload: { toolCallId, toolName, error } }); },
|
||||
});
|
||||
session.unsubscribe = session.agent.subscribe((event) => this.onPiEvent(session, event as AgentSessionEvent));
|
||||
}
|
||||
private onPiEvent(session: InternalSession, event: AgentSessionEvent): void {
|
||||
const run = session.currentRun;
|
||||
if (!run) return;
|
||||
this.touchRunTimeout(session);
|
||||
// The blocking Extension preflight is the single source for tool.requested.
|
||||
// Pi's later execution-start event has the same toolCallId and would create
|
||||
// duplicate assistant-ui Tool parts (and can expose pre-normalized paths).
|
||||
@@ -266,6 +317,15 @@ export class InMemorySessionRegistry implements SessionRegistry {
|
||||
if (session.currentRun?.runId === run.runId) session.currentRun = undefined;
|
||||
}
|
||||
}
|
||||
private touchRunTimeout(session: InternalSession): void {
|
||||
const run = session.currentRun;
|
||||
if (!run || !session.busy || run.controller.signal.aborted) return;
|
||||
if (session.timeout) clearTimeout(session.timeout);
|
||||
const runId = run.runId;
|
||||
session.timeout = setTimeout(() => {
|
||||
if (session.currentRun?.runId === runId) void this.cancel(session.sessionId, "timeout");
|
||||
}, this.runTimeoutMs);
|
||||
}
|
||||
async cancel(id: string, reason: "user" | "timeout" | "session_deleted" | "shutdown" = "user"): Promise<void> {
|
||||
const session = this.internal(id);
|
||||
const run = session.currentRun;
|
||||
@@ -296,6 +356,12 @@ export class InMemorySessionRegistry implements SessionRegistry {
|
||||
private async disposeAgent(session: InternalSession): Promise<void> { session.unsubscribe?.(); session.unsubscribe = undefined; session.agent?.dispose(); session.agent = undefined; }
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
|
||||
function assistantText(message: unknown): string {
|
||||
const content = objectValue(message).content;
|
||||
return Array.isArray(content) ? content.filter((part) => objectValue(part).type === "text").map((part) => String(objectValue(part).text ?? "")).join("\n") : "";
|
||||
}
|
||||
|
||||
function publicToolArguments(value: unknown, cwd: string, skill?: SkillIndexEntry): unknown {
|
||||
const redacted = redactValue(value);
|
||||
if (!redacted || typeof redacted !== "object" || Array.isArray(redacted)) return redacted;
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { reportDataSchema, type ReportData } from "./report-schema.js";
|
||||
import type { ReportStore } from "./report-store.js";
|
||||
|
||||
export const commands = {
|
||||
@@ -74,15 +74,114 @@ function unwrap(result: unknown): unknown {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Keep individual tool results below Pi's context-safe output limit. The Gateway
|
||||
// response remains the source of truth; only fields irrelevant to this report are
|
||||
// removed before the result enters the model conversation.
|
||||
const MAX_MODEL_RESULT_BYTES = 50 * 1024;
|
||||
function object(value: unknown): Record<string, unknown> { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
|
||||
function pick(value: unknown, keys: string[]): Record<string, unknown> {
|
||||
const source = object(value); const result: Record<string, unknown> = {};
|
||||
for (const key of keys) if (key in source) result[key] = source[key];
|
||||
return result;
|
||||
}
|
||||
function mapObjects<T>(value: unknown, mapper: (item: Record<string, unknown>) => T): T[] { return Array.isArray(value) ? value.map((item) => mapper(object(item))) : []; }
|
||||
function compactForModel(command: keyof typeof commands, data: unknown): unknown {
|
||||
const root = object(data);
|
||||
if (command === "whoami" && root.access) {
|
||||
const access = object(root.access); const orgs = object(root.orgs);
|
||||
const accountKeys = ["platform_account_binding_id", "tenant_id", "platform", "external_account_id", "account_name", "access_profile", "expires_at", "is_current", "reporting_timezone", "reporting_utc_offset_minutes", "account_currency"];
|
||||
return { access: { ...pick(access, ["user_id", "membership_id", "org_id", "role", "access_version", "current_account_reason"]), current_account: pick(access.current_account, accountKeys), accounts: mapObjects(access.accounts, (item) => pick(item, accountKeys)) }, orgs: { ...pick(orgs, ["current_org_id"]), organizations: mapObjects(orgs.organizations, (item) => pick(item, ["org_id", "name", "slug", "role"])) } };
|
||||
}
|
||||
if (command === "metrics.catalog" && Array.isArray(root.metrics)) return {
|
||||
metrics: mapObjects(root.metrics, (item) => pick(item, ["name", "label_zh", "unit", "agg", "type", "source", "family", "selectable", "supported_providers", "supported_subjects"])),
|
||||
dimensions: mapObjects(root.dimensions, (item) => pick(item, ["name", "label_zh", "type"])),
|
||||
query_kinds: mapObjects(root.query_kinds, (item) => pick(item, ["name", "label_zh", "default_chart_type"])),
|
||||
windows: root.windows, sources: mapObjects(root.sources, (item) => pick(item, ["name", "label_zh"])), families: mapObjects(root.families, (item) => pick(item, ["name", "label_zh", "source"])),
|
||||
default_metric_sets: root.default_metric_sets, request_schema: root.request_schema, filters_supported: root.filters_supported, raw_filters_supported: root.raw_filters_supported,
|
||||
};
|
||||
if (command === "metrics.query" && Array.isArray(root.rows)) {
|
||||
const meta = object(root.query_meta); const context = object(root.account_context);
|
||||
return { columns: mapObjects(root.columns, (item) => pick(item, ["name", "type", "unit"])), rows: root.rows, row_count: root.row_count, query_meta: { ...pick(meta, ["elapsed_ms", "truncated", "date_from", "date_to", "window", "attribution_mode", "reporting_timezone", "reporting_utc_offset_minutes"]), attribution_resolutions: mapObjects(meta.attribution_resolutions, (item) => pick(item, ["status", "provider", "source_kinds", "reason_code", "freshness", "unavailable_reason", "platform", "external_account_id", "subject_type", "external_subject_id", "subject_display_name"])) }, account_context: pick(context, ["tenant_id", "platform", "external_account_id", "reporting_timezone", "reporting_utc_offset_minutes", "account_currency", "switched"]) };
|
||||
}
|
||||
if (command === "runtime.proposals" && Array.isArray(root.proposals)) return { ...pick(root, ["external_account_id", "status"]), proposals: mapObjects(root.proposals, (item) => ({ ...pick(item, ["proposal_id", "action_kind", "target_scope", "portfolio_priority_score", "rationale", "confidence", "source", "status", "created_at", "reviewed_at", "materialized_at", "error_message"]), provenance: pick(item.provenance, ["findings_window", "ledger_as_of", "reporting_timezone", "reporting_utc_offset_minutes", "account_currency", "subject_type", "data_caveats"]), creative_brief: pick(item.creative_brief, ["market", "origin", "currency", "roas_target", "utilization", "daily_budget", "language_hint", "campaign_label", "evidence_window", "yesterday_spend", "external_campaign_id"]) })) };
|
||||
if (command === "runtime.account-overview" && root.account) return { account: pick(root.account, ["tenant_id", "platform", "external_account_id", "account_name", "orchestrator_status", "monitored_campaign_count", "account_subject_type", "total_spend", "total_installs", "total_conversions", "account_cpi", "account_cpr", "reporting_timezone", "reporting_utc_offset_minutes", "account_currency", "warning_count", "stale_count", "failed_count", "last_account_sync_at", "last_metric_collection_at", "last_attribution_collection_at", "last_successful_metric_sync_at", "rate_limit_state"]), campaigns: mapObjects(root.campaigns, (item) => ({ ...pick(item, ["campaign_id", "campaign_name", "platform", "market", "primary_goal", "external_account_id", "external_campaign_id", "workspace_type", "subject_type", "remote_status", "runtime_status", "freshness_status", "delivering_group_count", "latest_metrics", "metric_windows", "risk_level", "origin", "updated_at"]), commerce_metrics: item.commerce_metrics })) };
|
||||
if (command === "campaign.list" && Array.isArray(root.items)) return { items: mapObjects(root.items, (item) => ({ ...pick(item, ["campaign_id", "workspace_type", "subject_type", "campaign_name", "platform", "market", "stage", "health", "status_reason", "account_name", "external_account_id", "external_campaign_id", "objective_type", "operation_status", "secondary_status", "budget_mode", "budget", "group_count", "ad_count", "creative_count", "last_synced_at", "sync_warning_count", "created_at", "updated_at"]), commerce_summary: pick(item.commerce_summary, ["external_shop_id", "targeting_region_code", "promotion_type", "product_scope", "selected_product_count", "daily_budget", "currency", "target_roi", "schedule_timezone", "schedule_start_at", "schedule_end_at", "creative_strategy", "operation_status"]) })), ...pick(root, ["page", "page_size", "total", "account_context"]) };
|
||||
return data;
|
||||
}
|
||||
function boundedForModel(command: keyof typeof commands, data: unknown): unknown {
|
||||
const compacted = compactForModel(command, data);
|
||||
if (Buffer.byteLength(JSON.stringify(compacted)) > MAX_MODEL_RESULT_BYTES) throw failure("SWADS_RESPONSE_TOO_LARGE");
|
||||
return compacted;
|
||||
}
|
||||
function reportMarkdown(data: ReportData): string {
|
||||
const value = (input: number | null, suffix = "") => input === null ? "N/A" : `${Number.isInteger(input) ? input : input.toFixed(2)}${suffix}`;
|
||||
const anomalies = data.campaigns.anomalies.length ? data.campaigns.anomalies.map((item) => `- ${item.name}:${item.reason}`).join("\n") : "- 无可归因的 Campaign 异常;可能为零消耗或明细覆盖不足。";
|
||||
const recommendations = data.recommendations.map((item, index) => `${index + 1}. **${item.title}**:${item.detail}`).join("\n");
|
||||
return `## SW Ads 日报|${data.meta.report_date}\n\n**账户结论**:${data.meta.account_name}(${data.meta.status_label}),消耗 ${value(data.summary.spend)} ${data.meta.currency},GMV ${value(data.summary.revenue)} ${data.meta.currency},订单 ${value(data.summary.orders)},ROAS ${value(data.summary.roas)}。\n\n**关键指标**:CTR ${value(data.summary.ctr_percent, "%")};CPI ${value(data.summary.cpi)};单均成本 ${value(data.summary.cost_per_order)};简化广告 ROI ${value(data.summary.simple_roi_percent, "%")}。\n\n**关键异常**\n${anomalies}\n\n**三项建议**\n${recommendations}\n\n**范围与限制**:${data.meta.period_label};时区 ${data.meta.reporting_timezone}。${data.meta.attribution_label}`.slice(0, 6_000);
|
||||
}
|
||||
|
||||
type Observations = { whoami?: unknown; queries: Array<{ arguments: Record<string, unknown>; data: unknown }>; context: Record<string, unknown> };
|
||||
function queryKind(item: Observations["queries"][number]): string { return String(object(object(item.arguments).query).query_kind ?? ""); }
|
||||
function queryRows(item: Observations["queries"][number] | undefined): Array<Record<string, unknown>> {
|
||||
if (!item) return []; const data = object(item.data); const names = mapObjects(data.columns, (column) => object(column).name).map(String);
|
||||
return Array.isArray(data.rows) ? data.rows.map((row) => Object.fromEntries(names.map((name, index) => [name, Array.isArray(row) ? row[index] : object(row)[name]]))) : [];
|
||||
}
|
||||
function numberOrNull(value: unknown): number | null { const result = typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number(value) : NaN; return Number.isFinite(result) && result >= 0 ? result : null; }
|
||||
function countOrNull(value: unknown): number | null { const result = numberOrNull(value); return result === null ? null : Math.round(result); }
|
||||
function percentOrNull(value: unknown): number | null { const result = numberOrNull(value); return result === null ? null : Math.min(100, result <= 1 ? result * 100 : result); }
|
||||
function ratio(revenue: number | null, spend: number | null): number | null { return revenue === null || spend === null || spend === 0 ? null : revenue / spend; }
|
||||
function firstLabel(values: unknown[], fallback: string): string {
|
||||
for (const value of values) { const text = String(value ?? "").trim(); if (text) return text.slice(0, 160); }
|
||||
return fallback;
|
||||
}
|
||||
function fallbackReportData(observations: Observations): ReportData {
|
||||
const summaryQuery = observations.queries.find((item) => queryKind(item) === "summary");
|
||||
const trendQuery = observations.queries.find((item) => queryKind(item) === "timeseries");
|
||||
const campaignQuery = observations.queries.find((item) => queryKind(item) === "campaign_breakdown");
|
||||
const creativeQuery = observations.queries.find((item) => queryKind(item) === "creative_breakdown");
|
||||
if (!summaryQuery || !trendQuery || !campaignQuery || !creativeQuery) throw failure("SWADS_PREFLIGHT_REQUIRED");
|
||||
const summary = queryRows(summaryQuery)[0]; if (!summary) throw failure("SWADS_NO_DATA");
|
||||
const query = object(object(summaryQuery.arguments).query); const summaryData = object(summaryQuery.data); const accountContext = object(summaryData.account_context);
|
||||
const access = object(object(observations.whoami).access); const currentAccount = object(access.current_account);
|
||||
const reportDate = String(query.date_to ?? object(summaryData.query_meta).date_to ?? "");
|
||||
const spend = numberOrNull(summary.spend), revenue = numberOrNull(summary.gross_revenue), orders = countOrNull(summary.orders), roas = ratio(revenue, spend);
|
||||
const campaignStatuses = new Map(mapObjects(object(observations.context["campaign.list"]).items, (item) => [String(item.external_campaign_id ?? ""), firstLabel([item.operation_status, item.stage], "unknown")] as const));
|
||||
const campaignRows = queryRows(campaignQuery).map((row) => { const rowSpend = numberOrNull(row.spend), rowRevenue = numberOrNull(row.gross_revenue), rowRoas = ratio(rowRevenue, rowSpend); const id = firstLabel([row.external_campaign_id, row.campaign_id], "unknown"); return { name: firstLabel([row.external_campaign_name, row.campaign_name, id], "未命名 Campaign"), id, status: campaignStatuses.get(String(row.external_campaign_id ?? "")) ?? "unknown", spend: rowSpend, revenue: rowRevenue, orders: countOrNull(row.orders), roas: rowRoas, reason: rowSpend === 0 ? "报告日无消耗" : rowRoas === null ? "缺少可归因收入" : rowRoas < 1 ? `ROAS ${rowRoas.toFixed(2)},低于盈亏平衡参考线` : `ROAS ${rowRoas.toFixed(2)},需结合样本量复核` }; });
|
||||
const rankedCampaigns = campaignRows.filter((item) => (item.spend ?? 0) > 0).sort((a, b) => (b.roas ?? -1) - (a.roas ?? -1));
|
||||
const creativeRows = queryRows(creativeQuery).map((row) => { const rowSpend = numberOrNull(row.spend), rowRevenue = numberOrNull(row.gross_revenue), rowRoas = ratio(rowRevenue, rowSpend); const id = firstLabel([row.external_creative_id, row.creative_id], "unknown"); return { name: firstLabel([row.external_creative_name, row.creative_name, id], "未命名素材"), id, creator: firstLabel([row.creative_creator_name], "未知创作者"), spend: rowSpend, revenue: rowRevenue, orders: countOrNull(row.orders), roas: rowRoas, clicks: countOrNull(row.product_clicks ?? row.clicks), product_ctr_percent: percentOrNull(row.product_ctr), reason: rowSpend === 0 ? "样本消耗为零" : rowRoas === null ? "缺少可归因收入" : `ROAS ${rowRoas.toFixed(2)},Creative 归因仅表示相关性` }; });
|
||||
const rankedCreatives = creativeRows.filter((item) => (item.spend ?? 0) > 0).sort((a, b) => (b.roas ?? -1) - (a.roas ?? -1));
|
||||
const sourceGroups = [...new Set(creativeRows.map((item) => item.creator))].slice(0, 6).map((creator) => { const rows = creativeRows.filter((item) => item.creator === creator); const groupSpend = rows.reduce((sum, item) => sum + (item.spend ?? 0), 0), groupRevenue = rows.reduce((sum, item) => sum + (item.revenue ?? 0), 0); return { label: creator, spend: groupSpend, revenue: groupRevenue, orders: rows.reduce((sum, item) => sum + (item.orders ?? 0), 0), roas: ratio(groupRevenue, groupSpend) }; });
|
||||
const proposals = mapObjects(object(observations.context["runtime.proposals"]).proposals, (item) => item).slice(0, 10);
|
||||
const manualConfirmations = proposals.map((item) => ({ action: String(item.action_kind ?? "待审核平台变更"), required: true, reason: String(item.rationale ?? "远端提案必须人工确认后执行").slice(0, 500) }));
|
||||
const trend = queryRows(trendQuery).filter((row) => String(row.date ?? "") !== reportDate).slice(-7).map((row) => ({ label: String(row.date ?? "未知日期"), roas: ratio(numberOrNull(row.gross_revenue), numberOrNull(row.spend)), partial: false }));
|
||||
const comparisonDates = trend.map((item) => item.label);
|
||||
return reportDataSchema.parse({
|
||||
meta: { report_date: reportDate, account_name: String(currentAccount.account_name ?? accountContext.account_name ?? "TikTok Account"), account_id: String(currentAccount.external_account_id ?? accountContext.external_account_id ?? query.account_id ?? "unknown"), currency: String(currentAccount.account_currency ?? accountContext.account_currency ?? "USD"), reporting_timezone: String(currentAccount.reporting_timezone ?? accountContext.reporting_timezone ?? query.reporting_timezone ?? "UTC"), period_label: reportDate, comparison_label: comparisonDates.length ? `${comparisonDates[0]} 至 ${comparisonDates.at(-1)}` : "前七个完整业务日", freshness_label: "SW Ads Gateway 实时查询", attribution_label: "平台语义指标口径;Creative 归因仅表示相关性。", status_label: spend === 0 ? "报告日零消耗" : roas !== null && roas < 1 ? "投放中但效率偏低" : "投放中" },
|
||||
summary: { spend, revenue, orders, roas, ctr_percent: percentOrNull(summary.ctr), cpi: numberOrNull(summary.cpi), simple_roi_percent: spend && revenue !== null ? ((revenue - spend) / spend) * 100 : null, cost_per_order: orders ? spend === null ? null : spend / orders : null },
|
||||
monitoring_note: spend === 0 ? "报告日账户消耗为零;Campaign 空明细属于合法结果,请核查投放状态与数据同步。" : undefined,
|
||||
trend, campaigns: { winners: rankedCampaigns.slice(0, 3), anomalies: [...rankedCampaigns].reverse().filter((item) => (item.roas ?? 0) < 1).slice(0, 3) },
|
||||
creative: { row_count: creativeRows.length, coverage_percent: spend && spend > 0 ? Math.min(100, (creativeRows.reduce((sum, item) => sum + (item.spend ?? 0), 0) / spend) * 100) : null, source_groups: sourceGroups, winners: rankedCreatives.slice(0, 3), anomalies: [...rankedCreatives].reverse().filter((item) => (item.roas ?? 0) < 1).slice(0, 3), insights: [creativeRows.length ? `共观察到 ${creativeRows.length} 条 Creative 明细。` : "报告日无可用 Creative 明细。", "Creative 表现为相关性观察,不能单独证明因果。"] },
|
||||
recommendations: [
|
||||
{ title: "核查报告日投放状态", detail: spend === 0 ? "确认 GMV Max Campaign 是否按计划启用,并检查账户同步是否完整。" : "核对消耗、收入与订单回传完整性,确认日报口径稳定。", confirmation: "none" },
|
||||
{ title: proposals.length ? "逐项审核现有优化提案" : "复盘低效 Campaign", detail: proposals.length ? `当前读取到 ${proposals.length} 条提案;所有平台变更必须人工确认。` : "优先检查有足够消耗但 ROAS 低于 1 的 Campaign,不自动执行暂停或预算变更。", confirmation: proposals.length ? "required" : "none" },
|
||||
{ title: "监控下一完整业务日", detail: "使用相同账户、时区和归因口径复查消耗恢复及 ROAS 变化,避免依据低样本单日结论操作。", confirmation: "none" },
|
||||
], manual_confirmations: manualConfirmations, notes: ["由已完成的只读查询自动归一化生成;未执行任何平台写操作。", "简化广告 ROI 不含商品、佣金、退款及履约成本。"],
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
private evidence: { runId: string; whoami: boolean; catalog: boolean; query: boolean; queryFailed: boolean } | undefined;
|
||||
private observations: { runId: string; whoami?: unknown; queries: Array<{ arguments: Record<string, unknown>; data: unknown }>; context: Record<string, unknown> } | 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 };
|
||||
if (this.evidence?.runId !== run.runId) {
|
||||
this.evidence = { runId: run.runId, whoami: false, catalog: false, query: false, queryFailed: false };
|
||||
this.observations = { runId: run.runId, queries: [], context: {} };
|
||||
}
|
||||
try {
|
||||
return await this.gateway.execute(async (connection, requestSignal) => {
|
||||
const tools = await this.gateway.list(connection, requestSignal, params.command === "capabilities");
|
||||
@@ -97,28 +196,47 @@ export class SwadsReportTools {
|
||||
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 (!Array.isArray(rows.columns) || !Array.isArray(rows.rows)) throw failure("SWADS_INVALID_RESPONSE");
|
||||
// Empty entity breakdowns are valid (for example, a zero-spend day has no
|
||||
// nonzero-spend campaigns). Summary/trend queries still require observed rows.
|
||||
const queryKind = object(object(params.arguments).query).query_kind;
|
||||
if (!rows.rows.length && !(typeof queryKind === "string" && queryKind.endsWith("_breakdown"))) throw failure("SWADS_NO_DATA");
|
||||
if (rows.rows.length && (!rows.columns.length || !rows.columns.every((column) => typeof column.name === "string"))) throw failure("SWADS_INVALID_RESPONSE");
|
||||
}
|
||||
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;
|
||||
const modelData = boundedForModel(params.command, data);
|
||||
if (params.command === "whoami") { this.evidence!.whoami = true; this.observations!.whoami = modelData; }
|
||||
if (params.command === "metrics.catalog") this.evidence!.catalog = true;
|
||||
if (params.command === "metrics.query") this.evidence!.query = true;
|
||||
return data;
|
||||
if (params.command === "metrics.query") { this.evidence!.query = true; this.observations!.queries.push({ arguments: params.arguments as Record<string, unknown>, data: modelData }); }
|
||||
if (params.command.startsWith("runtime.") || params.command === "campaign.list") this.observations!.context[params.command] = modelData;
|
||||
return modelData;
|
||||
}, AbortSignal.any([signal, run.signal]));
|
||||
} catch (error) {
|
||||
if (["capabilities", "whoami", "metrics.catalog", "metrics.query"].includes(params.command)) this.evidence = undefined;
|
||||
// Keep completed identity/catalog preflight after a query failure so already queued
|
||||
// sequential queries report their own result instead of a misleading preflight error.
|
||||
// A sticky queryFailed bit still prevents rendering incomplete report data in this run.
|
||||
if (params.command === "metrics.query" && this.evidence?.runId === run.runId) {
|
||||
this.evidence.query = false;
|
||||
this.evidence.queryFailed = true;
|
||||
} else if (["capabilities", "whoami", "metrics.catalog"].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");
|
||||
if (!run || this.evidence?.runId !== run.runId || !this.evidence.whoami || !this.evidence.catalog || !this.evidence.query || this.evidence.queryFailed) 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"); }
|
||||
}
|
||||
async renderCollected(signal: AbortSignal): Promise<{ artifact: Awaited<ReturnType<ReportStore["create"]>>; markdown: string }> {
|
||||
const run = this.getRun(); const observations = this.observations;
|
||||
if (!run || observations?.runId !== run.runId || this.evidence?.queryFailed) throw failure("SWADS_PREFLIGHT_REQUIRED");
|
||||
const data = fallbackReportData(observations);
|
||||
const artifact = await this.render(data, signal);
|
||||
return { artifact, markdown: reportMarkdown(data) };
|
||||
}
|
||||
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.",
|
||||
@@ -128,7 +246,7 @@ export class SwadsReportTools {
|
||||
}), 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 }; },
|
||||
execute: async (_id, params, signal) => { const data = reportDataSchema.parse(this.gateway.sanitize(params.data)); const artifact = await this.render(data, signal ?? new AbortController().signal); return { content: [{ type: "text" as const, text: JSON.stringify(artifact) }], details: { artifact, markdown: reportMarkdown(data) }, terminate: true }; },
|
||||
})];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,12 +38,12 @@ class FakeFactory implements AgentSessionFactory {
|
||||
async create(input: AgentSessionFactoryInput): Promise<AgentSessionPort> { this.input = input; return this.port; }
|
||||
}
|
||||
|
||||
async function setup(supportsImages = true) {
|
||||
async function setup(supportsImages = true, runTimeoutMs = 5_000) {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "studio-session-")); const sessionsRoot = path.join(root, "sessions"); await mkdir(sessionsRoot);
|
||||
await mkdir(path.join(root, ".agents/skills"), { recursive: true }); await mkdir(path.join(root, ".pi/skills"), { recursive: true });
|
||||
const catalog = new SkillCatalog(root, path.join(root, "agent-dir")); await catalog.reload();
|
||||
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 registry = new InMemorySessionRegistry(sessionsRoot, events, attachments, models, catalog, factory, 500, runTimeoutMs);
|
||||
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, catalog, factory, registry, session };
|
||||
}
|
||||
@@ -71,6 +71,15 @@ describe("session lifecycle", () => {
|
||||
it("cancels an active run and disposes on deletion", async () => {
|
||||
const context = await setup(); await context.registry.send(context.session.sessionId, { text: "hello", attachmentIds: [] }); await context.registry.cancel(context.session.sessionId, "user"); expect(context.factory.port.aborted).toBe(true); await vi.waitFor(() => expect(context.session.busy).toBe(false)); await context.registry.delete(context.session.sessionId); expect(context.factory.port.disposed).toBe(true); await expect(stat(context.session.cwd)).rejects.toThrow();
|
||||
});
|
||||
it("treats the run timeout as an inactivity timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const context = await setup(true, 100); await context.registry.send(context.session.sessionId, { text: "hello", attachmentIds: [] });
|
||||
await vi.advanceTimersByTimeAsync(80); context.factory.port.emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { type: "text_delta", delta: "still working" } });
|
||||
await vi.advanceTimersByTimeAsync(80); expect(context.session.busy).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(21); await Promise.resolve(); expect(context.factory.port.aborted).toBe(true); expect(context.events.listAfter(context.session.sessionId).events.at(-1)?.type).toBe("run.cancelled");
|
||||
} finally { vi.useRealTimers(); }
|
||||
});
|
||||
it("maps fake Pi deltas and tool events centrally", async () => {
|
||||
const context = await setup(); const { runId } = await context.registry.send(context.session.sessionId, { text: "hello", attachmentIds: [] }); context.factory.port.emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { type: "text_delta", delta: "流式" } }); context.factory.input?.toolRequested("tool_1", "mock_ads_metrics", { accountId: "demo-account", days: 7 }); context.factory.port.emit({ type: "tool_execution_start", toolCallId: "tool_1", toolName: "mock_ads_metrics", args: {} }); context.factory.port.emit({ type: "tool_execution_end", toolCallId: "tool_1", toolName: "mock_ads_metrics", result: { ok: true }, isError: false }); context.factory.port.complete(); await vi.waitFor(() => expect(context.session.busy).toBe(false)); const runEvents = context.events.listAfter(context.session.sessionId).events.filter((event) => event.runId === runId); expect(runEvents.map((event) => event.type)).toContain("assistant.delta"); expect(runEvents.filter((event) => event.type === "tool.requested")).toHaveLength(1); expect(runEvents.map((event) => event.type)).toContain("tool.completed");
|
||||
});
|
||||
@@ -95,9 +104,9 @@ describe("session lifecycle", () => {
|
||||
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: "render", toolName: "render_swads_daily_report", isError: false, result: { content: [{ type: "text", text: "/private/SHOULD_NOT_LEAK full report body" }], details: { artifact, markdown: "## 可见日报摘要" } } });
|
||||
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();
|
||||
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, markdown: "## 可见日报摘要" }); 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();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -27,6 +27,12 @@ describe("swads CLI boundary", () => {
|
||||
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("compacts large identity results before they enter model context", async () => {
|
||||
const f = fixture(); const account = { platform_account_binding_id: "binding", tenant_id: "tenant", platform: "tiktok", external_account_id: "account", account_name: "Store", access_profile: "reader", permissions: Array(100).fill("private-permission"), is_current: false, reporting_timezone: "Asia/Kuala_Lumpur", reporting_utc_offset_minutes: 480, account_currency: "MYR" };
|
||||
vi.mocked(f.connection.call).mockResolvedValueOnce({ structuredContent: { access: { user_id: "user", current_account: { ...account, is_current: true }, accounts: Array.from({ length: 57 }, (_, index) => ({ ...account, external_account_id: `account-${index}` })) }, orgs: { current_org_id: "org", organizations: [{ org_id: "org", name: "Org", slug: "org", role: "member" }] } } });
|
||||
const result = await f.tools.cli({ command: "whoami", arguments: {} }, signal()); const serialized = JSON.stringify(result);
|
||||
expect(serialized).not.toContain("private-permission"); expect(Buffer.byteLength(serialized)).toBeLessThan(50 * 1024); expect((result as { access: { accounts: unknown[] } }).access.accounts).toHaveLength(57);
|
||||
});
|
||||
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();
|
||||
@@ -35,8 +41,33 @@ describe("swads CLI boundary", () => {
|
||||
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("renders a deterministic fallback from four collected query kinds", async () => {
|
||||
const f = fixture(); const result = (data: unknown) => ({ structuredContent: data });
|
||||
await f.tools.cli({ command: "capabilities", arguments: {} }, signal());
|
||||
vi.mocked(f.connection.call).mockResolvedValueOnce(result({ access: { current_account: { account_name: "Store", external_account_id: "account", account_currency: "MYR", reporting_timezone: "Asia/Kuala_Lumpur" } }, orgs: {} }));
|
||||
await f.tools.cli({ command: "whoami", arguments: {} }, signal());
|
||||
vi.mocked(f.connection.call).mockResolvedValueOnce(result({ metrics: [{ name: "spend" }] }));
|
||||
await f.tools.cli({ command: "metrics.catalog", arguments: { platform: "tiktok" } }, signal());
|
||||
const query = async (query_kind: string, columns: string[], rows: unknown[][]) => { vi.mocked(f.connection.call).mockResolvedValueOnce(result({ columns: columns.map((name) => ({ name })), rows, row_count: rows.length, account_context: { external_account_id: "account", account_currency: "MYR", reporting_timezone: "Asia/Kuala_Lumpur" } })); await f.tools.cli({ command: "metrics.query", arguments: { platform: "tiktok", query: { query_kind, date_from: "2026-09-06", date_to: "2026-09-06" } } }, signal()); };
|
||||
await query("summary", ["spend", "gross_revenue", "orders", "ctr"], [[10, 20, 2, 0.01]]);
|
||||
await query("timeseries", ["date", "spend", "gross_revenue"], [["2026-09-05", 10, 15], ["2026-09-06", 10, 20]]);
|
||||
await query("campaign_breakdown", ["external_campaign_id", "external_campaign_name", "spend", "gross_revenue", "orders"], [["campaign", "GMV Max", 10, 20, 2]]);
|
||||
await query("creative_breakdown", ["external_creative_id", "external_creative_name", "creative_creator_name", "spend", "gross_revenue", "orders", "product_clicks", "product_ctr"], [["creative", "", "", 10, 20, 2, 3, 0.02]]);
|
||||
const fallback = await f.tools.renderCollected(signal());
|
||||
expect(fallback.markdown).toContain("SW Ads 日报"); expect(f.create).toHaveBeenCalledOnce(); expect(vi.mocked(f.create).mock.calls[0]?.[0]).toMatchObject({ meta: { report_date: "2026-09-06" }, summary: { spend: 10, revenue: 20, roas: 2 }, creative: { source_groups: [{ label: "未知创作者" }], winners: [{ id: "creative", name: "creative", creator: "未知创作者" }] } });
|
||||
});
|
||||
it("accepts an empty entity breakdown when the observed account queries have data", async () => {
|
||||
const f = fixture(); await preflight(f.tools);
|
||||
vi.mocked(f.connection.call).mockResolvedValueOnce({ structuredContent: { columns: [], rows: [], row_count: 0 } });
|
||||
await expect(f.tools.cli({ command: "metrics.query", arguments: { platform: "tiktok", query: { query_kind: "campaign_breakdown" } } }, signal())).resolves.toMatchObject({ rows: [], row_count: 0 });
|
||||
await expect(f.tools.render(sampleReport(), signal())).resolves.toEqual({ ok: true });
|
||||
});
|
||||
it("preserves preflight for queued queries but invalidates render evidence after empty account data", async () => {
|
||||
const f = fixture(); await preflight(f.tools);
|
||||
vi.mocked(f.connection.call).mockResolvedValueOnce({ structuredContent: { columns: [], rows: [] } });
|
||||
await expect(f.tools.cli({ command: "metrics.query", arguments: { platform: "tiktok", query: { query_kind: "summary" } } }, signal())).rejects.toMatchObject({ code: "SWADS_NO_DATA" });
|
||||
await expect(f.tools.cli({ command: "metrics.query", arguments: { platform: "tiktok", query: { metrics: ["spend"] } } }, signal())).resolves.toMatchObject({ rows: [[100]] });
|
||||
await expect(f.tools.render(sampleReport(), signal())).rejects.toMatchObject({ code: "SWADS_PREFLIGHT_REQUIRED" });
|
||||
});
|
||||
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 }); }
|
||||
|
||||
@@ -24,7 +24,7 @@ export const harnessEventSchema = z.discriminatedUnion("type", [
|
||||
event("assistant.completed", z.object({ messageId: 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("report.generated", z.object({ assistantMessageId: z.string(), artifact: reportArtifactSchema, markdown: z.string().max(6_000).optional() }).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()),
|
||||
|
||||
Reference in New Issue
Block a user