feat(primitives): add signal module

This commit is contained in:
-Puter
2026-07-23 15:14:40 +05:30
parent e9260cbd17
commit ca967fcc1c
4 changed files with 436 additions and 1 deletions

View File

@@ -5,7 +5,8 @@
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./agent-os": "./src/agent-os.ts" "./agent-os": "./src/agent-os.ts",
"./signal": "./src/signal.ts"
}, },
"scripts": { "scripts": {
"check-types": "tsc --noEmit", "check-types": "tsc --noEmit",

View File

@@ -1 +1,3 @@
// oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules.
export * from "./agent-os"; export * from "./agent-os";
export * from "./signal";

View File

@@ -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");
});
});

View File

@@ -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>()(
"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<ConversationMessageId>();
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;
}
);