diff --git a/packages/primitives/package.json b/packages/primitives/package.json index 47d8bf1..0781e87 100644 --- a/packages/primitives/package.json +++ b/packages/primitives/package.json @@ -5,7 +5,8 @@ "type": "module", "exports": { ".": "./src/index.ts", - "./agent-os": "./src/agent-os.ts" + "./agent-os": "./src/agent-os.ts", + "./signal": "./src/signal.ts" }, "scripts": { "check-types": "tsc --noEmit", diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index 7c8f0e2..7228130 100644 --- a/packages/primitives/src/index.ts +++ b/packages/primitives/src/index.ts @@ -1 +1,3 @@ +// oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules. export * from "./agent-os"; +export * from "./signal"; diff --git a/packages/primitives/src/signal.test.ts b/packages/primitives/src/signal.test.ts new file mode 100644 index 0000000..e767463 --- /dev/null +++ b/packages/primitives/src/signal.test.ts @@ -0,0 +1,273 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; + +import { composeSignal, SignalValidationError } from "./signal"; +import type { SignalValidationReason } from "./signal"; + +type TestSignalScope = + | { + _tag: "Organization"; + organizationId: string; + } + | { + _tag: "Project"; + organizationId: string; + projectId: string; + }; + +interface TestSourceMessage { + conversationId: string; + messageId: string; + rawText: string; + createdAt: number; +} + +interface TestSignalInput { + id: string; + scope: TestSignalScope; + sourceMessages: [TestSourceMessage, TestSourceMessage]; + problemStatement: { + title: string; + summary: string; + desiredOutcome: string; + constraints: string[]; + }; + processedBy: { + agentName: string; + agentInstanceId: string; + }; + createdAt: number; +} + +const makeSignalInput = (): TestSignalInput => ({ + createdAt: 300, + id: "signal-1", + problemStatement: { + constraints: ["Keep existing account data"], + desiredOutcome: "Users can complete setup.", + summary: "The setup flow stops before completion.", + title: "Users cannot finish setup", + }, + processedBy: { + agentInstanceId: "organization-1", + agentName: "zopu", + }, + scope: { + _tag: "Organization", + organizationId: "organization-1", + }, + sourceMessages: [ + { + conversationId: "conversation-1", + createdAt: 100, + messageId: "message-1", + rawText: "First source", + }, + { + conversationId: "conversation-1", + createdAt: 200, + messageId: "message-2", + rawText: "Second source", + }, + ], +}); + +const runSignal = (input: unknown) => Effect.runSync(composeSignal(input)); + +const getValidationError = ( + input: unknown, + reason: SignalValidationReason +): SignalValidationError => { + const error = Effect.runSync(Effect.flip(composeSignal(input))); + expect(error).toBeInstanceOf(SignalValidationError); + expect(error.reason).toBe(reason); + return error; +}; + +describe("composeSignal", () => { + it("accepts organization scope", () => { + const signal = runSignal(makeSignalInput()); + + expect(signal.scope).toEqual({ + _tag: "Organization", + organizationId: "organization-1", + }); + }); + + it("accepts project scope", () => { + const input = makeSignalInput(); + input.scope = { + _tag: "Project", + organizationId: "organization-1", + projectId: "project-1", + }; + + const signal = runSignal(input); + + expect(signal.scope).toEqual({ + _tag: "Project", + organizationId: "organization-1", + projectId: "project-1", + }); + }); + + it("rejects empty IDs and processed-agent identity", () => { + const mutations: readonly ((input: TestSignalInput) => void)[] = [ + (input) => { + input.id = ""; + }, + (input) => { + input.scope.organizationId = ""; + }, + (input) => { + input.scope = { + _tag: "Project", + organizationId: "organization-1", + projectId: "", + }; + }, + (input) => { + input.sourceMessages[0].conversationId = ""; + }, + (input) => { + input.sourceMessages[0].messageId = ""; + }, + (input) => { + input.processedBy.agentName = ""; + }, + (input) => { + input.processedBy.agentInstanceId = ""; + }, + ]; + + for (const mutate of mutations) { + const input = makeSignalInput(); + mutate(input); + getValidationError(input, "InvalidInput"); + } + }); + + it("rejects negative and fractional timestamps", () => { + const mutations: readonly ((input: TestSignalInput) => void)[] = [ + (input) => { + input.sourceMessages[0].createdAt = -1; + }, + (input) => { + input.sourceMessages[0].createdAt = 1.5; + }, + (input) => { + input.createdAt = -1; + }, + (input) => { + input.createdAt = 1.5; + }, + ]; + + for (const mutate of mutations) { + const input = makeSignalInput(); + mutate(input); + getValidationError(input, "InvalidInput"); + } + }); + + it("rejects empty problem-statement fields", () => { + for (const field of ["title", "summary", "desiredOutcome"] as const) { + const input = makeSignalInput(); + input.problemStatement[field] = ""; + getValidationError(input, "InvalidInput"); + } + }); + + it("rejects whitespace-only problem fields and constraints", () => { + const input = makeSignalInput(); + input.problemStatement.title = " "; + input.problemStatement.constraints = ["\t\n"]; + + getValidationError(input, "InvalidInput"); + }); + + it("rejects an empty constraint item", () => { + const input = makeSignalInput(); + input.problemStatement.constraints = [""]; + + getValidationError(input, "InvalidInput"); + }); + + it("accepts an empty constraints list", () => { + const input = makeSignalInput(); + input.problemStatement.constraints = []; + + expect(runSignal(input).problemStatement.constraints).toEqual([]); + }); + + it("rejects an empty source list", () => { + const input = { + ...makeSignalInput(), + sourceMessages: [], + }; + + getValidationError(input, "InvalidInput"); + }); + + it("rejects duplicate source identities", () => { + const input = makeSignalInput(); + input.sourceMessages[1].messageId = input.sourceMessages[0].messageId; + + getValidationError(input, "DuplicateSource"); + }); + + it("rejects sources from mixed conversations", () => { + const input = makeSignalInput(); + input.sourceMessages[1].conversationId = "conversation-2"; + + getValidationError(input, "MixedConversation"); + }); + + it("rejects decreasing source timestamps", () => { + const input = makeSignalInput(); + input.sourceMessages[1].createdAt = 99; + + getValidationError(input, "OutOfOrderSource"); + }); + + it("preserves source order and exact raw whitespace", () => { + const input = makeSignalInput(); + input.sourceMessages[0].rawText = " first source\n"; + input.sourceMessages[1].rawText = "\tsecond source "; + + const signal = runSignal(input); + + expect(signal.sourceMessages.map(({ messageId }) => messageId)).toEqual([ + "message-1", + "message-2", + ]); + expect(signal.sourceMessages.map(({ rawText }) => rawText)).toEqual([ + " first source\n", + "\tsecond source ", + ]); + }); + + it("preserves problem-statement whitespace while validating trimmed non-emptiness", () => { + const input = makeSignalInput(); + input.problemStatement.title = " Setup blocked "; + input.problemStatement.constraints = [" Preserve data "]; + + const signal = runSignal(input); + + expect(signal.problemStatement.title).toBe(" Setup blocked "); + expect(signal.problemStatement.constraints).toEqual([" Preserve data "]); + }); + + it("does not include candidate, work-unit, or run fields", () => { + const signal = runSignal({ + ...makeSignalInput(), + candidateId: "candidate-1", + runId: "run-1", + workUnitId: "work-unit-1", + }); + + expect(signal).not.toHaveProperty("candidateId"); + expect(signal).not.toHaveProperty("workUnitId"); + expect(signal).not.toHaveProperty("runId"); + }); +}); diff --git a/packages/primitives/src/signal.ts b/packages/primitives/src/signal.ts new file mode 100644 index 0000000..ceb1f30 --- /dev/null +++ b/packages/primitives/src/signal.ts @@ -0,0 +1,159 @@ +import { Effect, Schema } from "effect"; + +const MeaningfulString = Schema.String.check( + Schema.makeFilter((value) => value.trim().length > 0, { + expected: "a non-empty string", + }) +); + +export const SignalId = MeaningfulString.pipe(Schema.brand("SignalId")); +export type SignalId = typeof SignalId.Type; + +export const OrganizationId = MeaningfulString.pipe( + Schema.brand("OrganizationId") +); +export type OrganizationId = typeof OrganizationId.Type; + +export const ProjectId = MeaningfulString.pipe(Schema.brand("ProjectId")); +export type ProjectId = typeof ProjectId.Type; + +export const ConversationId = MeaningfulString.pipe( + Schema.brand("ConversationId") +); +export type ConversationId = typeof ConversationId.Type; + +export const ConversationMessageId = MeaningfulString.pipe( + Schema.brand("ConversationMessageId") +); +export type ConversationMessageId = typeof ConversationMessageId.Type; + +export const TimestampMs = Schema.Int.check( + Schema.isGreaterThanOrEqualTo(0) +).pipe(Schema.brand("TimestampMs")); +export type TimestampMs = typeof TimestampMs.Type; + +export const OrganizationSignalScope = Schema.Struct({ + _tag: Schema.Literal("Organization"), + organizationId: OrganizationId, +}); +export type OrganizationSignalScope = typeof OrganizationSignalScope.Type; + +export const ProjectSignalScope = Schema.Struct({ + _tag: Schema.Literal("Project"), + organizationId: OrganizationId, + projectId: ProjectId, +}); +export type ProjectSignalScope = typeof ProjectSignalScope.Type; + +export const SignalScope = Schema.Union([ + OrganizationSignalScope, + ProjectSignalScope, +]); +export type SignalScope = typeof SignalScope.Type; + +export const SignalSourceMessage = Schema.Struct({ + conversationId: ConversationId, + createdAt: TimestampMs, + messageId: ConversationMessageId, + rawText: Schema.String, +}); +export type SignalSourceMessage = typeof SignalSourceMessage.Type; + +export const ProblemStatement = Schema.Struct({ + constraints: Schema.Array(MeaningfulString), + desiredOutcome: MeaningfulString, + summary: MeaningfulString, + title: MeaningfulString, +}); +export type ProblemStatement = typeof ProblemStatement.Type; + +export const ProcessedByAgent = Schema.Struct({ + agentInstanceId: MeaningfulString, + agentName: MeaningfulString, +}); +export type ProcessedByAgent = typeof ProcessedByAgent.Type; + +const signalFields = { + createdAt: TimestampMs, + id: SignalId, + problemStatement: ProblemStatement, + processedBy: ProcessedByAgent, + scope: SignalScope, + sourceMessages: Schema.NonEmptyArray(SignalSourceMessage), +} as const; + +export const SignalInput = Schema.Struct(signalFields); +export type SignalInput = typeof SignalInput.Type; + +export const Signal = Schema.Struct(signalFields); +export type Signal = typeof Signal.Type; + +export const SignalValidationReason = Schema.Literals([ + "InvalidInput", + "DuplicateSource", + "MixedConversation", + "OutOfOrderSource", +]); +export type SignalValidationReason = typeof SignalValidationReason.Type; + +export class SignalValidationError extends Schema.TaggedErrorClass()( + "SignalValidationError", + { + message: Schema.String, + reason: SignalValidationReason, + } +) {} + +export const composeSignal = Effect.fn("Signal.compose")( + function* composeSignal(input: unknown) { + const signal: Signal = yield* Schema.decodeUnknownEffect(SignalInput)( + input + ).pipe( + Effect.mapError( + (cause) => + new SignalValidationError({ + message: cause.message, + reason: "InvalidInput", + }) + ) + ); + + const [{ conversationId, createdAt: firstCreatedAt }] = + signal.sourceMessages; + let previousCreatedAt = firstCreatedAt; + const messageIds = new Set(); + + for (const source of signal.sourceMessages) { + if (source.conversationId !== conversationId) { + return yield* Effect.fail( + new SignalValidationError({ + message: "Signal sources must belong to one conversation", + reason: "MixedConversation", + }) + ); + } + + if (messageIds.has(source.messageId)) { + return yield* Effect.fail( + new SignalValidationError({ + message: "Signal sources must have unique message identities", + reason: "DuplicateSource", + }) + ); + } + messageIds.add(source.messageId); + + if (source.createdAt < previousCreatedAt) { + return yield* Effect.fail( + new SignalValidationError({ + message: "Signal source timestamps must be non-decreasing", + reason: "OutOfOrderSource", + }) + ); + } + previousCreatedAt = source.createdAt; + } + + return signal; + } +);