mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
* fix(app): open notifications on the right host Agent notifications previously omitted workspace ownership, so a cold target host was treated as missing and fell back to its empty home route. Carry the authoritative workspace and keep older notifications on a target-host resolver until lookup is conclusive. * fix(app): separate notification and agent URL routing Notifications use their authoritative workspace target directly. Stable agent URLs remain server-and-agent targets whose workspace is resolved by the agent route. * fix(protocol): preserve notification workspace targets Both attention message variants retain workspaceId through outbound validation so notification clicks receive the authoritative route target. * test(app): use workspace-scoped notification target * test(server): give notification agents workspace targets * test(app): target notification workspace in navigation e2e
259 lines
8.3 KiB
TypeScript
259 lines
8.3 KiB
TypeScript
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { createRequire } from "node:module";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { createJiti } from "jiti";
|
|
import { describe, expect, it } from "vitest";
|
|
import { WSOutboundMessageSchema as GeneratedWSOutboundMessageSchema } from "../../src/generated/validation/ws-outbound.aot.js";
|
|
|
|
interface GeneratedSchema {
|
|
safeParse(input: unknown): { success: boolean; data?: unknown };
|
|
}
|
|
|
|
const protocolRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const generatedWSOutboundPath = resolve(
|
|
protocolRoot,
|
|
"src/generated/validation/ws-outbound.aot.ts",
|
|
);
|
|
const require = createRequire(import.meta.url);
|
|
|
|
async function compileInlineSchema(sourceSchema: string): Promise<GeneratedSchema> {
|
|
const scratchRoot = resolve(protocolRoot, "../../.tmp");
|
|
await mkdir(scratchRoot, { recursive: true });
|
|
const tempDir = await mkdtemp(join(scratchRoot, "paseo-zod-aot-"));
|
|
|
|
try {
|
|
const sourcePath = join(tempDir, "schema.source.js");
|
|
const outputPath = join(tempDir, "schema.generated.ts");
|
|
await writeFile(join(tempDir, "package.json"), '{"type":"module"}\n');
|
|
await writeFile(
|
|
sourcePath,
|
|
[
|
|
'import { z } from "zod";',
|
|
'import { compile } from "zod-aot";',
|
|
sourceSchema,
|
|
"export const Schema = compile(SourceSchema);",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
|
|
const zodAotEntry = require.resolve("zod-aot");
|
|
const zodAotRoot = resolve(dirname(zodAotEntry), "..");
|
|
const [{ discoverSchemas }, { compileSchemas }, { generateCompiledFileContent }] =
|
|
await Promise.all([
|
|
import(pathToFileURL(resolve(zodAotRoot, "dist/discovery.js")).href),
|
|
import(pathToFileURL(resolve(zodAotRoot, "dist/core/pipeline.js")).href),
|
|
import(pathToFileURL(resolve(zodAotRoot, "dist/cli/emitter.js")).href),
|
|
]);
|
|
|
|
const schemas = await discoverSchemas(sourcePath, { cacheBust: true });
|
|
const compiled = compileSchemas(schemas, { mode: "inline" });
|
|
const content = generateCompiledFileContent(compiled, "./schema.source.js", {
|
|
zodCompat: false,
|
|
});
|
|
await writeFile(outputPath, content);
|
|
|
|
const jiti = createJiti(import.meta.url, { moduleCache: false });
|
|
const generated = await jiti.import(outputPath);
|
|
return generated.Schema as GeneratedSchema;
|
|
} finally {
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
describe("WS outbound zod-aot validation", () => {
|
|
it("applies defaults inside discriminated-union branches", async () => {
|
|
const schema = await compileInlineSchema(`
|
|
const SourceSchema = z.discriminatedUnion("type", [
|
|
z.object({
|
|
type: z.literal("with_default"),
|
|
enabled: z.boolean().default(true),
|
|
}),
|
|
z.object({
|
|
type: z.literal("without_default"),
|
|
label: z.string(),
|
|
}),
|
|
]);
|
|
`);
|
|
|
|
expect(schema.safeParse({ type: "with_default" })).toMatchObject({
|
|
success: true,
|
|
data: { type: "with_default", enabled: true },
|
|
});
|
|
});
|
|
|
|
it("routes tool-call-like status unions through the current sequential item union", async () => {
|
|
const schema = await compileInlineSchema(`
|
|
const ToolCallItemSchema = z.discriminatedUnion("status", [
|
|
z.object({ type: z.literal("tool_call"), status: z.literal("running"), callId: z.string() }),
|
|
z.object({ type: z.literal("tool_call"), status: z.literal("completed"), callId: z.string(), output: z.string() }),
|
|
z.object({ type: z.literal("tool_call"), status: z.literal("failed"), callId: z.string(), error: z.string() }),
|
|
z.object({ type: z.literal("tool_call"), status: z.literal("canceled"), callId: z.string() }),
|
|
]);
|
|
|
|
const TimelineItemSchema = z.union([
|
|
z.object({ type: z.literal("assistant_message"), text: z.string() }),
|
|
ToolCallItemSchema,
|
|
]);
|
|
|
|
const SourceSchema = z.object({
|
|
item: TimelineItemSchema,
|
|
});
|
|
`);
|
|
|
|
expect(
|
|
schema.safeParse({ item: { type: "tool_call", status: "running", callId: "run" } }),
|
|
).toMatchObject({
|
|
success: true,
|
|
data: { item: { type: "tool_call", status: "running", callId: "run" } },
|
|
});
|
|
expect(
|
|
schema.safeParse({
|
|
item: { type: "tool_call", status: "completed", callId: "done", output: "ok" },
|
|
}),
|
|
).toMatchObject({
|
|
success: true,
|
|
data: { item: { type: "tool_call", status: "completed", callId: "done", output: "ok" } },
|
|
});
|
|
expect(
|
|
schema.safeParse({
|
|
item: { type: "tool_call", status: "failed", callId: "fail", error: "boom" },
|
|
}),
|
|
).toMatchObject({
|
|
success: true,
|
|
data: { item: { type: "tool_call", status: "failed", callId: "fail", error: "boom" } },
|
|
});
|
|
expect(
|
|
schema.safeParse({ item: { type: "tool_call", status: "canceled", callId: "stop" } }),
|
|
).toMatchObject({
|
|
success: true,
|
|
data: { item: { type: "tool_call", status: "canceled", callId: "stop" } },
|
|
});
|
|
});
|
|
|
|
it("accepts a minimal valid envelope and rejects a corrupted envelope", () => {
|
|
expect(GeneratedWSOutboundMessageSchema.safeParse({ type: "pong" }).success).toBe(true);
|
|
expect(GeneratedWSOutboundMessageSchema.safeParse({ type: "not_a_message" }).success).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "dedicated attention message",
|
|
message: {
|
|
type: "agent_attention_required",
|
|
payload: {
|
|
agentId: "agent-1",
|
|
reason: "finished",
|
|
timestamp: "2026-07-22T18:00:00.000Z",
|
|
shouldNotify: true,
|
|
notification: {
|
|
title: "Agent finished",
|
|
body: "Done",
|
|
data: {
|
|
serverId: "server-1",
|
|
workspaceId: "workspace-1",
|
|
agentId: "agent-1",
|
|
reason: "finished",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "agent stream attention event",
|
|
message: {
|
|
type: "agent_stream",
|
|
payload: {
|
|
agentId: "agent-1",
|
|
timestamp: "2026-07-22T18:00:00.000Z",
|
|
event: {
|
|
type: "attention_required",
|
|
provider: "codex",
|
|
reason: "finished",
|
|
timestamp: "2026-07-22T18:00:00.000Z",
|
|
shouldNotify: true,
|
|
notification: {
|
|
title: "Agent finished",
|
|
body: "Done",
|
|
data: {
|
|
serverId: "server-1",
|
|
workspaceId: "workspace-1",
|
|
agentId: "agent-1",
|
|
reason: "finished",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
])("preserves workspaceId in a $name", ({ message }) => {
|
|
const envelope = { type: "session", message };
|
|
|
|
expect(GeneratedWSOutboundMessageSchema.safeParse(envelope)).toEqual({
|
|
success: true,
|
|
data: envelope,
|
|
});
|
|
});
|
|
|
|
it("emits runtime imports with .js extensions", async () => {
|
|
const generated = await readFile(generatedWSOutboundPath, "utf8");
|
|
expect(generated).toContain('from "../../validation/ws-outbound-schema-metadata.js"');
|
|
});
|
|
|
|
it("accepts a forge.search.response envelope", () => {
|
|
const result = GeneratedWSOutboundMessageSchema.safeParse({
|
|
type: "session",
|
|
message: {
|
|
type: "forge.search.response",
|
|
payload: {
|
|
items: [
|
|
{
|
|
kind: "change_request",
|
|
number: 17,
|
|
title: "Fix search",
|
|
url: "https://gitlab.com/acme/repo/-/merge_requests/17",
|
|
state: "open",
|
|
body: null,
|
|
labels: [],
|
|
},
|
|
],
|
|
authState: "authenticated",
|
|
error: null,
|
|
requestId: "search-forge",
|
|
},
|
|
},
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("accepts a legacy github_search_response envelope", () => {
|
|
const result = GeneratedWSOutboundMessageSchema.safeParse({
|
|
type: "session",
|
|
message: {
|
|
type: "github_search_response",
|
|
payload: {
|
|
items: [
|
|
{
|
|
kind: "pr",
|
|
number: 42,
|
|
title: "Legacy PR",
|
|
url: "https://github.com/acme/repo/pull/42",
|
|
state: "open",
|
|
body: null,
|
|
labels: [],
|
|
},
|
|
],
|
|
featuresEnabled: true,
|
|
githubFeaturesEnabled: true,
|
|
authState: "authenticated",
|
|
error: null,
|
|
requestId: "search-github",
|
|
},
|
|
},
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
});
|