Initial Daily Campaign Agent release

This commit is contained in:
joliehill294-hue
2026-09-16 21:00:13 +08:00
commit 14fc94b3f0
86 changed files with 10766 additions and 0 deletions
@@ -0,0 +1,33 @@
import { access, mkdtemp } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import sharp from "sharp";
import { describe, expect, it, vi } from "vitest";
import { MemoryApprovalBroker } from "../src/approval.js";
import { MemoryAttachmentStore } from "../src/attachments.js";
import { MemoryEventStore } from "../src/event-store.js";
import { MemoryModelConnectionStore } from "../src/model-store.js";
import { HarnessSession, MISSING_INPUT_POLICY, type AgentSessionPort } from "../src/session.js";
import { SkillCatalog } from "../src/skills.js";
import { DAILY_REPORT_PROMPT, type AccountProfile } from "@agent-studio/shared";
const projectRoot=path.resolve(import.meta.dirname,"../../..");
async function fixture(supportsImages=false, accountProfile?: AccountProfile) {
const temp=await mkdtemp(path.join(os.tmpdir(),"asm-session-")); const sessionsRoot=path.join(temp,"sessions"); const events=new MemoryEventStore(); const models=new MemoryModelConnectionStore();
await models.configure("s",{providerId:"openai",api:"openai-responses",apiKey:"sk-fake-testing-key",modelId:"gpt-5",supportsImages});
const approvals=new MemoryApprovalBroker(1000); const sessionRef:{current?:HarnessSession}={}; const attachments=new MemoryAttachmentStore(()=>sessionRef.current!.workspace); const skills=await SkillCatalog.create(projectRoot,path.join(temp,"agent"),"");
let resolvePrompt!:()=>void; const prompt=new Promise<void>((resolve)=>{resolvePrompt=resolve}); const port:AgentSessionPort={prompt:vi.fn(()=>prompt),abort:vi.fn(async()=>resolvePrompt()),subscribe:vi.fn(()=>()=>undefined),dispose:vi.fn()};
const session=new HarnessSession({id:"s",projectRoot,sessionsRoot,agentDir:path.join(temp,"agent"),fixturePath:path.join(projectRoot,"fixtures/ads-account.json"),eventStore:events,approvals,models,attachments,skills,getAccountProfile:()=>accountProfile,runTimeoutMs:10000,maxImages:4,fakeFactory:async()=>port}); sessionRef.current=session; await session.init();
return {temp,session,events,attachments,port,resolvePrompt};
}
describe("HarnessSession lifecycle",()=>{
it("defines a global clarification gate for required Skill and template inputs",()=>{ expect(MISSING_INPUT_POLICY).toContain("ask the user a concise, explicit clarification question"); expect(MISSING_INPUT_POLICY).toContain("Do not leave blank fields"); expect(MISSING_INPUT_POLICY).toContain("template"); });
it("rejects concurrent runs, cancels, unsubscribes and disposes",async()=>{ const x=await fixture(); const run=await x.session.start({text:"one",attachmentIds:[]}); expect(run).toMatch(/^run_/); await expect(x.session.start({text:"two",attachmentIds:[]})).rejects.toMatchObject({code:"SESSION_BUSY"}); await x.session.cancel("user"); expect(x.port.abort).toHaveBeenCalled(); await x.session.dispose(); expect(x.port.dispose).toHaveBeenCalled(); await expect(access(path.join(x.temp,"sessions","s"))).rejects.toThrow(); expect(x.events.listAfter("s").some((e)=>e.type==="run.cancelled"&&e.payload.reason==="user")).toBe(true); });
it("maps normalized images to Pi ImageContent and never events base64",async()=>{ const x=await fixture(true); const png=await sharp({create:{width:3,height:2,channels:3,background:"cyan"}}).png().toBuffer(); const upload={filename:"shot.png",mimetype:"image/png",stream:{async *[Symbol.asyncIterator](){yield png}}}; const attachment=await x.attachments.add("s",upload); await x.session.start({text:"看图",attachmentIds:[attachment.attachmentId]}); expect(x.port.prompt).toHaveBeenCalledWith("看图",{images:[expect.objectContaining({type:"image",mimeType:"image/png",data:expect.any(String)})]}); expect(JSON.stringify(x.events.listAfter("s"))).not.toContain(png.toString("base64")); x.resolvePrompt(); await x.session.dispose(); });
it("rejects images for text-only models",async()=>{ const x=await fixture(false); const png=await sharp({create:{width:2,height:2,channels:3,background:"red"}}).png().toBuffer(); const item=await x.attachments.add("s",{filename:"a.png",mimetype:"image/png",stream:{async *[Symbol.asyncIterator](){yield png}}}); await expect(x.session.start({text:"",attachmentIds:[item.attachmentId]})).rejects.toMatchObject({code:"MODEL_DOES_NOT_SUPPORT_IMAGES",status:422}); await x.session.dispose(); });
it("does not start the quick daily-report workflow without a spreadsheet",async()=>{ const x=await fixture(false); await expect(x.session.start({text:DAILY_REPORT_PROMPT,attachmentIds:[]})).rejects.toMatchObject({code:"REPORT_ATTACHMENT_REQUIRED",status:422}); expect(x.port.prompt).not.toHaveBeenCalled(); await x.session.dispose(); });
it("accepts spreadsheets on text-only models and gives the agent a controlled attachment reference",async()=>{ const x=await fixture(false); const csv=Buffer.from("date,ad,spend,installs,d1 retained\n2026-09-15,A,12,3,1\n"); const item=await x.attachments.add("s",{filename:"daily.csv",mimetype:"text/csv",stream:{async *[Symbol.asyncIterator](){yield csv}}}); await x.session.start({text:"分析日报",attachmentIds:[item.attachmentId]}); expect(x.port.prompt).toHaveBeenCalledWith(expect.stringContaining(`attachmentId: ${item.attachmentId}`),undefined); expect(x.port.prompt).toHaveBeenCalledWith(expect.stringContaining("analyze_uploaded_report"),undefined); expect(x.port.prompt).toHaveBeenCalledWith(expect.stringContaining("向用户发起一次清晰、合并后的澄清"),undefined); x.resolvePrompt(); await x.session.dispose(); });
it("requires the Skill-owned Word renderer after spreadsheet analysis",async()=>{ const x=await fixture(false); const csv=Buffer.from("date,ad,spend,installs,d1 retained\n2026-09-15,A,12,3,1\n"); const item=await x.attachments.add("s",{filename:"daily.csv",mimetype:"text/csv",stream:{async *[Symbol.asyncIterator](){yield csv}}}); await x.session.start({text:"分析日报",attachmentIds:[item.attachmentId]}); expect(x.port.prompt).toHaveBeenCalledWith(expect.stringContaining("必须调用 build_daily_brief"),undefined); x.resolvePrompt(); await x.session.dispose(); });
it("keeps the spreadsheet workflow across a clarification reply",async()=>{ const x=await fixture(false); const csv=Buffer.from("date,ad,spend,installs,d1 retained\n2026-09-15,A,12,3,1\n"); const item=await x.attachments.add("s",{filename:"daily.csv",mimetype:"text/csv",stream:{async *[Symbol.asyncIterator](){yield csv}}}); await x.session.start({text:"分析日报",attachmentIds:[item.attachmentId]}); x.resolvePrompt(); await vi.waitFor(()=>expect(x.session.state).toBe("idle")); await x.session.start({text:"上限,账户级",attachmentIds:[]}); expect(x.port.prompt).toHaveBeenLastCalledWith(expect.stringContaining(`attachmentId: ${item.attachmentId}`),undefined); expect(x.port.prompt).toHaveBeenLastCalledWith(expect.stringContaining("上一轮澄清尚未完成"),undefined); expect(x.port.prompt).toHaveBeenLastCalledWith(expect.stringContaining("必须调用 build_daily_brief"),undefined); await x.session.dispose(); });
it("injects the persistent account KPI card and forbids repeat questions",async()=>{ const profile:AccountProfile={accountName:"蚂蚁",analysisDatePolicy:"yesterday",retentionLagDays:1,kpis:[{metric:"CPI",role:"primary",formula:"spend / installs",direction:"lower",target:"≤ 4",scope:"账户级",maturityRule:"当日完整"}],pacing:{enabled:false}}; const x=await fixture(false,profile); const csv=Buffer.from("date,ad,spend,installs,d1 retained\n2026-09-15,A,12,3,1\n"); const item=await x.attachments.add("s",{filename:"daily.csv",mimetype:"text/csv",stream:{async *[Symbol.asyncIterator](){yield csv}}}); await x.session.start({text:"生成日报",attachmentIds:[item.attachmentId]}); expect(x.port.prompt).toHaveBeenCalledWith(expect.stringContaining('"accountName":"蚂蚁"'),undefined); expect(x.port.prompt).toHaveBeenCalledWith(expect.stringContaining("不得再次询问这些字段"),undefined); expect(x.port.prompt).toHaveBeenCalledWith(expect.stringContaining("pacing.enabled=false"),undefined); x.resolvePrompt(); await x.session.dispose(); });
});