feat(shared): define harness protocols
This commit is contained in:
@@ -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<typeof ApiErrorSchema>;
|
||||||
|
|
||||||
|
export function createApiError(
|
||||||
|
code: string,
|
||||||
|
message: string,
|
||||||
|
requestId: string,
|
||||||
|
details: Record<string, unknown> = {},
|
||||||
|
): ApiError {
|
||||||
|
return ApiErrorSchema.parse({ error: { code, message, requestId, details } });
|
||||||
|
}
|
||||||
@@ -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<const T extends string, P extends z.ZodType>(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<typeof HarnessEventSchema>;
|
||||||
|
export type HarnessEventType = HarnessEvent["type"];
|
||||||
|
export type HarnessEventOf<T extends HarnessEventType> = Extract<HarnessEvent, { type: T }>;
|
||||||
|
export type EventInput<T extends HarnessEventType> = Omit<
|
||||||
|
HarnessEventOf<T>,
|
||||||
|
"eventId" | "sessionId" | "sequence" | "timestamp"
|
||||||
|
>;
|
||||||
@@ -1 +1,4 @@
|
|||||||
export {};
|
export * from "./errors.js";
|
||||||
|
export * from "./events.js";
|
||||||
|
export * from "./messages.js";
|
||||||
|
export * from "./model-config.js";
|
||||||
|
|||||||
@@ -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<typeof MessageInputSchema>;
|
||||||
|
export type ImageAttachment = z.infer<typeof ImageAttachmentSchema>;
|
||||||
@@ -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<typeof ModelApiSchema>;
|
||||||
|
export type ModelConnectionInput = z.infer<typeof ModelConnectionInputSchema>;
|
||||||
|
|
||||||
|
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<typeof RedactedModelConnectionSchema>;
|
||||||
@@ -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<string, unknown>) => 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown> = {
|
||||||
|
"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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user