吸收数字员工长期记忆闭环
This commit is contained in:
@@ -604,6 +604,100 @@ describe("digital employee state service", () => {
|
||||
kind: "governance_resume_task",
|
||||
});
|
||||
});
|
||||
|
||||
it("upserts, lists, and syncs digital employee memories", async () => {
|
||||
const {
|
||||
listDigitalEmployeeMemories,
|
||||
readDigitalEmployeeRuntimeRecords,
|
||||
syncQimingclawMemoryEntries,
|
||||
upsertDigitalEmployeeMemory,
|
||||
} = await import("./stateService");
|
||||
|
||||
const localMemory = upsertDigitalEmployeeMemory({
|
||||
key: "preferred-report-style",
|
||||
content: "用户偏好日报先给结论再列证据。",
|
||||
category: "preference",
|
||||
source: "qimingclaw-digital-local",
|
||||
score: 0.88,
|
||||
payload: { source: "test" },
|
||||
});
|
||||
|
||||
expect(localMemory).toMatchObject({
|
||||
id: "memory-preferred-report-style",
|
||||
key: "preferred-report-style",
|
||||
category: "preference",
|
||||
status: "active",
|
||||
syncStatus: "pending",
|
||||
});
|
||||
expect(mockState.db?.outbox.get("memory:upsert:memory-preferred-report-style")).toMatchObject({
|
||||
entity_type: "memory",
|
||||
entity_id: "memory-preferred-report-style",
|
||||
operation: "upsert",
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
syncQimingclawMemoryEntries([
|
||||
{
|
||||
id: "mem-from-service",
|
||||
text: "用户希望所有提交信息使用中文。",
|
||||
category: "preference",
|
||||
source: "core",
|
||||
sourcePath: "MEMORY.md",
|
||||
importance: 0.9,
|
||||
status: "active",
|
||||
createdAt: 1780800000000,
|
||||
updatedAt: 1780800300000,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(listDigitalEmployeeMemories({ query: "中文" })).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "mem-from-service",
|
||||
key: "mem-from-service",
|
||||
content: "用户希望所有提交信息使用中文。",
|
||||
}),
|
||||
]);
|
||||
expect(listDigitalEmployeeMemories({ category: "preference" })).toHaveLength(2);
|
||||
expect(readDigitalEmployeeRuntimeRecords().memories).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: "mem-from-service" }),
|
||||
expect.objectContaining({ id: "memory-preferred-report-style" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("soft deletes digital employee memories and writes delete outbox", async () => {
|
||||
const {
|
||||
deleteDigitalEmployeeMemory,
|
||||
listDigitalEmployeeMemories,
|
||||
upsertDigitalEmployeeMemory,
|
||||
} = await import("./stateService");
|
||||
|
||||
upsertDigitalEmployeeMemory({
|
||||
id: "memory-delete-me",
|
||||
key: "delete-me",
|
||||
content: "这条记忆将被删除。",
|
||||
category: "fact",
|
||||
});
|
||||
|
||||
const deleted = deleteDigitalEmployeeMemory("delete-me");
|
||||
|
||||
expect(deleted).toMatchObject({
|
||||
id: "memory-delete-me",
|
||||
key: "delete-me",
|
||||
status: "deleted",
|
||||
deletedAt: "2026-06-07T08:00:00.000Z",
|
||||
syncStatus: "pending",
|
||||
});
|
||||
expect(listDigitalEmployeeMemories({ includeDeleted: false })).toEqual([]);
|
||||
expect(listDigitalEmployeeMemories({ includeDeleted: true })).toEqual([
|
||||
expect.objectContaining({ id: "memory-delete-me", status: "deleted" }),
|
||||
]);
|
||||
expect(mockState.db?.outbox.get("memory:delete:memory-delete-me")).toMatchObject({
|
||||
entity_type: "memory",
|
||||
entity_id: "memory-delete-me",
|
||||
operation: "delete",
|
||||
status: "pending",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createGovernableTask(options: {
|
||||
@@ -749,6 +843,23 @@ interface TestApprovalRow {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface TestMemoryRow {
|
||||
id: string;
|
||||
key: string;
|
||||
content: string;
|
||||
category: string;
|
||||
source: string;
|
||||
source_path: string | null;
|
||||
session_id: string | null;
|
||||
score: number | null;
|
||||
status: string;
|
||||
payload: string;
|
||||
sync_status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
interface TestScheduleRow {
|
||||
id: string;
|
||||
plan_id: string | null;
|
||||
@@ -813,6 +924,7 @@ class TestDb {
|
||||
events = new Map<string, TestEventRow>();
|
||||
artifacts = new Map<string, TestArtifactRow>();
|
||||
approvals = new Map<string, TestApprovalRow>();
|
||||
memories = new Map<string, TestMemoryRow>();
|
||||
schedules = new Map<string, TestScheduleRow>();
|
||||
scheduleRuns = new Map<string, TestScheduleRunRow>();
|
||||
outbox = new Map<string, TestOutboxRow>();
|
||||
@@ -844,6 +956,7 @@ class TestDb {
|
||||
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());
|
||||
if (sql.includes("FROM digital_memories")) return Array.from(this.memories.values());
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -882,6 +995,18 @@ class TestDb {
|
||||
if (sql.includes("FROM digital_schedule_runs") && sql.includes("WHERE id = ?")) {
|
||||
return this.scheduleRuns.get(args[0] as string);
|
||||
}
|
||||
if (sql.includes("FROM digital_memories") && sql.includes("WHERE id = ? OR key = ?")) {
|
||||
return Array.from(this.memories.values())
|
||||
.filter((memory) => memory.id === args[0] || memory.key === args[1])
|
||||
.sort((left, right) => {
|
||||
const leftDeleted = left.status === "deleted" ? 1 : 0;
|
||||
const rightDeleted = right.status === "deleted" ? 1 : 0;
|
||||
return leftDeleted - rightDeleted || right.updated_at.localeCompare(left.updated_at) || right.created_at.localeCompare(left.created_at);
|
||||
})[0];
|
||||
}
|
||||
if (sql.includes("FROM digital_memories") && sql.includes("WHERE id = ?")) {
|
||||
return this.memories.get(args[0] as string);
|
||||
}
|
||||
return { count: 0 };
|
||||
}
|
||||
|
||||
@@ -1258,6 +1383,57 @@ class TestDb {
|
||||
return { changes: 1 };
|
||||
}
|
||||
|
||||
if (sql.startsWith("INSERT INTO digital_memories")) {
|
||||
const [id, key, content, category, source, sourcePath, sessionId, score, status, payload, createdAt, updatedAt, deletedAt] = args as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string | null,
|
||||
string | null,
|
||||
number | null,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string | null,
|
||||
];
|
||||
const previous = this.memories.get(id);
|
||||
this.memories.set(id, {
|
||||
id,
|
||||
key,
|
||||
content,
|
||||
category,
|
||||
source,
|
||||
source_path: sourcePath,
|
||||
session_id: sessionId,
|
||||
score,
|
||||
status,
|
||||
payload,
|
||||
sync_status: previous?.sync_status === "synced" ? "pending" : previous?.sync_status ?? "pending",
|
||||
created_at: previous?.created_at ?? createdAt,
|
||||
updated_at: updatedAt,
|
||||
deleted_at: deletedAt,
|
||||
});
|
||||
return { changes: 1 };
|
||||
}
|
||||
|
||||
if (sql.startsWith("UPDATE digital_memories")) {
|
||||
const [deletedAt, updatedAt, id] = args as [string, string, string];
|
||||
const previous = this.memories.get(id);
|
||||
if (previous) {
|
||||
this.memories.set(id, {
|
||||
...previous,
|
||||
status: "deleted",
|
||||
deleted_at: deletedAt,
|
||||
updated_at: updatedAt,
|
||||
sync_status: "pending",
|
||||
});
|
||||
}
|
||||
return { changes: previous ? 1 : 0 };
|
||||
}
|
||||
|
||||
if (sql.startsWith("INSERT INTO digital_sync_outbox")) {
|
||||
const [id, entityType, entityId, operation, payload, createdAt, updatedAt] = args as [
|
||||
string,
|
||||
|
||||
Reference in New Issue
Block a user