| 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 { |
| |
| manifest: PaperclipPluginManifestV1; |
| |
| capabilities?: PluginCapability[]; |
| |
| config?: Record<string, unknown>; |
| } |
|
|
| export interface TestHarnessLogEntry { |
| level: "info" | "warn" | "error" | "debug"; |
| message: string; |
| meta?: Record<string, unknown>; |
| } |
|
|
| export interface TestHarness { |
| |
| ctx: PluginContext; |
| |
| seed(input: { |
| companies?: Company[]; |
| projects?: Project[]; |
| issues?: Issue[]; |
| issueComments?: IssueComment[]; |
| agents?: Agent[]; |
| goals?: Goal[]; |
| }): void; |
| setConfig(config: Record<string, unknown>): void; |
| |
| emit(eventType: PluginEventType | `plugin.${string}`, payload: unknown, base?: Partial<PluginEvent>): Promise<void>; |
| |
| runJob(jobKey: string, partial?: Partial<PluginJobContext>): Promise<void>; |
| |
| getData<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>; |
| |
| performAction<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>; |
| |
| executeTool<T = ToolResult>(name: string, params: unknown, runCtx?: Partial<ToolRunContext>): Promise<T>; |
| |
| getState(input: ScopeKey): unknown; |
| |
| 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); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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, |
| projectWorkspaceId: 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, |
| executionWorkspaceId: null, |
| executionWorkspacePreference: 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() }; |
| }, |
| 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) { |
| |
| }, |
| 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; |
| } |
|
|