feat(agent): 审美分析 - 接入费用确认与阶段任务恢复
This commit is contained in:
1 parent
757d1dd2c7
commit
d0024fbbc6
13 files changed
+432
-27
No files matched your search
@@ -0,0 +1,145 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { ApprovedToolExecution, type ApprovedToolExecutionState } from "../src/services/approved-tool-execution";
|
||||
import { creationRuns, type CreationRun, type CreationSubmission } from "../src/services/api/creation-runs";
|
||||
import { createModelChannel, defaultConfig } from "../src/stores/use-config-store";
|
||||
import type { GenerationTask } from "../src/services/api/task-center";
|
||||
|
||||
const identity = { kind: "art-critique", nodeId: "node", sourceFingerprint: "image", model: "model" };
|
||||
const config = { ...defaultConfig, model: "system::model", channels: [createModelChannel({ id: "system", scope: "system", models: ["model"], interfaceType: "chat-completion" })] };
|
||||
const request = (stage: string) => ({ config, prompt: stage, messages: [{ role: "user" as const, content: "review" }], tools: [], toolChoice: "auto" as const, metadata: { source: "art-critique", stage } });
|
||||
const quote = (id: string, stage: string, taskId?: string): CreationSubmission => ({ id, runId: "run", itemKey: `stage:${stage}`, requestHash: stage, taskId, quote: { model: "model", billingMode: "fixed_request", quantity: 1, amountMicrocredits: 1_000_000, estimated: false, expiresAt: "2099-01-01", quoteHash: id } });
|
||||
|
||||
function harness(submissions: CreationSubmission[] = [], state = identity) {
|
||||
let run: CreationRun = { id: "run", userId: "user", revision: 1, executionEpoch: 0, executionOwner: "", status: "idle", state, createdAt: "", updatedAt: "" };
|
||||
let view: ApprovedToolExecutionState = { busy: false };
|
||||
const listeners: Array<{ matches: (view: ApprovedToolExecutionState) => boolean; resolve: (view: ApprovedToolExecutionState) => void }> = [];
|
||||
const approvals: string[][] = [], executions: string[] = [], waits: string[] = [];
|
||||
let creates = 0, refreshes = 0;
|
||||
const api = { ...creationRuns,
|
||||
create: async () => { creates++; return { run, submissions }; },
|
||||
get: async () => ({ run, submissions }),
|
||||
claim: async (_id: string, input: { owner: string }) => (run = { ...run, executionEpoch: run.executionEpoch + 1, executionOwner: input.owner }),
|
||||
release: async () => ({ released: true }),
|
||||
prepare: async (_id: string, input: { itemKey: string }) => {
|
||||
const existing = submissions.find((item) => item.itemKey === input.itemKey);
|
||||
if (existing) return existing;
|
||||
const created = quote(`quote-${submissions.length}`, input.itemKey.replace("stage:", "")); submissions.push(created); return created;
|
||||
},
|
||||
approve: async (_id: string, input: { submissionIds: string[] }) => {
|
||||
approvals.push(input.submissionIds);
|
||||
submissions = submissions.map((item) => input.submissionIds.includes(item.id) ? { ...item, approvedAt: "2026-01-01" } : item);
|
||||
return { submissions: submissions.filter((item) => input.submissionIds.includes(item.id)) };
|
||||
},
|
||||
refreshQuote: async (_id: string, input: { submissionId: string }) => {
|
||||
refreshes++;
|
||||
const item = submissions.find((entry) => entry.id === input.submissionId)!;
|
||||
item.revokedAt = "2026-01-01";
|
||||
const fresh = { ...item, id: `fresh-${refreshes}`, itemKey: `requote:${item.id}`, revokedAt: undefined, approvedAt: undefined, quote: { ...item.quote, expiresAt: "2099-01-01" } };
|
||||
submissions.push(fresh); return fresh;
|
||||
},
|
||||
execute: async (_id: string, input: { submissionId: string }) => {
|
||||
const item = submissions.find((entry) => entry.id === input.submissionId)!;
|
||||
expect(Boolean(item.taskId || item.approvedAt)).toBe(true);
|
||||
executions.push(input.submissionId);
|
||||
return { id: item.taskId || `task-${item.id}` } as GenerationTask;
|
||||
},
|
||||
} as typeof creationRuns;
|
||||
const controller = new AbortController();
|
||||
const execution = new ApprovedToolExecution({ clientKey: "test", ...(submissions.length ? { runId: "run" } : {}), identity, signal: controller.signal, onRun: () => {}, onState: (next) => {
|
||||
view = next;
|
||||
for (const listener of [...listeners]) if (listener.matches(view)) { listeners.splice(listeners.indexOf(listener), 1); listener.resolve(view); }
|
||||
} }, api, async (id) => { waits.push(id); return { id, status: "succeeded", resultJson: JSON.stringify({ text: "report" }) } as GenerationTask; });
|
||||
return { execution, controller, api, approvals, executions, waits, counters: () => ({ creates, refreshes }), view: () => view,
|
||||
until: (matches: (view: ApprovedToolExecutionState) => boolean) => matches(view) ? Promise.resolve(view) : new Promise<ApprovedToolExecutionState>((resolve) => listeners.push({ matches, resolve })),
|
||||
};
|
||||
}
|
||||
|
||||
test("用户只批准费用卡已展示的项目,后来加入的阶段继续等待", async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
const first = h.execution.execute(request("first"));
|
||||
const shown = await h.until((view) => view.quote?.items.length === 1);
|
||||
const second = h.execution.execute(request("second"));
|
||||
await h.until((view) => view.quote?.items.length === 2);
|
||||
expect(h.executions).toEqual([]);
|
||||
await h.execution.approve(shown.quote!.items.map((item) => item.id));
|
||||
expect((await first).content).toBe("report");
|
||||
expect(h.executions).toHaveLength(1);
|
||||
expect(h.view().quote?.items).toHaveLength(1);
|
||||
await h.execution.approve(h.view().quote!.items.map((item) => item.id)); await second;
|
||||
expect(h.approvals).toEqual([["quote-0"], ["quote-1"]]);
|
||||
} finally { h.execution.dispose(); }
|
||||
});
|
||||
|
||||
test("恢复已提交阶段沿用原任务,即使原报价过期也不新增批准", async () => {
|
||||
const item = quote("existing", "stage", "original-task"); item.quote.expiresAt = "2000-01-01";
|
||||
const h = harness([item]);
|
||||
try {
|
||||
await h.execution.execute(request("stage"));
|
||||
expect(h.waits).toEqual(["original-task"]); expect(h.approvals).toEqual([]);
|
||||
expect(h.counters()).toEqual({ creates: 0, refreshes: 0 });
|
||||
} finally { h.execution.dispose(); }
|
||||
});
|
||||
|
||||
test("等待费用确认时取消,不提交模型;迟到按钮调用不抛未处理异常", async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
const result = h.execution.execute(request("stage")).catch((error: Error) => error);
|
||||
await h.until((view) => Boolean(view.quote)); h.controller.abort();
|
||||
expect((await result as Error).name).toBe("AbortError");
|
||||
await h.execution.approve(["quote-0"]); await h.execution.refresh();
|
||||
expect(h.executions).toEqual([]); expect(h.approvals).toEqual([]);
|
||||
} finally { h.execution.dispose(); }
|
||||
});
|
||||
|
||||
test("已批准但未提交的过期报价必须刷新并重新确认", async () => {
|
||||
const item = { ...quote("expired", "stage"), approvedAt: "2000-01-01" }; item.quote.expiresAt = "2000-01-01";
|
||||
const h = harness([item]);
|
||||
try {
|
||||
const result = h.execution.execute(request("stage"));
|
||||
await h.until((view) => Boolean(view.quote));
|
||||
expect(h.executions).toEqual([]); expect(h.counters().refreshes).toBe(1);
|
||||
await h.execution.approve(["expired"]); expect(h.executions).toEqual([]);
|
||||
await h.execution.approve(h.view().quote!.items.map((entry) => entry.id)); await result;
|
||||
expect(h.executions).toEqual(["fresh-1"]);
|
||||
} finally { h.execution.dispose(); }
|
||||
});
|
||||
|
||||
test("刷新后的报价可重新打开恢复,找回未撤销的替代条目", async () => {
|
||||
const old = { ...quote("old", "stage"), revokedAt: "2026-01-01" };
|
||||
const fresh = { ...quote("new", "stage", "original-task"), itemKey: "requote:old" };
|
||||
const h = harness([old, fresh]);
|
||||
try { await h.execution.execute(request("stage")); expect(h.executions).toEqual(["new"]); expect(h.waits).toEqual(["original-task"]); }
|
||||
finally { h.execution.dispose(); }
|
||||
});
|
||||
|
||||
test("图片或模型身份改变时拒绝恢复", async () => {
|
||||
const h = harness([quote("old", "stage")], { ...identity, model: "different" });
|
||||
try { await expect(h.execution.execute(request("stage"))).rejects.toThrow("已变化"); expect(h.executions).toEqual([]); }
|
||||
finally { h.execution.dispose(); }
|
||||
});
|
||||
|
||||
test("并行阶段报价失败会停止其他等待中的阶段,不永久挂起", async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
const first = h.execution.execute(request("first")).catch((error: Error) => error);
|
||||
await h.until((view) => Boolean(view.quote));
|
||||
h.api.prepare = async () => { throw new Error("报价服务不可用"); };
|
||||
await expect(h.execution.execute(request("second"))).rejects.toThrow("报价服务不可用");
|
||||
expect((await first as Error).name).toBe("AbortError"); expect(h.executions).toEqual([]);
|
||||
} finally { h.execution.dispose(); }
|
||||
});
|
||||
|
||||
test("刷新费用卡后旧按钮不能批准新报价", async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
const result = h.execution.execute(request("analyze_art_scene"));
|
||||
const shown = await h.until((view) => Boolean(view.quote));
|
||||
await h.execution.refresh();
|
||||
expect(h.view().quote?.items[0]?.label).toBe("理解画面");
|
||||
await h.execution.approve(shown.quote!.items.map((item) => item.id));
|
||||
expect(h.executions).toEqual([]); expect(h.view().error).toContain("报价已变化");
|
||||
await h.execution.approve(h.view().quote!.items.map((item) => item.id)); await result;
|
||||
expect(h.approvals).toEqual([["fresh-1"]]);
|
||||
} finally { h.execution.dispose(); }
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { executeArtCritique } from "../src/services/art-critique-execution";
|
||||
import { defaultConfig } from "../src/stores/use-config-store";
|
||||
import { createCanvasNode } from "../src/lib/canvas/canvas-project-domain";
|
||||
import { CanvasNodeType } from "../src/types/canvas";
|
||||
import type { ArtCritiquePipelineOptions } from "../src/lib/art-critique/pipeline";
|
||||
import { runArtCritiquePipeline, type ArtCritiquePipelineOptions } from "../src/lib/art-critique/pipeline";
|
||||
|
||||
const source = createCanvasNode(CanvasNodeType.Image, { x: 0, y: 0 }, { storageKey: "resource:original" });
|
||||
const options = () => ({ nodeId: "analysis", runId: "run", source, config: defaultConfig, signal: new AbortController().signal, onTaskCreated: (_stage: string, _id: string) => {} });
|
||||
@@ -44,3 +44,12 @@ test("准备素材期间取消不会提交后台模型任务", async () => {
|
||||
})).rejects.toThrow();
|
||||
expect(submitted).toBe(false);
|
||||
});
|
||||
|
||||
test("审批执行器中止不能被管线降级为已完成报告", async () => {
|
||||
await expect(runArtCritiquePipeline({ ...defaultConfig, textModel: "model" }, { dataUrl: "resource:image", title: "image", sourceFingerprint: "image" }, {
|
||||
requestStage: async ({ toolName }) => {
|
||||
if (toolName === "analyze_art_scene") throw new DOMException("报价服务不可用", "AbortError");
|
||||
return { content: "", toolCalls: [{ id: toolName, type: "function", function: { name: toolName, arguments: JSON.stringify({ candidates: [] }) } }] };
|
||||
},
|
||||
})).rejects.toThrow("报价服务不可用");
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import type { GenerationTask } from "../src/services/api/task-center";
|
||||
import { CanvasNodeType } from "../src/types/canvas";
|
||||
|
||||
function harness(state: CreativeAgentState, status: CreationRun["status"] = "paused", submissions: CreationSubmission[] = [], waitTask?: ConstructorParameters<typeof CreativeAgentController>[0]["waitTask"]) {
|
||||
let run: CreationRun = { id: "run", userId: "user", canvasId: "canvas", revision: 1, executionEpoch: 0, executionOwner: "", status, state: structuredClone(state) as unknown as Record<string, unknown>, approvedProposalVersion: state.proposal?.version, createdAt: "", updatedAt: "" };
|
||||
let run: CreationRun = { id: "run", userId: "user", canvasId: "canvas", revision: 1, executionEpoch: 0, executionOwner: "", status, state: structuredClone(state) as unknown as Record<string, unknown>, approvedProposalVersion: state.proposal?.version, approvedProposalHash: state.operations ? "approved-proposal-hash" : undefined, createdAt: "", updatedAt: "" };
|
||||
let snapshot: CanvasAgentSnapshot = { projectId: "canvas", title: "canvas", nodes: state.media.map((media) => ({ id: media.nodeId, type: CanvasNodeType.Image, title: media.ref, position: { x: 0, y: 0 }, width: 100, height: 100, metadata: {} })), connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
|
||||
let view: CreativeControllerView | undefined;
|
||||
let commits = 0, prepares = 0, executions = 0;
|
||||
@@ -68,6 +68,11 @@ describe("创作控制器恢复", () => {
|
||||
const h = harness({ ...initialCreativeState(), planning: { itemKey: "planning:key", protocol: [], model: "model" } }, "running", [submission("existing", "planning:key")]);
|
||||
try { await h.controller.load("run"); await h.controller.resume(); expect(h.view().state.planning?.submissionId).toBe("existing"); expect(h.view().quote).toBeDefined(); expect(h.counters().prepares).toBe(0); } finally { h.controller.dispose(); }
|
||||
});
|
||||
test("未批准的方案恢复时仍等待用户确认,不创建画布节点", async () => {
|
||||
const h = harness({ ...initialCreativeState(), proposal, canvasApplied: false });
|
||||
try { await h.controller.load("run"); await h.controller.resume(); expect(h.view().run?.status).toBe("waiting_proposal"); expect(h.snapshot().nodes).toHaveLength(0); expect(h.counters()).toEqual({ commits: 0, prepares: 0, executions: 0 }); }
|
||||
finally { h.controller.dispose(); }
|
||||
});
|
||||
test("第一项失败不阻止同批成功结果回写", async () => {
|
||||
const media = ["a", "b"].map((ref) => ({ ref, nodeId: ref, attempt: 1, submissionId: ref, taskId: ref, status: "queued" as const }));
|
||||
const h = harness({ ...initialCreativeState(), proposal, canvasApplied: true, media }, "paused", [submission("a", "a", "a"), submission("b", "b", "b")], async (id) => { if (id === "a") throw new Error("A生成失败"); return { id, status: "succeeded", resultJson: JSON.stringify({ images: [{ storageKey: "resource:output", dataUrl: "/api/resources/output/file" }] }) } as GenerationTask; });
|
||||
|
||||
Reference in new issue
Block a user