diff --git a/packages/shared/src/errors.ts b/packages/shared/src/errors.ts new file mode 100644 index 0000000..b54fc63 --- /dev/null +++ b/packages/shared/src/errors.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +export const ApiErrorSchema = z.object({ + error: z.object({ + code: z.string().trim().min(1).max(100), + message: z.string().trim().min(1).max(2000), + requestId: z.string().trim().min(1).max(200), + details: z.record(z.string(), z.unknown()), + }).strict(), +}).strict(); + +export type ApiError = z.infer; + +export function createApiError( + code: string, + message: string, + requestId: string, + details: Record = {}, +): ApiError { + return ApiErrorSchema.parse({ error: { code, message, requestId, details } }); +} diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts new file mode 100644 index 0000000..7643002 --- /dev/null +++ b/packages/shared/src/events.ts @@ -0,0 +1,136 @@ +import { z } from "zod"; + +const id = z.string().trim().min(1).max(200); +const safeUnknown = z.unknown(); + +const base = { + eventId: id, + sessionId: id, + runId: id.optional(), + timestamp: z.string().datetime({ offset: true }), + sequence: z.int().positive(), +}; + +function event(type: T, payload: P) { + return z.object({ ...base, type: z.literal(type), payload }).strict(); +} + +const sessionStarted = event("session.started", z.object({ + cwd: z.string().trim().min(1).max(1000), +}).strict()); +const sessionEnded = event("session.ended", z.object({ + reason: z.enum(["deleted", "shutdown", "error"]), +}).strict()); +const userMessage = event("user.message", z.object({ + messageId: id, + text: z.string().max(100_000), + attachmentIds: z.array(id).max(4), +}).strict()); +const assistantDelta = event("assistant.delta", z.object({ + messageId: id, + delta: z.string(), +}).strict()); +const assistantCompleted = event("assistant.completed", z.object({ + messageId: id, +}).strict()); +const skillRequested = event("skill.requested", z.object({ + name: z.string().trim().min(1).max(64), + source: z.string().trim().min(1).max(200), +}).strict()); +const skillLoaded = event("skill.loaded", z.object({ + name: z.string().trim().min(1).max(64), + source: z.string().trim().min(1).max(200), +}).strict()); +const toolRequested = event("tool.requested", z.object({ + toolCallId: id, + toolName: z.string().trim().min(1).max(100), + arguments: safeUnknown, +}).strict()); +const toolCompleted = event("tool.completed", z.object({ + toolCallId: id, + toolName: z.string().trim().min(1).max(100), + result: safeUnknown, +}).strict()); +const toolFailed = event("tool.failed", z.object({ + toolCallId: id, + toolName: z.string().trim().min(1).max(100), + error: z.string().trim().min(1).max(2000), +}).strict()); +const approvalRequired = event("approval.required", z.object({ + approvalId: id, + toolCallId: id, + toolName: z.string().trim().min(1).max(100), + arguments: safeUnknown, + riskLevel: z.enum(["low", "medium", "high"]), + expiresAt: z.string().datetime({ offset: true }), +}).strict()); +const approvalResolved = event("approval.resolved", z.object({ + approvalId: id, + status: z.enum(["approved", "denied", "expired", "cancelled"]), +}).strict()); +const attachmentUploaded = event("attachment.uploaded", z.object({ + attachmentId: id, + name: z.string().trim().min(1).max(255), + mediaType: z.enum(["image/jpeg", "image/png", "image/webp"]), + byteSize: z.int().positive(), + width: z.int().positive(), + height: z.int().positive(), +}).strict()); +const attachmentRejected = event("attachment.rejected", z.object({ + name: z.string().trim().min(1).max(255), + reason: z.string().trim().min(1).max(200), +}).strict()); +const modelConfigured = event("model.configured", z.object({ + providerId: z.string().trim().min(1).max(80), + modelId: z.string().trim().min(1).max(200), + supportsImages: z.boolean(), + source: z.enum(["session", "environment"]), +}).strict()); +const modelValidationFailed = event("model.validation_failed", z.object({ + code: z.string().trim().min(1).max(100), + message: z.string().trim().min(1).max(2000), +}).strict()); +const runStarted = event("run.started", z.object({ + modelId: z.string().trim().min(1).max(200), +}).strict()); +const runCancelled = event("run.cancelled", z.object({ + reason: z.enum(["user", "timeout", "session_deleted", "shutdown"]), +}).strict()); +const runCompleted = event("run.completed", z.object({ + messageId: id, +}).strict()); +const runFailed = event("run.failed", z.object({ + code: z.string().trim().min(1).max(100), + message: z.string().trim().min(1).max(2000), +}).strict()); + +export const HarnessEventSchema = z.discriminatedUnion("type", [ + sessionStarted, + sessionEnded, + userMessage, + assistantDelta, + assistantCompleted, + skillRequested, + skillLoaded, + toolRequested, + toolCompleted, + toolFailed, + approvalRequired, + approvalResolved, + attachmentUploaded, + attachmentRejected, + modelConfigured, + modelValidationFailed, + runStarted, + runCancelled, + runCompleted, + runFailed, +]); + +export type HarnessEvent = z.infer; +export type HarnessEventType = HarnessEvent["type"]; +export type HarnessEventOf = Extract; +export type EventInput = Omit< + HarnessEventOf, + "eventId" | "sessionId" | "sequence" | "timestamp" +>; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index cb0ff5c..80ef060 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1 +1,4 @@ -export {}; +export * from "./errors.js"; +export * from "./events.js"; +export * from "./messages.js"; +export * from "./model-config.js"; diff --git a/packages/shared/src/messages.ts b/packages/shared/src/messages.ts new file mode 100644 index 0000000..fda84a6 --- /dev/null +++ b/packages/shared/src/messages.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; + +export const EntityIdSchema = z.string().regex(/^[a-z]+_[a-zA-Z0-9_-]+$/); +export const AttachmentIdSchema = z.string().regex(/^att_[a-zA-Z0-9_-]+$/); + +export const MessageInputSchema = z.object({ + text: z.string().max(100_000).default(""), + attachmentIds: z.array(AttachmentIdSchema).max(4).default([]), +}).strict().superRefine((value, context) => { + if (value.text.trim().length === 0 && value.attachmentIds.length === 0) { + context.addIssue({ + code: "custom", + path: ["text"], + message: "A message requires text or at least one attachment", + }); + } + + if (new Set(value.attachmentIds).size !== value.attachmentIds.length) { + context.addIssue({ + code: "custom", + path: ["attachmentIds"], + message: "Attachment IDs must be unique", + }); + } +}); + +export const ImageAttachmentSchema = z.object({ + attachmentId: AttachmentIdSchema, + sessionId: z.string().regex(/^ses_[a-zA-Z0-9_-]+$/), + name: z.string().trim().min(1).max(255), + mediaType: z.enum(["image/jpeg", "image/png", "image/webp"]), + byteSize: z.int().positive(), + width: z.int().positive(), + height: z.int().positive(), + status: z.literal("ready"), +}).strict(); + +export type MessageInput = z.infer; +export type ImageAttachment = z.infer; diff --git a/packages/shared/src/model-config.ts b/packages/shared/src/model-config.ts new file mode 100644 index 0000000..728685d --- /dev/null +++ b/packages/shared/src/model-config.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; + +export const ModelApiSchema = z.enum([ + "openai-responses", + "openai-completions", + "anthropic-messages", +]); + +export const ModelConnectionInputSchema = z.object({ + providerId: z.string().trim().min(1).max(80).regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/), + api: ModelApiSchema, + baseUrl: z.url().max(2048).optional(), + apiKey: z.string().min(1).max(8192).optional(), + modelId: z.string().trim().min(1).max(200), + displayName: z.string().trim().min(1).max(200).optional(), + supportsImages: z.boolean(), + contextWindow: z.int().positive().max(10_000_000).optional(), + maxTokens: z.int().positive().max(1_000_000).optional(), +}).strict(); + +export type ModelApi = z.infer; +export type ModelConnectionInput = z.infer; + +export const RedactedModelConnectionSchema = ModelConnectionInputSchema + .omit({ apiKey: true }) + .extend({ + configured: z.boolean(), + source: z.enum(["session", "environment", "missing"]), + keyHint: z.string().max(12).optional(), + }) + .strict(); + +export type RedactedModelConnection = z.infer; diff --git a/packages/shared/tests/errors.test.ts b/packages/shared/tests/errors.test.ts new file mode 100644 index 0000000..0b0eebe --- /dev/null +++ b/packages/shared/tests/errors.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import * as shared from "../src/index.js"; + +describe("createApiError", () => { + it("returns the uniform safe error envelope", () => { + const factory = Reflect.get(shared, "createApiError") as ((code: string, message: string, requestId: string, details?: Record) => unknown) | undefined; + expect(factory, "createApiError must be exported").toBeDefined(); + expect(factory?.("SESSION_NOT_FOUND", "Session not found", "req_1", { sessionId: "ses_1" })).toEqual({ + error: { + code: "SESSION_NOT_FOUND", + message: "Session not found", + requestId: "req_1", + details: { sessionId: "ses_1" }, + }, + }); + }); + + it("rejects malformed envelopes", () => { + const candidate = Reflect.get(shared, "ApiErrorSchema") as { safeParse(value: unknown): { success: boolean } } | undefined; + expect(candidate, "ApiErrorSchema must be exported").toBeDefined(); + expect(candidate?.safeParse({ error: { code: "", message: "bad" } }).success).toBe(false); + }); +}); diff --git a/packages/shared/tests/events.test.ts b/packages/shared/tests/events.test.ts new file mode 100644 index 0000000..4227366 --- /dev/null +++ b/packages/shared/tests/events.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import * as shared from "../src/index.js"; + +function schema(): { parse(value: unknown): unknown; safeParse(value: unknown): { success: boolean } } { + const candidate = Reflect.get(shared, "HarnessEventSchema"); + expect(candidate, "HarnessEventSchema must be exported").toBeDefined(); + return candidate; +} + +const base = { + eventId: "evt_1", + sessionId: "ses_1", + timestamp: "2026-09-07T00:00:00.000Z", + sequence: 1, +}; + +describe("HarnessEventSchema", () => { + it("accepts every required event discriminant", () => { + const payloads: Record = { + "session.started": { cwd: "workspace/sessions/ses_1" }, + "session.ended": { reason: "deleted" }, + "user.message": { messageId: "msg_1", text: "hello", attachmentIds: [] }, + "assistant.delta": { messageId: "msg_2", delta: "hel" }, + "assistant.completed": { messageId: "msg_2" }, + "skill.requested": { name: "ads-analysis", source: "project" }, + "skill.loaded": { name: "ads-analysis", source: "project" }, + "tool.requested": { toolCallId: "call_1", toolName: "mock_ads_metrics", arguments: {} }, + "tool.completed": { toolCallId: "call_1", toolName: "mock_ads_metrics", result: {} }, + "tool.failed": { toolCallId: "call_1", toolName: "mock_ads_metrics", error: "failed" }, + "approval.required": { approvalId: "ap_1", toolCallId: "call_1", toolName: "save_report_draft", riskLevel: "medium", expiresAt: "2026-09-07T00:01:00.000Z", arguments: {} }, + "approval.resolved": { approvalId: "ap_1", status: "approved" }, + "attachment.uploaded": { attachmentId: "att_1", name: "creative.png", mediaType: "image/png", byteSize: 12, width: 1, height: 1 }, + "attachment.rejected": { name: "bad.svg", reason: "UNSUPPORTED_IMAGE" }, + "model.configured": { providerId: "openai", modelId: "gpt-image", supportsImages: true, source: "session" }, + "model.validation_failed": { code: "MODEL_CONNECTION_FAILED", message: "Connection failed" }, + "run.started": { modelId: "gpt-image" }, + "run.cancelled": { reason: "user" }, + "run.completed": { messageId: "msg_2" }, + "run.failed": { code: "MODEL_ERROR", message: "Run failed" }, + }; + + for (const [type, payload] of Object.entries(payloads)) { + expect(schema().safeParse({ ...base, type, payload }).success, type).toBe(true); + } + }); + + it("rejects a payload belonging to another discriminant", () => { + expect(schema().safeParse({ + ...base, + type: "assistant.delta", + payload: { approvalId: "ap_1" }, + }).success).toBe(false); + }); + + it("requires a positive sequence", () => { + expect(schema().safeParse({ + ...base, + sequence: 0, + type: "session.started", + payload: { cwd: "workspace/sessions/ses_1" }, + }).success).toBe(false); + }); +}); diff --git a/packages/shared/tests/messages.test.ts b/packages/shared/tests/messages.test.ts new file mode 100644 index 0000000..9914706 --- /dev/null +++ b/packages/shared/tests/messages.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import * as shared from "../src/index.js"; + +function messageSchema(): { safeParse(value: unknown): { success: boolean } } { + const candidate = Reflect.get(shared, "MessageInputSchema"); + expect(candidate, "MessageInputSchema must be exported").toBeDefined(); + return candidate; +} + +describe("MessageInputSchema", () => { + it("accepts text-only, image-only, and combined messages", () => { + expect(messageSchema().safeParse({ text: "hello", attachmentIds: [] }).success).toBe(true); + expect(messageSchema().safeParse({ text: "", attachmentIds: ["att_123"] }).success).toBe(true); + expect(messageSchema().safeParse({ text: "look", attachmentIds: ["att_123"] }).success).toBe(true); + }); + + it("rejects a message without text or attachments", () => { + expect(messageSchema().safeParse({ text: " ", attachmentIds: [] }).success).toBe(false); + }); + + it("rejects duplicate or malformed attachment ids", () => { + expect(messageSchema().safeParse({ text: "x", attachmentIds: ["att_1", "att_1"] }).success).toBe(false); + expect(messageSchema().safeParse({ text: "x", attachmentIds: ["../secret"] }).success).toBe(false); + }); +}); diff --git a/packages/shared/tests/model-config.test.ts b/packages/shared/tests/model-config.test.ts new file mode 100644 index 0000000..e09cbda --- /dev/null +++ b/packages/shared/tests/model-config.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import * as shared from "../src/index.js"; + +function modelSchema(): { safeParse(value: unknown): { success: boolean } } { + const candidate = Reflect.get(shared, "ModelConnectionInputSchema"); + expect(candidate, "ModelConnectionInputSchema must be exported").toBeDefined(); + return candidate; +} + +const valid = { + providerId: "openai", + api: "openai-responses", + modelId: "gpt-image", + supportsImages: true, +}; + +describe("ModelConnectionInputSchema", () => { + it("accepts a supported API with bounded model metadata", () => { + expect(modelSchema().safeParse({ + ...valid, + baseUrl: "https://api.example.com/v1", + apiKey: "sk-example", + displayName: "Training model", + contextWindow: 128_000, + maxTokens: 4096, + }).success).toBe(true); + }); + + it("rejects unknown fields instead of silently stripping them", () => { + expect(modelSchema().safeParse({ ...valid, headers: { authorization: "secret" } }).success).toBe(false); + }); + + it.each(["gemini", "openai-chat", ""])("rejects unsupported API %s", (api) => { + expect(modelSchema().safeParse({ ...valid, api }).success).toBe(false); + }); +});