Files
qiming/qimingclaw/crates/agent-electron-client/src/main/services/digitalEmployee/stateService.test.ts
2026-06-09 10:21:12 +08:00

744 lines
22 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const mockState = vi.hoisted(() => ({
db: null as TestDb | null,
ensureSchemaCalls: 0,
settings: new Map<string, unknown>(),
}));
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<string, unknown>();
});
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<string, TestPlanRow>();
planSteps = new Map<string, TestPlanStepRow>();
tasks = new Map<string, TestTaskRow>();
runs = new Map<string, TestRunRow>();
events = new Map<string, TestEventRow>();
artifacts = new Map<string, TestArtifactRow>();
approvals = new Map<string, TestApprovalRow>();
outbox = new Map<string, TestOutboxRow>();
transaction<T>(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}`);
}
}