Files
writing-agent/tests/phase-machine.test.ts
T
chenmoranzhiandCursor f37388761b Keep creation as a living DAG and archive playable products separately.
Opening accept no longer locks the graph; session.json stays creation-only while play starts from an instance snapshot. Sidebar products nest run saves, and play reading fills the feed at a denser type size.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 00:52:05 +08:00

703 lines
24 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { createDecision } from "../src/runtime/orchestrator.js";
import {
applyEvent,
createArtifact,
createSession,
} from "../src/runtime/phase-machine.js";
import { loadSkill } from "../src/skills/loader.js";
import { toActiveSkillSnapshot } from "../src/skills/snapshot.js";
const mockSkills = [
{ name: "world-simulator", description: "默认", category: "dialogue" },
];
describe("phase machine", () => {
it("runs design-core approve loop on agent-first pack", async () => {
const snap = toActiveSkillSnapshot(await loadSkill("world-simulator"));
let session = applyEvent(createSession("default"), {
type: "session_started",
payload: {
presetId: "default",
availableSkills: mockSkills,
initialSkill: snap,
},
}).session;
session = applyEvent(session, {
type: "user_submitted_input",
payload: { text: "1v1 网恋对话" },
}).session;
expect(session.phase).toBe("running");
const decision = createDecision({
action: "run_worker",
reason: "产出 Worker 集",
workerId: "design-core",
requiresApproval: true,
});
session = applyEvent(session, {
type: "main_agent_decision_created",
payload: { decision },
}).session;
expect(session.waitingReason?.kind).toBe("approve_step");
session = applyEvent(session, {
type: "user_approved_next_step",
payload: { decisionId: decision.id },
}).session;
session = applyEvent(session, {
type: "worker_started",
payload: { workerId: "design-core", acceptanceMode: "user_confirmed" },
}).session;
const artifact = createArtifact({
workerId: "design-core",
outputTags: ["设计.worker集"],
});
session = { ...session, artifacts: [artifact] };
session = applyEvent(session, {
type: "worker_completed",
payload: { artifactId: artifact.id },
}).session;
expect(session.waitingReason?.kind).toBe("review_artifact");
session = applyEvent(session, {
type: "user_accepted_artifact",
payload: { artifactId: artifact.id },
}).session;
expect(session.slots.designInstanceReady).toBe(true);
expect(session.phase).toBe("running");
});
it("hangs optional questions under review_artifact without blocking accept", () => {
let session = createSession("default");
session = {
...session,
phase: "running" as const,
acceptanceMode: "user_confirmed" as const,
};
const artifact = createArtifact({
workerId: "design-core",
outputTags: ["设计.worker集.草稿"],
});
session = { ...session, artifacts: [artifact] };
const completed = applyEvent(session, {
type: "worker_completed",
payload: {
artifactId: artifact.id,
questions: [
{
id: "q1",
prompt: "更偏哪种节奏?",
options: [{ id: "A", label: "慢热拉扯" }],
},
],
},
});
session = completed.session;
expect(session.waitingReason?.kind).toBe("review_artifact");
if (session.waitingReason?.kind !== "review_artifact") return;
expect(session.waitingReason.questions?.length).toBe(1);
expect(session.waitingReason.questions?.[0]?.required).toBe(false);
// 追问只挂 waitingReason,不另发「可选追问」消息(与产物同面展示)
expect(
completed.effects.some(
(e) =>
e.type === "emit_message" &&
typeof e.message === "string" &&
/可选追问/.test(e.message),
),
).toBe(false);
// 不答追问,直接接受产物
session = applyEvent(session, {
type: "user_accepted_artifact",
payload: { artifactId: artifact.id },
}).session;
expect(session.phase).toBe("running");
});
it("sidecar skip clears questions but stays in review", () => {
let session = createSession("default");
session = {
...session,
phase: "running" as const,
acceptanceMode: "user_confirmed" as const,
};
const artifact = createArtifact({
workerId: "design-core",
outputTags: ["设计.worker集.草稿"],
});
session = { ...session, artifacts: [artifact] };
session = applyEvent(session, {
type: "worker_completed",
payload: {
artifactId: artifact.id,
questions: ["还想补一点感官细节吗?"],
},
}).session;
session = applyEvent(session, {
type: "user_resolved_sidecar_questions",
payload: {},
}).session;
expect(session.waitingReason?.kind).toBe("review_artifact");
if (session.waitingReason?.kind !== "review_artifact") return;
expect(session.waitingReason.questions).toBeUndefined();
expect(session.pendingArtifactId).toBe(artifact.id);
});
it("accepting design-core with only draft does not set designInstanceReady", () => {
let session = createSession();
const artifact = createArtifact({
workerId: "design-core",
outputTags: ["设计.worker集.草稿", "创作.当前单位"],
});
session = {
...session,
artifacts: [artifact],
pendingArtifactId: artifact.id,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
};
session = applyEvent(session, {
type: "user_accepted_artifact",
payload: { artifactId: artifact.id },
}).session;
expect(session.slots.designInstanceReady).toBeUndefined();
});
it("rejecting artifact with feedback immediately reruns the same worker", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "design-step",
stepId: "step-aesthetics",
outputTags: ["设计.美学纲领"],
});
session = {
...session,
artifacts: [artifact],
pendingArtifactId: artifact.id,
currentStepId: "step-aesthetics",
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
slots: {
"用户.worker答复": "【追问作答】\n问:偏好?\n答:偏冷",
},
};
const result = applyEvent(session, {
type: "user_rejected_artifact",
payload: { artifactId: artifact.id, reason: "语气再冷一点" },
});
expect(result.session.phase).toBe("running");
expect(result.session.waitingReason).toBeUndefined();
expect(result.session.slots["用户.修订说明"]).toBe("语气再冷一点");
// 追问答复不得被修订说明冲掉
expect(result.session.slots["用户.worker答复"]).toContain("偏冷");
expect(result.session.artifacts[0]?.status).toBe("revision_requested");
// 只有这一份底稿可在重跑写不出新版时退回
expect(result.session.slots.revisionTargetArtifactId).toBe(artifact.id);
expect(result.session.currentWorkerId).toBe("design-step");
expect(result.session.currentStepId).toBe("step-aesthetics");
expect(result.effects).toEqual([
{ type: "run_worker", workerId: "design-step" },
]);
});
it("later revision notes accumulate instead of replacing earlier ones", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "design-step",
outputTags: ["设计.正文组成"],
});
session = {
...session,
artifacts: [artifact],
pendingArtifactId: artifact.id,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
};
session = applyEvent(session, {
type: "user_rejected_artifact",
payload: { artifactId: artifact.id, reason: "不如原本的上中下样式" },
}).session;
session = {
...session,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
pendingArtifactId: artifact.id,
};
const second = applyEvent(session, {
type: "user_rejected_artifact",
payload: { artifactId: artifact.id, reason: "状态很短,放到上面就行了" },
});
expect(second.session.slots["用户.修订说明"]).toContain("不如原本的上中下样式");
expect(second.session.slots["用户.修订说明"]).toContain("状态很短,放到上面就行了");
});
it("accepting a design-step artifact clears stale revision notes", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "design-step",
outputTags: ["设计.监控栏"],
});
session = {
...session,
artifacts: [artifact],
pendingArtifactId: artifact.id,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
slots: {
"用户.修订说明": "上一步的意见",
revisionInstruction: "上一步的意见",
"用户.worker答复": "监控对象是剩余食物",
},
};
session = applyEvent(session, {
type: "user_accepted_artifact",
payload: { artifactId: artifact.id },
}).session;
expect(session.slots["用户.修订说明"]).toBeUndefined();
expect(session.slots.revisionInstruction).toBeUndefined();
expect(session.slots["用户.worker答复"]).toBeUndefined();
expect(session.slots["用户.需求"]).toContain("监控对象是剩余食物");
expect(session.slots["用户.需求"]).toContain("上一步的意见");
});
it("failed revision rerun falls back to reviewing the previous artifact", () => {
let session = createSession("default");
const artifact = {
...createArtifact({
workerId: "design-step",
stepId: "step-aesthetics",
outputTags: ["设计.美学纲领"],
}),
status: "revision_requested" as const,
};
session = {
...session,
artifacts: [artifact],
currentWorkerId: "design-step",
currentStepId: "step-aesthetics",
phase: "running",
slots: { revisionTargetArtifactId: artifact.id },
};
const result = applyEvent(session, {
type: "worker_revision_produced_nothing",
payload: {
artifactId: artifact.id,
questions: ["这一步还没写出可验收的产物。请再补一点你最在意的体验。"],
},
});
expect(result.session.phase).toBe("waiting_user");
expect(result.session.waitingReason?.kind).toBe("review_artifact");
expect(result.session.pendingArtifactId).toBe(artifact.id);
expect(result.session.artifacts[0]?.status).toBe("under_review");
// 重试题降级为可选追问,用户仍可直接接受上一版
const reason = result.session.waitingReason;
expect(reason?.kind === "review_artifact" && reason.questions?.[0]?.required).toBe(
false,
);
// 退回后不再是重跑目标,避免下一步无产物时又被挂回来
expect(result.session.slots.revisionTargetArtifactId).toBeUndefined();
});
it("rejecting artifact without feedback waits for revision instruction", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "design-step",
outputTags: ["设计.美学纲领"],
});
session = {
...session,
artifacts: [artifact],
pendingArtifactId: artifact.id,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
};
const rejected = applyEvent(session, {
type: "user_rejected_artifact",
payload: { artifactId: artifact.id },
});
expect(rejected.session.waitingReason?.kind).toBe("revision");
expect(rejected.session.artifacts[0]?.status).toBe("rejected");
const resumed = applyEvent(rejected.session, {
type: "user_submitted_input",
payload: { text: "加一点冲突感" },
});
expect(resumed.session.phase).toBe("running");
expect(resumed.session.slots["用户.修订说明"]).toBe("加一点冲突感");
expect(resumed.effects).toEqual([
{ type: "run_worker", workerId: "design-step" },
]);
});
it("accepting design-step worker set proposes next step instead of invoking main agent", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "design-step",
stepId: "细化终稿",
outputTags: ["设计.worker集"],
});
session = {
...session,
artifacts: [artifact],
pendingArtifactId: artifact.id,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
};
const accepted = applyEvent(session, {
type: "user_accepted_artifact",
payload: { artifactId: artifact.id },
});
expect(accepted.session.slots.designInstanceReady).toBe(true);
expect(accepted.session.phase).toBe("running");
expect(accepted.session.waitingReason).toBeUndefined();
expect(accepted.effects).toEqual([{ type: "propose_next_creation_step" }]);
expect(accepted.effects.some((e) => e.type === "invoke_main_agent")).toBe(
false,
);
});
it("accepting design-step proposes next step instead of invoking main agent", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "design-step",
stepId: "美学纲领与交互范式",
outputTags: ["设计.美学纲领与交互范式"],
});
session = {
...session,
artifacts: [artifact],
pendingArtifactId: artifact.id,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
};
const accepted = applyEvent(session, {
type: "user_accepted_artifact",
payload: { artifactId: artifact.id },
});
expect(accepted.session.phase).toBe("running");
expect(accepted.effects).toEqual([{ type: "propose_next_creation_step" }]);
expect(accepted.effects.some((e) => e.type === "invoke_main_agent")).toBe(
false,
);
});
it("legacy next_intent still proposes next step and records intent", () => {
let session = createSession("default");
session = {
...session,
phase: "waiting_user",
waitingReason: { kind: "next_intent", afterWorkerId: "design-step" },
};
const continued = applyEvent(session, {
type: "user_submitted_input",
payload: { text: "接下来写怪物生成规则" },
});
expect(continued.session.phase).toBe("running");
expect(continued.session.slots["用户.下一步意向"]).toBe(
"接下来写怪物生成规则",
);
expect(continued.effects).toEqual([
{ type: "propose_next_creation_step" },
]);
});
it("accepting opening-setup materializes opening then returns to the graph", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "design-step",
stepId: "开场白与开场变量",
outputTags: ["设计.开场白与开场变量"],
});
session = {
...session,
artifacts: [artifact],
pendingArtifactId: artifact.id,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
};
const accepted = applyEvent(session, {
type: "user_accepted_artifact",
payload: { artifactId: artifact.id },
});
expect(accepted.session.phase).toBe("running");
expect(accepted.effects).toEqual([
{ type: "seal_creation_opening" },
{ type: "propose_next_creation_step" },
]);
expect(
accepted.effects.some((e) => e.type === "invoke_main_agent"),
).toBe(false);
});
it("accepting opening-generator also materializes opening then returns to the graph", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "opening-generator",
outputTags: ["输出.开场白", "运行.初始变量"],
});
session = {
...session,
artifacts: [artifact],
pendingArtifactId: artifact.id,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
};
const accepted = applyEvent(session, {
type: "user_accepted_artifact",
payload: { artifactId: artifact.id },
});
expect(accepted.session.phase).toBe("running");
expect(accepted.effects).toEqual([
{ type: "seal_creation_opening" },
{ type: "propose_next_creation_step" },
]);
});
it("sealed creation still lets the user keep designing", () => {
let session = createSession("default");
session = {
...session,
phase: "waiting_user",
waitingReason: { kind: "input" },
slots: { creationSealedByOpening: true, designInstanceReady: true },
};
const continued = applyEvent(session, {
type: "user_submitted_input",
payload: { text: "再补一个 NPC" },
});
expect(continued.session.phase).toBe("running");
expect(continued.effects).toEqual([{ type: "invoke_main_agent" }]);
expect(continued.effects.some((e) => e.type === "run_play_turn")).toBe(false);
});
it("play input starts a play turn even after opening seal", () => {
let session = createSession("default");
session = {
...session,
phase: "waiting_user",
waitingReason: { kind: "input" },
slots: {
creationSealedByOpening: true,
designInstanceReady: true,
uiLifecycleStage: "play",
},
};
const play = applyEvent(session, {
type: "user_submitted_input",
payload: { text: "我买金首饰" },
});
expect(play.session.phase).toBe("running");
expect(play.effects).toEqual([{ type: "run_play_turn" }]);
});
it("accepting a play worker continues the play pipeline", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "narrator",
outputTags: ["输出.用户展示"],
});
session = {
...session,
artifacts: [artifact],
pendingArtifactId: artifact.id,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
slots: {
designInstanceReady: true,
uiLifecycleStage: "play",
playLayerActive: true,
},
};
const accepted = applyEvent(session, {
type: "user_accepted_artifact",
payload: { artifactId: artifact.id },
});
expect(accepted.effects).toEqual([{ type: "continue_play_turn" }]);
expect(accepted.effects.some((e) => e.type === "invoke_main_agent")).toBe(
false,
);
expect(accepted.effects.some((e) => e.type === "propose_next_creation_step")).toBe(
false,
);
});
it("rejecting the next step goes back to design-flow instead of the main agent", () => {
let session = createSession("default");
const decision = {
id: "d1",
action: "run_worker" as const,
reason: "下一步开场白",
workerId: "design-step",
requiresApproval: true,
statePatchAllowed: false as const,
};
session = {
...session,
phase: "waiting_user",
waitingReason: { kind: "approve_step", decisionId: decision.id },
pendingDecision: decision,
};
const rejected = applyEvent(session, {
type: "user_rejected_next_step",
payload: { decisionId: decision.id, reason: "还没做 NPC" },
});
expect(rejected.session.phase).toBe("running");
expect(rejected.session.slots["用户.下一步意向"]).toBe("还没做 NPC");
expect(rejected.session.pendingDecision).toBeUndefined();
expect(rejected.effects).toEqual([
{ type: "unseal_creation_opening" },
{ type: "run_worker", workerId: "design-flow" },
]);
expect(rejected.effects.some((e) => e.type === "invoke_main_agent")).toBe(
false,
);
});
it("replan from opening review runs design-flow without revising the opening", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "design-step",
stepId: "开场白与开场变量",
outputTags: ["设计.开场白与开场变量"],
});
session = {
...session,
artifacts: [artifact],
pendingArtifactId: artifact.id,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
slots: { creationSealedByOpening: true },
};
const replanned = applyEvent(session, {
type: "user_requested_flow_replan",
payload: { reason: "漏了开局 NPC" },
});
expect(replanned.session.phase).toBe("running");
expect(replanned.session.pendingArtifactId).toBeUndefined();
expect(replanned.session.slots.creationSealedByOpening).toBeUndefined();
expect(replanned.session.artifacts[0]?.status).toBe("rejected");
expect(replanned.session.slots["用户.修订说明"]).toBe("漏了开局 NPC");
expect(replanned.effects).toEqual([
{ type: "unseal_creation_opening" },
{ type: "run_worker", workerId: "design-flow" },
]);
});
it("awaits pick on the layered graph and starts the clicked step", () => {
let session = createSession("default");
session = {
...session,
phase: "running",
};
const awaited = applyEvent(session, {
type: "creation_step_pick_awaited",
payload: {},
});
expect(awaited.session.phase).toBe("waiting_user");
expect(awaited.session.waitingReason?.kind).toBe("pick_creation_step");
expect(awaited.session.currentWorkerId).toBeUndefined();
const picked = applyEvent(awaited.session, {
type: "user_picked_creation_step",
payload: { stepId: "生成规则" },
});
expect(picked.session.phase).toBe("running");
expect(picked.session.currentWorkerId).toBe("design-step");
expect(picked.effects).toEqual([
{ type: "run_worker", workerId: "design-step" },
]);
});
it("leaving a creation step returns to the pick graph", () => {
let session = createSession("default");
session = {
...session,
phase: "waiting_user",
waitingReason: {
kind: "worker_questions",
workerId: "design-step",
questions: [{ id: "module-opening", prompt: "引导" }],
},
currentWorkerId: "design-step",
currentStepId: "生成规则#1",
};
const left = applyEvent(session, {
type: "user_left_creation_step",
payload: {},
});
expect(left.session.phase).toBe("waiting_user");
expect(left.session.waitingReason?.kind).toBe("pick_creation_step");
expect(left.session.currentWorkerId).toBeUndefined();
expect(left.session.currentStepId).toBeUndefined();
expect(left.session.resumeContext).toBeUndefined();
expect(left.effects).toEqual([]);
});
it("replan from the pick graph runs design-flow", () => {
let session = createSession("default");
session = {
...session,
phase: "waiting_user",
waitingReason: { kind: "pick_creation_step" },
};
const replanned = applyEvent(session, {
type: "user_requested_flow_replan",
payload: { reason: "补舞台骨架" },
});
expect(replanned.session.phase).toBe("running");
expect(replanned.session.slots["用户.下一步意向"]).toBe("补舞台骨架");
expect(replanned.effects).toEqual([
{ type: "unseal_creation_opening" },
{ type: "run_worker", workerId: "design-flow" },
]);
});
it("design-flow without confirmation proposes the pick graph", () => {
let session = createSession("default");
session = {
...session,
phase: "running",
acceptanceMode: "no_confirmation",
};
const artifact = createArtifact({
workerId: "design-flow",
outputTags: ["设计.创作流程"],
});
session = { ...session, artifacts: [artifact] };
const completed = applyEvent(session, {
type: "worker_completed",
payload: { artifactId: artifact.id },
});
expect(completed.session.artifacts[0]?.status).toBe("accepted");
expect(completed.session.pendingArtifactId).toBeUndefined();
expect(completed.effects).toEqual([
{ type: "propose_next_creation_step" },
]);
});
});