reality
This commit is contained in:
745
testing.ts
Normal file
745
testing.ts
Normal file
@@ -0,0 +1,745 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
PaperclipPluginManifestV1,
|
||||
PluginCapability,
|
||||
PluginEventType,
|
||||
Company,
|
||||
Project,
|
||||
Issue,
|
||||
IssueComment,
|
||||
Agent,
|
||||
Goal,
|
||||
} from "@paperclipai/shared";
|
||||
import type {
|
||||
EventFilter,
|
||||
PluginContext,
|
||||
PluginEntityRecord,
|
||||
PluginEntityUpsert,
|
||||
PluginJobContext,
|
||||
PluginLauncherRegistration,
|
||||
PluginEvent,
|
||||
ScopeKey,
|
||||
ToolResult,
|
||||
ToolRunContext,
|
||||
PluginWorkspace,
|
||||
AgentSession,
|
||||
AgentSessionEvent,
|
||||
} from "./types.js";
|
||||
|
||||
export interface TestHarnessOptions {
|
||||
/** Plugin manifest used to seed capability checks and metadata. */
|
||||
manifest: PaperclipPluginManifestV1;
|
||||
/** Optional capability override. Defaults to `manifest.capabilities`. */
|
||||
capabilities?: PluginCapability[];
|
||||
/** Initial config returned by `ctx.config.get()`. */
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TestHarnessLogEntry {
|
||||
level: "info" | "warn" | "error" | "debug";
|
||||
message: string;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TestHarness {
|
||||
/** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */
|
||||
ctx: PluginContext;
|
||||
/** Seed host entities for `ctx.companies/projects/issues/agents/goals` reads. */
|
||||
seed(input: {
|
||||
companies?: Company[];
|
||||
projects?: Project[];
|
||||
issues?: Issue[];
|
||||
issueComments?: IssueComment[];
|
||||
agents?: Agent[];
|
||||
goals?: Goal[];
|
||||
}): void;
|
||||
setConfig(config: Record<string, unknown>): void;
|
||||
/** Dispatch a host or plugin event to registered handlers. */
|
||||
emit(eventType: PluginEventType | `plugin.${string}`, payload: unknown, base?: Partial<PluginEvent>): Promise<void>;
|
||||
/** Execute a previously-registered scheduled job handler. */
|
||||
runJob(jobKey: string, partial?: Partial<PluginJobContext>): Promise<void>;
|
||||
/** Invoke a `ctx.data.register(...)` handler by key. */
|
||||
getData<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>;
|
||||
/** Invoke a `ctx.actions.register(...)` handler by key. */
|
||||
performAction<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>;
|
||||
/** Execute a registered tool handler via `ctx.tools.execute(...)`. */
|
||||
executeTool<T = ToolResult>(name: string, params: unknown, runCtx?: Partial<ToolRunContext>): Promise<T>;
|
||||
/** Read raw in-memory state for assertions. */
|
||||
getState(input: ScopeKey): unknown;
|
||||
/** Simulate a streaming event arriving for an active session. */
|
||||
simulateSessionEvent(sessionId: string, event: Omit<AgentSessionEvent, "sessionId">): void;
|
||||
logs: TestHarnessLogEntry[];
|
||||
activity: Array<{ message: string; entityType?: string; entityId?: string; metadata?: Record<string, unknown> }>;
|
||||
metrics: Array<{ name: string; value: number; tags?: Record<string, string> }>;
|
||||
}
|
||||
|
||||
type EventRegistration = {
|
||||
name: PluginEventType | `plugin.${string}`;
|
||||
filter?: EventFilter;
|
||||
fn: (event: PluginEvent) => Promise<void>;
|
||||
};
|
||||
|
||||
function normalizeScope(input: ScopeKey): Required<Pick<ScopeKey, "scopeKind" | "stateKey">> & Pick<ScopeKey, "scopeId" | "namespace"> {
|
||||
return {
|
||||
scopeKind: input.scopeKind,
|
||||
scopeId: input.scopeId,
|
||||
namespace: input.namespace ?? "default",
|
||||
stateKey: input.stateKey,
|
||||
};
|
||||
}
|
||||
|
||||
function stateMapKey(input: ScopeKey): string {
|
||||
const normalized = normalizeScope(input);
|
||||
return `${normalized.scopeKind}|${normalized.scopeId ?? ""}|${normalized.namespace}|${normalized.stateKey}`;
|
||||
}
|
||||
|
||||
function allowsEvent(filter: EventFilter | undefined, event: PluginEvent): boolean {
|
||||
if (!filter) return true;
|
||||
if (filter.companyId && filter.companyId !== String((event.payload as Record<string, unknown> | undefined)?.companyId ?? "")) return false;
|
||||
if (filter.projectId && filter.projectId !== String((event.payload as Record<string, unknown> | undefined)?.projectId ?? "")) return false;
|
||||
if (filter.agentId && filter.agentId !== String((event.payload as Record<string, unknown> | undefined)?.agentId ?? "")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function requireCapability(manifest: PaperclipPluginManifestV1, allowed: Set<PluginCapability>, capability: PluginCapability) {
|
||||
if (allowed.has(capability)) return;
|
||||
throw new Error(`Plugin '${manifest.id}' is missing required capability '${capability}' in test harness`);
|
||||
}
|
||||
|
||||
function requireCompanyId(companyId?: string): string {
|
||||
if (!companyId) throw new Error("companyId is required for this operation");
|
||||
return companyId;
|
||||
}
|
||||
|
||||
function isInCompany<T extends { companyId: string | null | undefined }>(
|
||||
record: T | null | undefined,
|
||||
companyId: string,
|
||||
): record is T {
|
||||
return Boolean(record && record.companyId === companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an in-memory host harness for plugin worker tests.
|
||||
*
|
||||
* The harness enforces declared capabilities and simulates host APIs, so tests
|
||||
* can validate plugin behavior without spinning up the Paperclip server runtime.
|
||||
*/
|
||||
export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
const manifest = options.manifest;
|
||||
const capabilitySet = new Set(options.capabilities ?? manifest.capabilities);
|
||||
let currentConfig = { ...(options.config ?? {}) };
|
||||
|
||||
const logs: TestHarnessLogEntry[] = [];
|
||||
const activity: TestHarness["activity"] = [];
|
||||
const metrics: TestHarness["metrics"] = [];
|
||||
|
||||
const state = new Map<string, unknown>();
|
||||
const entities = new Map<string, PluginEntityRecord>();
|
||||
const entityExternalIndex = new Map<string, string>();
|
||||
const companies = new Map<string, Company>();
|
||||
const projects = new Map<string, Project>();
|
||||
const issues = new Map<string, Issue>();
|
||||
const issueComments = new Map<string, IssueComment[]>();
|
||||
const agents = new Map<string, Agent>();
|
||||
const goals = new Map<string, Goal>();
|
||||
const projectWorkspaces = new Map<string, PluginWorkspace[]>();
|
||||
|
||||
const sessions = new Map<string, AgentSession>();
|
||||
const sessionEventCallbacks = new Map<string, (event: AgentSessionEvent) => void>();
|
||||
|
||||
const events: EventRegistration[] = [];
|
||||
const jobs = new Map<string, (job: PluginJobContext) => Promise<void>>();
|
||||
const launchers = new Map<string, PluginLauncherRegistration>();
|
||||
const dataHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
||||
const actionHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
||||
const toolHandlers = new Map<string, (params: unknown, runCtx: ToolRunContext) => Promise<ToolResult>>();
|
||||
|
||||
const ctx: PluginContext = {
|
||||
manifest,
|
||||
config: {
|
||||
async get() {
|
||||
return { ...currentConfig };
|
||||
},
|
||||
},
|
||||
events: {
|
||||
on(name: PluginEventType | `plugin.${string}`, filterOrFn: EventFilter | ((event: PluginEvent) => Promise<void>), maybeFn?: (event: PluginEvent) => Promise<void>): () => void {
|
||||
requireCapability(manifest, capabilitySet, "events.subscribe");
|
||||
let registration: EventRegistration;
|
||||
if (typeof filterOrFn === "function") {
|
||||
registration = { name, fn: filterOrFn };
|
||||
} else {
|
||||
if (!maybeFn) throw new Error("event handler is required");
|
||||
registration = { name, filter: filterOrFn, fn: maybeFn };
|
||||
}
|
||||
events.push(registration);
|
||||
return () => {
|
||||
const idx = events.indexOf(registration);
|
||||
if (idx !== -1) events.splice(idx, 1);
|
||||
};
|
||||
},
|
||||
async emit(name, companyId, payload) {
|
||||
requireCapability(manifest, capabilitySet, "events.emit");
|
||||
await harness.emit(`plugin.${manifest.id}.${name}`, payload, { companyId });
|
||||
},
|
||||
},
|
||||
jobs: {
|
||||
register(key, fn) {
|
||||
requireCapability(manifest, capabilitySet, "jobs.schedule");
|
||||
jobs.set(key, fn);
|
||||
},
|
||||
},
|
||||
launchers: {
|
||||
register(launcher) {
|
||||
launchers.set(launcher.id, launcher);
|
||||
},
|
||||
},
|
||||
http: {
|
||||
async fetch(url, init) {
|
||||
requireCapability(manifest, capabilitySet, "http.outbound");
|
||||
return fetch(url, init);
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
async resolve(secretRef) {
|
||||
requireCapability(manifest, capabilitySet, "secrets.read-ref");
|
||||
return `resolved:${secretRef}`;
|
||||
},
|
||||
},
|
||||
activity: {
|
||||
async log(entry) {
|
||||
requireCapability(manifest, capabilitySet, "activity.log.write");
|
||||
activity.push(entry);
|
||||
},
|
||||
},
|
||||
state: {
|
||||
async get(input) {
|
||||
requireCapability(manifest, capabilitySet, "plugin.state.read");
|
||||
return state.has(stateMapKey(input)) ? state.get(stateMapKey(input)) : null;
|
||||
},
|
||||
async set(input, value) {
|
||||
requireCapability(manifest, capabilitySet, "plugin.state.write");
|
||||
state.set(stateMapKey(input), value);
|
||||
},
|
||||
async delete(input) {
|
||||
requireCapability(manifest, capabilitySet, "plugin.state.write");
|
||||
state.delete(stateMapKey(input));
|
||||
},
|
||||
},
|
||||
entities: {
|
||||
async upsert(input: PluginEntityUpsert) {
|
||||
const externalKey = input.externalId
|
||||
? `${input.entityType}|${input.scopeKind}|${input.scopeId ?? ""}|${input.externalId}`
|
||||
: null;
|
||||
const existingId = externalKey ? entityExternalIndex.get(externalKey) : undefined;
|
||||
const existing = existingId ? entities.get(existingId) : undefined;
|
||||
const now = new Date().toISOString();
|
||||
const previousExternalKey = existing?.externalId
|
||||
? `${existing.entityType}|${existing.scopeKind}|${existing.scopeId ?? ""}|${existing.externalId}`
|
||||
: null;
|
||||
const record: PluginEntityRecord = existing
|
||||
? {
|
||||
...existing,
|
||||
entityType: input.entityType,
|
||||
scopeKind: input.scopeKind,
|
||||
scopeId: input.scopeId ?? null,
|
||||
externalId: input.externalId ?? null,
|
||||
title: input.title ?? null,
|
||||
status: input.status ?? null,
|
||||
data: input.data,
|
||||
updatedAt: now,
|
||||
}
|
||||
: {
|
||||
id: randomUUID(),
|
||||
entityType: input.entityType,
|
||||
scopeKind: input.scopeKind,
|
||||
scopeId: input.scopeId ?? null,
|
||||
externalId: input.externalId ?? null,
|
||||
title: input.title ?? null,
|
||||
status: input.status ?? null,
|
||||
data: input.data,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
entities.set(record.id, record);
|
||||
if (previousExternalKey && previousExternalKey !== externalKey) {
|
||||
entityExternalIndex.delete(previousExternalKey);
|
||||
}
|
||||
if (externalKey) entityExternalIndex.set(externalKey, record.id);
|
||||
return record;
|
||||
},
|
||||
async list(query) {
|
||||
let out = [...entities.values()];
|
||||
if (query.entityType) out = out.filter((r) => r.entityType === query.entityType);
|
||||
if (query.scopeKind) out = out.filter((r) => r.scopeKind === query.scopeKind);
|
||||
if (query.scopeId) out = out.filter((r) => r.scopeId === query.scopeId);
|
||||
if (query.externalId) out = out.filter((r) => r.externalId === query.externalId);
|
||||
if (query.offset) out = out.slice(query.offset);
|
||||
if (query.limit) out = out.slice(0, query.limit);
|
||||
return out;
|
||||
},
|
||||
},
|
||||
projects: {
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "projects.read");
|
||||
const companyId = requireCompanyId(input?.companyId);
|
||||
let out = [...projects.values()];
|
||||
out = out.filter((project) => project.companyId === companyId);
|
||||
if (input?.offset) out = out.slice(input.offset);
|
||||
if (input?.limit) out = out.slice(0, input.limit);
|
||||
return out;
|
||||
},
|
||||
async get(projectId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "projects.read");
|
||||
const project = projects.get(projectId);
|
||||
return isInCompany(project, companyId) ? project : null;
|
||||
},
|
||||
async listWorkspaces(projectId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "project.workspaces.read");
|
||||
if (!isInCompany(projects.get(projectId), companyId)) return [];
|
||||
return projectWorkspaces.get(projectId) ?? [];
|
||||
},
|
||||
async getPrimaryWorkspace(projectId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "project.workspaces.read");
|
||||
if (!isInCompany(projects.get(projectId), companyId)) return null;
|
||||
const workspaces = projectWorkspaces.get(projectId) ?? [];
|
||||
return workspaces.find((workspace) => workspace.isPrimary) ?? null;
|
||||
},
|
||||
async getWorkspaceForIssue(issueId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "project.workspaces.read");
|
||||
const issue = issues.get(issueId);
|
||||
if (!isInCompany(issue, companyId)) return null;
|
||||
const projectId = (issue as unknown as Record<string, unknown>)?.projectId as string | undefined;
|
||||
if (!projectId) return null;
|
||||
if (!isInCompany(projects.get(projectId), companyId)) return null;
|
||||
const workspaces = projectWorkspaces.get(projectId) ?? [];
|
||||
return workspaces.find((workspace) => workspace.isPrimary) ?? null;
|
||||
},
|
||||
},
|
||||
companies: {
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "companies.read");
|
||||
let out = [...companies.values()];
|
||||
if (input?.offset) out = out.slice(input.offset);
|
||||
if (input?.limit) out = out.slice(0, input.limit);
|
||||
return out;
|
||||
},
|
||||
async get(companyId) {
|
||||
requireCapability(manifest, capabilitySet, "companies.read");
|
||||
return companies.get(companyId) ?? null;
|
||||
},
|
||||
},
|
||||
issues: {
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "issues.read");
|
||||
const companyId = requireCompanyId(input?.companyId);
|
||||
let out = [...issues.values()];
|
||||
out = out.filter((issue) => issue.companyId === companyId);
|
||||
if (input?.projectId) out = out.filter((issue) => issue.projectId === input.projectId);
|
||||
if (input?.assigneeAgentId) out = out.filter((issue) => issue.assigneeAgentId === input.assigneeAgentId);
|
||||
if (input?.status) out = out.filter((issue) => issue.status === input.status);
|
||||
if (input?.offset) out = out.slice(input.offset);
|
||||
if (input?.limit) out = out.slice(0, input.limit);
|
||||
return out;
|
||||
},
|
||||
async get(issueId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "issues.read");
|
||||
const issue = issues.get(issueId);
|
||||
return isInCompany(issue, companyId) ? issue : null;
|
||||
},
|
||||
async create(input) {
|
||||
requireCapability(manifest, capabilitySet, "issues.create");
|
||||
const now = new Date();
|
||||
const record: Issue = {
|
||||
id: randomUUID(),
|
||||
companyId: input.companyId,
|
||||
projectId: input.projectId ?? null,
|
||||
goalId: input.goalId ?? null,
|
||||
parentId: input.parentId ?? null,
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
status: "todo",
|
||||
priority: input.priority ?? "medium",
|
||||
assigneeAgentId: input.assigneeAgentId ?? null,
|
||||
assigneeUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
issueNumber: null,
|
||||
identifier: null,
|
||||
requestDepth: 0,
|
||||
billingCode: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
executionWorkspaceSettings: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
hiddenAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
issues.set(record.id, record);
|
||||
return record;
|
||||
},
|
||||
async update(issueId, patch, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "issues.update");
|
||||
const record = issues.get(issueId);
|
||||
if (!isInCompany(record, companyId)) throw new Error(`Issue not found: ${issueId}`);
|
||||
const updated: Issue = {
|
||||
...record,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
issues.set(issueId, updated);
|
||||
return updated;
|
||||
},
|
||||
async listComments(issueId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "issue.comments.read");
|
||||
if (!isInCompany(issues.get(issueId), companyId)) return [];
|
||||
return issueComments.get(issueId) ?? [];
|
||||
},
|
||||
async createComment(issueId, body, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "issue.comments.create");
|
||||
const parentIssue = issues.get(issueId);
|
||||
if (!isInCompany(parentIssue, companyId)) {
|
||||
throw new Error(`Issue not found: ${issueId}`);
|
||||
}
|
||||
const now = new Date();
|
||||
const comment: IssueComment = {
|
||||
id: randomUUID(),
|
||||
companyId: parentIssue.companyId,
|
||||
issueId,
|
||||
authorAgentId: null,
|
||||
authorUserId: null,
|
||||
body,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const current = issueComments.get(issueId) ?? [];
|
||||
current.push(comment);
|
||||
issueComments.set(issueId, current);
|
||||
return comment;
|
||||
},
|
||||
documents: {
|
||||
async list(issueId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "issue.documents.read");
|
||||
if (!isInCompany(issues.get(issueId), companyId)) return [];
|
||||
return [];
|
||||
},
|
||||
async get(issueId, _key, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "issue.documents.read");
|
||||
if (!isInCompany(issues.get(issueId), companyId)) return null;
|
||||
return null;
|
||||
},
|
||||
async upsert(input) {
|
||||
requireCapability(manifest, capabilitySet, "issue.documents.write");
|
||||
const parentIssue = issues.get(input.issueId);
|
||||
if (!isInCompany(parentIssue, input.companyId)) {
|
||||
throw new Error(`Issue not found: ${input.issueId}`);
|
||||
}
|
||||
throw new Error("documents.upsert is not implemented in test context");
|
||||
},
|
||||
async delete(issueId, _key, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "issue.documents.write");
|
||||
const parentIssue = issues.get(issueId);
|
||||
if (!isInCompany(parentIssue, companyId)) {
|
||||
throw new Error(`Issue not found: ${issueId}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "agents.read");
|
||||
const companyId = requireCompanyId(input?.companyId);
|
||||
let out = [...agents.values()];
|
||||
out = out.filter((agent) => agent.companyId === companyId);
|
||||
if (input?.status) out = out.filter((agent) => agent.status === input.status);
|
||||
if (input?.offset) out = out.slice(input.offset);
|
||||
if (input?.limit) out = out.slice(0, input.limit);
|
||||
return out;
|
||||
},
|
||||
async get(agentId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "agents.read");
|
||||
const agent = agents.get(agentId);
|
||||
return isInCompany(agent, companyId) ? agent : null;
|
||||
},
|
||||
async pause(agentId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "agents.pause");
|
||||
const cid = requireCompanyId(companyId);
|
||||
const agent = agents.get(agentId);
|
||||
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
|
||||
if (agent!.status === "terminated") throw new Error("Cannot pause terminated agent");
|
||||
const updated: Agent = { ...agent!, status: "paused", updatedAt: new Date() };
|
||||
agents.set(agentId, updated);
|
||||
return updated;
|
||||
},
|
||||
async resume(agentId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "agents.resume");
|
||||
const cid = requireCompanyId(companyId);
|
||||
const agent = agents.get(agentId);
|
||||
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
|
||||
if (agent!.status === "terminated") throw new Error("Cannot resume terminated agent");
|
||||
if (agent!.status === "pending_approval") throw new Error("Pending approval agents cannot be resumed");
|
||||
const updated: Agent = { ...agent!, status: "idle", updatedAt: new Date() };
|
||||
agents.set(agentId, updated);
|
||||
return updated;
|
||||
},
|
||||
async invoke(agentId, companyId, opts) {
|
||||
requireCapability(manifest, capabilitySet, "agents.invoke");
|
||||
const cid = requireCompanyId(companyId);
|
||||
const agent = agents.get(agentId);
|
||||
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
|
||||
if (
|
||||
agent!.status === "paused" ||
|
||||
agent!.status === "terminated" ||
|
||||
agent!.status === "pending_approval"
|
||||
) {
|
||||
throw new Error(`Agent is not invokable in its current state: ${agent!.status}`);
|
||||
}
|
||||
return { runId: randomUUID() };
|
||||
},
|
||||
async updatePermissions(agentId, companyId, permissions) {
|
||||
requireCapability(manifest, capabilitySet, "agents.update-permissions");
|
||||
const cid = requireCompanyId(companyId);
|
||||
const agent = agents.get(agentId);
|
||||
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
|
||||
const updated: Agent = {
|
||||
...agent!,
|
||||
permissions: { ...agent!.permissions, ...permissions },
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
agents.set(agentId, updated);
|
||||
return updated;
|
||||
},
|
||||
sessions: {
|
||||
async create(agentId, companyId, opts) {
|
||||
requireCapability(manifest, capabilitySet, "agent.sessions.create");
|
||||
const cid = requireCompanyId(companyId);
|
||||
const agent = agents.get(agentId);
|
||||
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
|
||||
const session: AgentSession = {
|
||||
sessionId: randomUUID(),
|
||||
agentId,
|
||||
companyId: cid,
|
||||
status: "active",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
sessions.set(session.sessionId, session);
|
||||
return session;
|
||||
},
|
||||
async list(agentId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "agent.sessions.list");
|
||||
const cid = requireCompanyId(companyId);
|
||||
return [...sessions.values()].filter(
|
||||
(s) => s.agentId === agentId && s.companyId === cid && s.status === "active",
|
||||
);
|
||||
},
|
||||
async sendMessage(sessionId, companyId, opts) {
|
||||
requireCapability(manifest, capabilitySet, "agent.sessions.send");
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session || session.status !== "active") throw new Error(`Session not found or closed: ${sessionId}`);
|
||||
if (session.companyId !== companyId) throw new Error(`Session not found: ${sessionId}`);
|
||||
if (opts.onEvent) {
|
||||
sessionEventCallbacks.set(sessionId, opts.onEvent);
|
||||
}
|
||||
return { runId: randomUUID() };
|
||||
},
|
||||
async close(sessionId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "agent.sessions.close");
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) throw new Error(`Session not found: ${sessionId}`);
|
||||
if (session.companyId !== companyId) throw new Error(`Session not found: ${sessionId}`);
|
||||
session.status = "closed";
|
||||
sessionEventCallbacks.delete(sessionId);
|
||||
},
|
||||
},
|
||||
},
|
||||
goals: {
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "goals.read");
|
||||
const companyId = requireCompanyId(input?.companyId);
|
||||
let out = [...goals.values()];
|
||||
out = out.filter((goal) => goal.companyId === companyId);
|
||||
if (input?.level) out = out.filter((goal) => goal.level === input.level);
|
||||
if (input?.status) out = out.filter((goal) => goal.status === input.status);
|
||||
if (input?.offset) out = out.slice(input.offset);
|
||||
if (input?.limit) out = out.slice(0, input.limit);
|
||||
return out;
|
||||
},
|
||||
async get(goalId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "goals.read");
|
||||
const goal = goals.get(goalId);
|
||||
return isInCompany(goal, companyId) ? goal : null;
|
||||
},
|
||||
async create(input) {
|
||||
requireCapability(manifest, capabilitySet, "goals.create");
|
||||
const now = new Date();
|
||||
const record: Goal = {
|
||||
id: randomUUID(),
|
||||
companyId: input.companyId,
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
level: input.level ?? "task",
|
||||
status: input.status ?? "planned",
|
||||
parentId: input.parentId ?? null,
|
||||
ownerAgentId: input.ownerAgentId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
goals.set(record.id, record);
|
||||
return record;
|
||||
},
|
||||
async update(goalId, patch, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "goals.update");
|
||||
const record = goals.get(goalId);
|
||||
if (!isInCompany(record, companyId)) throw new Error(`Goal not found: ${goalId}`);
|
||||
const updated: Goal = {
|
||||
...record,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
goals.set(goalId, updated);
|
||||
return updated;
|
||||
},
|
||||
},
|
||||
data: {
|
||||
register(key, handler) {
|
||||
dataHandlers.set(key, handler);
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
register(key, handler) {
|
||||
actionHandlers.set(key, handler);
|
||||
},
|
||||
},
|
||||
streams: (() => {
|
||||
const channelCompanyMap = new Map<string, string>();
|
||||
return {
|
||||
open(channel: string, companyId: string) {
|
||||
channelCompanyMap.set(channel, companyId);
|
||||
},
|
||||
emit(_channel: string, _event: unknown) {
|
||||
// No-op in test harness — events are not forwarded
|
||||
},
|
||||
close(channel: string) {
|
||||
channelCompanyMap.delete(channel);
|
||||
},
|
||||
};
|
||||
})(),
|
||||
tools: {
|
||||
register(name, _decl, fn) {
|
||||
requireCapability(manifest, capabilitySet, "agent.tools.register");
|
||||
toolHandlers.set(name, fn);
|
||||
},
|
||||
},
|
||||
metrics: {
|
||||
async write(name, value, tags) {
|
||||
requireCapability(manifest, capabilitySet, "metrics.write");
|
||||
metrics.push({ name, value, tags });
|
||||
},
|
||||
},
|
||||
logger: {
|
||||
info(message, meta) {
|
||||
logs.push({ level: "info", message, meta });
|
||||
},
|
||||
warn(message, meta) {
|
||||
logs.push({ level: "warn", message, meta });
|
||||
},
|
||||
error(message, meta) {
|
||||
logs.push({ level: "error", message, meta });
|
||||
},
|
||||
debug(message, meta) {
|
||||
logs.push({ level: "debug", message, meta });
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const harness: TestHarness = {
|
||||
ctx,
|
||||
seed(input) {
|
||||
for (const row of input.companies ?? []) companies.set(row.id, row);
|
||||
for (const row of input.projects ?? []) projects.set(row.id, row);
|
||||
for (const row of input.issues ?? []) issues.set(row.id, row);
|
||||
for (const row of input.issueComments ?? []) {
|
||||
const list = issueComments.get(row.issueId) ?? [];
|
||||
list.push(row);
|
||||
issueComments.set(row.issueId, list);
|
||||
}
|
||||
for (const row of input.agents ?? []) agents.set(row.id, row);
|
||||
for (const row of input.goals ?? []) goals.set(row.id, row);
|
||||
},
|
||||
setConfig(config) {
|
||||
currentConfig = { ...config };
|
||||
},
|
||||
async emit(eventType, payload, base) {
|
||||
const event: PluginEvent = {
|
||||
eventId: base?.eventId ?? randomUUID(),
|
||||
eventType,
|
||||
companyId: base?.companyId ?? "test-company",
|
||||
occurredAt: base?.occurredAt ?? new Date().toISOString(),
|
||||
actorId: base?.actorId,
|
||||
actorType: base?.actorType,
|
||||
entityId: base?.entityId,
|
||||
entityType: base?.entityType,
|
||||
payload,
|
||||
};
|
||||
|
||||
for (const handler of events) {
|
||||
const exactMatch = handler.name === event.eventType;
|
||||
const wildcardPluginAll = handler.name === "plugin.*" && String(event.eventType).startsWith("plugin.");
|
||||
const wildcardPluginOne = String(handler.name).endsWith(".*")
|
||||
&& String(event.eventType).startsWith(String(handler.name).slice(0, -1));
|
||||
if (!exactMatch && !wildcardPluginAll && !wildcardPluginOne) continue;
|
||||
if (!allowsEvent(handler.filter, event)) continue;
|
||||
await handler.fn(event);
|
||||
}
|
||||
},
|
||||
async runJob(jobKey, partial = {}) {
|
||||
const handler = jobs.get(jobKey);
|
||||
if (!handler) throw new Error(`No job handler registered for '${jobKey}'`);
|
||||
await handler({
|
||||
jobKey,
|
||||
runId: partial.runId ?? randomUUID(),
|
||||
trigger: partial.trigger ?? "manual",
|
||||
scheduledAt: partial.scheduledAt ?? new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
async getData<T = unknown>(key: string, params: Record<string, unknown> = {}) {
|
||||
const handler = dataHandlers.get(key);
|
||||
if (!handler) throw new Error(`No data handler registered for '${key}'`);
|
||||
return await handler(params) as T;
|
||||
},
|
||||
async performAction<T = unknown>(key: string, params: Record<string, unknown> = {}) {
|
||||
const handler = actionHandlers.get(key);
|
||||
if (!handler) throw new Error(`No action handler registered for '${key}'`);
|
||||
return await handler(params) as T;
|
||||
},
|
||||
async executeTool<T = ToolResult>(name: string, params: unknown, runCtx: Partial<ToolRunContext> = {}) {
|
||||
const handler = toolHandlers.get(name);
|
||||
if (!handler) throw new Error(`No tool handler registered for '${name}'`);
|
||||
const ctxToPass: ToolRunContext = {
|
||||
agentId: runCtx.agentId ?? "agent-test",
|
||||
runId: runCtx.runId ?? randomUUID(),
|
||||
companyId: runCtx.companyId ?? "company-test",
|
||||
projectId: runCtx.projectId ?? "project-test",
|
||||
};
|
||||
return await handler(params, ctxToPass) as T;
|
||||
},
|
||||
getState(input) {
|
||||
return state.get(stateMapKey(input));
|
||||
},
|
||||
simulateSessionEvent(sessionId, event) {
|
||||
const cb = sessionEventCallbacks.get(sessionId);
|
||||
if (!cb) throw new Error(`No active session event callback for session: ${sessionId}`);
|
||||
cb({ ...event, sessionId });
|
||||
},
|
||||
logs,
|
||||
activity,
|
||||
metrics,
|
||||
};
|
||||
|
||||
return harness;
|
||||
}
|
||||
Reference in New Issue
Block a user