import { beforeEach, describe, expect, it, vi } from "vitest"; const mockState = vi.hoisted(() => ({ db: null as TestDb | null, ensureSchemaCalls: 0, settings: new Map(), })); vi.mock("../../db", () => ({ ensureDigitalEmployeeSchema: () => { mockState.ensureSchemaCalls += 1; return true; }, getDb: () => mockState.db, readSetting: (key: string) => mockState.settings.get(key) ?? null, writeSetting: (key: string, value: unknown) => mockState.settings.set(key, value), })); describe("digital employee state service", () => { beforeEach(() => { vi.resetModules(); vi.setSystemTime(new Date("2026-06-07T08:00:00.000Z")); mockState.db = new TestDb(); mockState.ensureSchemaCalls = 0; mockState.settings = new Map(); }); it("extracts artifacts and approvals from runtime event payload into syncable records", async () => { const { recordDigitalEmployeeRuntimeEvent } = await import("./stateService"); recordDigitalEmployeeRuntimeEvent({ kind: "tool_result", request_id: "req-1", message: "生成交付产物并等待确认", status: "completed", payload: { artifacts: [ { id: "summary-csv", name: "summary.csv", uri: "file:///tmp/summary.csv", }, ], outputPath: "/tmp/final-report.txt", approval_requests: [ { id: "approval-1", title: "确认发送日报给管理端", }, ], }, }); expect(Array.from(mockState.db?.artifacts.values() ?? [])).toEqual([ expect.objectContaining({ id: "computer-chat-run-req-1:artifact:summary-csv", name: "summary.csv", uri: "file:///tmp/summary.csv", }), expect.objectContaining({ id: "computer-chat-run-req-1:artifact:tmp-final-report-txt", name: "final-report.txt", uri: "/tmp/final-report.txt", }), ]); expect(Array.from(mockState.db?.approvals.values() ?? [])).toEqual([ expect.objectContaining({ id: "computer-chat-run-req-1:approval:approval-1", title: "确认发送日报给管理端", status: "pending", }), ]); expect(mockState.db?.outbox.get("artifact:upsert:computer-chat-run-req-1:artifact:summary-csv")).toMatchObject({ entity_type: "artifact", entity_id: "computer-chat-run-req-1:artifact:summary-csv", status: "pending", }); expect(mockState.db?.outbox.get("approval:upsert:computer-chat-run-req-1:approval:approval-1")).toMatchObject({ entity_type: "approval", entity_id: "computer-chat-run-req-1:approval:approval-1", status: "pending", }); expect(mockState.ensureSchemaCalls).toBeGreaterThan(0); }); it("updates formal approval records when UI approval decisions are saved", async () => { const { saveDigitalEmployeeUiState } = await import("./stateService"); mockState.db?.approvals.set("approval-1", { id: "approval-1", plan_id: "plan-1", task_id: "task-1", run_id: "run-1", title: "确认发送日报给管理端", status: "pending", payload: JSON.stringify({ source: "test" }), sync_status: "synced", created_at: "2026-06-07T07:00:00.000Z", updated_at: "2026-06-07T07:00:00.000Z", }); saveDigitalEmployeeUiState({ action: "decide_approval", date: "2026-06-07", metadata: { decision_id: "approval-1", decision: "approved", acp_permission: { permission_id: "permission-1", session_id: "session-1", response: "once", success: true, }, }, state: { selectedDutyIdsByDate: {}, confirmedDates: {}, reportScheduleTime: "18:00", reportGeneratedAtByDate: {}, decisionResultsByDate: { "2026-06-07": { "approval-1": "approved", }, }, profile: { name: "飞天数字员工", company: "qimingclaw 客户端", position: "客户端任务执行员", currentStatus: "working", }, }, }); expect(mockState.db?.approvals.get("approval-1")).toMatchObject({ id: "approval-1", status: "approved", sync_status: "pending", updated_at: "2026-06-07T08:00:00.000Z", }); expect(JSON.parse(mockState.db?.approvals.get("approval-1")?.payload ?? "{}")).toMatchObject({ source: "test", decision: "approved", decidedAt: "2026-06-07T08:00:00.000Z", previousStatus: "pending", acpPermission: { permission_id: "permission-1", session_id: "session-1", response: "once", success: true, }, }); expect(mockState.db?.outbox.get("approval:upsert:approval-1")).toMatchObject({ entity_type: "approval", entity_id: "approval-1", status: "pending", }); expect(mockState.ensureSchemaCalls).toBeGreaterThan(0); }); it("persists console role and role preferences in UI state", async () => { const { readDigitalEmployeeUiState, saveDigitalEmployeeUiState } = await import("./stateService"); saveDigitalEmployeeUiState({ action: "update_role_preferences", metadata: { source: "ops-settings" }, state: { selectedDutyIdsByDate: {}, confirmedDates: {}, reportScheduleTime: "17:30", reportGeneratedAtByDate: {}, decisionResultsByDate: {}, consoleRole: "engineering", rolePreferences: { workStart: "10:00", workEnd: "19:00", notification: "system", }, profile: { name: "飞天数字员工", company: "qimingclaw 客户端", position: "客户端任务执行员", currentStatus: "working", }, }, }); expect(readDigitalEmployeeUiState()).toMatchObject({ reportScheduleTime: "17:30", consoleRole: "engineering", rolePreferences: { workStart: "10:00", workEnd: "19:00", notification: "system", }, }); const preferenceEvent = Array.from(mockState.db?.events.values() ?? []) .find((event) => event.kind === "digital_workday_update_role_preferences"); expect(preferenceEvent).toMatchObject({ kind: "digital_workday_update_role_preferences", message: "数字员工运营偏好已更新", }); }); it("records governance commands as formal runtime records", async () => { const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService"); const ids = recordDigitalEmployeeGovernanceCommand({ commandKind: "create_plan", commandId: "command-1", correlationId: "corr-1", accepted: true, message: "已创建计划草稿", payload: { title: "生成今日客户跟进计划", objective: "把今日客户跟进事项整理成可执行计划", steps: [ { step_id: "collect-crm", title: "整理客户列表", skills: ["qimingclaw-mcp-tool-crm-crm-search"], tasks: [ { task_id: "task-collect-crm", title: "拉取 CRM 客户记录", description: "读取今日需要跟进的客户", }, ], }, ], }, result: { plan_id: "plan-customer-followup", status: "draft", }, }); expect(ids).toEqual({ planId: "plan-customer-followup", taskId: "governance-task-command-1", runId: "governance-run-command-1", }); expect(mockState.db?.plans.get("plan-customer-followup")).toMatchObject({ id: "plan-customer-followup", title: "生成今日客户跟进计划", status: "draft", sync_status: "pending", }); expect(mockState.db?.tasks.get("governance-task-command-1")).toMatchObject({ plan_id: "plan-customer-followup", title: "创建数字员工计划", assigned_skill_id: "qimingclaw-governance-command", }); expect(mockState.db?.tasks.get("task-collect-crm")).toMatchObject({ plan_id: "plan-customer-followup", title: "拉取 CRM 客户记录", status: "pending", assigned_skill_id: "qimingclaw-mcp-tool-crm-crm-search", }); expect(mockState.db?.planSteps.get("collect-crm")).toMatchObject({ plan_id: "plan-customer-followup", title: "整理客户列表", status: "pending", seq: 1, preferred_skill_ids: JSON.stringify(["qimingclaw-mcp-tool-crm-crm-search"]), }); expect(JSON.parse(mockState.db?.tasks.get("task-collect-crm")?.payload ?? "{}")).toMatchObject({ stepId: "collect-crm", }); expect(mockState.db?.runs.get("governance-run-command-1")).toMatchObject({ plan_id: "plan-customer-followup", task_id: "governance-task-command-1", status: "draft", finished_at: "2026-06-07T08:00:00.000Z", }); expect(mockState.db?.events.get("governance-run-command-1:create_plan:command-1")).toMatchObject({ kind: "governance_create_plan", message: "已创建计划草稿", }); expect(mockState.db?.outbox.get("plan:upsert:plan-customer-followup")).toMatchObject({ entity_type: "plan", entity_id: "plan-customer-followup", status: "pending", }); expect(mockState.db?.outbox.get("plan_step:upsert:collect-crm")).toMatchObject({ entity_type: "plan_step", entity_id: "collect-crm", status: "pending", }); expect(mockState.ensureSchemaCalls).toBeGreaterThan(0); }); it("creates a default plan step for top-level plan tasks", async () => { const { readDigitalEmployeeRuntimeRecords, recordDigitalEmployeeGovernanceCommand, } = await import("./stateService"); recordDigitalEmployeeGovernanceCommand({ commandKind: "create_plan", commandId: "command-default-step", accepted: true, payload: { title: "客户回访计划", tasks: [{ task_id: "task-call", title: "拨打客户电话" }], }, result: { plan_id: "plan-default-step", status: "draft" }, }); expect(mockState.db?.planSteps.get("plan-default-step:step:default")).toMatchObject({ plan_id: "plan-default-step", title: "默认执行步骤", seq: 1, }); expect(JSON.parse(mockState.db?.tasks.get("task-call")?.payload ?? "{}")).toMatchObject({ stepId: "plan-default-step:step:default", }); expect(mockState.db?.outbox.get("plan_step:upsert:plan-default-step:step:default")).toMatchObject({ entity_type: "plan_step", entity_id: "plan-default-step:step:default", }); expect(readDigitalEmployeeRuntimeRecords()).toMatchObject({ planSteps: [ expect.objectContaining({ id: "plan-default-step:step:default", planId: "plan-default-step", title: "默认执行步骤", }), ], }); }); it("preserves created plan title across follow-up governance commands", async () => { const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService"); recordDigitalEmployeeGovernanceCommand({ commandKind: "create_plan", commandId: "command-create", accepted: true, message: "已创建计划草稿", payload: { title: "客户回访重新执行", objective: "重新执行客户回访流程" }, result: { plan_id: "plan-restart", status: "draft" }, }); recordDigitalEmployeeGovernanceCommand({ commandKind: "approve_plan", commandId: "command-approve", accepted: true, message: "已批准计划", result: { plan_id: "plan-restart", status: "approved" }, }); expect(mockState.db?.plans.get("plan-restart")).toMatchObject({ title: "客户回访重新执行", objective: "重新执行客户回访流程", status: "approved", }); }); }); interface TestPlanRow { id: string; title: string; objective: string; status: string; payload: string; sync_status: string; created_at: string; updated_at: string; } interface TestTaskRow { id: string; plan_id: string; title: string; status: string; assigned_skill_id: string; payload: string; sync_status: string; created_at: string; updated_at: string; } interface TestPlanStepRow { id: string; plan_id: string; title: string; status: string; seq: number; depends_on: string; preferred_skill_ids: string; payload: string; sync_status: string; created_at: string; updated_at: string; } interface TestRunRow { id: string; plan_id: string; task_id: string; status: string; started_at: string; finished_at: string | null; payload: string; sync_status: string; created_at: string; updated_at: string; } interface TestEventRow { id: string; plan_id: string; task_id: string; run_id: string; kind: string; message: string; payload: string; sync_status: string; occurred_at: string; created_at: string; } interface TestArtifactRow { id: string; plan_id: string; task_id: string; run_id: string; kind: string; name: string | null; uri: string | null; payload: string; sync_status: string; created_at: string; } interface TestApprovalRow { id: string; plan_id: string; task_id: string; run_id: string; title: string; status: string; payload: string; sync_status: string; created_at: string; updated_at: string; } interface TestOutboxRow { id: string; entity_type: string; entity_id: string; operation: string; payload: string; status: string; attempts: number; created_at: string; updated_at: string; error: string | null; } class TestDb { plans = new Map(); planSteps = new Map(); tasks = new Map(); runs = new Map(); events = new Map(); artifacts = new Map(); approvals = new Map(); outbox = new Map(); transaction(callback: () => T): () => T { return callback; } prepare(sql: string): { all: (...args: unknown[]) => unknown[]; get: (...args: unknown[]) => unknown; run: (...args: unknown[]) => unknown; } { const normalized = sql.replace(/\s+/g, " ").trim(); return { all: (...args: unknown[]) => this.all(normalized, args), get: (...args: unknown[]) => this.get(normalized, args), run: (...args: unknown[]) => this.run(normalized, args), }; } private all(sql: string, _args: unknown[]): unknown[] { if (sql.includes("FROM digital_plans")) return Array.from(this.plans.values()); if (sql.includes("FROM digital_plan_steps")) return Array.from(this.planSteps.values()); if (sql.includes("FROM digital_tasks")) return Array.from(this.tasks.values()); if (sql.includes("FROM digital_runs")) return Array.from(this.runs.values()); if (sql.includes("FROM digital_events")) return Array.from(this.events.values()); if (sql.includes("FROM digital_artifacts")) return Array.from(this.artifacts.values()); if (sql.includes("FROM digital_approvals")) return Array.from(this.approvals.values()); return []; } private get(sql: string, args: unknown[]): unknown { if (sql.startsWith("SELECT title, objective FROM digital_plans")) { return this.plans.get(args[0] as string); } if (sql.startsWith("SELECT id, plan_id, task_id, run_id, title, status, payload, created_at FROM digital_approvals")) { return this.approvals.get(args[0] as string); } return { count: 0 }; } private run(sql: string, args: unknown[]): unknown { if (sql.startsWith("INSERT INTO digital_plans")) { const [id, title, objective, status, payload, createdAt, updatedAt] = args as [ string, string, string, string, string, string, string, ]; const previous = this.plans.get(id); this.plans.set(id, { id, title, objective, status, payload, sync_status: previous?.sync_status === "synced" ? "pending" : previous?.sync_status ?? "pending", created_at: previous?.created_at ?? createdAt, updated_at: updatedAt, }); return { changes: 1 }; } if (sql.startsWith("INSERT INTO digital_plan_steps")) { const [ id, planId, title, status, seq, dependsOn, preferredSkillIds, payload, createdAt, updatedAt, ] = args as [string, string, string, string, number, string, string, string, string, string]; const previous = this.planSteps.get(id); this.planSteps.set(id, { id, plan_id: planId, title, status, seq, depends_on: dependsOn, preferred_skill_ids: preferredSkillIds, payload, sync_status: previous?.sync_status === "synced" ? "pending" : previous?.sync_status ?? "pending", created_at: previous?.created_at ?? createdAt, updated_at: updatedAt, }); return { changes: 1 }; } if (sql.startsWith("INSERT INTO digital_tasks")) { const [id, planId, title, status, assignedSkillId, payload, createdAt, updatedAt] = args as [ string, string, string, string, string, string, string, string, ]; const previous = this.tasks.get(id); this.tasks.set(id, { id, plan_id: planId, title, status, assigned_skill_id: assignedSkillId, payload, sync_status: previous?.sync_status === "synced" ? "pending" : previous?.sync_status ?? "pending", created_at: previous?.created_at ?? createdAt, updated_at: updatedAt, }); return { changes: 1 }; } if (sql.startsWith("INSERT INTO digital_runs")) { const [id, planId, taskId, status, startedAt, payload, createdAt, updatedAt] = args as [ string, string, string, string, string, string, string, string, ]; const previous = this.runs.get(id); this.runs.set(id, { id, plan_id: planId, task_id: taskId, status, started_at: previous?.started_at ?? startedAt, finished_at: previous?.finished_at ?? null, payload, sync_status: previous?.sync_status === "synced" ? "pending" : previous?.sync_status ?? "pending", created_at: previous?.created_at ?? createdAt, updated_at: updatedAt, }); return { changes: 1 }; } if (sql.startsWith("UPDATE digital_runs")) { const [status, finishedAt, updatedAt, id] = args as [string, string, string, string]; const previous = this.runs.get(id); if (previous) { this.runs.set(id, { ...previous, status, finished_at: finishedAt, updated_at: updatedAt, sync_status: "pending", }); } return { changes: previous ? 1 : 0 }; } if (sql.startsWith("INSERT OR IGNORE INTO digital_events")) { const [id, planId, taskId, runId, kind, message, payload, occurredAt, createdAt] = args as [ string, string, string, string, string, string, string, string, string, ]; if (!this.events.has(id)) { this.events.set(id, { id, plan_id: planId, task_id: taskId, run_id: runId, kind, message, payload, sync_status: "pending", occurred_at: occurredAt, created_at: createdAt, }); } return { changes: 1 }; } if (sql.startsWith("INSERT INTO digital_artifacts")) { const [id, planId, taskId, runId, kind, name, uri, payload, createdAt] = args as [ string, string, string, string, string, string | null, string | null, string, string, ]; this.artifacts.set(id, { id, plan_id: planId, task_id: taskId, run_id: runId, kind, name, uri, payload, sync_status: "pending", created_at: createdAt, }); return { changes: 1 }; } if (sql.startsWith("INSERT INTO digital_approvals")) { const [id, planId, taskId, runId, title, status, payload, createdAt, updatedAt] = args as [ string, string, string, string, string, string, string, string, string, ]; const previous = this.approvals.get(id); this.approvals.set(id, { id, plan_id: planId, task_id: taskId, run_id: runId, title, status, payload, sync_status: previous?.sync_status === "synced" ? "pending" : previous?.sync_status ?? "pending", created_at: previous?.created_at ?? createdAt, updated_at: updatedAt, }); return { changes: 1 }; } if (sql.startsWith("INSERT INTO digital_sync_outbox")) { const [id, entityType, entityId, operation, payload, createdAt, updatedAt] = args as [ string, string, string, string, string, string, string, ]; this.outbox.set(id, { id, entity_type: entityType, entity_id: entityId, operation, payload, status: "pending", attempts: 0, created_at: createdAt, updated_at: updatedAt, error: null, }); return { changes: 1 }; } throw new Error(`Unhandled run query: ${sql}`); } }