feat(client): persist digital governance events
This commit is contained in:
@@ -10,10 +10,12 @@ import { getWindowsMcpStatus } from "../services/packages/windowsMcp";
|
||||
import { isWindows } from "../services/system/shellEnv";
|
||||
import {
|
||||
recordDigitalEmployeeSnapshot,
|
||||
recordDigitalEmployeeGovernanceCommand,
|
||||
readDigitalEmployeeRuntimeRecords,
|
||||
readDigitalEmployeeState,
|
||||
readDigitalEmployeeUiState,
|
||||
saveDigitalEmployeeUiState,
|
||||
type DigitalEmployeeGovernanceCommandRecord,
|
||||
type DigitalEmployeeServiceStatus,
|
||||
type DigitalEmployeeSnapshot,
|
||||
type DigitalEmployeeUiStateUpdate,
|
||||
@@ -66,6 +68,13 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
|
||||
ipcMain.handle("digitalEmployee:flushSync", async () => {
|
||||
return flushDigitalEmployeeSyncOutbox({ force: true });
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"digitalEmployee:recordGovernanceCommand",
|
||||
async (_, command: DigitalEmployeeGovernanceCommandRecord) => {
|
||||
return recordDigitalEmployeeGovernanceCommand(command);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
interface DigitalEmployeeSkillCapability {
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const mockDigitalEmployee = vi.hoisted(() => ({
|
||||
recordRuntimeEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("electron-log", () => ({
|
||||
default: {
|
||||
info: vi.fn(),
|
||||
@@ -23,6 +27,10 @@ vi.mock("../services/computerServer", () => ({
|
||||
pushSseEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/digitalEmployee/stateService", () => ({
|
||||
recordDigitalEmployeeRuntimeEvent: mockDigitalEmployee.recordRuntimeEvent,
|
||||
}));
|
||||
|
||||
// Use dynamic import to get the actual agentService (an EventEmitter) after mocking
|
||||
// The unifiedAgent module exports agentService as a singleton EventEmitter
|
||||
vi.mock("../services/engines/unifiedAgent", async () => {
|
||||
@@ -60,6 +68,7 @@ describe("eventForwarders", () => {
|
||||
(agentService as any).removeAllListeners();
|
||||
unregisterEventForwarders();
|
||||
vi.clearAllMocks();
|
||||
mockDigitalEmployee.recordRuntimeEvent.mockClear();
|
||||
});
|
||||
|
||||
it("registerEventForwarders registers listeners on agentService", () => {
|
||||
@@ -159,4 +168,79 @@ describe("eventForwarders", () => {
|
||||
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes computer tool updates into digital employee runtime events", () => {
|
||||
const { ctx } = createMockCtx();
|
||||
registerEventForwarders(ctx);
|
||||
|
||||
agentService.emit("computer:progress", {
|
||||
sessionId: "session-1",
|
||||
projectId: "project-1",
|
||||
subType: "tool_call_update",
|
||||
data: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "tool-1",
|
||||
title: "Write",
|
||||
kind: "edit",
|
||||
status: "completed",
|
||||
rawInput: { filePath: "/tmp/report.md" },
|
||||
rawOutput: { outputPath: "/tmp/report.md" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockDigitalEmployee.recordRuntimeEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: "computer_tool_call_update",
|
||||
message: "工具调用更新:Write",
|
||||
session_id: "session-1",
|
||||
project_id: "project-1",
|
||||
status: "completed",
|
||||
payload: expect.objectContaining({
|
||||
toolName: "Write",
|
||||
output: { outputPath: "/tmp/report.md" },
|
||||
input: { filePath: "/tmp/report.md" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("projects permission and file events into approval and artifact hints", () => {
|
||||
const { ctx } = createMockCtx();
|
||||
registerEventForwarders(ctx);
|
||||
|
||||
agentService.emit("permission.updated", {
|
||||
sessionId: "session-2",
|
||||
permissionId: "permission-1",
|
||||
toolCall: { title: "Bash", kind: "shell" },
|
||||
});
|
||||
agentService.emit("file.edited", {
|
||||
sessionId: "session-2",
|
||||
filePath: "/tmp/final.txt",
|
||||
});
|
||||
|
||||
expect(mockDigitalEmployee.recordRuntimeEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: "permission_updated",
|
||||
status: "pending",
|
||||
payload: expect.objectContaining({
|
||||
approval_requests: [
|
||||
expect.objectContaining({
|
||||
id: "permission-1",
|
||||
title: "权限确认:Bash",
|
||||
status: "pending",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockDigitalEmployee.recordRuntimeEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: "file_edited",
|
||||
message: "文件已编辑:/tmp/final.txt",
|
||||
payload: expect.objectContaining({
|
||||
artifacts: [expect.objectContaining({ kind: "file_edit", path: "/tmp/final.txt" })],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,31 +13,150 @@ const registeredHandlers: Array<{
|
||||
}> = [];
|
||||
|
||||
function readEventField(data: unknown, keys: string[]): string | undefined {
|
||||
if (!data || typeof data !== "object") return undefined;
|
||||
const record = data as Record<string, unknown>;
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.trim()) return value;
|
||||
for (const record of eventRecords(data)) {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.trim()) return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function recordForwardedEvent(eventType: string, data: unknown): void {
|
||||
recordDigitalEmployeeRuntimeEvent({
|
||||
kind: eventType,
|
||||
message:
|
||||
readEventField(data, ["message", "description", "title", "subType"]) ||
|
||||
eventType,
|
||||
const normalized = normalizeForwardedEvent(eventType, data);
|
||||
recordDigitalEmployeeRuntimeEvent(normalized);
|
||||
}
|
||||
|
||||
function normalizeForwardedEvent(
|
||||
eventType: string,
|
||||
data: unknown,
|
||||
): Parameters<typeof recordDigitalEmployeeRuntimeEvent>[0] {
|
||||
const subtype = readEventField(data, ["subType", "sessionUpdate", "type", "messageType"]);
|
||||
const toolName = readEventField(data, ["toolTitle", "title", "name", "kind"]);
|
||||
const message = eventMessage(eventType, data, subtype, toolName);
|
||||
return {
|
||||
kind: eventKind(eventType, subtype),
|
||||
message,
|
||||
request_id: readEventField(data, ["requestId", "request_id"]),
|
||||
session_id: readEventField(data, ["sessionId", "session_id", "id"]),
|
||||
project_id: readEventField(data, ["projectId", "project_id"]),
|
||||
status: eventType.includes("error")
|
||||
? "failed"
|
||||
: eventType.includes("idle") || eventType.includes("promptEnd")
|
||||
? "completed"
|
||||
: "running",
|
||||
payload: data,
|
||||
});
|
||||
status: eventStatus(eventType, data),
|
||||
payload: runtimePayload(eventType, data, subtype, toolName),
|
||||
};
|
||||
}
|
||||
|
||||
function eventKind(eventType: string, subtype?: string): string {
|
||||
if (eventType === "computer:progress" && subtype) return `computer_${subtype}`;
|
||||
return eventType.replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "") || eventType;
|
||||
}
|
||||
|
||||
function eventMessage(
|
||||
eventType: string,
|
||||
data: unknown,
|
||||
subtype?: string,
|
||||
toolName?: string,
|
||||
): string {
|
||||
if (subtype === "tool_call") return `工具调用开始:${toolName || "unknown"}`;
|
||||
if (subtype === "tool_call_update") return `工具调用更新:${toolName || "unknown"}`;
|
||||
if (subtype === "agent_message_chunk") return "Agent 输出内容";
|
||||
if (subtype === "agent_thought_chunk") return "Agent 思考内容";
|
||||
const explicit = readEventField(data, ["message", "description", "title"]);
|
||||
if (explicit) return explicit;
|
||||
if (eventType === "permission.updated") return `权限请求:${toolName || "待确认"}`;
|
||||
if (eventType === "permission.replied") return `权限请求已回复:${toolName || "已处理"}`;
|
||||
if (eventType === "file.edited") return `文件已编辑:${readEventField(data, ["filePath", "file_path", "path"]) || "unknown"}`;
|
||||
if (eventType === "session.diff") return "会话产生文件差异";
|
||||
return subtype || eventType;
|
||||
}
|
||||
|
||||
function eventStatus(
|
||||
eventType: string,
|
||||
data: unknown,
|
||||
): "running" | "completed" | "failed" | "pending" {
|
||||
if (eventType.includes("error")) return "failed";
|
||||
const status = (readEventField(data, ["status", "outcome", "reason"]) || "").toLowerCase();
|
||||
if (["failed", "failure", "error", "rejected", "denied", "cancelled", "canceled"].includes(status)) return "failed";
|
||||
if (["completed", "complete", "success", "succeeded", "done", "accepted", "approved", "end_turn"].includes(status)) return "completed";
|
||||
if (eventType.includes("idle") || eventType.includes("promptEnd")) return "completed";
|
||||
if (eventType.includes("permission")) return eventType.endsWith("replied") ? "completed" : "pending";
|
||||
return "running";
|
||||
}
|
||||
|
||||
function runtimePayload(
|
||||
eventType: string,
|
||||
data: unknown,
|
||||
subtype?: string,
|
||||
toolName?: string,
|
||||
): Record<string, unknown> {
|
||||
const primary = objectRecord(data) ?? {};
|
||||
const nested = objectRecord(primary.data) ?? {};
|
||||
const payload: Record<string, unknown> = {
|
||||
...primary,
|
||||
eventType,
|
||||
subtype,
|
||||
toolName,
|
||||
};
|
||||
const output = nested.rawOutput ?? nested.output ?? primary.output;
|
||||
if (output !== undefined) payload.output = output;
|
||||
const rawInput = nested.rawInput ?? nested.input ?? primary.input;
|
||||
if (rawInput !== undefined) payload.input = rawInput;
|
||||
const locations = Array.isArray(nested.locations) ? nested.locations : Array.isArray(primary.locations) ? primary.locations : [];
|
||||
const artifacts = artifactHints(eventType, primary, nested, locations);
|
||||
if (artifacts.length > 0) payload.artifacts = artifacts;
|
||||
const approval = approvalHint(eventType, primary, nested, toolName);
|
||||
if (approval) payload.approval_requests = [approval];
|
||||
return payload;
|
||||
}
|
||||
|
||||
function artifactHints(
|
||||
eventType: string,
|
||||
primary: Record<string, unknown>,
|
||||
nested: Record<string, unknown>,
|
||||
locations: unknown[],
|
||||
): Array<Record<string, unknown>> {
|
||||
const directPath = readEventField(primary, ["filePath", "file_path", "path", "uri"])
|
||||
|| readEventField(nested, ["filePath", "file_path", "path", "uri"]);
|
||||
const hints: Array<Record<string, unknown>> = locations
|
||||
.map((location) => objectRecord(location))
|
||||
.filter((location): location is Record<string, unknown> => Boolean(location?.path))
|
||||
.map((location) => ({ kind: "file", path: location.path, payload: location }));
|
||||
if (directPath && (eventType === "file.edited" || eventType === "session.diff")) {
|
||||
hints.unshift({ kind: eventType === "file.edited" ? "file_edit" : "diff", path: directPath });
|
||||
}
|
||||
return hints;
|
||||
}
|
||||
|
||||
function approvalHint(
|
||||
eventType: string,
|
||||
primary: Record<string, unknown>,
|
||||
nested: Record<string, unknown>,
|
||||
toolName?: string,
|
||||
): Record<string, unknown> | null {
|
||||
if (!eventType.includes("permission")) return null;
|
||||
const id = readEventField(primary, ["permissionId", "permission_id", "id"])
|
||||
|| readEventField(nested, ["permissionId", "permission_id", "id"])
|
||||
|| toolName
|
||||
|| eventType;
|
||||
return {
|
||||
id,
|
||||
title: `权限确认:${toolName || readEventField(primary, ["title", "message"]) || "工具调用"}`,
|
||||
status: eventType.endsWith("replied") ? "completed" : "pending",
|
||||
source: "acp-permission",
|
||||
payload: primary,
|
||||
};
|
||||
}
|
||||
|
||||
function eventRecords(data: unknown): Array<Record<string, unknown>> {
|
||||
const primary = objectRecord(data);
|
||||
if (!primary) return [];
|
||||
return [primary, objectRecord(primary.data), objectRecord(primary.toolCall)]
|
||||
.filter((record): record is Record<string, unknown> => Boolean(record));
|
||||
}
|
||||
|
||||
function objectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user