Extract client SDK package (#1052)
* Extract client SDK package * Polish SDK client identity defaults * Build client before dependent CI jobs * Restore daemon client server export * Extract protocol and client SDK packages * Fix provider override schema validation * Fix app test daemon client imports * Simplify workspace build targets * Fix CLI test server build bootstrap * Run SDK package tests in CI * Fix rebase package split drift * Restore lockfile registry metadata * Update SDK config test for prompt default * Move terminal stream router test to client package * Fix rebase drift for protocol imports * Fix SDK agent capability fixture * Restore legacy server client exports * Fix server export compatibility test * Advertise custom mode icon client capability * Remove server daemon-client exports * Format rebased mode control import * Fix rebase drift for protocol imports Files added by upstream PRs (#893, #1147, #1154) referenced the pre-split shared/ paths that this branch moves into @getpaseo/protocol. Redirect those imports to the protocol package so typecheck stays green after the rebase.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildAgentAttentionNotificationPayload,
|
||||
findLatestAssistantMessageFromTimeline,
|
||||
findLatestPermissionRequest,
|
||||
} from "./agent-attention-notification.js";
|
||||
|
||||
describe("buildAgentAttentionNotificationPayload", () => {
|
||||
it("builds finished notifications from markdown assistant text", () => {
|
||||
const payload = buildAgentAttentionNotificationPayload({
|
||||
reason: "finished",
|
||||
serverId: "srv-1",
|
||||
agentId: "agent-1",
|
||||
assistantMessage: "**Done**. Updated `README.md` and [link](https://example.com).",
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
title: "Agent finished",
|
||||
body: "Done. Updated README.md and link.",
|
||||
data: {
|
||||
serverId: "srv-1",
|
||||
agentId: "agent-1",
|
||||
reason: "finished",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds permission notifications from request details", () => {
|
||||
const payload = buildAgentAttentionNotificationPayload({
|
||||
reason: "permission",
|
||||
serverId: "srv-2",
|
||||
agentId: "agent-2",
|
||||
permissionRequest: {
|
||||
id: "perm-1",
|
||||
provider: "claude",
|
||||
name: "exec",
|
||||
kind: "tool",
|
||||
title: "**Approve command**",
|
||||
description: "Run `git push`",
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
title: "Agent needs permission",
|
||||
body: "Approve command - Run git push",
|
||||
data: {
|
||||
serverId: "srv-2",
|
||||
agentId: "agent-2",
|
||||
reason: "permission",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses error-specific defaults when reason is error", () => {
|
||||
const payload = buildAgentAttentionNotificationPayload({
|
||||
reason: "error",
|
||||
serverId: "srv-3",
|
||||
agentId: "agent-3",
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
title: "Agent needs attention",
|
||||
body: "Encountered an error.",
|
||||
data: {
|
||||
serverId: "srv-3",
|
||||
agentId: "agent-3",
|
||||
reason: "error",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findLatestAssistantMessageFromTimeline", () => {
|
||||
it("joins the latest contiguous assistant chunks", () => {
|
||||
expect(
|
||||
findLatestAssistantMessageFromTimeline([
|
||||
{ type: "user_message", text: "start" },
|
||||
{ type: "assistant_message", text: "Part " },
|
||||
{ type: "assistant_message", text: "one" },
|
||||
{ type: "reasoning", text: "thinking..." },
|
||||
{ type: "assistant_message", text: "Done " },
|
||||
{ type: "assistant_message", text: "now" },
|
||||
]),
|
||||
).toBe("Done now");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findLatestPermissionRequest", () => {
|
||||
it("returns the most recently inserted request", () => {
|
||||
const pending = new Map([
|
||||
["first", { id: "first", provider: "claude", name: "a", kind: "tool" } as const],
|
||||
["second", { id: "second", provider: "claude", name: "b", kind: "tool" } as const],
|
||||
]);
|
||||
|
||||
expect(findLatestPermissionRequest(pending)?.id).toBe("second");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
const NOTIFICATION_PREVIEW_LIMIT = 220;
|
||||
|
||||
export type AgentAttentionReason = "finished" | "error" | "permission";
|
||||
|
||||
export interface AgentAttentionNotificationData {
|
||||
[key: string]: unknown;
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
reason: AgentAttentionReason;
|
||||
}
|
||||
|
||||
export interface AgentAttentionNotificationPayload {
|
||||
title: string;
|
||||
body: string;
|
||||
data: AgentAttentionNotificationData;
|
||||
}
|
||||
|
||||
interface BuildAgentAttentionNotificationPayloadInput {
|
||||
reason: AgentAttentionReason;
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
assistantMessage?: string | null;
|
||||
permissionRequest?: NotificationPermissionRequest | null;
|
||||
}
|
||||
|
||||
export interface NotificationPermissionRequest {
|
||||
id: string;
|
||||
provider: string;
|
||||
name: string;
|
||||
kind: "tool" | "plan" | "question" | "mode" | "other";
|
||||
title?: string;
|
||||
description?: string;
|
||||
input?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type AssistantTimelineItem =
|
||||
| { type: "assistant_message"; text: string }
|
||||
| { type: string; text?: string };
|
||||
|
||||
const normalizeNotificationText = (text: string): string => text.replace(/\s+/g, " ").trim();
|
||||
|
||||
const truncateNotificationText = (text: string, limit: number): string => {
|
||||
if (text.length <= limit) {
|
||||
return text;
|
||||
}
|
||||
const trimmed = text.slice(0, Math.max(0, limit - 3)).trimEnd();
|
||||
return trimmed.length > 0 ? `${trimmed}...` : text.slice(0, limit);
|
||||
};
|
||||
|
||||
const stripMarkdownToText = (markdown: string): string => {
|
||||
let text = markdown.replace(/\r\n/g, "\n");
|
||||
|
||||
// Strip fenced code markers but keep the code content itself.
|
||||
text = text.replace(/^\s*```[^\n]*$/gm, "");
|
||||
text = text.replace(/^\s*~~~[^\n]*$/gm, "");
|
||||
|
||||
// Markdown links/images.
|
||||
text = text.replace(/!\[([^\]]*)\]\((?:[^()\\]|\\.)*\)/g, "$1");
|
||||
text = text.replace(/\[([^\]]+)\]\((?:[^()\\]|\\.)*\)/g, "$1");
|
||||
|
||||
// Structural prefixes.
|
||||
text = text.replace(/^\s{0,3}#{1,6}\s+/gm, "");
|
||||
text = text.replace(/^\s{0,3}>+\s?/gm, "");
|
||||
text = text.replace(/^\s{0,3}(?:[*+-]|\d+\.)\s+/gm, "");
|
||||
text = text.replace(/^\s{0,3}(?:[-*_]\s*){3,}$/gm, "");
|
||||
|
||||
// Inline markdown markers.
|
||||
text = text.replace(/`([^`]+)`/g, "$1");
|
||||
text = text.replace(/\*\*([^*]+)\*\*/g, "$1");
|
||||
text = text.replace(/__([^_]+)__/g, "$1");
|
||||
text = text.replace(/\*([^*\n]+)\*/g, "$1");
|
||||
text = text.replace(/_([^_\n]+)_/g, "$1");
|
||||
text = text.replace(/~~([^~]+)~~/g, "$1");
|
||||
|
||||
// Angle-bracketed URL autolinks.
|
||||
text = text.replace(/<([^>\n]+)>/g, "$1");
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
const buildNotificationPreview = (text: string | null | undefined): string | null => {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = normalizeNotificationText(stripMarkdownToText(text));
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return truncateNotificationText(normalized, NOTIFICATION_PREVIEW_LIMIT);
|
||||
};
|
||||
|
||||
const safeStringify = (value: unknown): string | null => {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const buildPermissionDetails = (
|
||||
request: NotificationPermissionRequest | null | undefined,
|
||||
): string | null => {
|
||||
if (!request) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = request.title?.trim();
|
||||
const description = request.description?.trim();
|
||||
const details: string[] = [];
|
||||
|
||||
if (title) {
|
||||
details.push(title);
|
||||
}
|
||||
if (description && description !== title) {
|
||||
details.push(description);
|
||||
}
|
||||
if (details.length > 0) {
|
||||
return details.join(" - ");
|
||||
}
|
||||
|
||||
const inputPreview = request.input ? safeStringify(request.input) : null;
|
||||
if (inputPreview) {
|
||||
return inputPreview;
|
||||
}
|
||||
|
||||
const metadataPreview = request.metadata ? safeStringify(request.metadata) : null;
|
||||
if (metadataPreview) {
|
||||
return metadataPreview;
|
||||
}
|
||||
|
||||
return request.name?.trim() || request.kind;
|
||||
};
|
||||
|
||||
export function findLatestAssistantMessageFromTimeline(
|
||||
timeline: readonly AssistantTimelineItem[],
|
||||
): string | null {
|
||||
// Providers may stream assistant content in consecutive chunks.
|
||||
const chunks: string[] = [];
|
||||
for (let i = timeline.length - 1; i >= 0; i -= 1) {
|
||||
const item = timeline[i];
|
||||
if (item.type !== "assistant_message" || typeof item.text !== "string") {
|
||||
if (chunks.length > 0) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
chunks.push(item.text);
|
||||
}
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return chunks.toReversed().join("");
|
||||
}
|
||||
|
||||
export function findLatestPermissionRequest(
|
||||
pendingPermissions: ReadonlyMap<string, NotificationPermissionRequest>,
|
||||
): NotificationPermissionRequest | null {
|
||||
let latest: NotificationPermissionRequest | null = null;
|
||||
for (const request of pendingPermissions.values()) {
|
||||
latest = request;
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function resolveAgentAttentionTitle(reason: AgentAttentionReason): string {
|
||||
if (reason === "permission") return "Agent needs permission";
|
||||
if (reason === "error") return "Agent needs attention";
|
||||
return "Agent finished";
|
||||
}
|
||||
|
||||
function resolveAgentAttentionPreview(
|
||||
input: BuildAgentAttentionNotificationPayloadInput,
|
||||
): string | null {
|
||||
if (input.reason === "finished") {
|
||||
return buildNotificationPreview(input.assistantMessage);
|
||||
}
|
||||
if (input.reason === "permission") {
|
||||
return buildNotificationPreview(buildPermissionDetails(input.permissionRequest));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveAgentAttentionFallbackBody(reason: AgentAttentionReason): string {
|
||||
if (reason === "permission") return "Permission requested.";
|
||||
if (reason === "error") return "Encountered an error.";
|
||||
return "Finished working.";
|
||||
}
|
||||
|
||||
export function buildAgentAttentionNotificationPayload(
|
||||
input: BuildAgentAttentionNotificationPayloadInput,
|
||||
): AgentAttentionNotificationPayload {
|
||||
const title = resolveAgentAttentionTitle(input.reason);
|
||||
const preview = resolveAgentAttentionPreview(input);
|
||||
const body = preview ?? resolveAgentAttentionFallbackBody(input.reason);
|
||||
|
||||
return {
|
||||
title,
|
||||
body,
|
||||
data: {
|
||||
serverId: input.serverId,
|
||||
agentId: input.agentId,
|
||||
reason: input.reason,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
AgentFeatureSchema,
|
||||
AgentSnapshotPayloadSchema,
|
||||
SetAgentFeatureRequestMessageSchema,
|
||||
SetAgentFeatureResponseMessageSchema,
|
||||
} from "./messages.js";
|
||||
|
||||
describe("agent feature schemas", () => {
|
||||
it("parses valid toggle features", () => {
|
||||
const parsed = AgentFeatureSchema.parse({
|
||||
type: "toggle",
|
||||
id: "fast_mode",
|
||||
label: "Fast mode",
|
||||
description: "Uses lower latency service tier",
|
||||
tooltip: "Toggle fast mode",
|
||||
icon: "bolt",
|
||||
value: true,
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("toggle");
|
||||
if (parsed.type !== "toggle") {
|
||||
throw new Error("Expected toggle feature");
|
||||
}
|
||||
expect(parsed.value).toBe(true);
|
||||
});
|
||||
|
||||
it("parses valid select features", () => {
|
||||
const parsed = AgentFeatureSchema.parse({
|
||||
type: "select",
|
||||
id: "service_tier",
|
||||
label: "Service tier",
|
||||
description: "Choose a processing tier",
|
||||
tooltip: "Select service tier",
|
||||
icon: "gauge",
|
||||
value: "flex",
|
||||
options: [
|
||||
{ id: "default", label: "Default", isDefault: true },
|
||||
{ id: "flex", label: "Flex" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("select");
|
||||
if (parsed.type !== "select") {
|
||||
throw new Error("Expected select feature");
|
||||
}
|
||||
expect(parsed.options).toHaveLength(2);
|
||||
expect(parsed.value).toBe("flex");
|
||||
});
|
||||
|
||||
it("rejects invalid features", () => {
|
||||
const invalidDiscriminator = AgentFeatureSchema.safeParse({
|
||||
type: "slider",
|
||||
id: "fast_mode",
|
||||
label: "Fast mode",
|
||||
value: true,
|
||||
});
|
||||
|
||||
const missingToggleValue = AgentFeatureSchema.safeParse({
|
||||
type: "toggle",
|
||||
id: "fast_mode",
|
||||
label: "Fast mode",
|
||||
});
|
||||
|
||||
const missingSelectOptions = AgentFeatureSchema.safeParse({
|
||||
type: "select",
|
||||
id: "service_tier",
|
||||
label: "Service tier",
|
||||
value: null,
|
||||
});
|
||||
|
||||
expect(invalidDiscriminator.success).toBe(false);
|
||||
expect(missingToggleValue.success).toBe(false);
|
||||
expect(missingSelectOptions.success).toBe(false);
|
||||
});
|
||||
|
||||
it("parses valid requests", () => {
|
||||
const parsed = SetAgentFeatureRequestMessageSchema.parse({
|
||||
type: "set_agent_feature_request",
|
||||
agentId: "agent-123",
|
||||
featureId: "fast_mode",
|
||||
value: true,
|
||||
requestId: "req-123",
|
||||
});
|
||||
|
||||
expect(parsed.featureId).toBe("fast_mode");
|
||||
expect(parsed.value).toBe(true);
|
||||
});
|
||||
|
||||
it("parses valid responses", () => {
|
||||
const parsed = SetAgentFeatureResponseMessageSchema.parse({
|
||||
type: "set_agent_feature_response",
|
||||
payload: {
|
||||
requestId: "req-123",
|
||||
agentId: "agent-123",
|
||||
accepted: true,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.payload.accepted).toBe(true);
|
||||
expect(parsed.payload.error).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts features on agent snapshot payloads", () => {
|
||||
const parsed = AgentSnapshotPayloadSchema.parse({
|
||||
id: "agent-123",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
model: "gpt-5",
|
||||
features: [
|
||||
{
|
||||
type: "toggle",
|
||||
id: "fast_mode",
|
||||
label: "Fast mode",
|
||||
tooltip: "Toggle fast mode",
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
thinkingOptionId: null,
|
||||
effectiveThinkingOptionId: null,
|
||||
createdAt: "2026-04-03T12:00:00.000Z",
|
||||
updatedAt: "2026-04-03T12:00:00.000Z",
|
||||
lastUserMessageAt: null,
|
||||
status: "idle",
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
title: null,
|
||||
labels: {},
|
||||
});
|
||||
|
||||
expect(parsed.features).toHaveLength(1);
|
||||
expect(parsed.features?.[0]?.id).toBe("fast_mode");
|
||||
});
|
||||
|
||||
it("defaults missing rewind capabilities to false", () => {
|
||||
const parsed = AgentSnapshotPayloadSchema.parse({
|
||||
id: "agent-123",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
model: "gpt-5",
|
||||
thinkingOptionId: null,
|
||||
effectiveThinkingOptionId: null,
|
||||
createdAt: "2026-04-03T12:00:00.000Z",
|
||||
updatedAt: "2026-04-03T12:00:00.000Z",
|
||||
lastUserMessageAt: null,
|
||||
status: "idle",
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
title: null,
|
||||
labels: {},
|
||||
});
|
||||
|
||||
expect(parsed.capabilities.supportsRewindConversation).toBe(false);
|
||||
expect(parsed.capabilities.supportsRewindFiles).toBe(false);
|
||||
expect(parsed.capabilities.supportsRewindBoth).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export const PARENT_AGENT_ID_LABEL = "paseo.parent-agent-id";
|
||||
@@ -0,0 +1,9 @@
|
||||
export const AGENT_LIFECYCLE_STATUSES = [
|
||||
"initializing",
|
||||
"idle",
|
||||
"running",
|
||||
"error",
|
||||
"closed",
|
||||
] as const;
|
||||
|
||||
export type AgentLifecycleStatus = (typeof AGENT_LIFECYCLE_STATUSES)[number];
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
deriveAgentStateBucket,
|
||||
getAgentStatusPriority,
|
||||
getWorkspaceStateBucketPriority,
|
||||
} from "./agent-state-bucket.js";
|
||||
|
||||
describe("deriveAgentStateBucket", () => {
|
||||
it("prioritizes pending permissions as needs_input", () => {
|
||||
expect(
|
||||
deriveAgentStateBucket({
|
||||
status: "idle",
|
||||
pendingPermissionCount: 1,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
}),
|
||||
).toBe("needs_input");
|
||||
});
|
||||
|
||||
it("keeps legacy permission attention in needs_input", () => {
|
||||
expect(
|
||||
deriveAgentStateBucket({
|
||||
status: "idle",
|
||||
pendingPermissionCount: 0,
|
||||
requiresAttention: true,
|
||||
attentionReason: "permission",
|
||||
}),
|
||||
).toBe("needs_input");
|
||||
});
|
||||
|
||||
it("prioritizes error attention before running status", () => {
|
||||
expect(
|
||||
deriveAgentStateBucket({
|
||||
status: "running",
|
||||
pendingPermissionCount: 0,
|
||||
requiresAttention: true,
|
||||
attentionReason: "error",
|
||||
}),
|
||||
).toBe("failed");
|
||||
});
|
||||
|
||||
it("treats unread finished agents as attention", () => {
|
||||
expect(
|
||||
deriveAgentStateBucket({
|
||||
status: "idle",
|
||||
pendingPermissionCount: 0,
|
||||
requiresAttention: true,
|
||||
attentionReason: "finished",
|
||||
}),
|
||||
).toBe("attention");
|
||||
});
|
||||
|
||||
it("treats initializing agents as done for workspace buckets", () => {
|
||||
expect(
|
||||
deriveAgentStateBucket({
|
||||
status: "initializing",
|
||||
pendingPermissionCount: 0,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
}),
|
||||
).toBe("done");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWorkspaceStateBucketPriority", () => {
|
||||
it("orders active buckets before done", () => {
|
||||
expect(
|
||||
["done", "attention", "running", "failed", "needs_input"].sort(
|
||||
(left, right) =>
|
||||
getWorkspaceStateBucketPriority(left) - getWorkspaceStateBucketPriority(right),
|
||||
),
|
||||
).toEqual(["needs_input", "failed", "running", "attention", "done"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAgentStatusPriority", () => {
|
||||
it("keeps initializing agents ahead of completed agents in agent lists", () => {
|
||||
expect(getAgentStatusPriority({ status: "initializing" })).toBeLessThan(
|
||||
getAgentStatusPriority({ status: "idle" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("prioritizes pending permissions before errors and running agents", () => {
|
||||
const permission = getAgentStatusPriority({ status: "running", pendingPermissionCount: 1 });
|
||||
expect(permission).toBeLessThan(
|
||||
getAgentStatusPriority({ status: "error", pendingPermissionCount: 0 }),
|
||||
);
|
||||
expect(permission).toBeLessThan(
|
||||
getAgentStatusPriority({ status: "running", pendingPermissionCount: 0 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { AgentLifecycleStatus } from "./agent-lifecycle.js";
|
||||
import type { WorkspaceStateBucket } from "./messages.js";
|
||||
|
||||
export type AgentAttentionReason = "finished" | "error" | "permission" | null | undefined;
|
||||
|
||||
export interface AgentStateBucketInput {
|
||||
status: AgentLifecycleStatus;
|
||||
pendingPermissionCount?: number;
|
||||
requiresAttention?: boolean;
|
||||
attentionReason?: AgentAttentionReason;
|
||||
}
|
||||
|
||||
const WORKSPACE_STATE_BUCKET_PRIORITY = {
|
||||
needs_input: 0,
|
||||
failed: 1,
|
||||
running: 2,
|
||||
attention: 3,
|
||||
done: 4,
|
||||
} as const satisfies Record<WorkspaceStateBucket, number>;
|
||||
|
||||
export function deriveAgentStateBucket(input: AgentStateBucketInput): WorkspaceStateBucket {
|
||||
if ((input.pendingPermissionCount ?? 0) > 0 || input.attentionReason === "permission") {
|
||||
return "needs_input";
|
||||
}
|
||||
if (input.status === "error" || input.attentionReason === "error") {
|
||||
return "failed";
|
||||
}
|
||||
if (input.status === "running") {
|
||||
return "running";
|
||||
}
|
||||
if (input.requiresAttention) {
|
||||
return "attention";
|
||||
}
|
||||
return "done";
|
||||
}
|
||||
|
||||
export function getWorkspaceStateBucketPriority(bucket: WorkspaceStateBucket): number {
|
||||
return WORKSPACE_STATE_BUCKET_PRIORITY[bucket];
|
||||
}
|
||||
|
||||
export function getAgentStatusPriority(input: AgentStateBucketInput): number {
|
||||
if ((input.pendingPermissionCount ?? 0) > 0 || input.attentionReason === "permission") {
|
||||
return 0;
|
||||
}
|
||||
if (input.status === "error" || input.attentionReason === "error") {
|
||||
return 1;
|
||||
}
|
||||
if (input.status === "running") {
|
||||
return 2;
|
||||
}
|
||||
if (input.status === "initializing") {
|
||||
return 3;
|
||||
}
|
||||
return 4;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const MAX_EXPLICIT_AGENT_TITLE_CHARS = 200;
|
||||
export const MAX_AUTO_AGENT_TITLE_CHARS = 40;
|
||||
@@ -0,0 +1,466 @@
|
||||
import type { AgentAttachment } from "./messages.js";
|
||||
|
||||
export type AgentProvider = string;
|
||||
|
||||
export interface AgentMetadata {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stdio-based MCP server (spawns a subprocess).
|
||||
*/
|
||||
export interface McpStdioServerConfig {
|
||||
type: "stdio";
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP-based MCP server.
|
||||
*/
|
||||
export interface McpHttpServerConfig {
|
||||
type: "http";
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE-based MCP server (Server-Sent Events over HTTP).
|
||||
*/
|
||||
export interface McpSseServerConfig {
|
||||
type: "sse";
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical MCP server configuration.
|
||||
* Discriminated union by `type` field.
|
||||
* Each provider normalizes this to their expected format.
|
||||
*/
|
||||
export type McpServerConfig = McpStdioServerConfig | McpHttpServerConfig | McpSseServerConfig;
|
||||
|
||||
export interface AgentMode {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
colorTier?: string;
|
||||
}
|
||||
|
||||
export type ProviderStatus = "ready" | "loading" | "error" | "unavailable";
|
||||
|
||||
export interface AgentModelDefinition {
|
||||
provider: AgentProvider;
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
isDefault?: boolean;
|
||||
metadata?: AgentMetadata;
|
||||
thinkingOptions?: AgentSelectOption[];
|
||||
defaultThinkingOptionId?: string;
|
||||
}
|
||||
|
||||
export interface AgentSelectOption {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
isDefault?: boolean;
|
||||
metadata?: AgentMetadata;
|
||||
}
|
||||
|
||||
export function normalizeAgentModelDefinition(model: AgentModelDefinition): AgentModelDefinition {
|
||||
const defaultThinkingOptionId =
|
||||
model.defaultThinkingOptionId ?? model.thinkingOptions?.find((option) => option.isDefault)?.id;
|
||||
if (!defaultThinkingOptionId || defaultThinkingOptionId === model.defaultThinkingOptionId) {
|
||||
return model;
|
||||
}
|
||||
return { ...model, defaultThinkingOptionId };
|
||||
}
|
||||
|
||||
export interface ProviderSnapshotEntry {
|
||||
provider: AgentProvider;
|
||||
status: ProviderStatus;
|
||||
enabled: boolean;
|
||||
error?: string;
|
||||
models?: AgentModelDefinition[];
|
||||
modes?: AgentMode[];
|
||||
fetchedAt?: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
defaultModeId?: string | null;
|
||||
}
|
||||
|
||||
export interface AgentFeatureToggle {
|
||||
type: "toggle";
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
tooltip?: string;
|
||||
icon?: string;
|
||||
value: boolean;
|
||||
}
|
||||
|
||||
export interface AgentFeatureSelect {
|
||||
type: "select";
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
tooltip?: string;
|
||||
icon?: string;
|
||||
value: string | null;
|
||||
options: AgentSelectOption[];
|
||||
}
|
||||
|
||||
export type AgentFeature = AgentFeatureToggle | AgentFeatureSelect;
|
||||
|
||||
export interface AgentCapabilityFlags {
|
||||
supportsStreaming: boolean;
|
||||
supportsSessionPersistence: boolean;
|
||||
supportsDynamicModes: boolean;
|
||||
supportsMcpServers: boolean;
|
||||
supportsReasoningStream: boolean;
|
||||
supportsToolInvocations: boolean;
|
||||
supportsRewindConversation?: boolean;
|
||||
supportsRewindFiles?: boolean;
|
||||
supportsRewindBoth?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentPersistenceHandle {
|
||||
provider: AgentProvider;
|
||||
sessionId: string;
|
||||
/** Provider specific handle (Codex thread id, Claude resume token, etc). */
|
||||
nativeHandle?: string;
|
||||
metadata?: AgentMetadata;
|
||||
}
|
||||
|
||||
export type AgentPromptContentBlock =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image"; data: string; mimeType: string }
|
||||
| AgentAttachment;
|
||||
|
||||
export type AgentPromptInput = string | AgentPromptContentBlock[];
|
||||
|
||||
export interface AgentRunOptions {
|
||||
outputSchema?: unknown;
|
||||
resumeFrom?: AgentPersistenceHandle;
|
||||
maxThinkingTokens?: number;
|
||||
}
|
||||
|
||||
export interface AgentUsage {
|
||||
inputTokens?: number;
|
||||
cachedInputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalCostUsd?: number;
|
||||
contextWindowMaxTokens?: number;
|
||||
contextWindowUsedTokens?: number;
|
||||
}
|
||||
|
||||
export const TOOL_CALL_ICON_NAMES = [
|
||||
"wrench",
|
||||
"square_terminal",
|
||||
"eye",
|
||||
"pencil",
|
||||
"search",
|
||||
"bot",
|
||||
"sparkles",
|
||||
"brain",
|
||||
"mic_vocal",
|
||||
] as const;
|
||||
|
||||
export type ToolCallIconName = (typeof TOOL_CALL_ICON_NAMES)[number];
|
||||
|
||||
export type ToolCallDetail =
|
||||
| {
|
||||
type: "shell";
|
||||
command: string;
|
||||
cwd?: string;
|
||||
output?: string;
|
||||
exitCode?: number | null;
|
||||
}
|
||||
| {
|
||||
type: "read";
|
||||
filePath: string;
|
||||
content?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
| {
|
||||
type: "edit";
|
||||
filePath: string;
|
||||
oldString?: string;
|
||||
newString?: string;
|
||||
unifiedDiff?: string;
|
||||
}
|
||||
| {
|
||||
type: "write";
|
||||
filePath: string;
|
||||
content?: string;
|
||||
}
|
||||
| {
|
||||
type: "search";
|
||||
query: string;
|
||||
toolName?: "search" | "grep" | "glob" | "web_search";
|
||||
content?: string;
|
||||
filePaths?: string[];
|
||||
webResults?: Array<{
|
||||
title: string;
|
||||
url: string;
|
||||
}>;
|
||||
annotations?: string[];
|
||||
numFiles?: number;
|
||||
numMatches?: number;
|
||||
durationMs?: number;
|
||||
durationSeconds?: number;
|
||||
truncated?: boolean;
|
||||
mode?: "content" | "files_with_matches" | "count";
|
||||
}
|
||||
| {
|
||||
type: "fetch";
|
||||
url: string;
|
||||
prompt?: string;
|
||||
result?: string;
|
||||
code?: number;
|
||||
codeText?: string;
|
||||
bytes?: number;
|
||||
durationMs?: number;
|
||||
}
|
||||
| {
|
||||
type: "worktree_setup";
|
||||
worktreePath: string;
|
||||
branchName: string;
|
||||
log: string;
|
||||
commands: Array<{
|
||||
index: number;
|
||||
command: string;
|
||||
cwd: string;
|
||||
log: string;
|
||||
status: "running" | "completed" | "failed";
|
||||
exitCode: number | null;
|
||||
durationMs?: number;
|
||||
}>;
|
||||
truncated?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "sub_agent";
|
||||
subAgentType?: string;
|
||||
description?: string;
|
||||
childSessionId?: string;
|
||||
log: string;
|
||||
actions?: Array<{
|
||||
index: number;
|
||||
toolName: string;
|
||||
summary?: string;
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
type: "plain_text";
|
||||
label?: string;
|
||||
text?: string;
|
||||
icon?: ToolCallIconName;
|
||||
}
|
||||
| {
|
||||
type: "plan";
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: "unknown";
|
||||
input: unknown;
|
||||
output: unknown;
|
||||
};
|
||||
|
||||
interface ToolCallBase {
|
||||
[key: string]: unknown;
|
||||
type: "tool_call";
|
||||
callId: string;
|
||||
name: string;
|
||||
detail: ToolCallDetail;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type ToolCallRunningTimelineItem = ToolCallBase & {
|
||||
status: "running";
|
||||
error: null;
|
||||
};
|
||||
|
||||
type ToolCallCompletedTimelineItem = ToolCallBase & {
|
||||
status: "completed";
|
||||
error: null;
|
||||
};
|
||||
|
||||
type ToolCallFailedTimelineItem = ToolCallBase & {
|
||||
status: "failed";
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
type ToolCallCanceledTimelineItem = ToolCallBase & {
|
||||
status: "canceled";
|
||||
error: null;
|
||||
};
|
||||
|
||||
export type ToolCallTimelineItem =
|
||||
| ToolCallRunningTimelineItem
|
||||
| ToolCallCompletedTimelineItem
|
||||
| ToolCallFailedTimelineItem
|
||||
| ToolCallCanceledTimelineItem;
|
||||
|
||||
export interface CompactionTimelineItem {
|
||||
[key: string]: unknown;
|
||||
type: "compaction";
|
||||
status: "loading" | "completed";
|
||||
trigger?: "auto" | "manual";
|
||||
preTokens?: number;
|
||||
}
|
||||
|
||||
export type AgentTimelineItem =
|
||||
| { type: "user_message"; text: string; messageId?: string }
|
||||
| { type: "assistant_message"; text: string; messageId?: string }
|
||||
| { type: "reasoning"; text: string }
|
||||
| ToolCallTimelineItem
|
||||
| { type: "todo"; items: { text: string; completed: boolean }[] }
|
||||
| { type: "error"; message: string }
|
||||
| CompactionTimelineItem;
|
||||
|
||||
export type AgentStreamEvent =
|
||||
| { type: "thread_started"; sessionId: string; provider: AgentProvider }
|
||||
| { type: "turn_started"; provider: AgentProvider; turnId?: string }
|
||||
| { type: "turn_completed"; provider: AgentProvider; usage?: AgentUsage; turnId?: string }
|
||||
| { type: "usage_updated"; provider: AgentProvider; usage: AgentUsage; turnId?: string }
|
||||
| {
|
||||
type: "mode_changed";
|
||||
provider: AgentProvider;
|
||||
currentModeId: string | null;
|
||||
availableModes: AgentMode[];
|
||||
}
|
||||
| { type: "model_changed"; provider: AgentProvider; runtimeInfo: AgentRuntimeInfo }
|
||||
| {
|
||||
type: "thinking_option_changed";
|
||||
provider: AgentProvider;
|
||||
thinkingOptionId: string | null;
|
||||
}
|
||||
| {
|
||||
type: "turn_failed";
|
||||
provider: AgentProvider;
|
||||
error: string;
|
||||
code?: string;
|
||||
diagnostic?: string;
|
||||
turnId?: string;
|
||||
}
|
||||
| { type: "turn_canceled"; provider: AgentProvider; reason: string; turnId?: string }
|
||||
| {
|
||||
type: "timeline";
|
||||
item: AgentTimelineItem;
|
||||
provider: AgentProvider;
|
||||
turnId?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
| {
|
||||
type: "permission_requested";
|
||||
provider: AgentProvider;
|
||||
request: AgentPermissionRequest;
|
||||
turnId?: string;
|
||||
}
|
||||
| {
|
||||
type: "permission_resolved";
|
||||
provider: AgentProvider;
|
||||
requestId: string;
|
||||
resolution: AgentPermissionResponse;
|
||||
turnId?: string;
|
||||
}
|
||||
| {
|
||||
type: "attention_required";
|
||||
provider: AgentProvider;
|
||||
reason: "finished" | "error" | "permission";
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export function getAgentStreamEventTurnId(event: AgentStreamEvent): string | undefined {
|
||||
return "turnId" in event ? event.turnId : undefined;
|
||||
}
|
||||
|
||||
export type AgentPermissionRequestKind = "tool" | "plan" | "question" | "mode" | "other";
|
||||
|
||||
export type AgentPermissionUpdate = AgentMetadata;
|
||||
|
||||
export interface AgentPermissionAction {
|
||||
id: string;
|
||||
label: string;
|
||||
behavior: "allow" | "deny";
|
||||
variant?: "primary" | "secondary" | "danger";
|
||||
intent?: "implement" | "implement_resume" | "dismiss";
|
||||
}
|
||||
|
||||
export interface AgentPermissionRequest {
|
||||
id: string;
|
||||
provider: AgentProvider;
|
||||
name: string;
|
||||
kind: AgentPermissionRequestKind;
|
||||
title?: string;
|
||||
description?: string;
|
||||
input?: AgentMetadata;
|
||||
detail?: ToolCallDetail;
|
||||
suggestions?: AgentPermissionUpdate[];
|
||||
actions?: AgentPermissionAction[];
|
||||
metadata?: AgentMetadata;
|
||||
}
|
||||
|
||||
export type AgentPermissionResponse =
|
||||
| {
|
||||
behavior: "allow";
|
||||
selectedActionId?: string;
|
||||
updatedInput?: AgentMetadata;
|
||||
updatedPermissions?: AgentPermissionUpdate[];
|
||||
}
|
||||
| {
|
||||
behavior: "deny";
|
||||
selectedActionId?: string;
|
||||
message?: string;
|
||||
interrupt?: boolean;
|
||||
};
|
||||
|
||||
export interface AgentRunResult {
|
||||
sessionId: string;
|
||||
finalText: string;
|
||||
usage?: AgentUsage;
|
||||
timeline: AgentTimelineItem[];
|
||||
canceled?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentSessionConfig {
|
||||
provider: AgentProvider;
|
||||
cwd: string;
|
||||
/**
|
||||
* Provider-agnostic system/developer instruction string.
|
||||
* Mapped by each provider to its native instruction field.
|
||||
*/
|
||||
systemPrompt?: string;
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
title?: string | null;
|
||||
approvalPolicy?: string;
|
||||
sandboxMode?: string;
|
||||
networkAccess?: boolean;
|
||||
webSearch?: boolean;
|
||||
extra?: {
|
||||
codex?: AgentMetadata;
|
||||
claude?: AgentMetadata;
|
||||
};
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
/**
|
||||
* Internal agents are hidden from listings and don't trigger notifications.
|
||||
* They are used for ephemeral system tasks like commit/PR generation.
|
||||
*/
|
||||
internal?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentRuntimeInfo {
|
||||
provider: AgentProvider;
|
||||
sessionId: string | null;
|
||||
model?: string | null;
|
||||
thinkingOptionId?: string | null;
|
||||
modeId?: string | null;
|
||||
extra?: AgentMetadata;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
FileTransferOpcode,
|
||||
TerminalStreamOpcode,
|
||||
decodeFileTransferFrame,
|
||||
decodeTerminalStreamFrame,
|
||||
encodeFileTransferFrame,
|
||||
} from "./index.js";
|
||||
|
||||
describe("file transfer binary frames", () => {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
it("uses non-terminal opcodes", () => {
|
||||
expect(Object.values(TerminalStreamOpcode)).not.toContain(FileTransferOpcode.FileBegin);
|
||||
expect(Object.values(TerminalStreamOpcode)).not.toContain(FileTransferOpcode.FileChunk);
|
||||
expect(Object.values(TerminalStreamOpcode)).not.toContain(FileTransferOpcode.FileEnd);
|
||||
});
|
||||
|
||||
it("encodes FileBegin as opcode plus request id prefix plus JSON metadata", () => {
|
||||
const encoded = encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileBegin,
|
||||
requestId: "req-1",
|
||||
metadata: {
|
||||
mime: "image/png",
|
||||
size: 6,
|
||||
encoding: "binary",
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
const requestId = encoder.encode("req-1");
|
||||
const metadata = encoder.encode(
|
||||
JSON.stringify({
|
||||
mime: "image/png",
|
||||
size: 6,
|
||||
encoding: "binary",
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(decodeTerminalStreamFrame(encoded)).toBeNull();
|
||||
expect(encoded[0]).toBe(FileTransferOpcode.FileBegin);
|
||||
expect(encoded[1]).toBe(requestId.byteLength);
|
||||
expect(encoded.subarray(2, 2 + requestId.byteLength)).toEqual(requestId);
|
||||
expect(
|
||||
new DataView(encoded.buffer, encoded.byteOffset).getUint16(2 + requestId.byteLength),
|
||||
).toBe(metadata.byteLength);
|
||||
expect(encoded.subarray(4 + requestId.byteLength)).toEqual(metadata);
|
||||
|
||||
expect(decodeFileTransferFrame(encoded)).toEqual({
|
||||
opcode: FileTransferOpcode.FileBegin,
|
||||
requestId: "req-1",
|
||||
metadata: {
|
||||
mime: "image/png",
|
||||
size: 6,
|
||||
encoding: "binary",
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
},
|
||||
payload: new Uint8Array(),
|
||||
});
|
||||
});
|
||||
|
||||
it("encodes FileChunk as opcode plus request id prefix plus binary payload", () => {
|
||||
const payload = new Uint8Array([0, 1, 2, 253, 254, 255]);
|
||||
const encoded = encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileChunk,
|
||||
requestId: "req-1",
|
||||
payload,
|
||||
});
|
||||
const requestId = encoder.encode("req-1");
|
||||
|
||||
expect(encoded[0]).toBe(FileTransferOpcode.FileChunk);
|
||||
expect(encoded[1]).toBe(requestId.byteLength);
|
||||
expect(encoded.subarray(2, 2 + requestId.byteLength)).toEqual(requestId);
|
||||
expect(encoded.subarray(2 + requestId.byteLength)).toEqual(payload);
|
||||
const decoded = decodeFileTransferFrame(encoded);
|
||||
|
||||
expect(decoded).toEqual({
|
||||
opcode: FileTransferOpcode.FileChunk,
|
||||
requestId: "req-1",
|
||||
payload,
|
||||
});
|
||||
expect(decoded?.payload).toBeInstanceOf(Uint8Array);
|
||||
expect(decoded?.payload).toEqual(payload);
|
||||
});
|
||||
|
||||
it("encodes FileEnd as opcode plus request id prefix only", () => {
|
||||
const encoded = encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileEnd,
|
||||
requestId: "req-1",
|
||||
});
|
||||
const requestId = encoder.encode("req-1");
|
||||
|
||||
expect(encoded).toEqual(
|
||||
new Uint8Array([FileTransferOpcode.FileEnd, requestId.byteLength, ...requestId]),
|
||||
);
|
||||
|
||||
expect(decodeFileTransferFrame(encoded)).toEqual({
|
||||
opcode: FileTransferOpcode.FileEnd,
|
||||
requestId: "req-1",
|
||||
payload: new Uint8Array(),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed metadata and unknown metadata fields", () => {
|
||||
expect(
|
||||
decodeFileTransferFrame(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileBegin,
|
||||
requestId: "req-1",
|
||||
metadata: {
|
||||
mime: "image/png",
|
||||
size: -1,
|
||||
encoding: "binary",
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
const json = encoder.encode(
|
||||
JSON.stringify({
|
||||
mime: "image/png",
|
||||
size: 1,
|
||||
encoding: "binary",
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
extra: true,
|
||||
}),
|
||||
);
|
||||
const requestId = encoder.encode("req-1");
|
||||
const encoded = new Uint8Array(4 + requestId.byteLength + json.byteLength);
|
||||
encoded[0] = FileTransferOpcode.FileBegin;
|
||||
encoded[1] = requestId.byteLength;
|
||||
encoded.set(requestId, 2);
|
||||
new DataView(encoded.buffer).setUint16(2 + requestId.byteLength, json.byteLength);
|
||||
encoded.set(json, 4 + requestId.byteLength);
|
||||
|
||||
expect(decodeFileTransferFrame(encoded)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects malformed request id prefixes and frame tails", () => {
|
||||
expect(decodeFileTransferFrame(new Uint8Array([FileTransferOpcode.FileEnd, 0]))).toBeNull();
|
||||
expect(decodeFileTransferFrame(new Uint8Array([FileTransferOpcode.FileEnd, 6, 1]))).toBeNull();
|
||||
|
||||
const requestId = encoder.encode("req-1");
|
||||
const encoded = new Uint8Array([
|
||||
FileTransferOpcode.FileEnd,
|
||||
requestId.byteLength,
|
||||
...requestId,
|
||||
1,
|
||||
]);
|
||||
expect(decodeFileTransferFrame(encoded)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { z } from "zod";
|
||||
import { asUint8Array } from "./terminal.js";
|
||||
|
||||
export const FileTransferOpcode = {
|
||||
FileBegin: 0x10,
|
||||
FileChunk: 0x11,
|
||||
FileEnd: 0x12,
|
||||
} as const;
|
||||
|
||||
export type FileTransferOpcode = (typeof FileTransferOpcode)[keyof typeof FileTransferOpcode];
|
||||
|
||||
export const FileBeginMetadataSchema = z
|
||||
.object({
|
||||
mime: z.string().min(1),
|
||||
size: z.number().int().nonnegative(),
|
||||
encoding: z.enum(["utf-8", "binary"]),
|
||||
modifiedAt: z.string(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export interface FileBegin {
|
||||
opcode: typeof FileTransferOpcode.FileBegin;
|
||||
requestId: string;
|
||||
metadata: z.infer<typeof FileBeginMetadataSchema>;
|
||||
payload: Uint8Array;
|
||||
}
|
||||
|
||||
export interface FileChunk {
|
||||
opcode: typeof FileTransferOpcode.FileChunk;
|
||||
requestId: string;
|
||||
payload: Uint8Array;
|
||||
}
|
||||
|
||||
export interface FileEnd {
|
||||
opcode: typeof FileTransferOpcode.FileEnd;
|
||||
requestId: string;
|
||||
payload: Uint8Array;
|
||||
}
|
||||
|
||||
export type FileTransferFrame = FileBegin | FileChunk | FileEnd;
|
||||
|
||||
type FileTransferFrameInput =
|
||||
| {
|
||||
opcode: typeof FileTransferOpcode.FileBegin;
|
||||
requestId: string;
|
||||
metadata: z.infer<typeof FileBeginMetadataSchema>;
|
||||
}
|
||||
| {
|
||||
opcode: typeof FileTransferOpcode.FileChunk;
|
||||
requestId: string;
|
||||
payload?: Uint8Array | ArrayBuffer | string;
|
||||
}
|
||||
| {
|
||||
opcode: typeof FileTransferOpcode.FileEnd;
|
||||
requestId: string;
|
||||
};
|
||||
|
||||
export function encodeFileTransferFrame(input: FileTransferFrameInput): Uint8Array {
|
||||
const requestId = encodeRequestId(input.requestId);
|
||||
|
||||
if (input.opcode === FileTransferOpcode.FileBegin) {
|
||||
const metadata = encodeJsonPayload(input.metadata);
|
||||
if (metadata.byteLength > 0xffff) {
|
||||
throw new RangeError("FileBegin metadata is too long");
|
||||
}
|
||||
const bytes = new Uint8Array(4 + requestId.byteLength + metadata.byteLength);
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
bytes[0] = input.opcode;
|
||||
bytes[1] = requestId.byteLength;
|
||||
bytes.set(requestId, 2);
|
||||
view.setUint16(2 + requestId.byteLength, metadata.byteLength);
|
||||
bytes.set(metadata, 4 + requestId.byteLength);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const payload =
|
||||
input.opcode === FileTransferOpcode.FileChunk
|
||||
? (asUint8Array(input.payload ?? new Uint8Array()) ?? new Uint8Array())
|
||||
: new Uint8Array();
|
||||
const bytes = new Uint8Array(2 + requestId.byteLength + payload.byteLength);
|
||||
bytes[0] = input.opcode;
|
||||
bytes[1] = requestId.byteLength;
|
||||
bytes.set(requestId, 2);
|
||||
bytes.set(payload, 2 + requestId.byteLength);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function decodeFileTransferFrame(bytes: Uint8Array): FileTransferFrame | null {
|
||||
if (bytes.byteLength < 2) {
|
||||
return null;
|
||||
}
|
||||
const opcode = bytes[0];
|
||||
if (!isFileTransferOpcode(opcode)) {
|
||||
return null;
|
||||
}
|
||||
const requestIdLength = bytes[1];
|
||||
if (requestIdLength === 0 || requestIdLength > bytes.byteLength - 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestId = decodeRequestId(bytes.subarray(2, 2 + requestIdLength));
|
||||
const body = bytes.subarray(2 + requestIdLength);
|
||||
|
||||
if (opcode === FileTransferOpcode.FileBegin) {
|
||||
if (body.byteLength < 2) {
|
||||
return null;
|
||||
}
|
||||
const view = new DataView(body.buffer, body.byteOffset, body.byteLength);
|
||||
const metadataLength = view.getUint16(0);
|
||||
if (metadataLength !== body.byteLength - 2) {
|
||||
return null;
|
||||
}
|
||||
const metadataBytes = body.subarray(2);
|
||||
const result = FileBeginMetadataSchema.safeParse(decodeJsonPayload(metadataBytes));
|
||||
return result.success
|
||||
? { opcode, requestId, metadata: result.data, payload: new Uint8Array() }
|
||||
: null;
|
||||
}
|
||||
|
||||
if (opcode === FileTransferOpcode.FileChunk) {
|
||||
return { opcode, requestId, payload: body };
|
||||
}
|
||||
|
||||
if (body.byteLength !== 0) {
|
||||
return null;
|
||||
}
|
||||
return { opcode, requestId, payload: new Uint8Array() };
|
||||
}
|
||||
|
||||
function isFileTransferOpcode(value: number): value is FileTransferOpcode {
|
||||
return (
|
||||
value === FileTransferOpcode.FileBegin ||
|
||||
value === FileTransferOpcode.FileChunk ||
|
||||
value === FileTransferOpcode.FileEnd
|
||||
);
|
||||
}
|
||||
|
||||
function encodeJsonPayload(value: unknown): Uint8Array {
|
||||
return new TextEncoder().encode(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function encodeRequestId(requestId: string): Uint8Array {
|
||||
const bytes = new TextEncoder().encode(requestId);
|
||||
if (bytes.byteLength === 0) {
|
||||
throw new RangeError("File transfer requestId is required");
|
||||
}
|
||||
if (bytes.byteLength > 0xff) {
|
||||
throw new RangeError("File transfer requestId is too long");
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function decodeRequestId(bytes: Uint8Array): string {
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
function decodeJsonPayload(bytes: Uint8Array): unknown {
|
||||
try {
|
||||
return JSON.parse(new TextDecoder().decode(bytes));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./file-transfer.js";
|
||||
export * from "./terminal.js";
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
TerminalStreamOpcode,
|
||||
decodeTerminalResizePayload,
|
||||
decodeTerminalSnapshotPayload,
|
||||
decodeTerminalStreamFrame,
|
||||
encodeTerminalResizePayload,
|
||||
encodeTerminalSnapshotPayload,
|
||||
encodeTerminalStreamFrame,
|
||||
} from "./index.js";
|
||||
|
||||
describe("terminal binary frames", () => {
|
||||
it("encodes output frames as opcode plus slot plus payload", () => {
|
||||
const payload = new TextEncoder().encode("hello");
|
||||
const encoded = encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Output,
|
||||
slot: 7,
|
||||
payload,
|
||||
});
|
||||
|
||||
expect(encoded[0]).toBe(TerminalStreamOpcode.Output);
|
||||
expect(encoded[1]).toBe(7);
|
||||
expect(Array.from(encoded.subarray(2))).toEqual(Array.from(payload));
|
||||
|
||||
const decoded = decodeTerminalStreamFrame(encoded);
|
||||
expect(decoded).toEqual({
|
||||
opcode: TerminalStreamOpcode.Output,
|
||||
slot: 7,
|
||||
payload,
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips resize payloads", () => {
|
||||
const payload = encodeTerminalResizePayload({
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
});
|
||||
|
||||
expect(decodeTerminalResizePayload(payload)).toEqual({
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips snapshot payloads", () => {
|
||||
const state = {
|
||||
rows: 1,
|
||||
cols: 2,
|
||||
grid: [[{ char: "A" }, { char: "B" }]],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 2 },
|
||||
};
|
||||
|
||||
const payload = encodeTerminalSnapshotPayload(state);
|
||||
expect(decodeTerminalSnapshotPayload(payload)).toEqual(state);
|
||||
});
|
||||
|
||||
it("rejects unknown opcodes", () => {
|
||||
expect(decodeTerminalStreamFrame(new Uint8Array([0xff, 0x01, 0x02]))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects frames without a slot byte", () => {
|
||||
expect(decodeTerminalStreamFrame(new Uint8Array([TerminalStreamOpcode.Output]))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects malformed JSON payloads", () => {
|
||||
const malformed = new TextEncoder().encode("{");
|
||||
|
||||
expect(decodeTerminalResizePayload(malformed)).toBeNull();
|
||||
expect(decodeTerminalSnapshotPayload(malformed)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects invalid resize and snapshot shapes", () => {
|
||||
expect(
|
||||
decodeTerminalResizePayload(
|
||||
new TextEncoder().encode(JSON.stringify({ rows: "24", cols: 80 })),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
decodeTerminalSnapshotPayload(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
grid: [[{ char: "A" }]],
|
||||
scrollback: [],
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects unknown fields in resize and snapshot payloads", () => {
|
||||
expect(
|
||||
decodeTerminalResizePayload(
|
||||
new TextEncoder().encode(JSON.stringify({ rows: 24, cols: 80, extra: true })),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
decodeTerminalSnapshotPayload(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
grid: [[{ char: "A", extra: true }]],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 1 },
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { z } from "zod";
|
||||
import { TerminalStateSchema } from "../messages.js";
|
||||
|
||||
export const TerminalStreamResizeSchema = z
|
||||
.object({
|
||||
rows: z.number().int().positive(),
|
||||
cols: z.number().int().positive(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const TerminalStreamOpcode = {
|
||||
Output: 0x01,
|
||||
Input: 0x02,
|
||||
Resize: 0x03,
|
||||
Snapshot: 0x04,
|
||||
Restore: 0x05,
|
||||
} as const;
|
||||
|
||||
export type TerminalStreamOpcode = (typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode];
|
||||
|
||||
export interface TerminalStreamFrame {
|
||||
opcode: TerminalStreamOpcode;
|
||||
slot: number;
|
||||
payload: Uint8Array;
|
||||
}
|
||||
|
||||
export function asUint8Array(data: unknown): Uint8Array | null {
|
||||
if (typeof data === "string") {
|
||||
if (typeof TextEncoder !== "undefined") {
|
||||
return new TextEncoder().encode(data);
|
||||
}
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return new Uint8Array(Buffer.from(data, "utf8"));
|
||||
}
|
||||
const out = new Uint8Array(data.length);
|
||||
for (let index = 0; index < data.length; index += 1) {
|
||||
out[index] = data.charCodeAt(index) & 0xff;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (data instanceof Uint8Array) {
|
||||
return data;
|
||||
}
|
||||
if (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) {
|
||||
return new Uint8Array(data);
|
||||
}
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||
}
|
||||
if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) {
|
||||
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
|
||||
return (
|
||||
value === TerminalStreamOpcode.Output ||
|
||||
value === TerminalStreamOpcode.Input ||
|
||||
value === TerminalStreamOpcode.Resize ||
|
||||
value === TerminalStreamOpcode.Snapshot ||
|
||||
value === TerminalStreamOpcode.Restore
|
||||
);
|
||||
}
|
||||
|
||||
export function encodeTerminalStreamFrame(input: {
|
||||
opcode: TerminalStreamOpcode;
|
||||
slot: number;
|
||||
payload?: Uint8Array | ArrayBuffer | string;
|
||||
}): Uint8Array {
|
||||
const payload = asUint8Array(input.payload ?? new Uint8Array(0)) ?? new Uint8Array(0);
|
||||
const bytes = new Uint8Array(2 + payload.byteLength);
|
||||
bytes[0] = input.opcode;
|
||||
bytes[1] = input.slot & 0xff;
|
||||
bytes.set(payload, 2);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function decodeTerminalStreamFrame(bytes: Uint8Array): TerminalStreamFrame | null {
|
||||
if (bytes.byteLength < 2) {
|
||||
return null;
|
||||
}
|
||||
const opcode = bytes[0];
|
||||
if (!isTerminalStreamOpcode(opcode)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
opcode,
|
||||
slot: bytes[1],
|
||||
payload: bytes.subarray(2),
|
||||
};
|
||||
}
|
||||
|
||||
export function encodeTerminalSnapshotPayload(
|
||||
state: z.infer<typeof TerminalStateSchema>,
|
||||
): Uint8Array {
|
||||
return encodeJsonPayload(state);
|
||||
}
|
||||
|
||||
export function decodeTerminalSnapshotPayload(
|
||||
bytes: Uint8Array,
|
||||
): z.infer<typeof TerminalStateSchema> | null {
|
||||
const parsed = decodeJsonPayload(bytes);
|
||||
const result = TerminalStateSchema.safeParse(parsed);
|
||||
return result.success ? result.data : null;
|
||||
}
|
||||
|
||||
export function encodeTerminalResizePayload(
|
||||
input: z.infer<typeof TerminalStreamResizeSchema>,
|
||||
): Uint8Array {
|
||||
return encodeJsonPayload(input);
|
||||
}
|
||||
|
||||
export function decodeTerminalResizePayload(
|
||||
bytes: Uint8Array,
|
||||
): z.infer<typeof TerminalStreamResizeSchema> | null {
|
||||
const parsed = decodeJsonPayload(bytes);
|
||||
const result = TerminalStreamResizeSchema.safeParse(parsed);
|
||||
return result.success ? result.data : null;
|
||||
}
|
||||
|
||||
function encodeJsonPayload(value: unknown): Uint8Array {
|
||||
return new TextEncoder().encode(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function decodeJsonPayload(bytes: Uint8Array): unknown {
|
||||
try {
|
||||
const text = new TextDecoder().decode(bytes);
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { z } from "zod";
|
||||
import { ChatMessageSchema, ChatRoomDetailSchema } from "./types.js";
|
||||
|
||||
export const ChatCreateRequestSchema = z.object({
|
||||
type: z.literal("chat/create"),
|
||||
requestId: z.string(),
|
||||
name: z.string(),
|
||||
purpose: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ChatListRequestSchema = z.object({
|
||||
type: z.literal("chat/list"),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ChatInspectRequestSchema = z.object({
|
||||
type: z.literal("chat/inspect"),
|
||||
requestId: z.string(),
|
||||
room: z.string(),
|
||||
});
|
||||
|
||||
export const ChatDeleteRequestSchema = z.object({
|
||||
type: z.literal("chat/delete"),
|
||||
requestId: z.string(),
|
||||
room: z.string(),
|
||||
});
|
||||
|
||||
export const ChatPostRequestSchema = z.object({
|
||||
type: z.literal("chat/post"),
|
||||
requestId: z.string(),
|
||||
room: z.string(),
|
||||
body: z.string(),
|
||||
authorAgentId: z.string().optional(),
|
||||
replyToMessageId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ChatReadRequestSchema = z.object({
|
||||
type: z.literal("chat/read"),
|
||||
requestId: z.string(),
|
||||
room: z.string(),
|
||||
limit: z.number().int().nonnegative().optional(),
|
||||
since: z.string().optional(),
|
||||
authorAgentId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ChatWaitRequestSchema = z.object({
|
||||
type: z.literal("chat/wait"),
|
||||
requestId: z.string(),
|
||||
room: z.string(),
|
||||
afterMessageId: z.string().optional(),
|
||||
timeoutMs: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
export const ChatCreateResponseSchema = z.object({
|
||||
type: z.literal("chat/create/response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
room: ChatRoomDetailSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ChatListResponseSchema = z.object({
|
||||
type: z.literal("chat/list/response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
rooms: z.array(ChatRoomDetailSchema),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ChatInspectResponseSchema = z.object({
|
||||
type: z.literal("chat/inspect/response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
room: ChatRoomDetailSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ChatDeleteResponseSchema = z.object({
|
||||
type: z.literal("chat/delete/response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
room: ChatRoomDetailSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ChatPostResponseSchema = z.object({
|
||||
type: z.literal("chat/post/response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
message: ChatMessageSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ChatReadResponseSchema = z.object({
|
||||
type: z.literal("chat/read/response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
messages: z.array(ChatMessageSchema),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ChatWaitResponseSchema = z.object({
|
||||
type: z.literal("chat/wait/response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
messages: z.array(ChatMessageSchema),
|
||||
timedOut: z.boolean(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ChatRoomSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
purpose: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
export type ChatRoom = z.infer<typeof ChatRoomSchema>;
|
||||
|
||||
export const ChatMessageSchema = z.object({
|
||||
id: z.string(),
|
||||
roomId: z.string(),
|
||||
authorAgentId: z.string(),
|
||||
body: z.string(),
|
||||
replyToMessageId: z.string().nullable(),
|
||||
mentionAgentIds: z.array(z.string()),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
|
||||
export type ChatMessage = z.infer<typeof ChatMessageSchema>;
|
||||
|
||||
export const ChatRoomDetailSchema = ChatRoomSchema.extend({
|
||||
messageCount: z.number().int().nonnegative(),
|
||||
lastMessageAt: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type ChatRoomDetail = z.infer<typeof ChatRoomDetailSchema>;
|
||||
@@ -0,0 +1,10 @@
|
||||
export const CLIENT_CAPS = {
|
||||
reasoningMergeEnum: "reasoning_merge_enum",
|
||||
// COMPAT(customModeIcons): added in v0.1.84. Old clients pin AgentModeIcon to
|
||||
// a closed enum and crash rendering unknown values; daemon downgrades icons
|
||||
// outside the legacy set to "ShieldCheck" when this cap is absent. Drop the
|
||||
// gate when floor >= v0.1.84.
|
||||
customModeIcons: "custom_mode_icons",
|
||||
} as const;
|
||||
|
||||
export type ClientCapability = (typeof CLIENT_CAPS)[keyof typeof CLIENT_CAPS];
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ConnectionOfferSchema,
|
||||
decodeOfferFragmentPayload,
|
||||
parseConnectionOfferFromUrl,
|
||||
} from "./connection-offer.js";
|
||||
|
||||
function encodeBase64UrlNoPadUtf8(input: string): string {
|
||||
return Buffer.from(input, "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
describe("connection offer", () => {
|
||||
it("decodes base64url JSON payloads", () => {
|
||||
const payload = {
|
||||
v: 2,
|
||||
serverId: "server-123",
|
||||
daemonPublicKeyB64: "pubkey",
|
||||
relay: { endpoint: "relay.paseo.sh:443" },
|
||||
};
|
||||
|
||||
expect(decodeOfferFragmentPayload(encodeBase64UrlNoPadUtf8(JSON.stringify(payload)))).toEqual(
|
||||
payload,
|
||||
);
|
||||
});
|
||||
|
||||
it("parses connection offers from QR-style URLs", () => {
|
||||
const offer = ConnectionOfferSchema.parse({
|
||||
v: 2,
|
||||
serverId: "server-123",
|
||||
daemonPublicKeyB64: "pubkey",
|
||||
relay: { endpoint: "relay.paseo.sh:443" },
|
||||
});
|
||||
const encoded = encodeBase64UrlNoPadUtf8(JSON.stringify(offer));
|
||||
|
||||
expect(parseConnectionOfferFromUrl(`https://app.paseo.sh/#offer=${encoded}`)).toEqual(offer);
|
||||
});
|
||||
|
||||
it("leaves relay TLS unset when absent", () => {
|
||||
expect(
|
||||
ConnectionOfferSchema.parse({
|
||||
v: 2,
|
||||
serverId: "server-123",
|
||||
daemonPublicKeyB64: "pubkey",
|
||||
relay: { endpoint: "relay.example.com:80" },
|
||||
}),
|
||||
).toEqual({
|
||||
v: 2,
|
||||
serverId: "server-123",
|
||||
daemonPublicKeyB64: "pubkey",
|
||||
relay: { endpoint: "relay.example.com:80" },
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips relay TLS in offers without rejecting extra relay fields", () => {
|
||||
const offer = ConnectionOfferSchema.parse({
|
||||
v: 2,
|
||||
serverId: "server-123",
|
||||
daemonPublicKeyB64: "pubkey",
|
||||
relay: { endpoint: "relay.example.com:443", useTls: true, extra: "future" },
|
||||
});
|
||||
const encoded = encodeBase64UrlNoPadUtf8(JSON.stringify(offer));
|
||||
|
||||
expect(parseConnectionOfferFromUrl(`https://app.paseo.sh/#offer=${encoded}`)).toEqual({
|
||||
v: 2,
|
||||
serverId: "server-123",
|
||||
daemonPublicKeyB64: "pubkey",
|
||||
relay: { endpoint: "relay.example.com:443", useTls: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when the URL has no offer fragment", () => {
|
||||
expect(parseConnectionOfferFromUrl("https://app.paseo.sh/pair")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Relay-only pairing offer.
|
||||
*
|
||||
* `serverId` is a stable daemon identifier scoped to `PASEO_HOME`, and is also
|
||||
* used as the relay session identifier.
|
||||
*/
|
||||
export const ConnectionOfferV2Schema = z.object({
|
||||
v: z.literal(2),
|
||||
serverId: z.string().min(1),
|
||||
daemonPublicKeyB64: z.string().min(1),
|
||||
relay: z.object({
|
||||
endpoint: z.string().min(1),
|
||||
useTls: z.boolean().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type ConnectionOfferV2 = z.infer<typeof ConnectionOfferV2Schema>;
|
||||
|
||||
export const ConnectionOfferSchema = ConnectionOfferV2Schema;
|
||||
export type ConnectionOffer = ConnectionOfferV2;
|
||||
|
||||
function decodeBase64UrlToUtf8(input: string): string {
|
||||
const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
|
||||
const binary = globalThis.atob(padded);
|
||||
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
}
|
||||
|
||||
export function decodeOfferFragmentPayload(encoded: string): unknown {
|
||||
const json = decodeBase64UrlToUtf8(encoded);
|
||||
return JSON.parse(json) as unknown;
|
||||
}
|
||||
|
||||
const OFFER_FRAGMENT_PREFIX = "#offer=";
|
||||
|
||||
function extractOfferFragmentEncoded(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
const fragmentIndex = trimmed.indexOf(OFFER_FRAGMENT_PREFIX);
|
||||
if (fragmentIndex === -1) return null;
|
||||
const encoded = trimmed.slice(fragmentIndex + OFFER_FRAGMENT_PREFIX.length).trim();
|
||||
return encoded.length > 0 ? encoded : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a pairing-offer URL of the form `https://app.paseo.sh/#offer=<base64url>`.
|
||||
*
|
||||
* Returns `null` if the input has no `#offer=` fragment. Throws if the fragment
|
||||
* exists but the payload is malformed or fails schema validation.
|
||||
*/
|
||||
export function parseConnectionOfferFromUrl(input: string): ConnectionOffer | null {
|
||||
const encoded = extractOfferFragmentEncoded(input);
|
||||
if (!encoded) return null;
|
||||
const payload = decodeOfferFragmentPayload(encoded);
|
||||
return ConnectionOfferSchema.parse(payload);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
buildDaemonWebSocketUrl,
|
||||
buildRelayWebSocketUrl,
|
||||
CURRENT_RELAY_PROTOCOL_VERSION,
|
||||
extractHostPortFromWebSocketUrl,
|
||||
normalizeRelayProtocolVersion,
|
||||
parseConnectionUri,
|
||||
serializeConnectionUri,
|
||||
serializeConnectionUriForStorage,
|
||||
shouldUseTlsForDefaultHostedRelay,
|
||||
} from "./daemon-endpoints.js";
|
||||
|
||||
describe("connection URI parsing", () => {
|
||||
test("round-trips a tcp host and port", () => {
|
||||
const parsed = parseConnectionUri("tcp://localhost:6767");
|
||||
|
||||
expect(parsed).toEqual({
|
||||
host: "localhost",
|
||||
port: 6767,
|
||||
isIpv6: false,
|
||||
useTls: false,
|
||||
});
|
||||
expect(serializeConnectionUri(parsed)).toBe("tcp://localhost:6767");
|
||||
});
|
||||
|
||||
test("round-trips an SSL-enabled tcp host and port", () => {
|
||||
const parsed = parseConnectionUri("tcp://example.com:443?ssl=true");
|
||||
|
||||
expect(parsed).toEqual({
|
||||
host: "example.com",
|
||||
port: 443,
|
||||
isIpv6: false,
|
||||
useTls: true,
|
||||
});
|
||||
expect(serializeConnectionUri(parsed)).toBe("tcp://example.com:443?ssl=true");
|
||||
});
|
||||
|
||||
test("round-trips an IPv6 host", () => {
|
||||
const parsed = parseConnectionUri("tcp://[::1]:6767?ssl=true");
|
||||
|
||||
expect(parsed).toEqual({
|
||||
host: "::1",
|
||||
port: 6767,
|
||||
isIpv6: true,
|
||||
useTls: true,
|
||||
});
|
||||
expect(serializeConnectionUri(parsed)).toBe("tcp://[::1]:6767?ssl=true");
|
||||
});
|
||||
|
||||
test("rejects a missing port", () => {
|
||||
expect(() => parseConnectionUri("tcp://localhost")).toThrow("Connection URI port is required");
|
||||
});
|
||||
|
||||
test("rejects an invalid scheme", () => {
|
||||
expect(() => parseConnectionUri("http://localhost:6767")).toThrow(
|
||||
"Connection URI protocol must be tcp:",
|
||||
);
|
||||
});
|
||||
|
||||
test("parses password without including it in the public serializer", () => {
|
||||
const parsed = parseConnectionUri("tcp://localhost:6767?ssl=true&password=secret");
|
||||
|
||||
expect(parsed).toEqual({
|
||||
host: "localhost",
|
||||
port: 6767,
|
||||
isIpv6: false,
|
||||
useTls: true,
|
||||
password: "secret",
|
||||
});
|
||||
expect(serializeConnectionUri(parsed)).toBe("tcp://localhost:6767?ssl=true");
|
||||
expect(serializeConnectionUriForStorage(parsed)).toBe(
|
||||
"tcp://localhost:6767?ssl=true&password=secret",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects userinfo passwords", () => {
|
||||
expect(() => parseConnectionUri("tcp://:secret@localhost:6767?ssl=true")).toThrow(
|
||||
"Connection URI userinfo is not supported",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("daemon websocket URLs", () => {
|
||||
test("uses ws for port 443 when TLS is disabled", () => {
|
||||
expect(buildDaemonWebSocketUrl("example.com:443", { useTls: false })).toBe(
|
||||
"ws://example.com:443/ws",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses wss for non-443 ports when TLS is enabled", () => {
|
||||
expect(buildDaemonWebSocketUrl("example.com:6767", { useTls: true })).toBe(
|
||||
"wss://example.com:6767/ws",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("relay websocket URL versioning", () => {
|
||||
test("defaults relay URLs to v2", () => {
|
||||
const url = new URL(
|
||||
buildRelayWebSocketUrl({
|
||||
endpoint: "relay.paseo.sh:443",
|
||||
useTls: true,
|
||||
serverId: "srv_test",
|
||||
role: "client",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(url.searchParams.get("v")).toBe(CURRENT_RELAY_PROTOCOL_VERSION);
|
||||
expect(url.searchParams.has("connectionId")).toBe(false);
|
||||
});
|
||||
|
||||
test("includes connectionId when provided (server data sockets)", () => {
|
||||
const url = new URL(
|
||||
buildRelayWebSocketUrl({
|
||||
endpoint: "relay.paseo.sh:443",
|
||||
useTls: true,
|
||||
serverId: "srv_test",
|
||||
role: "server",
|
||||
connectionId: "conn_abc123",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(url.searchParams.get("connectionId")).toBe("conn_abc123");
|
||||
});
|
||||
|
||||
test("allows explicitly requesting v1 relay URLs", () => {
|
||||
const url = new URL(
|
||||
buildRelayWebSocketUrl({
|
||||
endpoint: "relay.paseo.sh:443",
|
||||
useTls: true,
|
||||
serverId: "srv_test",
|
||||
role: "server",
|
||||
version: "1",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(url.searchParams.get("v")).toBe("1");
|
||||
});
|
||||
|
||||
test("normalizes numeric relay versions", () => {
|
||||
expect(normalizeRelayProtocolVersion(2)).toBe("2");
|
||||
expect(normalizeRelayProtocolVersion(1)).toBe("1");
|
||||
});
|
||||
|
||||
test("rejects unsupported relay versions", () => {
|
||||
expect(() => normalizeRelayProtocolVersion("3")).toThrow('Relay version must be "1" or "2"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("relay websocket URLs", () => {
|
||||
test("uses ws for port 443 when TLS is disabled", () => {
|
||||
const url = new URL(
|
||||
buildRelayWebSocketUrl({
|
||||
endpoint: "relay.paseo.sh:443",
|
||||
useTls: false,
|
||||
serverId: "srv_test",
|
||||
role: "client",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(url.protocol).toBe("ws:");
|
||||
});
|
||||
|
||||
test("uses wss for non-443 ports when TLS is enabled", () => {
|
||||
const url = new URL(
|
||||
buildRelayWebSocketUrl({
|
||||
endpoint: "relay.paseo.sh:6767",
|
||||
useTls: true,
|
||||
serverId: "srv_test",
|
||||
role: "client",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(url.protocol).toBe("wss:");
|
||||
});
|
||||
|
||||
test("round-trips IPv6 relay endpoints with TLS enabled", () => {
|
||||
const wsUrl = buildRelayWebSocketUrl({
|
||||
endpoint: "[::1]:443",
|
||||
useTls: true,
|
||||
serverId: "srv_test",
|
||||
role: "client",
|
||||
});
|
||||
const url = new URL(wsUrl);
|
||||
|
||||
expect(url.protocol).toBe("wss:");
|
||||
expect(extractHostPortFromWebSocketUrl(wsUrl)).toBe("[::1]:443");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldUseTlsForDefaultHostedRelay", () => {
|
||||
test("returns true for the hosted Paseo relay on port 443", () => {
|
||||
expect(shouldUseTlsForDefaultHostedRelay("relay.paseo.sh:443")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for any self-hosted relay on port 443", () => {
|
||||
expect(shouldUseTlsForDefaultHostedRelay("relay.example.com:443")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for an IPv6 relay on port 443", () => {
|
||||
expect(shouldUseTlsForDefaultHostedRelay("[::1]:443")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for a relay on a non-443 port", () => {
|
||||
expect(shouldUseTlsForDefaultHostedRelay("relay.example.com:8080")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for malformed endpoints", () => {
|
||||
expect(shouldUseTlsForDefaultHostedRelay("not-an-endpoint")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
export interface HostPortParts {
|
||||
host: string;
|
||||
port: number;
|
||||
isIpv6: boolean;
|
||||
}
|
||||
|
||||
export interface ConnectionUriParts extends HostPortParts {
|
||||
useTls: boolean;
|
||||
}
|
||||
|
||||
export interface ParsedConnectionUri extends ConnectionUriParts {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export type RelayRole = "server" | "client";
|
||||
export type RelayProtocolVersion = "1" | "2";
|
||||
|
||||
export const CURRENT_RELAY_PROTOCOL_VERSION: RelayProtocolVersion = "2";
|
||||
export const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
|
||||
|
||||
export function normalizeRelayProtocolVersion(
|
||||
value: unknown,
|
||||
fallback: RelayProtocolVersion = CURRENT_RELAY_PROTOCOL_VERSION,
|
||||
): RelayProtocolVersion {
|
||||
if (value == null) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
let normalized = "";
|
||||
if (typeof value === "string") {
|
||||
normalized = value.trim();
|
||||
} else if (typeof value === "number") {
|
||||
normalized = String(value);
|
||||
}
|
||||
if (!normalized) {
|
||||
return fallback;
|
||||
}
|
||||
if (normalized === "1" || normalized === "2") {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error('Relay version must be "1" or "2"');
|
||||
}
|
||||
|
||||
function parsePort(portStr: string, context: string): number {
|
||||
const port = Number(portStr);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(`${context}: port must be between 1 and 65535`);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
export function parseHostPort(input: string): HostPortParts {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Host is required");
|
||||
}
|
||||
|
||||
// IPv6: [::1]:6767
|
||||
if (trimmed.startsWith("[")) {
|
||||
const match = trimmed.match(/^\[([^\]]+)\]:(\d{1,5})$/);
|
||||
if (!match) {
|
||||
throw new Error("Invalid host:port (expected [::1]:6767)");
|
||||
}
|
||||
const host = match[1].trim();
|
||||
if (!host) throw new Error("Host is required");
|
||||
const port = parsePort(match[2], "Invalid host:port");
|
||||
return { host, port, isIpv6: true };
|
||||
}
|
||||
|
||||
const match = trimmed.match(/^(.+):(\d{1,5})$/);
|
||||
if (!match) {
|
||||
throw new Error("Invalid host:port (expected localhost:6767)");
|
||||
}
|
||||
const host = match[1].trim();
|
||||
if (!host) throw new Error("Host is required");
|
||||
const port = parsePort(match[2], "Invalid host:port");
|
||||
return { host, port, isIpv6: false };
|
||||
}
|
||||
|
||||
export function normalizeHostPort(input: string): string {
|
||||
const { host, port, isIpv6 } = parseHostPort(input);
|
||||
return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`;
|
||||
}
|
||||
|
||||
export function parseConnectionUri(input: string): ParsedConnectionUri {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Connection URI is required");
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
throw new Error("Invalid connection URI");
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "tcp:") {
|
||||
throw new Error("Connection URI protocol must be tcp:");
|
||||
}
|
||||
if (!parsed.hostname) {
|
||||
throw new Error("Connection URI host is required");
|
||||
}
|
||||
if (!parsed.port) {
|
||||
throw new Error("Connection URI port is required");
|
||||
}
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new Error("Connection URI userinfo is not supported");
|
||||
}
|
||||
|
||||
const isIpv6 = parsed.hostname.startsWith("[") && parsed.hostname.endsWith("]");
|
||||
const host = isIpv6 ? parsed.hostname.slice(1, -1) : parsed.hostname;
|
||||
const password = parsed.searchParams.get("password") || undefined;
|
||||
|
||||
return {
|
||||
host,
|
||||
port: parsePort(parsed.port, "Invalid connection URI"),
|
||||
isIpv6,
|
||||
useTls: parsed.searchParams.get("ssl") === "true",
|
||||
...(password ? { password } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeConnectionUri(parts: ConnectionUriParts): string {
|
||||
return createConnectionUri(parts).toString();
|
||||
}
|
||||
|
||||
export function serializeConnectionUriForStorage(parts: ParsedConnectionUri): string {
|
||||
const url = createConnectionUri(parts);
|
||||
if (parts.password) {
|
||||
url.searchParams.set("password", parts.password);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function createConnectionUri(parts: ConnectionUriParts): URL {
|
||||
const hostPart = parts.isIpv6 ? `[${parts.host}]` : parts.host;
|
||||
const url = new URL(`tcp://${hostPart}:${parts.port}`);
|
||||
if (parts.useTls) {
|
||||
url.searchParams.set("ssl", "true");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function normalizeLoopbackToLocalhost(endpoint: string): string {
|
||||
const { host, port, isIpv6 } = parseHostPort(endpoint);
|
||||
if (host === "127.0.0.1" || (!isIpv6 && host === "0.0.0.0")) {
|
||||
return `localhost:${port}`;
|
||||
}
|
||||
if (isIpv6 && (host === "::1" || host === "::")) {
|
||||
return `localhost:${port}`;
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
export function deriveLabelFromEndpoint(endpoint: string): string {
|
||||
try {
|
||||
const { host } = parseHostPort(endpoint);
|
||||
return host || "Unnamed Host";
|
||||
} catch {
|
||||
return "Unnamed Host";
|
||||
}
|
||||
}
|
||||
|
||||
export interface WebSocketUrlOptions {
|
||||
useTls: boolean;
|
||||
}
|
||||
|
||||
export function buildDaemonWebSocketUrl(endpoint: string, opts: WebSocketUrlOptions): string {
|
||||
const { host, port, isIpv6 } = parseHostPort(endpoint);
|
||||
const protocol = opts.useTls ? "wss" : "ws";
|
||||
const hostPart = isIpv6 ? `[${host}]` : host;
|
||||
return new URL(`${protocol}://${hostPart}:${port}/ws`).toString();
|
||||
}
|
||||
|
||||
export function buildRelayWebSocketUrl(params: {
|
||||
endpoint: string;
|
||||
useTls: boolean;
|
||||
serverId: string;
|
||||
role: RelayRole;
|
||||
/**
|
||||
* Per-connection routing identifier used by the daemon to open server data sockets.
|
||||
* Clients should NOT provide this — the relay assigns a routing ID on connect.
|
||||
*/
|
||||
connectionId?: string;
|
||||
version?: RelayProtocolVersion | 1 | 2;
|
||||
}): string {
|
||||
const { host, port, isIpv6 } = parseHostPort(params.endpoint);
|
||||
const protocol = params.useTls ? "wss" : "ws";
|
||||
const hostPart = isIpv6 ? `[${host}]` : host;
|
||||
const url = new URL(`${protocol}://${hostPart}:${port}/ws`);
|
||||
url.searchParams.set("serverId", params.serverId);
|
||||
url.searchParams.set("role", params.role);
|
||||
url.searchParams.set("v", normalizeRelayProtocolVersion(params.version));
|
||||
if (params.connectionId) {
|
||||
url.searchParams.set("connectionId", params.connectionId);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Migration fallback for stored relay connections/offers that predate
|
||||
* explicit relay useTls. Delete after two release cycles.
|
||||
*/
|
||||
export function shouldUseTlsForDefaultHostedRelay(endpoint: string): boolean {
|
||||
try {
|
||||
const { port } = parseHostPort(endpoint);
|
||||
return port === 443;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractHostPortFromWebSocketUrl(wsUrl: string): string {
|
||||
const parsed = new URL(wsUrl);
|
||||
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
|
||||
throw new Error("Invalid WebSocket URL protocol");
|
||||
}
|
||||
if (parsed.pathname.replace(/\/+$/, "") !== "/ws") {
|
||||
throw new Error("Invalid WebSocket URL (expected /ws path)");
|
||||
}
|
||||
|
||||
const host = parsed.hostname;
|
||||
let port: number;
|
||||
if (parsed.port) {
|
||||
port = Number(parsed.port);
|
||||
} else if (parsed.protocol === "wss:") {
|
||||
port = 443;
|
||||
} else {
|
||||
port = 80;
|
||||
}
|
||||
if (!host) {
|
||||
throw new Error("Invalid WebSocket URL (missing hostname)");
|
||||
}
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error("Invalid WebSocket URL (invalid port)");
|
||||
}
|
||||
|
||||
const isIpv6 = host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
|
||||
return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`;
|
||||
}
|
||||
|
||||
export function isRelayClientWebSocketUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.searchParams.get("role") === "client" && parsed.searchParams.has("serverId");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Extracts a human-readable error message from an unknown error value.
|
||||
* Handles Error instances, string errors, and other types safely.
|
||||
*/
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts an error message from an unknown error value, with a fallback
|
||||
* for when no message can be extracted.
|
||||
*/
|
||||
export function getErrorMessageOr(error: unknown, fallback: string): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === "string" && error.length > 0) {
|
||||
return error;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
const GITHUB_HOSTS = new Set(["github.com", "ssh.github.com"]);
|
||||
|
||||
const TRANSPORT_BY_PROTOCOL: Record<string, GitRemoteLocation["transport"]> = {
|
||||
"https:": "https",
|
||||
"http:": "http",
|
||||
"ssh:": "ssh",
|
||||
};
|
||||
|
||||
export interface GitRemoteLocation {
|
||||
transport: "scp" | "ssh" | "http" | "https";
|
||||
host: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface GitHubRemoteIdentity {
|
||||
owner: string;
|
||||
name: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
export function parseGitHubRemoteUrl(remoteUrl: string): GitHubRemoteIdentity | null {
|
||||
const location = parseGitRemoteLocation(remoteUrl);
|
||||
if (!location || !isGitHubHost(location.host)) return null;
|
||||
return parseGitHubRemoteIdentity(location.path);
|
||||
}
|
||||
|
||||
export function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | null {
|
||||
const trimmed = remoteUrl.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/u);
|
||||
if (scpLike) {
|
||||
const host = normalizeHost(scpLike[1] ?? "");
|
||||
const path = normalizeRemotePath(scpLike[2] ?? "");
|
||||
if (!isValidRemoteHost(host) || !path) return null;
|
||||
return { transport: "scp", host, path };
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const transport = TRANSPORT_BY_PROTOCOL[parsed.protocol.toLowerCase()];
|
||||
if (!transport) return null;
|
||||
|
||||
const host = normalizeHost(parsed.hostname);
|
||||
let path: string;
|
||||
try {
|
||||
path = decodeURIComponent(parsed.pathname);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const normalizedPath = normalizeRemotePath(path);
|
||||
if (!isValidRemoteHost(host) || !normalizedPath) return null;
|
||||
|
||||
return { transport, host, path: normalizedPath };
|
||||
}
|
||||
|
||||
export function parseGitHubRemoteIdentity(path: string): GitHubRemoteIdentity | null {
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
if (segments.length !== 2) return null;
|
||||
const [owner, name] = segments;
|
||||
if (!owner || !name) return null;
|
||||
return { owner, name, repo: `${owner}/${name}` };
|
||||
}
|
||||
|
||||
export function isGitHubHost(host: string): boolean {
|
||||
return GITHUB_HOSTS.has(host);
|
||||
}
|
||||
|
||||
export function normalizeHost(host: string): string {
|
||||
return host.trim().replace(/\.+$/u, "").toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeRemotePath(path: string): string | null {
|
||||
let normalized = path.trim().replace(/^\/+|\/+$/gu, "");
|
||||
if (normalized.endsWith(".git")) normalized = normalized.slice(0, -4);
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function isValidRemoteHost(host: string): boolean {
|
||||
return /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/u.test(host);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const DirectTcpHostConnectionSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("directTcp"),
|
||||
endpoint: z.string(),
|
||||
useTls: z.boolean().optional().default(false),
|
||||
password: z.string().optional(),
|
||||
});
|
||||
|
||||
export type DirectTcpHostConnection = z.input<typeof DirectTcpHostConnectionSchema>;
|
||||
export type NormalizedDirectTcpHostConnection = z.output<typeof DirectTcpHostConnectionSchema>;
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Providers eligible for "import recent session" discovery. ACP-based
|
||||
* providers (gemini, copilot, generic acp) are excluded because they either
|
||||
* don't expose persisted history quickly or duplicate other providers.
|
||||
*/
|
||||
export const IMPORTABLE_PROVIDERS = ["claude", "codex", "opencode", "pi"] as const;
|
||||
@@ -0,0 +1,3 @@
|
||||
// Adapted from type-fest's LiteralUnion:
|
||||
// https://github.com/sindresorhus/type-fest/blob/main/source/literal-union.d.ts
|
||||
export type LiteralUnion<T extends U, U extends string> = T | (U & Record<never, never>);
|
||||
@@ -0,0 +1,195 @@
|
||||
import { z } from "zod";
|
||||
import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
|
||||
|
||||
export const LoopLogEntrySchema = z.object({
|
||||
seq: z.number().int().positive(),
|
||||
timestamp: z.string(),
|
||||
iteration: z.number().int().positive().nullable(),
|
||||
source: z.enum(["loop", "worker", "verifier", "verify-check"]),
|
||||
level: z.enum(["info", "error"]),
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
export const LoopVerifyCheckResultSchema = z.object({
|
||||
command: z.string(),
|
||||
exitCode: z.number().int(),
|
||||
passed: z.boolean(),
|
||||
stdout: z.string(),
|
||||
stderr: z.string(),
|
||||
startedAt: z.string(),
|
||||
completedAt: z.string(),
|
||||
});
|
||||
|
||||
export const LoopVerifyPromptResultSchema = z.object({
|
||||
passed: z.boolean(),
|
||||
reason: z.string(),
|
||||
verifierAgentId: z.string().nullable(),
|
||||
startedAt: z.string(),
|
||||
completedAt: z.string(),
|
||||
});
|
||||
|
||||
export const LoopIterationRecordSchema = z.object({
|
||||
index: z.number().int().positive(),
|
||||
workerAgentId: z.string().nullable(),
|
||||
workerStartedAt: z.string(),
|
||||
workerCompletedAt: z.string().nullable(),
|
||||
verifierAgentId: z.string().nullable(),
|
||||
status: z.enum(["running", "succeeded", "failed", "stopped"]),
|
||||
workerOutcome: z.enum(["completed", "failed", "canceled"]).nullable(),
|
||||
failureReason: z.string().nullable(),
|
||||
verifyChecks: z.array(LoopVerifyCheckResultSchema),
|
||||
verifyPrompt: LoopVerifyPromptResultSchema.nullable(),
|
||||
});
|
||||
|
||||
export const LoopRecordSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().nullable(),
|
||||
prompt: z.string(),
|
||||
cwd: z.string(),
|
||||
provider: AgentProviderSchema,
|
||||
model: z.string().nullable(),
|
||||
modeId: z.string().nullable().default(null),
|
||||
workerProvider: AgentProviderSchema.nullable(),
|
||||
workerModel: z.string().nullable(),
|
||||
verifierProvider: AgentProviderSchema.nullable(),
|
||||
verifierModel: z.string().nullable(),
|
||||
verifierModeId: z.string().nullable().default(null),
|
||||
verifyPrompt: z.string().nullable(),
|
||||
verifyChecks: z.array(z.string()),
|
||||
archive: z.boolean(),
|
||||
sleepMs: z.number().int().nonnegative(),
|
||||
maxIterations: z.number().int().positive().nullable(),
|
||||
maxTimeMs: z.number().int().positive().nullable(),
|
||||
status: z.enum(["running", "succeeded", "failed", "stopped"]),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
startedAt: z.string(),
|
||||
completedAt: z.string().nullable(),
|
||||
stopRequestedAt: z.string().nullable(),
|
||||
iterations: z.array(LoopIterationRecordSchema),
|
||||
logs: z.array(LoopLogEntrySchema),
|
||||
nextLogSeq: z.number().int().positive(),
|
||||
activeIteration: z.number().int().positive().nullable(),
|
||||
activeWorkerAgentId: z.string().nullable(),
|
||||
activeVerifierAgentId: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const LoopListItemSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().nullable(),
|
||||
status: z.enum(["running", "succeeded", "failed", "stopped"]),
|
||||
cwd: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
activeIteration: z.number().int().positive().nullable(),
|
||||
});
|
||||
|
||||
export const LoopRunRequestSchema = z.object({
|
||||
type: z.literal("loop/run"),
|
||||
requestId: z.string(),
|
||||
prompt: z.string().trim().min(1),
|
||||
cwd: z.string(),
|
||||
provider: AgentProviderSchema.optional(),
|
||||
model: z.string().trim().min(1).optional(),
|
||||
modeId: z.string().trim().min(1).optional(),
|
||||
workerProvider: AgentProviderSchema.optional(),
|
||||
workerModel: z.string().trim().min(1).optional(),
|
||||
verifierProvider: AgentProviderSchema.optional(),
|
||||
verifierModel: z.string().trim().min(1).optional(),
|
||||
verifierModeId: z.string().trim().min(1).optional(),
|
||||
verifyPrompt: z.string().trim().min(1).optional(),
|
||||
verifyChecks: z.array(z.string().trim().min(1)).optional(),
|
||||
archive: z.boolean().optional(),
|
||||
name: z.string().trim().min(1).optional(),
|
||||
sleepMs: z.number().int().nonnegative().optional(),
|
||||
maxIterations: z.number().int().positive().optional(),
|
||||
maxTimeMs: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export const LoopListRequestSchema = z.object({
|
||||
type: z.literal("loop/list"),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const LoopInspectRequestSchema = z.object({
|
||||
type: z.literal("loop/inspect"),
|
||||
requestId: z.string(),
|
||||
id: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const LoopLogsRequestSchema = z.object({
|
||||
type: z.literal("loop/logs"),
|
||||
requestId: z.string(),
|
||||
id: z.string().trim().min(1),
|
||||
afterSeq: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
export const LoopStopRequestSchema = z.object({
|
||||
type: z.literal("loop/stop"),
|
||||
requestId: z.string(),
|
||||
id: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const LoopRunResponseSchema = z.object({
|
||||
type: z.literal("loop/run/response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
loop: LoopRecordSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const LoopListResponseSchema = z.object({
|
||||
type: z.literal("loop/list/response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
loops: z.array(LoopListItemSchema),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const LoopInspectResponseSchema = z.object({
|
||||
type: z.literal("loop/inspect/response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
loop: LoopRecordSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const LoopLogsResponseSchema = z.object({
|
||||
type: z.literal("loop/logs/response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
loop: LoopRecordSchema.nullable(),
|
||||
entries: z.array(LoopLogEntrySchema),
|
||||
nextCursor: z.number().int().nonnegative(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const LoopStopResponseSchema = z.object({
|
||||
type: z.literal("loop/stop/response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
loop: LoopRecordSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type LoopLogEntry = z.infer<typeof LoopLogEntrySchema>;
|
||||
export type LoopVerifyCheckResult = z.infer<typeof LoopVerifyCheckResultSchema>;
|
||||
export type LoopVerifyPromptResult = z.infer<typeof LoopVerifyPromptResultSchema>;
|
||||
export type LoopIterationRecord = z.infer<typeof LoopIterationRecordSchema>;
|
||||
export type LoopRecord = z.infer<typeof LoopRecordSchema>;
|
||||
export type LoopListItem = z.infer<typeof LoopListItemSchema>;
|
||||
export type LoopRunRequest = z.infer<typeof LoopRunRequestSchema>;
|
||||
export type LoopListRequest = z.infer<typeof LoopListRequestSchema>;
|
||||
export type LoopInspectRequest = z.infer<typeof LoopInspectRequestSchema>;
|
||||
export type LoopLogsRequest = z.infer<typeof LoopLogsRequestSchema>;
|
||||
export type LoopStopRequest = z.infer<typeof LoopStopRequestSchema>;
|
||||
export type LoopRunResponse = z.infer<typeof LoopRunResponseSchema>;
|
||||
export type LoopListResponse = z.infer<typeof LoopListResponseSchema>;
|
||||
export type LoopInspectResponse = z.infer<typeof LoopInspectResponseSchema>;
|
||||
export type LoopLogsResponse = z.infer<typeof LoopLogsResponseSchema>;
|
||||
export type LoopStopResponse = z.infer<typeof LoopStopResponseSchema>;
|
||||
@@ -0,0 +1,327 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CreateAgentRequestMessageSchema,
|
||||
CreatePaseoWorktreeRequestSchema,
|
||||
SendAgentMessageRequestSchema,
|
||||
} from "./messages.js";
|
||||
|
||||
describe("shared messages attachments", () => {
|
||||
it("keeps valid review attachments", () => {
|
||||
const parsed = SendAgentMessageRequestSchema.parse({
|
||||
type: "send_agent_message_request",
|
||||
requestId: "req-review",
|
||||
agentId: "agent-1",
|
||||
text: "Please address these comments",
|
||||
attachments: [
|
||||
{
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/tmp/repo",
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
comments: [
|
||||
{
|
||||
filePath: "src/index.ts",
|
||||
side: "new",
|
||||
lineNumber: 42,
|
||||
body: "Please guard this nullable value.",
|
||||
context: {
|
||||
hunkHeader: "@@ -40,3 +40,4 @@",
|
||||
targetLine: {
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 42,
|
||||
type: "add",
|
||||
content: "const value = maybeNull.name;",
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
oldLineNumber: 41,
|
||||
newLineNumber: 41,
|
||||
type: "context",
|
||||
content: "const before = true;",
|
||||
},
|
||||
{
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 42,
|
||||
type: "add",
|
||||
content: "const value = maybeNull.name;",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.attachments).toEqual([
|
||||
{
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/tmp/repo",
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
comments: [
|
||||
{
|
||||
filePath: "src/index.ts",
|
||||
side: "new",
|
||||
lineNumber: 42,
|
||||
body: "Please guard this nullable value.",
|
||||
context: {
|
||||
hunkHeader: "@@ -40,3 +40,4 @@",
|
||||
targetLine: {
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 42,
|
||||
type: "add",
|
||||
content: "const value = maybeNull.name;",
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
oldLineNumber: 41,
|
||||
newLineNumber: 41,
|
||||
type: "context",
|
||||
content: "const before = true;",
|
||||
},
|
||||
{
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 42,
|
||||
type: "add",
|
||||
content: "const value = maybeNull.name;",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops malformed review attachments while keeping valid attachments", () => {
|
||||
const parsed = SendAgentMessageRequestSchema.parse({
|
||||
type: "send_agent_message_request",
|
||||
requestId: "req-bad-review",
|
||||
agentId: "agent-1",
|
||||
text: "Review",
|
||||
attachments: [
|
||||
{
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/tmp/repo",
|
||||
mode: "uncommitted",
|
||||
comments: [
|
||||
{
|
||||
filePath: "src/index.ts",
|
||||
side: "new",
|
||||
lineNumber: "42",
|
||||
body: "This line number is malformed.",
|
||||
context: {
|
||||
hunkHeader: "@@ -40,3 +40,4 @@",
|
||||
targetLine: {
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 42,
|
||||
type: "add",
|
||||
content: "const value = maybeNull.name;",
|
||||
},
|
||||
lines: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "github_issue",
|
||||
mimeType: "application/github-issue",
|
||||
number: 55,
|
||||
title: "Improve startup error details",
|
||||
url: "https://github.com/getpaseo/paseo/issues/55",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.attachments).toEqual([
|
||||
{
|
||||
type: "github_issue",
|
||||
mimeType: "application/github-issue",
|
||||
number: 55,
|
||||
title: "Improve startup error details",
|
||||
url: "https://github.com/getpaseo/paseo/issues/55",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps known attachments and drops unknown create-agent attachments", () => {
|
||||
const parsed = CreateAgentRequestMessageSchema.parse({
|
||||
type: "create_agent_request",
|
||||
requestId: "req-1",
|
||||
config: {
|
||||
provider: "codex",
|
||||
cwd: "/tmp/repo",
|
||||
},
|
||||
initialPrompt: "Review this PR",
|
||||
attachments: [
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 123,
|
||||
title: "Fix race in worktree setup",
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
body: "Body",
|
||||
baseRefName: "main",
|
||||
headRefName: "fix/worktree-race",
|
||||
},
|
||||
{
|
||||
type: "future_attachment",
|
||||
mimeType: "application/future",
|
||||
foo: "bar",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.attachments).toEqual([
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 123,
|
||||
title: "Fix race in worktree setup",
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
body: "Body",
|
||||
baseRefName: "main",
|
||||
headRefName: "fix/worktree-race",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps known attachments and drops unknown send-message attachments", () => {
|
||||
const parsed = SendAgentMessageRequestSchema.parse({
|
||||
type: "send_agent_message_request",
|
||||
requestId: "req-2",
|
||||
agentId: "agent-1",
|
||||
text: "Review",
|
||||
attachments: [
|
||||
{
|
||||
type: "github_issue",
|
||||
mimeType: "application/github-issue",
|
||||
number: 55,
|
||||
title: "Improve startup error details",
|
||||
url: "https://github.com/getpaseo/paseo/issues/55",
|
||||
body: "Body",
|
||||
},
|
||||
{
|
||||
type: "future_attachment",
|
||||
mimeType: "application/future",
|
||||
foo: "bar",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.attachments).toEqual([
|
||||
{
|
||||
type: "github_issue",
|
||||
mimeType: "application/github-issue",
|
||||
number: 55,
|
||||
title: "Improve startup error details",
|
||||
url: "https://github.com/getpaseo/paseo/issues/55",
|
||||
body: "Body",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps known firstAgentContext attachments and drops unknown ones", () => {
|
||||
const parsed = CreatePaseoWorktreeRequestSchema.parse({
|
||||
type: "create_paseo_worktree_request",
|
||||
requestId: "req-3",
|
||||
cwd: "/tmp/repo",
|
||||
firstAgentContext: {
|
||||
prompt: "Investigate flaky test",
|
||||
attachments: [
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 99,
|
||||
title: "Fork-safe PR checkout",
|
||||
url: "https://github.com/getpaseo/paseo/pull/99",
|
||||
},
|
||||
{
|
||||
type: "future_attachment",
|
||||
mimeType: "application/future",
|
||||
foo: "bar",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.firstAgentContext?.attachments).toEqual([
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 99,
|
||||
title: "Fork-safe PR checkout",
|
||||
url: "https://github.com/getpaseo/paseo/pull/99",
|
||||
},
|
||||
]);
|
||||
expect(parsed.firstAgentContext?.prompt).toBe("Investigate flaky test");
|
||||
});
|
||||
|
||||
it("parses worktree-create payloads without a firstAgentContext", () => {
|
||||
const parsed = CreatePaseoWorktreeRequestSchema.parse({
|
||||
type: "create_paseo_worktree_request",
|
||||
requestId: "req-4",
|
||||
cwd: "/tmp/repo",
|
||||
});
|
||||
|
||||
expect(parsed).toEqual({
|
||||
type: "create_paseo_worktree_request",
|
||||
requestId: "req-4",
|
||||
cwd: "/tmp/repo",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts and strips create-worktree intent fields compatibly", () => {
|
||||
const parsed = CreatePaseoWorktreeRequestSchema.parse({
|
||||
type: "create_paseo_worktree_request",
|
||||
requestId: "req-5",
|
||||
cwd: "/tmp/repo",
|
||||
action: "checkout",
|
||||
refName: "feature/ref-picker",
|
||||
githubPrNumber: 42,
|
||||
futureField: "ignored",
|
||||
});
|
||||
|
||||
expect(parsed).toEqual({
|
||||
type: "create_paseo_worktree_request",
|
||||
requestId: "req-5",
|
||||
cwd: "/tmp/repo",
|
||||
action: "checkout",
|
||||
refName: "feature/ref-picker",
|
||||
githubPrNumber: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts optional create-agent git intent fields and strips unknown git fields", () => {
|
||||
const parsed = CreateAgentRequestMessageSchema.parse({
|
||||
type: "create_agent_request",
|
||||
requestId: "req-6",
|
||||
config: {
|
||||
provider: "codex",
|
||||
cwd: "/tmp/repo",
|
||||
},
|
||||
attachments: [],
|
||||
git: {
|
||||
createWorktree: true,
|
||||
worktreeSlug: "review-42",
|
||||
action: "checkout",
|
||||
refName: "head-ref",
|
||||
githubPrNumber: 42,
|
||||
futureGitField: "ignored",
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.git).toEqual({
|
||||
createWorktree: true,
|
||||
worktreeSlug: "review-42",
|
||||
action: "checkout",
|
||||
refName: "head-ref",
|
||||
githubPrNumber: 42,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
CheckoutGithubSetAutoMergeRequestSchema,
|
||||
CheckoutGithubSetAutoMergeResponseSchema,
|
||||
CheckoutPrMergeRequestSchema,
|
||||
CheckoutPrStatusSchema,
|
||||
ServerInfoStatusPayloadSchema,
|
||||
} from "./messages.js";
|
||||
|
||||
describe("checkout PR schemas", () => {
|
||||
test("parses PR status payloads without mergeability", () => {
|
||||
expect(
|
||||
CheckoutPrStatusSchema.parse({
|
||||
number: 42,
|
||||
url: "https://github.com/getpaseo/paseo/pull/42",
|
||||
title: "Ship it",
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/ship-it",
|
||||
isMerged: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
number: 42,
|
||||
mergeable: "UNKNOWN",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps missing provider-specific GitHub PR facts absent for old daemons", () => {
|
||||
const parsed = CheckoutPrStatusSchema.parse({
|
||||
number: 42,
|
||||
url: "https://github.com/getpaseo/paseo/pull/42",
|
||||
title: "Ship it",
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/ship-it",
|
||||
isMerged: false,
|
||||
mergeable: "MERGEABLE",
|
||||
});
|
||||
|
||||
expect(parsed.github).toBeUndefined();
|
||||
});
|
||||
|
||||
test("parses provider-specific GitHub PR status facts", () => {
|
||||
expect(
|
||||
CheckoutPrStatusSchema.parse({
|
||||
number: 993,
|
||||
url: "https://github.com/getpaseo/paseo/pull/993",
|
||||
title: "Block direct merge while checks run",
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "phase-2",
|
||||
isMerged: false,
|
||||
mergeable: "MERGEABLE",
|
||||
checks: [{ name: "server tests", status: "pending", url: null }],
|
||||
checksStatus: "pending",
|
||||
github: {
|
||||
mergeStateStatus: "BLOCKED",
|
||||
autoMergeRequest: null,
|
||||
viewerCanEnableAutoMerge: true,
|
||||
viewerCanDisableAutoMerge: false,
|
||||
viewerCanMergeAsAdmin: false,
|
||||
viewerCanUpdateBranch: true,
|
||||
repository: {
|
||||
autoMergeAllowed: true,
|
||||
mergeCommitAllowed: false,
|
||||
squashMergeAllowed: true,
|
||||
rebaseMergeAllowed: false,
|
||||
viewerDefaultMergeMethod: "SQUASH",
|
||||
},
|
||||
isMergeQueueEnabled: false,
|
||||
isInMergeQueue: false,
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
mergeable: "MERGEABLE",
|
||||
checksStatus: "pending",
|
||||
github: {
|
||||
mergeStateStatus: "BLOCKED",
|
||||
viewerCanEnableAutoMerge: true,
|
||||
repository: {
|
||||
autoMergeAllowed: true,
|
||||
squashMergeAllowed: true,
|
||||
viewerDefaultMergeMethod: "SQUASH",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.each(["merge", "squash", "rebase"] as const)(
|
||||
"accepts %s as a PR merge method",
|
||||
(mergeMethod) => {
|
||||
expect(
|
||||
CheckoutPrMergeRequestSchema.parse({
|
||||
type: "checkout_pr_merge_request",
|
||||
cwd: "/tmp/repo",
|
||||
mergeMethod,
|
||||
requestId: "request-merge-pr",
|
||||
}),
|
||||
).toMatchObject({ mergeMethod });
|
||||
},
|
||||
);
|
||||
|
||||
test("rejects unknown PR merge methods", () => {
|
||||
expect(() =>
|
||||
CheckoutPrMergeRequestSchema.parse({
|
||||
type: "checkout_pr_merge_request",
|
||||
cwd: "/tmp/repo",
|
||||
mergeMethod: "auto",
|
||||
requestId: "request-merge-pr",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test.each(["merge", "squash", "rebase"] as const)(
|
||||
"accepts %s as a GitHub set-auto-merge enable method",
|
||||
(mergeMethod) => {
|
||||
expect(
|
||||
CheckoutGithubSetAutoMergeRequestSchema.parse({
|
||||
type: "checkout.github.set_auto_merge.request",
|
||||
cwd: "/tmp/repo",
|
||||
enabled: true,
|
||||
mergeMethod,
|
||||
requestId: "request-enable-auto-merge",
|
||||
}),
|
||||
).toMatchObject({ enabled: true, mergeMethod });
|
||||
},
|
||||
);
|
||||
|
||||
test("rejects unknown GitHub set-auto-merge enable methods", () => {
|
||||
expect(() =>
|
||||
CheckoutGithubSetAutoMergeRequestSchema.parse({
|
||||
type: "checkout.github.set_auto_merge.request",
|
||||
cwd: "/tmp/repo",
|
||||
enabled: true,
|
||||
mergeMethod: "auto",
|
||||
requestId: "request-enable-auto-merge",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("accepts GitHub set-auto-merge disable requests", () => {
|
||||
expect(
|
||||
CheckoutGithubSetAutoMergeRequestSchema.parse({
|
||||
type: "checkout.github.set_auto_merge.request",
|
||||
cwd: "/tmp/repo",
|
||||
enabled: false,
|
||||
requestId: "request-disable-auto-merge",
|
||||
}),
|
||||
).toMatchObject({
|
||||
cwd: "/tmp/repo",
|
||||
enabled: false,
|
||||
requestId: "request-disable-auto-merge",
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts GitHub set-auto-merge responses", () => {
|
||||
const payload = {
|
||||
cwd: "/tmp/repo",
|
||||
enabled: true,
|
||||
success: true,
|
||||
error: null,
|
||||
requestId: "request-auto-merge",
|
||||
};
|
||||
|
||||
expect(
|
||||
CheckoutGithubSetAutoMergeResponseSchema.parse({
|
||||
type: "checkout.github.set_auto_merge.response",
|
||||
payload,
|
||||
}).payload,
|
||||
).toEqual(payload);
|
||||
});
|
||||
|
||||
test("accepts the GitHub auto-merge server_info feature flag", () => {
|
||||
expect(
|
||||
ServerInfoStatusPayloadSchema.parse({
|
||||
status: "server_info",
|
||||
serverId: "srv_test",
|
||||
features: {
|
||||
providersSnapshot: true,
|
||||
checkoutGithubSetAutoMerge: true,
|
||||
},
|
||||
}).features,
|
||||
).toEqual({
|
||||
providersSnapshot: true,
|
||||
checkoutGithubSetAutoMerge: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SessionInboundMessageSchema } from "./messages";
|
||||
import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "@getpaseo/protocol/agent-title-limits";
|
||||
|
||||
describe("create_agent_request clientMessageId", () => {
|
||||
it("accepts clientMessageId for stable initial prompt transfer", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "create_agent_request",
|
||||
requestId: "req-1",
|
||||
clientMessageId: "client-msg-1",
|
||||
config: {
|
||||
provider: "claude",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
initialPrompt: "hello",
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("create_agent_request");
|
||||
if (parsed.type !== "create_agent_request") {
|
||||
throw new Error("Expected create_agent_request");
|
||||
}
|
||||
expect(parsed.clientMessageId).toBe("client-msg-1");
|
||||
});
|
||||
|
||||
it("accepts explicit titles up to the create-agent limit", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "create_agent_request",
|
||||
requestId: "req-title-ok",
|
||||
config: {
|
||||
provider: "claude",
|
||||
cwd: "/tmp/project",
|
||||
title: "x".repeat(MAX_EXPLICIT_AGENT_TITLE_CHARS),
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("create_agent_request");
|
||||
if (parsed.type !== "create_agent_request") {
|
||||
throw new Error("Expected create_agent_request");
|
||||
}
|
||||
expect(parsed.config.title).toHaveLength(MAX_EXPLICIT_AGENT_TITLE_CHARS);
|
||||
});
|
||||
|
||||
it("rejects explicit titles longer than the create-agent limit", () => {
|
||||
const parsed = SessionInboundMessageSchema.safeParse({
|
||||
type: "create_agent_request",
|
||||
requestId: "req-title-too-long",
|
||||
config: {
|
||||
provider: "claude",
|
||||
cwd: "/tmp/project",
|
||||
title: "x".repeat(MAX_EXPLICIT_AGENT_TITLE_CHARS + 1),
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { SessionInboundMessageSchema } from "./messages.js";
|
||||
|
||||
describe("create_agent_request worktree and autoArchive fields", () => {
|
||||
test("accepts optional worktree branch-off target and autoArchive", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "create_agent_request",
|
||||
requestId: "create-agent-worktree",
|
||||
config: {
|
||||
provider: "codex",
|
||||
cwd: "/repo/app",
|
||||
},
|
||||
worktree: {
|
||||
mode: "branch-off",
|
||||
newBranch: "agent-lifecycle-dispatch",
|
||||
base: "main",
|
||||
},
|
||||
autoArchive: true,
|
||||
});
|
||||
|
||||
expect(parsed).toEqual({
|
||||
type: "create_agent_request",
|
||||
requestId: "create-agent-worktree",
|
||||
config: {
|
||||
provider: "codex",
|
||||
cwd: "/repo/app",
|
||||
},
|
||||
worktree: {
|
||||
mode: "branch-off",
|
||||
newBranch: "agent-lifecycle-dispatch",
|
||||
base: "main",
|
||||
},
|
||||
autoArchive: true,
|
||||
labels: {},
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps legacy create_agent_request defaults unchanged", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "create_agent_request",
|
||||
requestId: "legacy-create-agent",
|
||||
config: {
|
||||
provider: "codex",
|
||||
cwd: "/repo/app",
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed).toEqual({
|
||||
type: "create_agent_request",
|
||||
requestId: "legacy-create-agent",
|
||||
config: {
|
||||
provider: "codex",
|
||||
cwd: "/repo/app",
|
||||
},
|
||||
labels: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { SessionInboundMessageSchema } from "./messages.js";
|
||||
|
||||
describe("list_commands_request schema", () => {
|
||||
test("accepts legacy agent-only payload", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "list_commands_request",
|
||||
agentId: "agent-123",
|
||||
requestId: "req-123",
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("list_commands_request");
|
||||
if (parsed.type !== "list_commands_request") {
|
||||
throw new Error("Expected list_commands_request message");
|
||||
}
|
||||
expect(parsed.agentId).toBe("agent-123");
|
||||
expect(parsed.draftConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
test("accepts draft command context payload", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "list_commands_request",
|
||||
agentId: "__new_agent__",
|
||||
draftConfig: {
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
modeId: "bypassPermissions",
|
||||
model: "gpt-5",
|
||||
thinkingOptionId: "off",
|
||||
featureValues: {
|
||||
plan_mode: true,
|
||||
},
|
||||
},
|
||||
requestId: "req-456",
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("list_commands_request");
|
||||
if (parsed.type !== "list_commands_request") {
|
||||
throw new Error("Expected list_commands_request message");
|
||||
}
|
||||
expect(parsed.draftConfig).toEqual({
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
modeId: "bypassPermissions",
|
||||
model: "gpt-5",
|
||||
thinkingOptionId: "off",
|
||||
featureValues: {
|
||||
plan_mode: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
GetProvidersSnapshotResponseMessageSchema,
|
||||
ProviderSnapshotEntrySchema,
|
||||
ProvidersSnapshotUpdateMessageSchema,
|
||||
} from "./messages.js";
|
||||
|
||||
describe("provider snapshot message schemas", () => {
|
||||
test("defaults missing provider snapshot entry enabled state to true", () => {
|
||||
const parsed = ProviderSnapshotEntrySchema.parse({
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
label: "Codex",
|
||||
});
|
||||
|
||||
expect(parsed.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test("preserves disabled provider snapshot entries", () => {
|
||||
const parsed = ProviderSnapshotEntrySchema.parse({
|
||||
provider: "claude",
|
||||
status: "unavailable",
|
||||
enabled: false,
|
||||
label: "Claude",
|
||||
});
|
||||
|
||||
expect(parsed.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("preserves enabled provider snapshot entries", () => {
|
||||
const parsed = ProviderSnapshotEntrySchema.parse({
|
||||
provider: "opencode",
|
||||
status: "loading",
|
||||
enabled: true,
|
||||
label: "OpenCode",
|
||||
});
|
||||
|
||||
expect(parsed.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test("normalizes thinking option defaults on provider snapshot models", () => {
|
||||
const parsed = ProviderSnapshotEntrySchema.parse({
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
models: [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "MiniMax-M2.7",
|
||||
label: "MiniMax-M2.7",
|
||||
isDefault: true,
|
||||
thinkingOptions: [
|
||||
{ id: "off", label: "Off" },
|
||||
{ id: "max", label: "Max", isDefault: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.models).toEqual([
|
||||
{
|
||||
provider: "claude",
|
||||
id: "MiniMax-M2.7",
|
||||
label: "MiniMax-M2.7",
|
||||
isDefault: true,
|
||||
thinkingOptions: [
|
||||
{ id: "off", label: "Off" },
|
||||
{ id: "max", label: "Max", isDefault: true },
|
||||
],
|
||||
defaultThinkingOptionId: "max",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("defaults missing enabled state in providers snapshot response entries", () => {
|
||||
const parsed = GetProvidersSnapshotResponseMessageSchema.parse({
|
||||
type: "get_providers_snapshot_response",
|
||||
payload: {
|
||||
entries: [
|
||||
{
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
label: "Codex",
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
status: "unavailable",
|
||||
enabled: false,
|
||||
label: "Claude",
|
||||
},
|
||||
],
|
||||
generatedAt: "2026-04-24T00:00:00.000Z",
|
||||
requestId: "req-providers",
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.payload.entries.map((entry) => entry.enabled)).toEqual([true, false]);
|
||||
});
|
||||
|
||||
test("defaults missing enabled state in providers snapshot update entries", () => {
|
||||
const parsed = ProvidersSnapshotUpdateMessageSchema.parse({
|
||||
type: "providers_snapshot_update",
|
||||
payload: {
|
||||
cwd: "/tmp/repo",
|
||||
entries: [
|
||||
{
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
label: "Codex",
|
||||
},
|
||||
],
|
||||
generatedAt: "2026-04-24T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.payload.entries[0]?.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
CheckoutPrStatusResponseSchema,
|
||||
PullRequestTimelineRequestSchema,
|
||||
PullRequestTimelineResponseSchema,
|
||||
SessionInboundMessageSchema,
|
||||
SessionOutboundMessageSchema,
|
||||
} from "./messages.js";
|
||||
|
||||
describe("pull request timeline message schemas", () => {
|
||||
test("requires request identity fields", () => {
|
||||
expect(() =>
|
||||
PullRequestTimelineRequestSchema.parse({
|
||||
type: "pull_request_timeline_request",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("parses request fields", () => {
|
||||
const parsed = PullRequestTimelineRequestSchema.parse({
|
||||
type: "pull_request_timeline_request",
|
||||
cwd: "/tmp/repo",
|
||||
prNumber: 42,
|
||||
repoOwner: "getpaseo",
|
||||
repoName: "paseo",
|
||||
requestId: "request-1",
|
||||
});
|
||||
|
||||
expect(parsed).toEqual({
|
||||
type: "pull_request_timeline_request",
|
||||
cwd: "/tmp/repo",
|
||||
prNumber: 42,
|
||||
repoOwner: "getpaseo",
|
||||
repoName: "paseo",
|
||||
requestId: "request-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses request through the inbound message union", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "pull_request_timeline_request",
|
||||
cwd: "/tmp/repo",
|
||||
prNumber: 42,
|
||||
repoOwner: "getpaseo",
|
||||
repoName: "paseo",
|
||||
requestId: "request-1",
|
||||
});
|
||||
|
||||
expect(parsed).toEqual({
|
||||
type: "pull_request_timeline_request",
|
||||
cwd: "/tmp/repo",
|
||||
prNumber: 42,
|
||||
repoOwner: "getpaseo",
|
||||
repoName: "paseo",
|
||||
requestId: "request-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("defaults optional response payload and malformed timeline item fields", () => {
|
||||
const parsed = PullRequestTimelineResponseSchema.parse({
|
||||
type: "pull_request_timeline_response",
|
||||
payload: {
|
||||
items: [{}, { kind: "review" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed).toEqual({
|
||||
type: "pull_request_timeline_response",
|
||||
payload: {
|
||||
cwd: "",
|
||||
prNumber: null,
|
||||
items: [
|
||||
{
|
||||
id: "",
|
||||
kind: "comment",
|
||||
author: "unknown",
|
||||
body: "",
|
||||
createdAt: 0,
|
||||
url: "",
|
||||
},
|
||||
{
|
||||
id: "",
|
||||
kind: "review",
|
||||
author: "unknown",
|
||||
body: "",
|
||||
createdAt: 0,
|
||||
url: "",
|
||||
reviewState: "commented",
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
error: null,
|
||||
requestId: "",
|
||||
githubFeaturesEnabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes unknown timeline item kinds to comments", () => {
|
||||
const parsed = PullRequestTimelineResponseSchema.parse({
|
||||
type: "pull_request_timeline_response",
|
||||
payload: {
|
||||
items: [
|
||||
{
|
||||
id: "future-1",
|
||||
kind: "unknown_future_kind",
|
||||
author: "octocat",
|
||||
body: "Future daemon item",
|
||||
createdAt: 1710000000000,
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#future-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.payload.items).toEqual([
|
||||
{
|
||||
id: "future-1",
|
||||
kind: "comment",
|
||||
author: "octocat",
|
||||
body: "Future daemon item",
|
||||
createdAt: 1710000000000,
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#future-1",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses response through the outbound message union", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "pull_request_timeline_response",
|
||||
payload: {
|
||||
cwd: "/tmp/repo",
|
||||
prNumber: 42,
|
||||
items: [
|
||||
{
|
||||
id: "review-1",
|
||||
kind: "review",
|
||||
author: "octocat",
|
||||
body: "Looks good",
|
||||
createdAt: 1710000000000,
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#pullrequestreview-1",
|
||||
reviewState: "approved",
|
||||
},
|
||||
{
|
||||
id: "comment-1",
|
||||
kind: "comment",
|
||||
author: "hubot",
|
||||
body: "Left a note",
|
||||
createdAt: 1710000001000,
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#issuecomment-1",
|
||||
},
|
||||
],
|
||||
truncated: true,
|
||||
error: { kind: "unknown", message: "rate limited" },
|
||||
requestId: "request-1",
|
||||
githubFeaturesEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("pull_request_timeline_response");
|
||||
});
|
||||
|
||||
test("normalizes future error kinds to unknown", () => {
|
||||
const parsed = PullRequestTimelineResponseSchema.parse({
|
||||
type: "pull_request_timeline_response",
|
||||
payload: {
|
||||
error: { kind: "future_kind", message: "x" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.payload.error).toEqual({ kind: "unknown", message: "x" });
|
||||
});
|
||||
|
||||
test.each(["rate_limited", "", undefined])(
|
||||
"normalizes malformed error kind %j to unknown",
|
||||
(kind) => {
|
||||
const parsed = PullRequestTimelineResponseSchema.parse({
|
||||
type: "pull_request_timeline_response",
|
||||
payload: {
|
||||
error: { kind },
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.payload.error).toEqual({ kind: "unknown", message: "" });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("checkout PR status compatibility", () => {
|
||||
test("an old client schema parses a new daemon checkout PR status response", () => {
|
||||
const oldClientCheckoutPrStatusResponseSchema = z.object({
|
||||
type: z.literal("checkout_pr_status_response"),
|
||||
payload: z.object({
|
||||
cwd: z.string(),
|
||||
status: z
|
||||
.object({
|
||||
url: z.string(),
|
||||
title: z.string(),
|
||||
state: z.string(),
|
||||
baseRefName: z.string(),
|
||||
headRefName: z.string(),
|
||||
isMerged: z.boolean(),
|
||||
checks: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
status: z.string(),
|
||||
url: z.string().nullable(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.default([]),
|
||||
checksStatus: z.string().optional(),
|
||||
reviewDecision: z.string().nullable().optional(),
|
||||
})
|
||||
.nullable(),
|
||||
githubFeaturesEnabled: z.boolean(),
|
||||
error: z.unknown().nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
const newDaemonPayload = CheckoutPrStatusResponseSchema.parse({
|
||||
type: "checkout_pr_status_response",
|
||||
payload: {
|
||||
cwd: "/tmp/repo",
|
||||
status: {
|
||||
number: 42,
|
||||
url: "https://github.com/getpaseo/paseo/pull/42",
|
||||
title: "Wire real PR pane data",
|
||||
state: "OPEN",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/pr-pane",
|
||||
isMerged: false,
|
||||
isDraft: true,
|
||||
checks: [
|
||||
{
|
||||
name: "server-tests",
|
||||
status: "success",
|
||||
url: "https://github.com/getpaseo/paseo/actions/runs/123",
|
||||
workflow: "Server CI",
|
||||
duration: "2m 14s",
|
||||
},
|
||||
],
|
||||
checksStatus: "success",
|
||||
reviewDecision: "APPROVED",
|
||||
},
|
||||
githubFeaturesEnabled: true,
|
||||
error: null,
|
||||
requestId: "request-compat",
|
||||
},
|
||||
});
|
||||
|
||||
expect(oldClientCheckoutPrStatusResponseSchema.parse(newDaemonPayload)).toEqual({
|
||||
type: "checkout_pr_status_response",
|
||||
payload: {
|
||||
cwd: "/tmp/repo",
|
||||
status: {
|
||||
url: "https://github.com/getpaseo/paseo/pull/42",
|
||||
title: "Wire real PR pane data",
|
||||
state: "OPEN",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/pr-pane",
|
||||
isMerged: false,
|
||||
checks: [
|
||||
{
|
||||
name: "server-tests",
|
||||
status: "success",
|
||||
url: "https://github.com/getpaseo/paseo/actions/runs/123",
|
||||
},
|
||||
],
|
||||
checksStatus: "success",
|
||||
reviewDecision: "APPROVED",
|
||||
},
|
||||
githubFeaturesEnabled: true,
|
||||
error: null,
|
||||
requestId: "request-compat",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { z } from "zod";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { SessionInboundMessageSchema, SessionOutboundMessageSchema } from "./messages.js";
|
||||
|
||||
type SessionMessageOption = z.ZodDiscriminatedUnionOption<"type">;
|
||||
|
||||
function schemaWithoutMessageTypes(
|
||||
schema: { options: SessionMessageOption[] },
|
||||
excludedTypes: string[],
|
||||
) {
|
||||
const excluded = new Set(excludedTypes);
|
||||
const options = schema.options.filter((option) => !excluded.has(option.shape.type.value));
|
||||
|
||||
return z.discriminatedUnion("type", options as [SessionMessageOption, ...SessionMessageOption[]]);
|
||||
}
|
||||
|
||||
describe("rename entity message schemas", () => {
|
||||
test("new client schema still parses old daemon checkout and terminal responses", () => {
|
||||
const checkoutResponse = SessionOutboundMessageSchema.parse({
|
||||
type: "checkout_switch_branch_response",
|
||||
payload: {
|
||||
cwd: "/tmp/repo",
|
||||
success: true,
|
||||
branch: "main",
|
||||
source: "local",
|
||||
error: null,
|
||||
requestId: "request-switch",
|
||||
},
|
||||
});
|
||||
const terminalResponse = SessionOutboundMessageSchema.parse({
|
||||
type: "kill_terminal_response",
|
||||
payload: {
|
||||
terminalId: "terminal-1",
|
||||
success: true,
|
||||
requestId: "request-kill",
|
||||
},
|
||||
});
|
||||
|
||||
expect(checkoutResponse).toEqual({
|
||||
type: "checkout_switch_branch_response",
|
||||
payload: {
|
||||
cwd: "/tmp/repo",
|
||||
success: true,
|
||||
branch: "main",
|
||||
source: "local",
|
||||
error: null,
|
||||
requestId: "request-switch",
|
||||
},
|
||||
});
|
||||
expect(terminalResponse).toEqual({
|
||||
type: "kill_terminal_response",
|
||||
payload: {
|
||||
terminalId: "terminal-1",
|
||||
success: true,
|
||||
requestId: "request-kill",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("old unions without rename variants reject rename messages and still parse existing messages", () => {
|
||||
const legacyInboundSchema = schemaWithoutMessageTypes(SessionInboundMessageSchema, [
|
||||
"terminal.rename.request",
|
||||
"checkout.rename_branch.request",
|
||||
]);
|
||||
const legacyOutboundSchema = schemaWithoutMessageTypes(SessionOutboundMessageSchema, [
|
||||
"terminal.rename.response",
|
||||
"checkout.rename_branch.response",
|
||||
]);
|
||||
|
||||
expect(
|
||||
legacyInboundSchema.safeParse({
|
||||
type: "terminal.rename.request",
|
||||
terminalId: "terminal-1",
|
||||
title: "Server logs",
|
||||
requestId: "request-terminal-rename",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
legacyInboundSchema.safeParse({
|
||||
type: "checkout.rename_branch.request",
|
||||
cwd: "/tmp/repo",
|
||||
branch: "feature/new-name",
|
||||
requestId: "request-branch-rename",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
legacyOutboundSchema.safeParse({
|
||||
type: "terminal.rename.response",
|
||||
payload: {
|
||||
requestId: "request-terminal-rename",
|
||||
success: true,
|
||||
error: null,
|
||||
},
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
legacyOutboundSchema.safeParse({
|
||||
type: "checkout.rename_branch.response",
|
||||
payload: {
|
||||
requestId: "request-branch-rename",
|
||||
success: true,
|
||||
cwd: "/tmp/repo",
|
||||
currentBranch: "feature/new-name",
|
||||
error: null,
|
||||
},
|
||||
}).success,
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
legacyInboundSchema.parse({
|
||||
type: "checkout_switch_branch_request",
|
||||
cwd: "/tmp/repo",
|
||||
branch: "main",
|
||||
requestId: "request-switch",
|
||||
}),
|
||||
).toEqual({
|
||||
type: "checkout_switch_branch_request",
|
||||
cwd: "/tmp/repo",
|
||||
branch: "main",
|
||||
requestId: "request-switch",
|
||||
});
|
||||
expect(
|
||||
legacyOutboundSchema.parse({
|
||||
type: "kill_terminal_response",
|
||||
payload: {
|
||||
terminalId: "terminal-1",
|
||||
success: true,
|
||||
requestId: "request-kill",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
type: "kill_terminal_response",
|
||||
payload: {
|
||||
terminalId: "terminal-1",
|
||||
success: true,
|
||||
requestId: "request-kill",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,405 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
AgentStreamMessageSchema,
|
||||
FetchAgentTimelineResponseMessageSchema,
|
||||
SessionInboundMessageSchema,
|
||||
SessionOutboundMessageSchema,
|
||||
WSOutboundMessageSchema,
|
||||
} from "./messages.js";
|
||||
|
||||
describe("shared messages stream parsing", () => {
|
||||
it("parses representative fetch_agent_timeline_response payload", () => {
|
||||
const parsed = FetchAgentTimelineResponseMessageSchema.parse({
|
||||
type: "fetch_agent_timeline_response",
|
||||
payload: {
|
||||
requestId: "req-1",
|
||||
agentId: "agent_live",
|
||||
agent: null,
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
epoch: "epoch-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: 2, nextSeq: 3 },
|
||||
startCursor: { epoch: "epoch-1", seq: 1 },
|
||||
endCursor: { epoch: "epoch-1", seq: 2 },
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
entries: [
|
||||
{
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text: "hello" },
|
||||
timestamp: "2026-02-08T20:10:00.000Z",
|
||||
seqStart: 1,
|
||||
seqEnd: 2,
|
||||
sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }],
|
||||
collapsed: ["assistant_merge"],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.payload.entries).toHaveLength(1);
|
||||
expect(parsed.payload.entries[0]?.item.type).toBe("assistant_message");
|
||||
});
|
||||
|
||||
it("parses legacy worktree setup timeline entries without per-command log", () => {
|
||||
const parsed = FetchAgentTimelineResponseMessageSchema.parse({
|
||||
type: "fetch_agent_timeline_response",
|
||||
payload: {
|
||||
requestId: "req-legacy-setup",
|
||||
agentId: "agent_legacy_setup",
|
||||
agent: null,
|
||||
direction: "tail",
|
||||
projection: "canonical",
|
||||
epoch: "epoch-setup",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
|
||||
startCursor: { epoch: "epoch-setup", seq: 1 },
|
||||
endCursor: { epoch: "epoch-setup", seq: 1 },
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
entries: [
|
||||
{
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "setup-1",
|
||||
name: "paseo_worktree_setup",
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/repo/.paseo/worktrees/feature",
|
||||
branchName: "feature",
|
||||
log: "setup complete",
|
||||
commands: [
|
||||
{
|
||||
index: 1,
|
||||
command: "npm install",
|
||||
cwd: "/repo/.paseo/worktrees/feature",
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
durationMs: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
timestamp: "2026-04-22T00:00:00.000Z",
|
||||
seqStart: 1,
|
||||
seqEnd: 1,
|
||||
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
|
||||
collapsed: [],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
const item = parsed.payload.entries[0]?.item;
|
||||
expect(item?.type).toBe("tool_call");
|
||||
if (item?.type !== "tool_call" || item.detail.type !== "worktree_setup") {
|
||||
throw new Error("Expected worktree setup tool call");
|
||||
}
|
||||
expect(item.detail.commands[0]?.log).toBe("");
|
||||
});
|
||||
|
||||
it("parses explicit shutdown and restart lifecycle request payloads as distinct message types", () => {
|
||||
const shutdownParsed = SessionInboundMessageSchema.safeParse({
|
||||
type: "shutdown_server_request",
|
||||
requestId: "req-shutdown-1",
|
||||
});
|
||||
expect(shutdownParsed.success).toBe(true);
|
||||
|
||||
const restartParsed = SessionInboundMessageSchema.safeParse({
|
||||
type: "restart_server_request",
|
||||
requestId: "req-restart-1",
|
||||
reason: "settings_changed",
|
||||
});
|
||||
expect(restartParsed.success).toBe(true);
|
||||
|
||||
expect(shutdownParsed.success && shutdownParsed.data.type).toBe("shutdown_server_request");
|
||||
expect(restartParsed.success && restartParsed.data.type).toBe("restart_server_request");
|
||||
});
|
||||
|
||||
it("parses representative agent_stream tool_call event", () => {
|
||||
const parsed = AgentStreamMessageSchema.parse({
|
||||
type: "agent_stream",
|
||||
payload: {
|
||||
agentId: "agent_live",
|
||||
timestamp: "2026-02-08T20:10:00.000Z",
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "call_live",
|
||||
name: "shell",
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "ls",
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.payload.event.type).toBe("timeline");
|
||||
if (parsed.payload.event.type === "timeline") {
|
||||
expect(parsed.payload.event.item.type).toBe("tool_call");
|
||||
if (parsed.payload.event.item.type === "tool_call") {
|
||||
expect(parsed.payload.event.item.status).toBe("running");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("parses representative sub_agent tool_call event", () => {
|
||||
const parsed = AgentStreamMessageSchema.parse({
|
||||
type: "agent_stream",
|
||||
payload: {
|
||||
agentId: "agent_live",
|
||||
timestamp: "2026-02-08T20:10:00.000Z",
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "call_sub_agent_live",
|
||||
name: "Task",
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "Explore",
|
||||
description: "Inspect repository structure",
|
||||
log: "[Read] README.md",
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.payload.event.type).toBe("timeline");
|
||||
if (parsed.payload.event.type === "timeline") {
|
||||
expect(parsed.payload.event.item.type).toBe("tool_call");
|
||||
if (parsed.payload.event.item.type === "tool_call") {
|
||||
expect(parsed.payload.event.item.detail.type).toBe("sub_agent");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("parses optional permission actions and selectedActionId compatibly", () => {
|
||||
const requestParsed = AgentStreamMessageSchema.parse({
|
||||
type: "agent_stream",
|
||||
payload: {
|
||||
agentId: "agent_live",
|
||||
timestamp: "2026-02-08T20:10:00.000Z",
|
||||
event: {
|
||||
type: "permission_requested",
|
||||
provider: "codex",
|
||||
request: {
|
||||
id: "perm-1",
|
||||
provider: "codex",
|
||||
name: "CodexPlanApproval",
|
||||
kind: "plan",
|
||||
input: { plan: "- step 1" },
|
||||
actions: [
|
||||
{
|
||||
id: "reject",
|
||||
label: "Reject",
|
||||
behavior: "deny",
|
||||
variant: "danger",
|
||||
intent: "dismiss",
|
||||
},
|
||||
{
|
||||
id: "implement",
|
||||
label: "Implement",
|
||||
behavior: "allow",
|
||||
variant: "primary",
|
||||
intent: "implement",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestParsed.payload.event.type).toBe("permission_requested");
|
||||
if (requestParsed.payload.event.type === "permission_requested") {
|
||||
expect(requestParsed.payload.event.request.actions).toHaveLength(2);
|
||||
expect(requestParsed.payload.event.request.actions?.[1]?.label).toBe("Implement");
|
||||
}
|
||||
|
||||
const resolutionParsed = AgentStreamMessageSchema.parse({
|
||||
type: "agent_stream",
|
||||
payload: {
|
||||
agentId: "agent_live",
|
||||
timestamp: "2026-02-08T20:10:01.000Z",
|
||||
event: {
|
||||
type: "permission_resolved",
|
||||
provider: "claude",
|
||||
requestId: "perm-1",
|
||||
resolution: {
|
||||
behavior: "allow",
|
||||
selectedActionId: "implement_resume",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolutionParsed.payload.event.type).toBe("permission_resolved");
|
||||
if (resolutionParsed.payload.event.type === "permission_resolved") {
|
||||
expect(resolutionParsed.payload.event.resolution).toEqual({
|
||||
behavior: "allow",
|
||||
selectedActionId: "implement_resume",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("parses permission request detail compatibly", () => {
|
||||
const parsed = AgentStreamMessageSchema.parse({
|
||||
type: "agent_stream",
|
||||
payload: {
|
||||
agentId: "agent_live",
|
||||
timestamp: "2026-02-08T20:10:00.000Z",
|
||||
event: {
|
||||
type: "permission_requested",
|
||||
provider: "opencode",
|
||||
request: {
|
||||
id: "perm-shell-1",
|
||||
provider: "opencode",
|
||||
name: "external_directory",
|
||||
kind: "tool",
|
||||
title: "Access external directory",
|
||||
input: {
|
||||
command: "ls /tmp/outside",
|
||||
},
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "ls /tmp/outside",
|
||||
cwd: "/home/dev/project",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.payload.event.type).toBe("permission_requested");
|
||||
if (parsed.payload.event.type === "permission_requested") {
|
||||
expect(parsed.payload.event.request.detail).toEqual({
|
||||
type: "shell",
|
||||
command: "ls /tmp/outside",
|
||||
cwd: "/home/dev/project",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects removed initialize_agent_request inbound payload", () => {
|
||||
const parsed = SessionInboundMessageSchema.safeParse({
|
||||
type: "initialize_agent_request",
|
||||
agentId: "agent-legacy",
|
||||
requestId: "req-legacy-1",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects removed initialize_agent_request outbound payload", () => {
|
||||
const parsed = SessionOutboundMessageSchema.safeParse({
|
||||
type: "initialize_agent_request",
|
||||
payload: {
|
||||
requestId: "req-legacy-1",
|
||||
agentId: "agent-legacy",
|
||||
agentStatus: "running",
|
||||
timelineSize: 12,
|
||||
},
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("parses directory suggestions request and response payloads", () => {
|
||||
const requestParsed = SessionInboundMessageSchema.safeParse({
|
||||
type: "directory_suggestions_request",
|
||||
query: "proj",
|
||||
cwd: "/tmp/project",
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
matchMode: "suffix",
|
||||
limit: 20,
|
||||
requestId: "req-dir-1",
|
||||
});
|
||||
expect(requestParsed.success).toBe(true);
|
||||
|
||||
const responseParsed = SessionOutboundMessageSchema.safeParse({
|
||||
type: "directory_suggestions_response",
|
||||
payload: {
|
||||
directories: ["/Users/test/projects/paseo"],
|
||||
entries: [{ path: "/Users/test/projects/paseo", kind: "directory" }],
|
||||
error: null,
|
||||
requestId: "req-dir-1",
|
||||
},
|
||||
});
|
||||
expect(responseParsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects websocket envelope for removed agent_stream_snapshot message type", () => {
|
||||
const fixture = {
|
||||
type: "agent_stream_snapshot",
|
||||
payload: {
|
||||
agentId: "agent-legacy",
|
||||
events: [],
|
||||
},
|
||||
};
|
||||
const wrapped = WSOutboundMessageSchema.safeParse({
|
||||
type: "session",
|
||||
message: fixture,
|
||||
});
|
||||
expect(wrapped.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects removed legacy git diff request messages", () => {
|
||||
const gitDiffParsed = SessionInboundMessageSchema.safeParse({
|
||||
type: "git_diff_request",
|
||||
agentId: "agent-1",
|
||||
requestId: "req-1",
|
||||
});
|
||||
expect(gitDiffParsed.success).toBe(false);
|
||||
|
||||
const highlightedParsed = SessionInboundMessageSchema.safeParse({
|
||||
type: "highlighted_diff_request",
|
||||
agentId: "agent-1",
|
||||
requestId: "req-2",
|
||||
});
|
||||
expect(highlightedParsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects removed legacy git diff response messages", () => {
|
||||
const gitDiffParsed = SessionOutboundMessageSchema.safeParse({
|
||||
type: "git_diff_response",
|
||||
payload: {
|
||||
agentId: "agent-1",
|
||||
diff: "",
|
||||
error: null,
|
||||
requestId: "req-1",
|
||||
},
|
||||
});
|
||||
expect(gitDiffParsed.success).toBe(false);
|
||||
|
||||
const highlightedParsed = SessionOutboundMessageSchema.safeParse({
|
||||
type: "highlighted_diff_response",
|
||||
payload: {
|
||||
agentId: "agent-1",
|
||||
files: [],
|
||||
error: null,
|
||||
requestId: "req-2",
|
||||
},
|
||||
});
|
||||
expect(highlightedParsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { ServerInfoStatusPayloadSchema, SubscribeTerminalRequestSchema } from "./messages.js";
|
||||
|
||||
describe("terminal restore schemas", () => {
|
||||
test("accepts legacy terminal subscribe requests without restore options", () => {
|
||||
expect(
|
||||
SubscribeTerminalRequestSchema.parse({
|
||||
type: "subscribe_terminal_request",
|
||||
terminalId: "term-1",
|
||||
requestId: "req-1",
|
||||
}),
|
||||
).toEqual({
|
||||
type: "subscribe_terminal_request",
|
||||
terminalId: "term-1",
|
||||
requestId: "req-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts kebab-case terminal restore modes", () => {
|
||||
for (const mode of ["live", "visible-snapshot", "full-snapshot"] as const) {
|
||||
expect(
|
||||
SubscribeTerminalRequestSchema.parse({
|
||||
type: "subscribe_terminal_request",
|
||||
terminalId: "term-1",
|
||||
requestId: `req-${mode}`,
|
||||
restore: {
|
||||
mode,
|
||||
scrollbackLines: 200,
|
||||
size: { rows: 24, cols: 80 },
|
||||
},
|
||||
}).restore?.mode,
|
||||
).toBe(mode);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects camel-case terminal restore modes", () => {
|
||||
expect(() =>
|
||||
SubscribeTerminalRequestSchema.parse({
|
||||
type: "subscribe_terminal_request",
|
||||
terminalId: "term-1",
|
||||
requestId: "req-1",
|
||||
restore: {
|
||||
mode: "visibleSnapshot",
|
||||
},
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("accepts terminal restore mode feature metadata", () => {
|
||||
expect(
|
||||
ServerInfoStatusPayloadSchema.parse({
|
||||
status: "server_info",
|
||||
serverId: "server-1",
|
||||
features: {
|
||||
"terminal-restore-modes": true,
|
||||
},
|
||||
}).features?.["terminal-restore-modes"],
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { FileExplorerRequestSchema, SessionOutboundMessageSchema } from "./messages.js";
|
||||
|
||||
function workspaceDescriptor(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "ws-1",
|
||||
projectId: "remote:github.com/acme/app",
|
||||
projectDisplayName: "acme/app",
|
||||
projectRootPath: "/repo/app",
|
||||
workspaceDirectory: "/repo/app",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "app",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function fetchWorkspacesResponse(workspace: Record<string, unknown>) {
|
||||
return {
|
||||
type: "fetch_workspaces_response",
|
||||
payload: {
|
||||
requestId: "req-1",
|
||||
entries: [workspace],
|
||||
pageInfo: {
|
||||
nextCursor: null,
|
||||
prevCursor: null,
|
||||
hasMore: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("workspace descriptor message compatibility", () => {
|
||||
test("old-shaped fetch_workspaces_response without project still parses", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse(
|
||||
fetchWorkspacesResponse(workspaceDescriptor()),
|
||||
);
|
||||
|
||||
expect(parsed.type).toBe("fetch_workspaces_response");
|
||||
if (parsed.type !== "fetch_workspaces_response") {
|
||||
throw new Error("Expected fetch_workspaces_response");
|
||||
}
|
||||
expect(parsed.payload.entries[0]?.project).toBeUndefined();
|
||||
});
|
||||
|
||||
test("new-shaped fetch_workspaces_response with project placement parses", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse(
|
||||
fetchWorkspacesResponse(
|
||||
workspaceDescriptor({
|
||||
project: {
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
checkout: {
|
||||
cwd: "/repo/app",
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
worktreeRoot: "/repo/app",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(parsed.type).toBe("fetch_workspaces_response");
|
||||
if (parsed.type !== "fetch_workspaces_response") {
|
||||
throw new Error("Expected fetch_workspaces_response");
|
||||
}
|
||||
expect(parsed.payload.entries[0]?.project).toEqual({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
checkout: {
|
||||
cwd: "/repo/app",
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
worktreeRoot: "/repo/app",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("adding project does not narrow existing descriptor fields", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse(
|
||||
fetchWorkspacesResponse(
|
||||
workspaceDescriptor({
|
||||
workspaceDirectory: undefined,
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "directory",
|
||||
gitRuntime: null,
|
||||
githubRuntime: null,
|
||||
project: {
|
||||
projectKey: "/repo/local",
|
||||
projectName: "local",
|
||||
checkout: {
|
||||
cwd: "/repo/local",
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(parsed.type).toBe("fetch_workspaces_response");
|
||||
if (parsed.type !== "fetch_workspaces_response") {
|
||||
throw new Error("Expected fetch_workspaces_response");
|
||||
}
|
||||
expect(parsed.payload.entries[0]).toMatchObject({
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "directory",
|
||||
workspaceDirectory: "/repo/app",
|
||||
gitRuntime: null,
|
||||
githubRuntime: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("file explorer request compatibility", () => {
|
||||
test("acceptBinary is optional for old clients and accepted for new clients", () => {
|
||||
expect(
|
||||
FileExplorerRequestSchema.parse({
|
||||
type: "file_explorer_request",
|
||||
cwd: "/repo/app",
|
||||
path: "image.png",
|
||||
mode: "file",
|
||||
requestId: "req-old",
|
||||
}),
|
||||
).toEqual({
|
||||
type: "file_explorer_request",
|
||||
cwd: "/repo/app",
|
||||
path: "image.png",
|
||||
mode: "file",
|
||||
requestId: "req-old",
|
||||
});
|
||||
|
||||
expect(
|
||||
FileExplorerRequestSchema.parse({
|
||||
type: "file_explorer_request",
|
||||
cwd: "/repo/app",
|
||||
path: "image.png",
|
||||
mode: "file",
|
||||
requestId: "req-new",
|
||||
acceptBinary: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: "file_explorer_request",
|
||||
requestId: "req-new",
|
||||
acceptBinary: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AgentTimelineItemPayloadSchema } from "./messages.js";
|
||||
|
||||
function canonicalBase() {
|
||||
return {
|
||||
type: "tool_call" as const,
|
||||
callId: "call_123",
|
||||
name: "shell",
|
||||
detail: {
|
||||
type: "shell" as const,
|
||||
command: "pwd",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("shared messages tool_call schema", () => {
|
||||
it("parses each status-discriminated tool_call variant at runtime", () => {
|
||||
const running = AgentTimelineItemPayloadSchema.parse({
|
||||
...canonicalBase(),
|
||||
status: "running",
|
||||
error: null,
|
||||
});
|
||||
|
||||
const completed = AgentTimelineItemPayloadSchema.parse({
|
||||
...canonicalBase(),
|
||||
status: "completed",
|
||||
error: null,
|
||||
});
|
||||
|
||||
const failed = AgentTimelineItemPayloadSchema.parse({
|
||||
...canonicalBase(),
|
||||
status: "failed",
|
||||
error: { message: "command failed" },
|
||||
});
|
||||
|
||||
const canceled = AgentTimelineItemPayloadSchema.parse({
|
||||
...canonicalBase(),
|
||||
status: "canceled",
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(running.type).toBe("tool_call");
|
||||
expect(completed.type).toBe("tool_call");
|
||||
expect(failed.type).toBe("tool_call");
|
||||
expect(canceled.type).toBe("tool_call");
|
||||
});
|
||||
|
||||
it("rejects non-recoverable invalid tool_call payloads", () => {
|
||||
const missingCallId = AgentTimelineItemPayloadSchema.safeParse({
|
||||
type: "tool_call",
|
||||
name: "shell",
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "pwd",
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
|
||||
const unknownStatus = AgentTimelineItemPayloadSchema.safeParse({
|
||||
...canonicalBase(),
|
||||
status: "mystery_status",
|
||||
error: null,
|
||||
});
|
||||
|
||||
const withTopLevelInputOutput = AgentTimelineItemPayloadSchema.safeParse({
|
||||
...canonicalBase(),
|
||||
status: "running",
|
||||
error: null,
|
||||
input: { command: "pwd" },
|
||||
output: { exitCode: 0 },
|
||||
});
|
||||
|
||||
expect(missingCallId.success).toBe(false);
|
||||
expect(unknownStatus.success).toBe(false);
|
||||
expect(withTopLevelInputOutput.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects legacy status/error combinations without normalization", () => {
|
||||
const completedWithError = AgentTimelineItemPayloadSchema.safeParse({
|
||||
...canonicalBase(),
|
||||
status: "completed",
|
||||
error: { message: "unexpected" },
|
||||
});
|
||||
|
||||
const failedWithoutError = AgentTimelineItemPayloadSchema.safeParse({
|
||||
...canonicalBase(),
|
||||
status: "failed",
|
||||
error: null,
|
||||
});
|
||||
|
||||
const missingDetail = AgentTimelineItemPayloadSchema.safeParse({
|
||||
type: "tool_call",
|
||||
callId: "call_missing_detail",
|
||||
name: "shell",
|
||||
status: "running",
|
||||
error: null,
|
||||
});
|
||||
|
||||
const legacyStatus = AgentTimelineItemPayloadSchema.safeParse({
|
||||
...canonicalBase(),
|
||||
status: "inProgress",
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(completedWithError.success).toBe(false);
|
||||
expect(failedWithoutError.success).toBe(false);
|
||||
expect(missingDetail.success).toBe(false);
|
||||
expect(legacyStatus.success).toBe(false);
|
||||
});
|
||||
|
||||
it("parses canonical sub_agent detail payload", () => {
|
||||
const parsed = AgentTimelineItemPayloadSchema.parse({
|
||||
type: "tool_call",
|
||||
callId: "call_sub_agent_1",
|
||||
name: "Task",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "Explore",
|
||||
description: "Inspect repository structure",
|
||||
log: "[Read] README.md\n[Bash] ls",
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("tool_call");
|
||||
if (parsed.type === "tool_call") {
|
||||
expect(parsed.detail.type).toBe("sub_agent");
|
||||
if (parsed.detail.type === "sub_agent") {
|
||||
expect(parsed.detail.subAgentType).toBe("Explore");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("parses sub_agent detail without structured actions", () => {
|
||||
const parsed = AgentTimelineItemPayloadSchema.parse({
|
||||
type: "tool_call",
|
||||
callId: "call_sub_agent_legacy",
|
||||
name: "Task",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "Explore",
|
||||
description: "Inspect repository structure",
|
||||
log: "[Read] README.md",
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("tool_call");
|
||||
if (parsed.type === "tool_call") {
|
||||
expect(parsed.detail.type).toBe("sub_agent");
|
||||
if (parsed.detail.type === "sub_agent") {
|
||||
expect(parsed.detail.log).toBe("[Read] README.md");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("parses plain_text detail with icon and rejects unknown icon names", () => {
|
||||
const parsed = AgentTimelineItemPayloadSchema.parse({
|
||||
type: "tool_call",
|
||||
callId: "call_plain_text_1",
|
||||
name: "task_notification",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
label: "Background task completed",
|
||||
icon: "wrench",
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("tool_call");
|
||||
if (parsed.type === "tool_call") {
|
||||
expect(parsed.detail.type).toBe("plain_text");
|
||||
if (parsed.detail.type === "plain_text") {
|
||||
expect(parsed.detail.icon).toBe("wrench");
|
||||
}
|
||||
}
|
||||
|
||||
const invalid = AgentTimelineItemPayloadSchema.safeParse({
|
||||
type: "tool_call",
|
||||
callId: "call_plain_text_invalid",
|
||||
name: "task_notification",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
label: "Background task completed",
|
||||
icon: "laser",
|
||||
},
|
||||
});
|
||||
|
||||
expect(invalid.success).toBe(false);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,774 @@
|
||||
import { z } from "zod";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
RecentProviderSessionDescriptorPayloadSchema,
|
||||
SessionInboundMessageSchema,
|
||||
SessionOutboundMessageSchema,
|
||||
WorkspaceDescriptorPayloadSchema,
|
||||
} from "./messages.js";
|
||||
|
||||
describe("workspace message schemas", () => {
|
||||
test("parses fetch_workspaces_request", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "fetch_workspaces_request",
|
||||
requestId: "req-1",
|
||||
filter: {
|
||||
query: "repo",
|
||||
projectId: "proj-12",
|
||||
idPrefix: "/Users/me",
|
||||
},
|
||||
sort: [{ key: "activity_at", direction: "desc" }],
|
||||
page: { limit: 50 },
|
||||
subscribe: {},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("fetch_workspaces_request");
|
||||
});
|
||||
|
||||
test("parses active-scoped fetch_agents_request as an optional extension", () => {
|
||||
const legacy = SessionInboundMessageSchema.parse({
|
||||
type: "fetch_agents_request",
|
||||
requestId: "req-agents-legacy",
|
||||
page: { limit: 50 },
|
||||
});
|
||||
const activeScoped = SessionInboundMessageSchema.parse({
|
||||
type: "fetch_agents_request",
|
||||
requestId: "req-agents-active",
|
||||
scope: "active",
|
||||
page: { limit: 50 },
|
||||
subscribe: {},
|
||||
});
|
||||
|
||||
expect(legacy.type).toBe("fetch_agents_request");
|
||||
expect(activeScoped.type).toBe("fetch_agents_request");
|
||||
if (activeScoped.type !== "fetch_agents_request") {
|
||||
throw new Error("Expected fetch_agents_request");
|
||||
}
|
||||
expect(activeScoped.scope).toBe("active");
|
||||
});
|
||||
|
||||
test("parses agent_update without project placement", () => {
|
||||
const result = SessionOutboundMessageSchema.safeParse({
|
||||
type: "agent_update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/repo",
|
||||
model: null,
|
||||
features: [],
|
||||
thinkingOptionId: null,
|
||||
effectiveThinkingOptionId: null,
|
||||
createdAt: "2026-04-04T00:00:00.000Z",
|
||||
updatedAt: "2026-04-04T00:00:00.000Z",
|
||||
lastUserMessageAt: null,
|
||||
status: "running",
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
title: null,
|
||||
labels: {},
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("parses paginated fetch_agent_history_request and response", () => {
|
||||
const request = SessionInboundMessageSchema.parse({
|
||||
type: "fetch_agent_history_request",
|
||||
requestId: "req-history",
|
||||
page: { limit: 25, cursor: "cursor-1" },
|
||||
sort: [{ key: "updated_at", direction: "desc" }],
|
||||
});
|
||||
const response = SessionOutboundMessageSchema.parse({
|
||||
type: "fetch_agent_history_response",
|
||||
payload: {
|
||||
requestId: "req-history",
|
||||
entries: [],
|
||||
pageInfo: {
|
||||
nextCursor: "cursor-2",
|
||||
prevCursor: "cursor-1",
|
||||
hasMore: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(request.type).toBe("fetch_agent_history_request");
|
||||
expect(response.type).toBe("fetch_agent_history_response");
|
||||
});
|
||||
|
||||
test("parses recent provider session descriptors without legacy handle fields", () => {
|
||||
const parsed = RecentProviderSessionDescriptorPayloadSchema.parse({
|
||||
providerId: "custom-codex",
|
||||
providerLabel: "Custom Codex",
|
||||
providerHandleId: "thread-1",
|
||||
cwd: "/tmp/repo",
|
||||
title: "Resume this",
|
||||
firstPromptPreview: "first prompt",
|
||||
lastPromptPreview: "last prompt",
|
||||
lastActivityAt: "2026-04-30T12:34:56.000Z",
|
||||
});
|
||||
|
||||
expect(parsed).toEqual({
|
||||
providerId: "custom-codex",
|
||||
providerLabel: "Custom Codex",
|
||||
providerHandleId: "thread-1",
|
||||
cwd: "/tmp/repo",
|
||||
title: "Resume this",
|
||||
firstPromptPreview: "first prompt",
|
||||
lastPromptPreview: "last prompt",
|
||||
lastActivityAt: "2026-04-30T12:34:56.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses fetch_recent_provider_sessions request and response", () => {
|
||||
const request = SessionInboundMessageSchema.parse({
|
||||
type: "fetch_recent_provider_sessions_request",
|
||||
requestId: "req-recent-provider-sessions",
|
||||
cwd: "/tmp/repo",
|
||||
providers: ["my-claude"],
|
||||
since: "2026-04-30T00:00:00.000Z",
|
||||
limit: 25,
|
||||
});
|
||||
const response = SessionOutboundMessageSchema.parse({
|
||||
type: "fetch_recent_provider_sessions_response",
|
||||
payload: {
|
||||
requestId: "req-recent-provider-sessions",
|
||||
entries: [
|
||||
{
|
||||
providerId: "my-claude",
|
||||
providerLabel: "My Claude",
|
||||
providerHandleId: "thread-1",
|
||||
cwd: "/tmp/repo",
|
||||
title: "Resume this",
|
||||
firstPromptPreview: "first prompt",
|
||||
lastPromptPreview: "last prompt",
|
||||
lastActivityAt: "2026-04-30T12:34:56.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(request.type).toBe("fetch_recent_provider_sessions_request");
|
||||
expect(request.providers).toEqual(["my-claude"]);
|
||||
expect(response.payload).toEqual({
|
||||
requestId: "req-recent-provider-sessions",
|
||||
entries: [
|
||||
{
|
||||
providerId: "my-claude",
|
||||
providerLabel: "My Claude",
|
||||
providerHandleId: "thread-1",
|
||||
cwd: "/tmp/repo",
|
||||
title: "Resume this",
|
||||
firstPromptPreview: "first prompt",
|
||||
lastPromptPreview: "last prompt",
|
||||
lastActivityAt: "2026-04-30T12:34:56.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("parses fetch_recent_provider_sessions response with filteredAlreadyImportedCount", () => {
|
||||
const response = SessionOutboundMessageSchema.parse({
|
||||
type: "fetch_recent_provider_sessions_response",
|
||||
payload: {
|
||||
requestId: "req-recent-provider-sessions",
|
||||
entries: [],
|
||||
filteredAlreadyImportedCount: 3,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.type !== "fetch_recent_provider_sessions_response") {
|
||||
throw new Error("expected fetch_recent_provider_sessions_response");
|
||||
}
|
||||
expect(response.payload.filteredAlreadyImportedCount).toBe(3);
|
||||
});
|
||||
|
||||
test("parses new and legacy import agent requests", () => {
|
||||
const newRequest = SessionInboundMessageSchema.parse({
|
||||
type: "import_agent_request",
|
||||
requestId: "req-import-new",
|
||||
providerId: "custom-codex",
|
||||
providerHandleId: "thread-1",
|
||||
cwd: "/tmp/repo",
|
||||
});
|
||||
const legacyRequest = SessionInboundMessageSchema.parse({
|
||||
type: "import_agent_request",
|
||||
requestId: "req-import-legacy",
|
||||
provider: "custom-codex",
|
||||
sessionId: "thread-1",
|
||||
cwd: "/tmp/repo",
|
||||
});
|
||||
|
||||
expect(newRequest).toEqual({
|
||||
type: "import_agent_request",
|
||||
requestId: "req-import-new",
|
||||
providerId: "custom-codex",
|
||||
providerHandleId: "thread-1",
|
||||
cwd: "/tmp/repo",
|
||||
});
|
||||
expect(legacyRequest).toEqual({
|
||||
type: "import_agent_request",
|
||||
requestId: "req-import-legacy",
|
||||
provider: "custom-codex",
|
||||
sessionId: "thread-1",
|
||||
cwd: "/tmp/repo",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses open_project_request", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "open_project_request",
|
||||
cwd: "/tmp/repo",
|
||||
requestId: "req-open",
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("open_project_request");
|
||||
});
|
||||
|
||||
test("parses list_available_editors_request", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "list_available_editors_request",
|
||||
requestId: "req-editors",
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("list_available_editors_request");
|
||||
});
|
||||
|
||||
test("parses open_in_editor_request with flexible editor ids", () => {
|
||||
const knownEditor = SessionInboundMessageSchema.parse({
|
||||
type: "open_in_editor_request",
|
||||
requestId: "req-open-webstorm",
|
||||
editorId: "webstorm",
|
||||
path: "/tmp/repo",
|
||||
});
|
||||
const unknownEditor = SessionInboundMessageSchema.parse({
|
||||
type: "open_in_editor_request",
|
||||
requestId: "req-open-custom",
|
||||
editorId: "unknown-editor",
|
||||
path: "/tmp/repo",
|
||||
});
|
||||
|
||||
expect(knownEditor.type).toBe("open_in_editor_request");
|
||||
expect(unknownEditor.type).toBe("open_in_editor_request");
|
||||
});
|
||||
|
||||
test("parses open_in_editor_response", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "open_in_editor_response",
|
||||
payload: {
|
||||
requestId: "req-open-editor",
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("open_in_editor_response");
|
||||
});
|
||||
|
||||
test("parses list_available_editors_response with unknown editor ids", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "list_available_editors_response",
|
||||
payload: {
|
||||
requestId: "req-editors",
|
||||
editors: [
|
||||
{ id: "cursor", label: "Cursor" },
|
||||
{ id: "unknown-editor", label: "Unknown Editor" },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("list_available_editors_response");
|
||||
});
|
||||
|
||||
test("rejects empty editor ids", () => {
|
||||
const result = SessionInboundMessageSchema.safeParse({
|
||||
type: "open_in_editor_request",
|
||||
requestId: "req-open-empty",
|
||||
editorId: "",
|
||||
path: "/tmp/repo",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects invalid workspace update payload", () => {
|
||||
const result = SessionOutboundMessageSchema.safeParse({
|
||||
type: "workspace_update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
workspace: {
|
||||
id: "ws-invalid",
|
||||
projectId: "proj-invalid",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/repo",
|
||||
projectKind: "directory",
|
||||
workspaceKind: "checkout",
|
||||
name: "",
|
||||
status: "not-a-bucket",
|
||||
activityAt: null,
|
||||
scripts: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("parses workspace descriptors with scripts", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "workspace_update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
workspace: {
|
||||
id: "ws-1",
|
||||
projectId: "proj-1",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: "/repo",
|
||||
projectKind: "directory",
|
||||
workspaceKind: "checkout",
|
||||
name: "repo",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
scripts: [
|
||||
{
|
||||
scriptName: "web",
|
||||
hostname: "web.paseo.localhost",
|
||||
port: 3000,
|
||||
proxyUrl: "http://web.paseo.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("workspace_update");
|
||||
if (parsed.type !== "workspace_update" || parsed.payload.kind !== "upsert") {
|
||||
throw new Error("Expected workspace_update upsert payload");
|
||||
}
|
||||
expect(parsed.payload.workspace.scripts).toEqual([
|
||||
{
|
||||
scriptName: "web",
|
||||
type: "service",
|
||||
hostname: "web.paseo.localhost",
|
||||
port: 3000,
|
||||
proxyUrl: "http://web.paseo.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
terminalId: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses legacy workspace descriptors without workspaceDirectory", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "workspace_update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
workspace: {
|
||||
id: "legacy-workspace",
|
||||
projectId: "legacy-project",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "repo",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
scripts: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("workspace_update");
|
||||
if (parsed.type !== "workspace_update" || parsed.payload.kind !== "upsert") {
|
||||
throw new Error("Expected workspace_update upsert payload");
|
||||
}
|
||||
expect(parsed.payload.workspace.workspaceDirectory).toBe("/repo");
|
||||
});
|
||||
|
||||
test("defaults omitted workspace archiving state and preserves present timestamps", () => {
|
||||
const baseWorkspace = {
|
||||
id: "ws-archiving",
|
||||
projectId: "proj-archiving",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: "/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: "feature",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
scripts: [],
|
||||
} as const;
|
||||
const archivingAt = "2026-04-30T20:45:00.000Z";
|
||||
|
||||
expect(WorkspaceDescriptorPayloadSchema.parse(baseWorkspace).archivingAt).toBeNull();
|
||||
expect(
|
||||
WorkspaceDescriptorPayloadSchema.parse({
|
||||
...baseWorkspace,
|
||||
archivingAt,
|
||||
}).archivingAt,
|
||||
).toBe(archivingAt);
|
||||
});
|
||||
|
||||
test("parses legacy workspace descriptor enum values", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "workspace_update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
workspace: {
|
||||
id: "legacy-workspace",
|
||||
projectId: "legacy-project",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: "/repo",
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "directory",
|
||||
name: "repo",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
scripts: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("workspace_update");
|
||||
if (parsed.type !== "workspace_update" || parsed.payload.kind !== "upsert") {
|
||||
throw new Error("Expected workspace_update upsert payload");
|
||||
}
|
||||
expect(parsed.payload.workspace.projectKind).toBe("non_git");
|
||||
expect(parsed.payload.workspace.workspaceKind).toBe("directory");
|
||||
});
|
||||
|
||||
test("parses script_status_update payload", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "script_status_update",
|
||||
payload: {
|
||||
workspaceId: "ws-repo",
|
||||
scripts: [
|
||||
{
|
||||
scriptName: "web",
|
||||
hostname: "web.paseo.localhost",
|
||||
port: null,
|
||||
proxyUrl: null,
|
||||
lifecycle: "stopped",
|
||||
health: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("script_status_update");
|
||||
expect(parsed.payload.workspaceId).toBe("ws-repo");
|
||||
expect(parsed.payload.scripts[0]).toMatchObject({
|
||||
type: "service",
|
||||
exitCode: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("parses workspace_setup_progress payload", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "workspace_setup_progress",
|
||||
payload: {
|
||||
workspaceId: "ws-feature-a",
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/repo/.paseo/worktrees/feature-a",
|
||||
branchName: "feature-a",
|
||||
log: "done",
|
||||
commands: [
|
||||
{
|
||||
index: 1,
|
||||
command: "npm install",
|
||||
cwd: "/repo/.paseo/worktrees/feature-a",
|
||||
log: "done",
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
durationMs: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("workspace_setup_progress");
|
||||
});
|
||||
|
||||
test("parses workspace_setup_status_request", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "workspace_setup_status_request",
|
||||
workspaceId: "ws-feature-a",
|
||||
requestId: "req-status",
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("workspace_setup_status_request");
|
||||
});
|
||||
|
||||
test("parses workspace_setup_status_response payload", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "workspace_setup_status_response",
|
||||
payload: {
|
||||
requestId: "req-status",
|
||||
workspaceId: "ws-feature-a",
|
||||
snapshot: {
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/repo/.paseo/worktrees/feature-a",
|
||||
branchName: "feature-a",
|
||||
log: "done",
|
||||
commands: [],
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("workspace_setup_status_response");
|
||||
});
|
||||
|
||||
test("parses fetch_workspaces_response with optional runtime fields", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "fetch_workspaces_response",
|
||||
payload: {
|
||||
requestId: "req-workspaces",
|
||||
entries: [
|
||||
{
|
||||
id: "ws-main",
|
||||
projectId: "remote:github.com/acme/repo",
|
||||
projectDisplayName: "acme/repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
workspaceDirectory: "/tmp/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: {
|
||||
additions: 3,
|
||||
deletions: 1,
|
||||
},
|
||||
gitRuntime: {
|
||||
currentBranch: "main",
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
isPaseoOwnedWorktree: false,
|
||||
isDirty: true,
|
||||
aheadBehind: {
|
||||
ahead: 2,
|
||||
behind: 1,
|
||||
},
|
||||
aheadOfOrigin: 2,
|
||||
behindOfOrigin: 1,
|
||||
},
|
||||
githubRuntime: {
|
||||
featuresEnabled: true,
|
||||
pullRequest: {
|
||||
url: "https://github.com/acme/repo/pull/123",
|
||||
title: "Runtime payloads",
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "workspace-git-service",
|
||||
isMerged: false,
|
||||
},
|
||||
error: null,
|
||||
refreshedAt: "2026-04-12T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
nextCursor: null,
|
||||
prevCursor: null,
|
||||
hasMore: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("fetch_workspaces_response");
|
||||
expect(parsed.payload.entries[0]?.gitRuntime).toMatchObject({
|
||||
currentBranch: "main",
|
||||
isDirty: true,
|
||||
aheadOfOrigin: 2,
|
||||
});
|
||||
expect(parsed.payload.entries[0]?.githubRuntime?.pullRequest?.title).toBe("Runtime payloads");
|
||||
});
|
||||
|
||||
test("older workspace parsers ignore additive runtime fields", () => {
|
||||
const message = {
|
||||
type: "fetch_workspaces_response",
|
||||
payload: {
|
||||
requestId: "req-workspaces",
|
||||
entries: [
|
||||
{
|
||||
id: "ws-main",
|
||||
projectId: "remote:github.com/acme/repo",
|
||||
projectDisplayName: "acme/repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
gitRuntime: {
|
||||
currentBranch: "main",
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
isPaseoOwnedWorktree: false,
|
||||
isDirty: false,
|
||||
aheadBehind: {
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
},
|
||||
aheadOfOrigin: 0,
|
||||
behindOfOrigin: 0,
|
||||
},
|
||||
githubRuntime: {
|
||||
featuresEnabled: true,
|
||||
pullRequest: null,
|
||||
error: null,
|
||||
refreshedAt: "2026-04-12T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
nextCursor: null,
|
||||
prevCursor: null,
|
||||
hasMore: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const legacyWorkspaceSchema = z.object({
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
projectDisplayName: z.string(),
|
||||
projectRootPath: z.string(),
|
||||
projectKind: z.enum(["git", "non_git"]),
|
||||
workspaceKind: z.enum(["local_checkout", "worktree", "directory"]),
|
||||
name: z.string(),
|
||||
status: z.enum(["needs_input", "failed", "running", "attention", "done"]),
|
||||
activityAt: z.string().nullable(),
|
||||
diffStat: z
|
||||
.object({
|
||||
additions: z.number(),
|
||||
deletions: z.number(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
});
|
||||
const legacyMessageSchema = z.object({
|
||||
type: z.literal("fetch_workspaces_response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
entries: z.array(legacyWorkspaceSchema),
|
||||
pageInfo: z.object({
|
||||
nextCursor: z.string().nullable(),
|
||||
prevCursor: z.string().nullable(),
|
||||
hasMore: z.boolean(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const parsed = legacyMessageSchema.parse(message);
|
||||
|
||||
expect(parsed.payload.entries[0]).toEqual({
|
||||
id: "ws-main",
|
||||
projectId: "remote:github.com/acme/repo",
|
||||
projectDisplayName: "acme/repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("parses legacy fetch_agents_response checkout payloads without worktreeRoot", () => {
|
||||
const result = SessionOutboundMessageSchema.safeParse({
|
||||
type: "fetch_agents_response",
|
||||
payload: {
|
||||
requestId: "req-1",
|
||||
entries: [
|
||||
{
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
cwd: "C:\\repo",
|
||||
model: null,
|
||||
features: [],
|
||||
thinkingOptionId: null,
|
||||
effectiveThinkingOptionId: null,
|
||||
createdAt: "2026-04-04T00:00:00.000Z",
|
||||
updatedAt: "2026-04-04T00:00:00.000Z",
|
||||
lastUserMessageAt: null,
|
||||
status: "running",
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
title: "Agent 1",
|
||||
labels: {},
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
},
|
||||
project: {
|
||||
projectKey: "remote:github.com/acme/repo",
|
||||
projectName: "acme/repo",
|
||||
checkout: {
|
||||
cwd: "C:\\repo",
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
nextCursor: null,
|
||||
prevCursor: null,
|
||||
hasMore: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkout = result.data.payload.entries[0]?.project.checkout;
|
||||
expect(checkout?.worktreeRoot).toBe("C:\\repo");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PaseoConfigRawSchema, PaseoConfigSchema } from "@getpaseo/protocol/paseo-config-schema";
|
||||
|
||||
describe("paseo config schema", () => {
|
||||
it("parses an empty config without metadata generation", () => {
|
||||
const parsed = PaseoConfigSchema.parse({});
|
||||
|
||||
expect(parsed).toEqual({});
|
||||
expect(parsed.metadataGeneration).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses old-style worktree and scripts config unchanged", () => {
|
||||
const config = {
|
||||
worktree: {
|
||||
setup: "npm install",
|
||||
teardown: ["npm run clean"],
|
||||
},
|
||||
scripts: {
|
||||
dev: {
|
||||
type: "service",
|
||||
command: "npm run dev",
|
||||
port: 5173,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(PaseoConfigSchema.parse(config)).toEqual({
|
||||
worktree: {
|
||||
setup: ["npm install"],
|
||||
teardown: ["npm run clean"],
|
||||
},
|
||||
scripts: config.scripts,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses all metadata generation instruction entries", () => {
|
||||
expect(
|
||||
PaseoConfigSchema.parse({
|
||||
metadataGeneration: {
|
||||
agentTitle: { instructions: "Use concise titles." },
|
||||
branchName: { instructions: "Prefix branches with feat/." },
|
||||
commitMessage: { instructions: "Use imperative mood." },
|
||||
pullRequest: { instructions: "Include risk notes." },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
metadataGeneration: {
|
||||
agentTitle: { instructions: "Use concise titles." },
|
||||
branchName: { instructions: "Prefix branches with feat/." },
|
||||
commitMessage: { instructions: "Use imperative mood." },
|
||||
pullRequest: { instructions: "Include risk notes." },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("parses partial metadata generation instructions with missing entries undefined", () => {
|
||||
const parsed = PaseoConfigSchema.parse({
|
||||
metadataGeneration: {
|
||||
agentTitle: { instructions: "Keep it short." },
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.metadataGeneration).toEqual({
|
||||
agentTitle: { instructions: "Keep it short." },
|
||||
});
|
||||
expect(parsed.metadataGeneration?.branchName).toBeUndefined();
|
||||
expect(parsed.metadataGeneration?.commitMessage).toBeUndefined();
|
||||
expect(parsed.metadataGeneration?.pullRequest).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes through unknown metadata generation fields", () => {
|
||||
expect(
|
||||
PaseoConfigSchema.parse({
|
||||
metadataGeneration: {
|
||||
agentTitle: { instructions: "Use concise titles." },
|
||||
futureField: 42,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
metadataGeneration: {
|
||||
agentTitle: { instructions: "Use concise titles." },
|
||||
futureField: 42,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("passes through unknown metadata generator entry fields", () => {
|
||||
expect(
|
||||
PaseoConfigSchema.parse({
|
||||
metadataGeneration: {
|
||||
agentTitle: {
|
||||
instructions: "Use concise titles.",
|
||||
model: "haiku",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
metadataGeneration: {
|
||||
agentTitle: {
|
||||
instructions: "Use concise titles.",
|
||||
model: "haiku",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to an empty metadata generator entry when instructions has an invalid type", () => {
|
||||
expect(
|
||||
PaseoConfigSchema.parse({
|
||||
metadataGeneration: {
|
||||
agentTitle: { instructions: 42 },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
metadataGeneration: {
|
||||
agentTitle: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("raw schema preserves old-style config while accepting metadata generation", () => {
|
||||
const config = {
|
||||
worktree: {
|
||||
setup: "npm install",
|
||||
teardown: ["npm run clean"],
|
||||
},
|
||||
scripts: {
|
||||
dev: {
|
||||
type: "service",
|
||||
command: "npm run dev",
|
||||
},
|
||||
},
|
||||
metadataGeneration: {
|
||||
agentTitle: { instructions: "Use concise titles." },
|
||||
},
|
||||
};
|
||||
|
||||
expect(PaseoConfigRawSchema.parse(config)).toEqual(config);
|
||||
});
|
||||
|
||||
it("raw schema falls back to an empty metadata generator entry when instructions has an invalid type", () => {
|
||||
expect(
|
||||
PaseoConfigRawSchema.parse({
|
||||
metadataGeneration: {
|
||||
agentTitle: { instructions: 42 },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
metadataGeneration: {
|
||||
agentTitle: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export function normalizeLifecycleCommands(commands: unknown): string[] {
|
||||
if (typeof commands === "string") {
|
||||
return commands.trim().length > 0 ? [commands] : [];
|
||||
}
|
||||
if (!Array.isArray(commands)) {
|
||||
return [];
|
||||
}
|
||||
return commands.filter((command): command is string => {
|
||||
return typeof command === "string" && command.trim().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export const PaseoLifecycleCommandRawSchema = z.union([z.string(), z.array(z.string())]);
|
||||
|
||||
export const PaseoScriptEntryRawSchema = z
|
||||
.object({
|
||||
type: z.unknown().optional(),
|
||||
command: z.unknown().optional(),
|
||||
port: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const PaseoWorktreeConfigRawSchema = z
|
||||
.object({
|
||||
setup: PaseoLifecycleCommandRawSchema.optional(),
|
||||
teardown: PaseoLifecycleCommandRawSchema.optional(),
|
||||
terminals: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const PaseoMetadataGenerationEntrySchema = z
|
||||
.object({
|
||||
instructions: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.catch({});
|
||||
|
||||
export const PaseoMetadataGenerationSchema = z
|
||||
.object({
|
||||
agentTitle: PaseoMetadataGenerationEntrySchema.optional(),
|
||||
branchName: PaseoMetadataGenerationEntrySchema.optional(),
|
||||
commitMessage: PaseoMetadataGenerationEntrySchema.optional(),
|
||||
pullRequest: PaseoMetadataGenerationEntrySchema.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.catch({});
|
||||
|
||||
export const PaseoConfigRawSchema = z
|
||||
.object({
|
||||
worktree: PaseoWorktreeConfigRawSchema.optional(),
|
||||
scripts: z.record(z.string(), PaseoScriptEntryRawSchema).optional(),
|
||||
metadataGeneration: PaseoMetadataGenerationSchema.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const WorktreeConfigSchema = PaseoWorktreeConfigRawSchema.extend({
|
||||
setup: z.unknown().transform(normalizeLifecycleCommands),
|
||||
teardown: z.unknown().transform(normalizeLifecycleCommands),
|
||||
})
|
||||
.passthrough()
|
||||
.catch({ setup: [], teardown: [] });
|
||||
|
||||
export const ScriptEntrySchema = PaseoScriptEntryRawSchema.catch({});
|
||||
|
||||
export const PaseoConfigSchema = PaseoConfigRawSchema.extend({
|
||||
worktree: WorktreeConfigSchema.optional(),
|
||||
scripts: z.record(z.string(), ScriptEntrySchema).optional().catch({}),
|
||||
metadataGeneration: PaseoMetadataGenerationSchema.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.catch({});
|
||||
|
||||
export const PaseoConfigRevisionSchema = z.object({
|
||||
mtimeMs: z.number(),
|
||||
size: z.number(),
|
||||
});
|
||||
|
||||
export const ProjectConfigRpcErrorSchema = z.discriminatedUnion("code", [
|
||||
z.object({ code: z.literal("project_not_found") }),
|
||||
z.object({ code: z.literal("invalid_project_config") }),
|
||||
z.object({
|
||||
code: z.literal("stale_project_config"),
|
||||
currentRevision: PaseoConfigRevisionSchema.nullable(),
|
||||
}),
|
||||
z.object({ code: z.literal("write_failed") }),
|
||||
]);
|
||||
|
||||
export type PaseoScriptEntryRaw = z.infer<typeof PaseoScriptEntryRawSchema>;
|
||||
export type PaseoMetadataGenerationEntry = z.infer<typeof PaseoMetadataGenerationEntrySchema>;
|
||||
export type PaseoMetadataGeneration = z.infer<typeof PaseoMetadataGenerationSchema>;
|
||||
export type PaseoConfigRaw = z.infer<typeof PaseoConfigRawSchema>;
|
||||
export type PaseoConfig = z.infer<typeof PaseoConfigSchema>;
|
||||
export type PaseoConfigRevision = z.infer<typeof PaseoConfigRevisionSchema>;
|
||||
export type ProjectConfigRpcError = z.infer<typeof ProjectConfigRpcErrorSchema>;
|
||||
@@ -0,0 +1,17 @@
|
||||
export function stripCwdPrefix(filePath: string, cwd?: string): string {
|
||||
if (!cwd || !filePath) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const normalizedCwd = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const normalizedPath = filePath.replace(/\\/g, "/");
|
||||
const prefix = `${normalizedCwd}/`;
|
||||
|
||||
if (normalizedPath.startsWith(prefix)) {
|
||||
return normalizedPath.slice(prefix.length);
|
||||
}
|
||||
if (normalizedPath === normalizedCwd) {
|
||||
return ".";
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { z } from "zod";
|
||||
import type { AgentProvider } from "./agent-types.js";
|
||||
import { AgentProviderSchema } from "./provider-manifest.js";
|
||||
|
||||
const ProviderCommandDefaultSchema = z
|
||||
.object({
|
||||
mode: z.literal("default"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ProviderCommandAppendSchema = z
|
||||
.object({
|
||||
mode: z.literal("append"),
|
||||
args: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ProviderCommandReplaceSchema = z
|
||||
.object({
|
||||
mode: z.literal("replace"),
|
||||
argv: z.array(z.string().min(1)).min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const ProviderCommandSchema = z.discriminatedUnion("mode", [
|
||||
ProviderCommandDefaultSchema,
|
||||
ProviderCommandAppendSchema,
|
||||
ProviderCommandReplaceSchema,
|
||||
]);
|
||||
|
||||
export const ProviderRuntimeSettingsSchema = z
|
||||
.object({
|
||||
command: ProviderCommandSchema.optional(),
|
||||
env: z.record(z.string()).optional(),
|
||||
disallowedTools: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ProviderProfileThinkingOptionSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const ProviderProfileModelSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
thinkingOptions: z.array(ProviderProfileThinkingOptionSchema).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const ProviderOverrideSchema = z
|
||||
.object({
|
||||
extends: z.string().optional(),
|
||||
label: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
command: z.array(z.string().min(1)).min(1).optional(),
|
||||
env: z.record(z.string()).optional(),
|
||||
models: z.array(ProviderProfileModelSchema).optional(),
|
||||
additionalModels: z.array(ProviderProfileModelSchema).optional(),
|
||||
disallowedTools: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
order: z.number().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi"] as const;
|
||||
const PROVIDER_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
|
||||
|
||||
export const ProviderOverridesSchema = z
|
||||
.record(ProviderOverrideSchema)
|
||||
.superRefine((providers, ctx) => {
|
||||
const builtinProviderIdSet = new Set<string>(BUILTIN_PROVIDER_IDS);
|
||||
const validExtendsValues = new Set<string>([...BUILTIN_PROVIDER_IDS, "acp"]);
|
||||
|
||||
for (const [providerId, provider] of Object.entries(providers)) {
|
||||
if (!PROVIDER_ID_PATTERN.test(providerId)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [providerId],
|
||||
message: `Provider ID "${providerId}" must match ${PROVIDER_ID_PATTERN}.`,
|
||||
});
|
||||
}
|
||||
|
||||
const isBuiltinProvider = builtinProviderIdSet.has(providerId);
|
||||
if (!isBuiltinProvider && !provider.extends) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [providerId, "extends"],
|
||||
message: `Custom provider "${providerId}" must declare extends.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isBuiltinProvider && !provider.label) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [providerId, "label"],
|
||||
message: `Custom provider "${providerId}" must declare label.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (provider.extends && !validExtendsValues.has(provider.extends)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [providerId, "extends"],
|
||||
message: `Provider "${providerId}" extends unknown provider "${provider.extends}".`,
|
||||
});
|
||||
}
|
||||
|
||||
if (provider.extends === "acp" && !provider.command) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [providerId, "command"],
|
||||
message: `Provider "${providerId}" extending "acp" must declare command.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const AgentProviderRuntimeSettingsMapSchema = z
|
||||
.record(ProviderRuntimeSettingsSchema)
|
||||
.superRefine((providers, ctx) => {
|
||||
for (const providerId of Object.keys(providers)) {
|
||||
const parsedProviderId = AgentProviderSchema.safeParse(providerId);
|
||||
if (!parsedProviderId.success) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [providerId],
|
||||
message: `Invalid agent provider "${providerId}".`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type ProviderCommand = z.infer<typeof ProviderCommandSchema>;
|
||||
export type ProviderRuntimeSettings = z.infer<typeof ProviderRuntimeSettingsSchema>;
|
||||
export type ProviderProfileModel = z.infer<typeof ProviderProfileModelSchema>;
|
||||
export type ProviderOverride = z.infer<typeof ProviderOverrideSchema>;
|
||||
export type ProviderOverrides = z.infer<typeof ProviderOverridesSchema>;
|
||||
export type AgentProviderRuntimeSettingsMap = Partial<
|
||||
Record<AgentProvider, ProviderRuntimeSettings>
|
||||
>;
|
||||
@@ -0,0 +1,280 @@
|
||||
import { z } from "zod";
|
||||
import type { AgentMode } from "./agent-types.js";
|
||||
|
||||
export type AgentModeColorTier = "safe" | "moderate" | "dangerous" | "planning" | `#${string}`;
|
||||
// Open string by design: the client looks icons up in a registry and falls back
|
||||
// to a default for unknown values. Daemon downgrades unknown icons for clients
|
||||
// that pre-date the open-string contract (see CLIENT_CAPS.customModeIcons).
|
||||
export type AgentModeIcon = string;
|
||||
|
||||
export interface AgentModeVisuals {
|
||||
icon: AgentModeIcon;
|
||||
colorTier: AgentModeColorTier;
|
||||
}
|
||||
|
||||
export type AgentProviderModeDefinition = Omit<AgentMode, "icon" | "colorTier"> &
|
||||
AgentModeVisuals & {
|
||||
// Marks the provider's most-permissioned no-prompt mode. Selecting it means tools run without approval; the runtime mechanism is provider-specific.
|
||||
isUnattended?: boolean;
|
||||
};
|
||||
|
||||
// TODO: `modes` should not be static. Providers (especially ACP) report their
|
||||
// own modes at runtime via session/new. We should fetch modes from the provider
|
||||
// as source of truth and enrich with UI metadata (icons, colorTier) on top.
|
||||
export interface AgentProviderDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
defaultModeId: string | null;
|
||||
modes: AgentProviderModeDefinition[];
|
||||
voice?: {
|
||||
enabled: boolean;
|
||||
defaultModeId: string;
|
||||
defaultModel?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const CLAUDE_MODES: AgentProviderModeDefinition[] = [
|
||||
{
|
||||
id: "default",
|
||||
label: "Always Ask",
|
||||
description: "Prompts for permission the first time a tool is used",
|
||||
icon: "ShieldCheck",
|
||||
colorTier: "safe",
|
||||
},
|
||||
{
|
||||
id: "auto",
|
||||
label: "Auto mode",
|
||||
description: "Uses a model classifier to review permission prompts automatically",
|
||||
icon: "ShieldQuestionMark",
|
||||
colorTier: "moderate",
|
||||
},
|
||||
{
|
||||
id: "acceptEdits",
|
||||
label: "Accept File Edits",
|
||||
description: "Automatically approves edit-focused tools without prompting",
|
||||
icon: "ShieldAlert",
|
||||
colorTier: "moderate",
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
label: "Plan Mode",
|
||||
description: "Analyze the codebase without executing tools or edits",
|
||||
icon: "ShieldCheck",
|
||||
colorTier: "planning",
|
||||
},
|
||||
{
|
||||
id: "bypassPermissions",
|
||||
label: "Bypass",
|
||||
description: "Skip all permission prompts (use with caution)",
|
||||
icon: "ShieldAlert",
|
||||
colorTier: "dangerous",
|
||||
isUnattended: true,
|
||||
},
|
||||
];
|
||||
|
||||
const CODEX_MODES: AgentProviderModeDefinition[] = [
|
||||
{
|
||||
id: "auto",
|
||||
label: "Default Permissions",
|
||||
description: "Edit files and run commands with Codex's default approval flow.",
|
||||
icon: "ShieldAlert",
|
||||
colorTier: "moderate",
|
||||
},
|
||||
{
|
||||
id: "auto-review",
|
||||
label: "Auto-review",
|
||||
description:
|
||||
"Same workspace-write permissions as Default, but eligible `on-request` approvals are routed through the auto-reviewer subagent.",
|
||||
icon: "ShieldQuestionMark",
|
||||
colorTier: "moderate",
|
||||
},
|
||||
{
|
||||
id: "full-access",
|
||||
label: "Full Access",
|
||||
description: "Edit files, run commands, and access the network without additional prompts.",
|
||||
icon: "ShieldAlert",
|
||||
colorTier: "dangerous",
|
||||
isUnattended: true,
|
||||
},
|
||||
];
|
||||
|
||||
const COPILOT_MODES: AgentProviderModeDefinition[] = [
|
||||
{
|
||||
id: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
||||
label: "Agent",
|
||||
description: "Default agent mode for conversational interactions",
|
||||
icon: "ShieldAlert",
|
||||
colorTier: "moderate",
|
||||
},
|
||||
{
|
||||
id: "https://agentclientprotocol.com/protocol/session-modes#plan",
|
||||
label: "Plan",
|
||||
description: "Plan mode for creating and executing multi-step plans",
|
||||
icon: "ShieldCheck",
|
||||
colorTier: "planning",
|
||||
},
|
||||
{
|
||||
id: "allow-all",
|
||||
label: "Allow All",
|
||||
description: "Automatically approves all Copilot tool, path, and URL requests.",
|
||||
icon: "ShieldOff",
|
||||
colorTier: "dangerous",
|
||||
isUnattended: true,
|
||||
},
|
||||
];
|
||||
|
||||
const OPENCODE_MODES: AgentProviderModeDefinition[] = [
|
||||
{
|
||||
id: "build",
|
||||
label: "Build",
|
||||
description: "Allows edits and tool execution for implementation work",
|
||||
icon: "Bot",
|
||||
colorTier: "moderate",
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
label: "Plan",
|
||||
description: "Read-only planning mode that avoids file edits",
|
||||
icon: "Bot",
|
||||
colorTier: "planning",
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_LOAD_TEST_MODES: AgentProviderModeDefinition[] = [
|
||||
{
|
||||
id: "load-test",
|
||||
label: "Load Test",
|
||||
description: "Streams repeated markdown, reasoning, and tool calls for app stress testing",
|
||||
icon: "ShieldOff",
|
||||
colorTier: "dangerous",
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_SLOW_MODES: AgentProviderModeDefinition[] = [
|
||||
{
|
||||
id: "default",
|
||||
label: "Default",
|
||||
description: "Dev-only mode for the mock slow provider",
|
||||
icon: "ShieldOff",
|
||||
colorTier: "dangerous",
|
||||
},
|
||||
];
|
||||
|
||||
export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||
{
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
description: "Anthropic's multi-tool assistant with MCP support, streaming, and deep reasoning",
|
||||
defaultModeId: "default",
|
||||
modes: CLAUDE_MODES,
|
||||
voice: {
|
||||
enabled: true,
|
||||
defaultModeId: "default",
|
||||
defaultModel: "haiku",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
description: "OpenAI's Codex workspace agent with sandbox controls and optional network access",
|
||||
defaultModeId: "auto",
|
||||
modes: CODEX_MODES,
|
||||
voice: {
|
||||
enabled: true,
|
||||
defaultModeId: "auto",
|
||||
defaultModel: "gpt-5.4-mini",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "copilot",
|
||||
label: "Copilot",
|
||||
description: "GitHub Copilot via Agent Client Protocol with dynamic modes and session support",
|
||||
defaultModeId: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
||||
modes: COPILOT_MODES,
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
label: "OpenCode",
|
||||
description: "Open-source coding assistant with multi-provider model support",
|
||||
defaultModeId: "build",
|
||||
modes: OPENCODE_MODES,
|
||||
voice: {
|
||||
enabled: true,
|
||||
defaultModeId: "build",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "pi",
|
||||
label: "Pi",
|
||||
description: "Minimal terminal-based coding agent with multi-provider LLM support",
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const DEV_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||
{
|
||||
id: "mock",
|
||||
label: "Mock Load Test",
|
||||
description:
|
||||
"Development-only provider that emits synthetic agent traffic for performance tests",
|
||||
defaultModeId: "load-test",
|
||||
modes: MOCK_LOAD_TEST_MODES,
|
||||
},
|
||||
{
|
||||
id: "mock-slow",
|
||||
label: "Mock Slow Provider",
|
||||
description: "Dev-only: hangs during model discovery to test loading and timeout UI",
|
||||
defaultModeId: "default",
|
||||
modes: MOCK_SLOW_MODES,
|
||||
},
|
||||
];
|
||||
|
||||
export function getAgentProviderDefinition(
|
||||
provider: string,
|
||||
definitions: AgentProviderDefinition[] = [
|
||||
...AGENT_PROVIDER_DEFINITIONS,
|
||||
...DEV_AGENT_PROVIDER_DEFINITIONS,
|
||||
],
|
||||
): AgentProviderDefinition {
|
||||
const definition = definitions.find((entry) => entry.id === provider);
|
||||
if (!definition) {
|
||||
throw new Error(`Unknown agent provider: ${provider}`);
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
export const BUILTIN_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id);
|
||||
export const AGENT_PROVIDER_IDS = BUILTIN_PROVIDER_IDS;
|
||||
|
||||
export const AgentProviderSchema = z.string();
|
||||
|
||||
export function isValidAgentProvider(
|
||||
value: string,
|
||||
validIds: Iterable<string> = BUILTIN_PROVIDER_IDS,
|
||||
): boolean {
|
||||
return Array.isArray(validIds) ? validIds.includes(value) : new Set(validIds).has(value);
|
||||
}
|
||||
|
||||
export function getUnattendedModeId(
|
||||
provider: string,
|
||||
definitions: AgentProviderDefinition[] = [
|
||||
...AGENT_PROVIDER_DEFINITIONS,
|
||||
...DEV_AGENT_PROVIDER_DEFINITIONS,
|
||||
],
|
||||
): string | undefined {
|
||||
const definition = definitions.find((entry) => entry.id === provider);
|
||||
return definition?.modes.find((mode) => mode.isUnattended)?.id;
|
||||
}
|
||||
|
||||
export function getModeVisuals(
|
||||
provider: string,
|
||||
modeId: string,
|
||||
definitions: AgentProviderDefinition[],
|
||||
): AgentModeVisuals | undefined {
|
||||
const definition = definitions.find((entry) => entry.id === provider);
|
||||
const mode = definition?.modes.find((m) => m.id === modeId);
|
||||
if (!mode) return undefined;
|
||||
return { icon: mode.icon, colorTier: mode.colorTier };
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ScheduleCadenceSchema,
|
||||
ScheduleRunSchema,
|
||||
ScheduleSummarySchema,
|
||||
StoredScheduleSchema,
|
||||
ScheduleTargetSchema,
|
||||
} from "./types.js";
|
||||
|
||||
const ScheduleCreateTargetSchema = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("self"),
|
||||
agentId: z.string().uuid(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("agent"),
|
||||
agentId: z.string().uuid(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("new-agent"),
|
||||
config: ScheduleTargetSchema.options[1].shape.config,
|
||||
}),
|
||||
]);
|
||||
|
||||
export const ScheduleCreateRequestSchema = z.object({
|
||||
type: z.literal("schedule/create"),
|
||||
requestId: z.string(),
|
||||
prompt: z.string().min(1),
|
||||
name: z.string().optional(),
|
||||
cadence: ScheduleCadenceSchema,
|
||||
target: ScheduleCreateTargetSchema,
|
||||
maxRuns: z.number().int().positive().optional(),
|
||||
expiresAt: z.string().optional(),
|
||||
runOnCreate: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const ScheduleListRequestSchema = z.object({
|
||||
type: z.literal("schedule/list"),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ScheduleInspectRequestSchema = z.object({
|
||||
type: z.literal("schedule/inspect"),
|
||||
requestId: z.string(),
|
||||
scheduleId: z.string(),
|
||||
});
|
||||
|
||||
export const ScheduleLogsRequestSchema = z.object({
|
||||
type: z.literal("schedule/logs"),
|
||||
requestId: z.string(),
|
||||
scheduleId: z.string(),
|
||||
});
|
||||
|
||||
export const SchedulePauseRequestSchema = z.object({
|
||||
type: z.literal("schedule/pause"),
|
||||
requestId: z.string(),
|
||||
scheduleId: z.string(),
|
||||
});
|
||||
|
||||
export const ScheduleResumeRequestSchema = z.object({
|
||||
type: z.literal("schedule/resume"),
|
||||
requestId: z.string(),
|
||||
scheduleId: z.string(),
|
||||
});
|
||||
|
||||
export const ScheduleDeleteRequestSchema = z.object({
|
||||
type: z.literal("schedule/delete"),
|
||||
requestId: z.string(),
|
||||
scheduleId: z.string(),
|
||||
});
|
||||
|
||||
export const ScheduleRunOnceRequestSchema = z.object({
|
||||
type: z.literal("schedule/run-once"),
|
||||
requestId: z.string(),
|
||||
scheduleId: z.string(),
|
||||
});
|
||||
|
||||
const ScheduleUpdateNewAgentConfigSchema = z.object({
|
||||
provider: z.string().trim().min(1).optional(),
|
||||
model: z.string().trim().min(1).nullable().optional(),
|
||||
modeId: z.string().trim().min(1).nullable().optional(),
|
||||
cwd: z.string().trim().min(1).optional(),
|
||||
});
|
||||
|
||||
export const ScheduleUpdateRequestSchema = z.object({
|
||||
type: z.literal("schedule/update"),
|
||||
requestId: z.string(),
|
||||
scheduleId: z.string(),
|
||||
name: z.string().nullable().optional(),
|
||||
prompt: z.string().min(1).optional(),
|
||||
cadence: ScheduleCadenceSchema.optional(),
|
||||
newAgentConfig: ScheduleUpdateNewAgentConfigSchema.optional(),
|
||||
maxRuns: z.number().int().positive().nullable().optional(),
|
||||
expiresAt: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const ScheduleCreateResponseSchema = z.object({
|
||||
type: z.literal("schedule/create/response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
schedule: ScheduleSummarySchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ScheduleListResponseSchema = z.object({
|
||||
type: z.literal("schedule/list/response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
schedules: z.array(ScheduleSummarySchema),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ScheduleInspectResponseSchema = z.object({
|
||||
type: z.literal("schedule/inspect/response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
schedule: StoredScheduleSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ScheduleLogsResponseSchema = z.object({
|
||||
type: z.literal("schedule/logs/response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
runs: z.array(ScheduleRunSchema),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const SchedulePauseResponseSchema = z.object({
|
||||
type: z.literal("schedule/pause/response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
schedule: ScheduleSummarySchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ScheduleResumeResponseSchema = z.object({
|
||||
type: z.literal("schedule/resume/response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
schedule: ScheduleSummarySchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ScheduleDeleteResponseSchema = z.object({
|
||||
type: z.literal("schedule/delete/response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
scheduleId: z.string(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ScheduleRunOnceResponseSchema = z.object({
|
||||
type: z.literal("schedule/run-once/response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
schedule: StoredScheduleSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ScheduleUpdateResponseSchema = z.object({
|
||||
type: z.literal("schedule/update/response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
schedule: StoredScheduleSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { z } from "zod";
|
||||
import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
|
||||
|
||||
export const ScheduleStatusSchema = z.enum(["active", "paused", "completed"]);
|
||||
export type ScheduleStatus = z.infer<typeof ScheduleStatusSchema>;
|
||||
|
||||
export const ScheduleCadenceSchema = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("every"),
|
||||
everyMs: z.number().int().positive(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("cron"),
|
||||
expression: z.string().trim().min(1),
|
||||
}),
|
||||
]);
|
||||
export type ScheduleCadence = z.infer<typeof ScheduleCadenceSchema>;
|
||||
|
||||
export const ScheduleTargetSchema = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("agent"),
|
||||
agentId: z.string().uuid(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("new-agent"),
|
||||
config: z.object({
|
||||
provider: AgentProviderSchema,
|
||||
cwd: z.string().trim().min(1),
|
||||
modeId: z.string().trim().min(1).optional(),
|
||||
model: z.string().trim().min(1).optional(),
|
||||
thinkingOptionId: z.string().trim().min(1).optional(),
|
||||
title: z.string().trim().min(1).nullable().optional(),
|
||||
approvalPolicy: z.string().trim().min(1).optional(),
|
||||
sandboxMode: z.string().trim().min(1).optional(),
|
||||
networkAccess: z.boolean().optional(),
|
||||
webSearch: z.boolean().optional(),
|
||||
featureValues: z.record(z.unknown()).optional(),
|
||||
extra: z
|
||||
.object({
|
||||
codex: z.record(z.unknown()).optional(),
|
||||
claude: z.record(z.unknown()).optional(),
|
||||
})
|
||||
.partial()
|
||||
.optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
mcpServers: z.record(z.unknown()).optional(),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
export type ScheduleTarget = z.infer<typeof ScheduleTargetSchema>;
|
||||
|
||||
export const ScheduleRunSchema = z.object({
|
||||
id: z.string(),
|
||||
scheduledFor: z.string(),
|
||||
startedAt: z.string(),
|
||||
endedAt: z.string().nullable(),
|
||||
status: z.enum(["running", "succeeded", "failed"]),
|
||||
agentId: z.string().uuid().nullable(),
|
||||
output: z.string().nullable(),
|
||||
error: z.string().nullable(),
|
||||
});
|
||||
export type ScheduleRun = z.infer<typeof ScheduleRunSchema>;
|
||||
|
||||
export const StoredScheduleSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().nullable(),
|
||||
prompt: z.string().min(1),
|
||||
cadence: ScheduleCadenceSchema,
|
||||
target: ScheduleTargetSchema,
|
||||
status: ScheduleStatusSchema,
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
nextRunAt: z.string().nullable(),
|
||||
lastRunAt: z.string().nullable(),
|
||||
pausedAt: z.string().nullable(),
|
||||
expiresAt: z.string().nullable(),
|
||||
maxRuns: z.number().int().positive().nullable(),
|
||||
runs: z.array(ScheduleRunSchema),
|
||||
});
|
||||
export type StoredSchedule = z.infer<typeof StoredScheduleSchema>;
|
||||
|
||||
export const ScheduleSummarySchema = StoredScheduleSchema.omit({
|
||||
runs: true,
|
||||
});
|
||||
export type ScheduleSummary = z.infer<typeof ScheduleSummarySchema>;
|
||||
|
||||
export interface CreateScheduleInput {
|
||||
name?: string | null;
|
||||
prompt: string;
|
||||
cadence: ScheduleCadence;
|
||||
target: ScheduleTarget;
|
||||
maxRuns?: number | null;
|
||||
expiresAt?: string | null;
|
||||
runOnCreate?: boolean | null;
|
||||
}
|
||||
|
||||
export interface UpdateScheduleNewAgentConfig {
|
||||
provider?: string;
|
||||
model?: string | null;
|
||||
modeId?: string | null;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface UpdateScheduleInput {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
prompt?: string;
|
||||
cadence?: ScheduleCadence;
|
||||
newAgentConfig?: UpdateScheduleNewAgentConfig;
|
||||
maxRuns?: number | null;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
export interface ScheduleExecutionResult {
|
||||
agentId: string | null;
|
||||
output: string | null;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TerminalInputModeTracker } from "./terminal-input-mode.js";
|
||||
|
||||
describe("TerminalInputModeTracker", () => {
|
||||
it("activates from a pushed Kitty keyboard mode and builds a replay preamble", () => {
|
||||
const tracker = new TerminalInputModeTracker();
|
||||
|
||||
expect(tracker.feed("\x1b[>7u").changed).toBe(true);
|
||||
|
||||
expect(tracker.supportsModifiedEnter()).toBe(true);
|
||||
expect(tracker.getKittyKeyboardFlags()).toBe(7);
|
||||
expect(tracker.getPreamble()).toBe("\x1b[=7;1u");
|
||||
});
|
||||
|
||||
it("tracks split terminal output chunks", () => {
|
||||
const tracker = new TerminalInputModeTracker();
|
||||
|
||||
tracker.feed("\x1b[>");
|
||||
const result = tracker.feed("1u");
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect(tracker.getKittyKeyboardFlags()).toBe(1);
|
||||
});
|
||||
|
||||
it("restores pushed Kitty keyboard modes when the foreground program pops them", () => {
|
||||
const tracker = new TerminalInputModeTracker();
|
||||
|
||||
tracker.feed("\x1b[>1u");
|
||||
tracker.feed("\x1b[>7u");
|
||||
expect(tracker.getKittyKeyboardFlags()).toBe(7);
|
||||
|
||||
expect(tracker.feed("\x1b[<u").changed).toBe(true);
|
||||
expect(tracker.getKittyKeyboardFlags()).toBe(1);
|
||||
|
||||
expect(tracker.feed("\x1b[<u").changed).toBe(true);
|
||||
expect(tracker.supportsModifiedEnter()).toBe(false);
|
||||
});
|
||||
|
||||
it("answers Kitty keyboard mode queries with the current flags", () => {
|
||||
const tracker = new TerminalInputModeTracker();
|
||||
tracker.feed("\x1b[=3;1u");
|
||||
|
||||
expect(tracker.feed("\x1b[?u").responses).toEqual(["\x1b[?3u"]);
|
||||
});
|
||||
|
||||
it("tracks ConPTY Win32 input mode and replays it after snapshots", () => {
|
||||
const tracker = new TerminalInputModeTracker();
|
||||
|
||||
expect(tracker.feed("\x1b[?9001h").changed).toBe(true);
|
||||
|
||||
expect(tracker.getState()).toEqual({
|
||||
kittyKeyboardFlags: 0,
|
||||
win32InputMode: true,
|
||||
});
|
||||
expect(tracker.supportsModifiedEnter()).toBe(true);
|
||||
expect(tracker.getPreamble()).toBe("\x1b[?9001h");
|
||||
|
||||
expect(tracker.feed("\x1b[?9001l").changed).toBe(true);
|
||||
expect(tracker.supportsModifiedEnter()).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps Kitty and Win32 input modes independent", () => {
|
||||
const tracker = new TerminalInputModeTracker();
|
||||
|
||||
tracker.feed("\x1b[>7u\x1b[?9001h");
|
||||
|
||||
expect(tracker.getState()).toEqual({
|
||||
kittyKeyboardFlags: 7,
|
||||
win32InputMode: true,
|
||||
});
|
||||
expect(tracker.getPreamble()).toBe("\x1b[=7;1u\x1b[?9001h");
|
||||
});
|
||||
|
||||
it("ignores encoded key input sequences", () => {
|
||||
const tracker = new TerminalInputModeTracker();
|
||||
|
||||
tracker.feed("\x1b[13;2u");
|
||||
|
||||
expect(tracker.supportsModifiedEnter()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
export interface TerminalInputModeFeedResult {
|
||||
changed: boolean;
|
||||
responses: string[];
|
||||
}
|
||||
|
||||
export interface TerminalInputModeState {
|
||||
kittyKeyboardFlags: number;
|
||||
win32InputMode: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_TERMINAL_INPUT_MODE_STATE: TerminalInputModeState = {
|
||||
kittyKeyboardFlags: 0,
|
||||
win32InputMode: false,
|
||||
};
|
||||
|
||||
const ESC = String.fromCharCode(0x1b);
|
||||
const WIN32_INPUT_MODE = 9001;
|
||||
const CSI_INPUT_MODE_SEQUENCE = new RegExp(
|
||||
`${ESC}\\[(?:([<>=?]?)([0-9;]*)u|\\?([0-9;]*)([hl]))`,
|
||||
"g",
|
||||
);
|
||||
const INCOMPLETE_CSI_INPUT_MODE_SEQUENCE = new RegExp(`${ESC}\\[[<>=?]?[0-9;]*$`);
|
||||
|
||||
function parseFirstParam(params: string): number | null {
|
||||
const first = params.split(";")[0];
|
||||
if (!first || !/^\d+$/.test(first)) {
|
||||
return null;
|
||||
}
|
||||
return Number(first);
|
||||
}
|
||||
|
||||
function parseSecondParam(params: string): number | null {
|
||||
const second = params.split(";")[1];
|
||||
if (!second || !/^\d+$/.test(second)) {
|
||||
return null;
|
||||
}
|
||||
return Number(second);
|
||||
}
|
||||
|
||||
function parsePrivateModeParams(params: string): Set<number> {
|
||||
const modes = new Set<number>();
|
||||
for (const param of params.split(";")) {
|
||||
if (/^\d+$/.test(param)) {
|
||||
modes.add(Number(param));
|
||||
}
|
||||
}
|
||||
return modes;
|
||||
}
|
||||
|
||||
export function terminalInputModeSupportsModifiedEnter(state: TerminalInputModeState): boolean {
|
||||
return state.kittyKeyboardFlags > 0 || state.win32InputMode;
|
||||
}
|
||||
|
||||
export function terminalInputModeStatesEqual(
|
||||
left: TerminalInputModeState,
|
||||
right: TerminalInputModeState,
|
||||
): boolean {
|
||||
return (
|
||||
left.kittyKeyboardFlags === right.kittyKeyboardFlags &&
|
||||
left.win32InputMode === right.win32InputMode
|
||||
);
|
||||
}
|
||||
|
||||
export class TerminalInputModeTracker {
|
||||
private kittyKeyboardFlags = 0;
|
||||
private win32InputMode = false;
|
||||
private readonly kittyKeyboardStack: number[] = [];
|
||||
private pending = "";
|
||||
|
||||
feed(data: string): TerminalInputModeFeedResult {
|
||||
if (data.length === 0) {
|
||||
return { changed: false, responses: [] };
|
||||
}
|
||||
|
||||
const text = `${this.pending}${data}`;
|
||||
this.pending = "";
|
||||
|
||||
let changed = false;
|
||||
const responses: string[] = [];
|
||||
let consumedUntil = 0;
|
||||
|
||||
CSI_INPUT_MODE_SEQUENCE.lastIndex = 0;
|
||||
for (;;) {
|
||||
const match = CSI_INPUT_MODE_SEQUENCE.exec(text);
|
||||
if (!match) {
|
||||
break;
|
||||
}
|
||||
consumedUntil = CSI_INPUT_MODE_SEQUENCE.lastIndex;
|
||||
|
||||
if (match[4]) {
|
||||
changed = this.applyPrivateModeSequence(match[3] ?? "", match[4]) || changed;
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = this.applyKittyKeyboardSequence(match[1] ?? "", match[2] ?? "");
|
||||
changed = changed || result.changed;
|
||||
responses.push(...result.responses);
|
||||
}
|
||||
|
||||
const tail = text.slice(consumedUntil);
|
||||
const pendingStart = tail.lastIndexOf(`${ESC}[`);
|
||||
if (pendingStart >= 0) {
|
||||
const pending = tail.slice(pendingStart);
|
||||
if (INCOMPLETE_CSI_INPUT_MODE_SEQUENCE.test(pending)) {
|
||||
this.pending = pending;
|
||||
}
|
||||
}
|
||||
|
||||
return { changed, responses };
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.kittyKeyboardFlags = 0;
|
||||
this.win32InputMode = false;
|
||||
this.kittyKeyboardStack.length = 0;
|
||||
this.pending = "";
|
||||
}
|
||||
|
||||
getState(): TerminalInputModeState {
|
||||
return {
|
||||
kittyKeyboardFlags: this.kittyKeyboardFlags,
|
||||
win32InputMode: this.win32InputMode,
|
||||
};
|
||||
}
|
||||
|
||||
getKittyKeyboardFlags(): number {
|
||||
return this.kittyKeyboardFlags;
|
||||
}
|
||||
|
||||
supportsModifiedEnter(): boolean {
|
||||
return terminalInputModeSupportsModifiedEnter(this.getState());
|
||||
}
|
||||
|
||||
getPreamble(): string {
|
||||
const parts: string[] = [];
|
||||
if (this.kittyKeyboardFlags > 0) {
|
||||
parts.push(`\x1b[=${this.kittyKeyboardFlags};1u`);
|
||||
}
|
||||
if (this.win32InputMode) {
|
||||
parts.push("\x1b[?9001h");
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
private applyKittyKeyboardSequence(
|
||||
prefix: string,
|
||||
params: string,
|
||||
): { changed: boolean; responses: string[] } {
|
||||
const previousFlags = this.kittyKeyboardFlags;
|
||||
|
||||
switch (prefix) {
|
||||
case ">": {
|
||||
this.kittyKeyboardStack.push(this.kittyKeyboardFlags);
|
||||
this.kittyKeyboardFlags = parseFirstParam(params) ?? 1;
|
||||
break;
|
||||
}
|
||||
case "=": {
|
||||
const mode = parseSecondParam(params) ?? 1;
|
||||
this.kittyKeyboardFlags = mode === 0 ? 0 : (parseFirstParam(params) ?? 0);
|
||||
break;
|
||||
}
|
||||
case "<": {
|
||||
const count = Math.max(1, parseFirstParam(params) ?? 1);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
this.kittyKeyboardFlags = this.kittyKeyboardStack.pop() ?? 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "?":
|
||||
return {
|
||||
changed: false,
|
||||
responses: [`\x1b[?${this.kittyKeyboardFlags}u`],
|
||||
};
|
||||
default:
|
||||
return { changed: false, responses: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
changed: this.kittyKeyboardFlags !== previousFlags,
|
||||
responses: [],
|
||||
};
|
||||
}
|
||||
|
||||
private applyPrivateModeSequence(params: string, final: string): boolean {
|
||||
const modes = parsePrivateModeParams(params);
|
||||
if (!modes.has(WIN32_INPUT_MODE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const previous = this.win32InputMode;
|
||||
this.win32InputMode = final === "h";
|
||||
return this.win32InputMode !== previous;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { encodeTerminalKeyInput } from "./terminal-key-input.js";
|
||||
|
||||
describe("encodeTerminalKeyInput", () => {
|
||||
it("encodes ctrl+b for tmux prefix", () => {
|
||||
expect(encodeTerminalKeyInput({ key: "b", ctrl: true })).toBe("\x02");
|
||||
});
|
||||
|
||||
it("encodes shifted arrow key modifiers", () => {
|
||||
expect(encodeTerminalKeyInput({ key: "ArrowLeft", shift: true })).toBe("\x1b[1;2D");
|
||||
});
|
||||
|
||||
it("encodes alt-modified printable keys", () => {
|
||||
expect(encodeTerminalKeyInput({ key: "x", alt: true })).toBe("\x1bx");
|
||||
});
|
||||
|
||||
it("encodes enter and backspace", () => {
|
||||
expect(encodeTerminalKeyInput({ key: "Enter" })).toBe("\r");
|
||||
expect(encodeTerminalKeyInput({ key: "Backspace" })).toBe("\x7f");
|
||||
});
|
||||
|
||||
it("keeps modified Enter as carriage return before enhanced input mode is active", () => {
|
||||
expect(encodeTerminalKeyInput({ key: "Enter", shift: true })).toBe("\r");
|
||||
});
|
||||
|
||||
it("encodes Enter with modifiers using CSI u after Kitty keyboard mode is active", () => {
|
||||
const options = { inputMode: { kittyKeyboardFlags: 7, win32InputMode: false } };
|
||||
|
||||
expect(encodeTerminalKeyInput({ key: "Enter", shift: true }, options)).toBe("\x1b[13;2u");
|
||||
expect(encodeTerminalKeyInput({ key: "Enter", ctrl: true }, options)).toBe("\x1b[13;5u");
|
||||
expect(encodeTerminalKeyInput({ key: "Enter", alt: true }, options)).toBe("\x1b[13;3u");
|
||||
expect(encodeTerminalKeyInput({ key: "Enter", meta: true }, options)).toBe("\x1b[13;9u");
|
||||
expect(encodeTerminalKeyInput({ key: "Enter", shift: true, ctrl: true }, options)).toBe(
|
||||
"\x1b[13;6u",
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes Shift+Enter using Win32 input mode when ConPTY requests it", () => {
|
||||
const options = { inputMode: { kittyKeyboardFlags: 0, win32InputMode: true } };
|
||||
|
||||
expect(encodeTerminalKeyInput({ key: "Enter", shift: true }, options)).toBe(
|
||||
"\x1b[13;28;13;1;16;1_",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers Win32 input mode over CSI u when both modes are active", () => {
|
||||
const options = { inputMode: { kittyKeyboardFlags: 7, win32InputMode: true } };
|
||||
|
||||
expect(encodeTerminalKeyInput({ key: "Enter", shift: true }, options)).toBe(
|
||||
"\x1b[13;28;13;1;16;1_",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty string for unsupported keys", () => {
|
||||
expect(encodeTerminalKeyInput({ key: "UnidentifiedKey" })).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { TerminalInputModeState } from "./terminal-input-mode.js";
|
||||
|
||||
export interface TerminalKeyInput {
|
||||
key: string;
|
||||
ctrl?: boolean;
|
||||
alt?: boolean;
|
||||
shift?: boolean;
|
||||
meta?: boolean;
|
||||
}
|
||||
|
||||
export interface TerminalKeyInputEncodingOptions {
|
||||
inputMode?: TerminalInputModeState;
|
||||
}
|
||||
|
||||
const WIN32_LEFT_ALT_PRESSED = 0x0002;
|
||||
const WIN32_LEFT_CTRL_PRESSED = 0x0008;
|
||||
const WIN32_SHIFT_PRESSED = 0x0010;
|
||||
|
||||
function modifierParam(input: TerminalKeyInput): number {
|
||||
let value = 1;
|
||||
if (input.shift) value += 1;
|
||||
if (input.alt) value += 2;
|
||||
if (input.ctrl) value += 4;
|
||||
if (input.meta) value += 8;
|
||||
return value;
|
||||
}
|
||||
|
||||
function win32ControlKeyState(input: TerminalKeyInput): number {
|
||||
let value = 0;
|
||||
if (input.shift) value += WIN32_SHIFT_PRESSED;
|
||||
if (input.ctrl) value += WIN32_LEFT_CTRL_PRESSED;
|
||||
if (input.alt) value += WIN32_LEFT_ALT_PRESSED;
|
||||
return value;
|
||||
}
|
||||
|
||||
function shouldUseWin32InputMode(
|
||||
input: TerminalKeyInput,
|
||||
options: TerminalKeyInputEncodingOptions,
|
||||
): boolean {
|
||||
if (!options.inputMode?.win32InputMode) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(input.shift || input.ctrl || input.alt);
|
||||
}
|
||||
|
||||
function shouldUseKittyKeyboardMode(
|
||||
input: TerminalKeyInput,
|
||||
options: TerminalKeyInputEncodingOptions,
|
||||
): boolean {
|
||||
if ((options.inputMode?.kittyKeyboardFlags ?? 0) <= 0) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(input.shift || input.ctrl || input.alt || input.meta);
|
||||
}
|
||||
|
||||
function encodeWin32EnterKeyInput(input: TerminalKeyInput): string {
|
||||
const controlKeyState = win32ControlKeyState(input);
|
||||
// ConPTY Win32 input mode: CSI Vk;Sc;Uc;Kd;Cs;Rc _
|
||||
return `\x1b[13;28;13;1;${controlKeyState};1_`;
|
||||
}
|
||||
|
||||
function applyAltLikePrefix(sequence: string, input: TerminalKeyInput): string {
|
||||
return input.alt ? `\x1b${sequence}` : sequence;
|
||||
}
|
||||
|
||||
function ctrlSymbolCode(char: string): string | null {
|
||||
switch (char) {
|
||||
case " ":
|
||||
case "@":
|
||||
case "2":
|
||||
return "\x00";
|
||||
case "[":
|
||||
case "3":
|
||||
return "\x1b";
|
||||
case "\\":
|
||||
case "4":
|
||||
return "\x1c";
|
||||
case "]":
|
||||
case "5":
|
||||
return "\x1d";
|
||||
case "^":
|
||||
case "6":
|
||||
return "\x1e";
|
||||
case "_":
|
||||
case "/":
|
||||
case "7":
|
||||
return "\x1f";
|
||||
case "8":
|
||||
case "?":
|
||||
return "\x7f";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeCtrlChar(char: string, input: TerminalKeyInput): string {
|
||||
const upper = char.toUpperCase();
|
||||
if (upper.length === 1 && upper >= "A" && upper <= "Z") {
|
||||
return applyAltLikePrefix(String.fromCharCode(upper.charCodeAt(0) - 64), input);
|
||||
}
|
||||
const symbol = ctrlSymbolCode(char);
|
||||
if (symbol !== null) {
|
||||
return applyAltLikePrefix(symbol, input);
|
||||
}
|
||||
if (char.length === 1) {
|
||||
const code = char.charCodeAt(0) & 0x1f;
|
||||
return applyAltLikePrefix(String.fromCharCode(code), input);
|
||||
}
|
||||
return applyAltLikePrefix(char, input);
|
||||
}
|
||||
|
||||
function encodePrintableKey(input: TerminalKeyInput): string {
|
||||
const raw = input.key;
|
||||
const char = input.shift ? raw.toUpperCase() : raw;
|
||||
|
||||
if (input.ctrl) {
|
||||
return encodeCtrlChar(char, input);
|
||||
}
|
||||
|
||||
return applyAltLikePrefix(char, input);
|
||||
}
|
||||
|
||||
function csiWithModifier(finalByte: string, input: TerminalKeyInput): string {
|
||||
const mod = modifierParam(input);
|
||||
return mod === 1 ? `\x1b[${finalByte}` : `\x1b[1;${mod}${finalByte}`;
|
||||
}
|
||||
|
||||
function csiTilde(base: number, input: TerminalKeyInput): string {
|
||||
const mod = modifierParam(input);
|
||||
return mod === 1 ? `\x1b[${base}~` : `\x1b[${base};${mod}~`;
|
||||
}
|
||||
|
||||
function encodeFunctionKey(key: string, input: TerminalKeyInput): string | null {
|
||||
switch (key) {
|
||||
case "F1":
|
||||
return modifierParam(input) === 1 ? "\x1bOP" : csiWithModifier("P", input);
|
||||
case "F2":
|
||||
return modifierParam(input) === 1 ? "\x1bOQ" : csiWithModifier("Q", input);
|
||||
case "F3":
|
||||
return modifierParam(input) === 1 ? "\x1bOR" : csiWithModifier("R", input);
|
||||
case "F4":
|
||||
return modifierParam(input) === 1 ? "\x1bOS" : csiWithModifier("S", input);
|
||||
case "F5":
|
||||
return csiTilde(15, input);
|
||||
case "F6":
|
||||
return csiTilde(17, input);
|
||||
case "F7":
|
||||
return csiTilde(18, input);
|
||||
case "F8":
|
||||
return csiTilde(19, input);
|
||||
case "F9":
|
||||
return csiTilde(20, input);
|
||||
case "F10":
|
||||
return csiTilde(21, input);
|
||||
case "F11":
|
||||
return csiTilde(23, input);
|
||||
case "F12":
|
||||
return csiTilde(24, input);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeNavigationKey(key: string, input: TerminalKeyInput): string | null {
|
||||
switch (key) {
|
||||
case "ArrowUp":
|
||||
return csiWithModifier("A", input);
|
||||
case "ArrowDown":
|
||||
return csiWithModifier("B", input);
|
||||
case "ArrowRight":
|
||||
return csiWithModifier("C", input);
|
||||
case "ArrowLeft":
|
||||
return csiWithModifier("D", input);
|
||||
case "Home":
|
||||
return csiWithModifier("H", input);
|
||||
case "End":
|
||||
return csiWithModifier("F", input);
|
||||
case "Insert":
|
||||
return csiTilde(2, input);
|
||||
case "Delete":
|
||||
return csiTilde(3, input);
|
||||
case "PageUp":
|
||||
return csiTilde(5, input);
|
||||
case "PageDown":
|
||||
return csiTilde(6, input);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeTerminalKeyInput(
|
||||
input: TerminalKeyInput,
|
||||
options: TerminalKeyInputEncodingOptions = {},
|
||||
): string {
|
||||
const key = input.key;
|
||||
if (!key) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (key.length === 1) {
|
||||
return encodePrintableKey(input);
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "Enter": {
|
||||
const mod = modifierParam(input);
|
||||
if (mod > 1 && shouldUseWin32InputMode(input, options)) {
|
||||
return encodeWin32EnterKeyInput(input);
|
||||
}
|
||||
if (mod > 1 && shouldUseKittyKeyboardMode(input, options)) {
|
||||
return `\x1b[13;${mod}u`;
|
||||
}
|
||||
return "\r";
|
||||
}
|
||||
case "Tab":
|
||||
if (input.shift && !input.ctrl && !input.alt && !input.meta) {
|
||||
return "\x1b[Z";
|
||||
}
|
||||
return applyAltLikePrefix("\t", input);
|
||||
case "Backspace":
|
||||
return applyAltLikePrefix("\x7f", input);
|
||||
case "Escape":
|
||||
return "\x1b";
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const nav = encodeNavigationKey(key, input);
|
||||
if (nav !== null) return nav;
|
||||
const fn = encodeFunctionKey(key, input);
|
||||
if (fn !== null) return fn;
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { TerminalCell, TerminalState } from "./messages.js";
|
||||
|
||||
interface TerminalStyle {
|
||||
fg: number | undefined;
|
||||
bg: number | undefined;
|
||||
fgMode: number | undefined;
|
||||
bgMode: number | undefined;
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
underline: boolean;
|
||||
dim: boolean;
|
||||
inverse: boolean;
|
||||
strikethrough: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_STYLE: TerminalStyle = {
|
||||
fg: undefined,
|
||||
bg: undefined,
|
||||
fgMode: undefined,
|
||||
bgMode: undefined,
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
dim: false,
|
||||
inverse: false,
|
||||
strikethrough: false,
|
||||
};
|
||||
|
||||
export function renderTerminalSnapshotToAnsi(state: TerminalState): string {
|
||||
const rows = [...state.scrollback, ...state.grid];
|
||||
const lines: string[] = ["\u001b[?7l"];
|
||||
|
||||
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
|
||||
const row = rows[rowIndex] ?? [];
|
||||
lines.push(renderTerminalRow(row));
|
||||
if (rowIndex < rows.length - 1) {
|
||||
lines.push("\r\n");
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("\u001b[0m");
|
||||
const cursorPresentationAnsi = renderCursorPresentationToAnsi(state.cursor);
|
||||
if (cursorPresentationAnsi) {
|
||||
lines.push(cursorPresentationAnsi);
|
||||
}
|
||||
lines.push(`\u001b[${state.cursor.row + 1};${state.cursor.col + 1}H`);
|
||||
lines.push(state.cursor.hidden ? "\u001b[?25l" : "\u001b[?25h");
|
||||
lines.push("\u001b[?7h");
|
||||
return lines.join("");
|
||||
}
|
||||
|
||||
function renderCursorPresentationToAnsi(cursor: TerminalState["cursor"]): string | null {
|
||||
if (!cursor.style) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cursorStyleCode = (() => {
|
||||
if (cursor.style === "block") {
|
||||
return cursor.blink === false ? 2 : 1;
|
||||
}
|
||||
if (cursor.style === "underline") {
|
||||
return cursor.blink === false ? 4 : 3;
|
||||
}
|
||||
return cursor.blink === false ? 6 : 5;
|
||||
})();
|
||||
|
||||
return `\u001b[${cursorStyleCode} q`;
|
||||
}
|
||||
|
||||
function renderTerminalRow(row: TerminalCell[]): string {
|
||||
const output: string[] = [];
|
||||
const length = getTerminalRowLength(row);
|
||||
let previousStyle = DEFAULT_STYLE;
|
||||
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const cell = row[index] ?? { char: " " };
|
||||
const nextStyle = getTerminalStyle(cell);
|
||||
if (!terminalStylesEqual(previousStyle, nextStyle)) {
|
||||
output.push(styleToAnsi(nextStyle));
|
||||
previousStyle = nextStyle;
|
||||
}
|
||||
output.push(cell.char || " ");
|
||||
}
|
||||
|
||||
if (!terminalStylesEqual(previousStyle, DEFAULT_STYLE)) {
|
||||
output.push("\u001b[0m");
|
||||
}
|
||||
|
||||
return output.join("");
|
||||
}
|
||||
|
||||
function getTerminalRowLength(row: TerminalCell[]): number {
|
||||
for (let index = row.length - 1; index >= 0; index -= 1) {
|
||||
const cell = row[index];
|
||||
if (!cell) {
|
||||
continue;
|
||||
}
|
||||
if (cell.char !== " ") {
|
||||
return index + 1;
|
||||
}
|
||||
if (
|
||||
cell.fg !== undefined ||
|
||||
cell.bg !== undefined ||
|
||||
cell.fgMode !== undefined ||
|
||||
cell.bgMode !== undefined ||
|
||||
cell.bold ||
|
||||
cell.italic ||
|
||||
cell.underline ||
|
||||
cell.dim ||
|
||||
cell.inverse ||
|
||||
cell.strikethrough
|
||||
) {
|
||||
return index + 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getTerminalStyle(cell: TerminalCell): TerminalStyle {
|
||||
return {
|
||||
fg: cell.fg,
|
||||
bg: cell.bg,
|
||||
fgMode: cell.fgMode,
|
||||
bgMode: cell.bgMode,
|
||||
bold: Boolean(cell.bold),
|
||||
italic: Boolean(cell.italic),
|
||||
underline: Boolean(cell.underline),
|
||||
dim: Boolean(cell.dim),
|
||||
inverse: Boolean(cell.inverse),
|
||||
strikethrough: Boolean(cell.strikethrough),
|
||||
};
|
||||
}
|
||||
|
||||
function terminalStylesEqual(left: TerminalStyle, right: TerminalStyle): boolean {
|
||||
return (
|
||||
left.fg === right.fg &&
|
||||
left.bg === right.bg &&
|
||||
left.fgMode === right.fgMode &&
|
||||
left.bgMode === right.bgMode &&
|
||||
left.bold === right.bold &&
|
||||
left.italic === right.italic &&
|
||||
left.underline === right.underline &&
|
||||
left.dim === right.dim &&
|
||||
left.inverse === right.inverse &&
|
||||
left.strikethrough === right.strikethrough
|
||||
);
|
||||
}
|
||||
|
||||
function styleToAnsi(style: TerminalStyle): string {
|
||||
const codes = ["0"];
|
||||
|
||||
if (style.bold) {
|
||||
codes.push("1");
|
||||
}
|
||||
if (style.dim) {
|
||||
codes.push("2");
|
||||
}
|
||||
if (style.italic) {
|
||||
codes.push("3");
|
||||
}
|
||||
if (style.underline) {
|
||||
codes.push("4");
|
||||
}
|
||||
if (style.inverse) {
|
||||
codes.push("7");
|
||||
}
|
||||
if (style.strikethrough) {
|
||||
codes.push("9");
|
||||
}
|
||||
|
||||
if (style.fg !== undefined && style.fgMode !== undefined) {
|
||||
codes.push(...colorToSgr(style.fgMode, style.fg, false));
|
||||
}
|
||||
|
||||
if (style.bg !== undefined && style.bgMode !== undefined) {
|
||||
codes.push(...colorToSgr(style.bgMode, style.bg, true));
|
||||
}
|
||||
|
||||
return `\u001b[${codes.join(";")}m`;
|
||||
}
|
||||
|
||||
function colorToSgr(mode: number, value: number, background: boolean): string[] {
|
||||
if (mode === 1) {
|
||||
if (value >= 8) {
|
||||
return [String((background ? 100 : 90) + (value - 8))];
|
||||
}
|
||||
return [String((background ? 40 : 30) + value)];
|
||||
}
|
||||
|
||||
if (mode === 2) {
|
||||
return [background ? "48" : "38", "5", String(value)];
|
||||
}
|
||||
|
||||
if (mode === 3) {
|
||||
return [
|
||||
background ? "48" : "38",
|
||||
"2",
|
||||
String((value >> 16) & 0xff),
|
||||
String((value >> 8) & 0xff),
|
||||
String(value & 0xff),
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import * as terminalBinaryFrames from "./binary-frames/index.js";
|
||||
import * as legacyTerminalStreamProtocol from "./terminal-stream-protocol.js";
|
||||
|
||||
describe("terminal stream protocol", () => {
|
||||
it("keeps the old import path compatible with terminal binary frames", () => {
|
||||
expect(legacyTerminalStreamProtocol.TerminalStreamOpcode).toBe(
|
||||
terminalBinaryFrames.TerminalStreamOpcode,
|
||||
);
|
||||
expect(legacyTerminalStreamProtocol.encodeTerminalStreamFrame).toBe(
|
||||
terminalBinaryFrames.encodeTerminalStreamFrame,
|
||||
);
|
||||
expect(legacyTerminalStreamProtocol.decodeTerminalStreamFrame).toBe(
|
||||
terminalBinaryFrames.decodeTerminalStreamFrame,
|
||||
);
|
||||
|
||||
const payload = new TextEncoder().encode("hello");
|
||||
const encoded = legacyTerminalStreamProtocol.encodeTerminalStreamFrame({
|
||||
opcode: legacyTerminalStreamProtocol.TerminalStreamOpcode.Output,
|
||||
slot: 7,
|
||||
payload,
|
||||
});
|
||||
|
||||
expect(encoded[0]).toBe(terminalBinaryFrames.TerminalStreamOpcode.Output);
|
||||
expect(encoded[1]).toBe(7);
|
||||
expect(Array.from(encoded.subarray(2))).toEqual(Array.from(payload));
|
||||
|
||||
const decoded = terminalBinaryFrames.decodeTerminalStreamFrame(encoded);
|
||||
expect(decoded).toEqual({
|
||||
opcode: legacyTerminalStreamProtocol.TerminalStreamOpcode.Output,
|
||||
slot: 7,
|
||||
payload,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./binary-frames/terminal.js";
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildToolCallDisplayModel } from "./tool-call-display.js";
|
||||
|
||||
describe("shared tool-call display mapping", () => {
|
||||
it("builds summary from canonical detail", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "read_file",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "read",
|
||||
filePath: "/tmp/repo/src/index.ts",
|
||||
},
|
||||
cwd: "/tmp/repo",
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
displayName: "Read",
|
||||
summary: "src/index.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not infer summaries from unknown raw detail", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "exec_command",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: { command: "npm test" },
|
||||
output: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
displayName: "Exec Command",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses sub-agent detail for task label and description", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "task",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "Explore",
|
||||
description: "Inspect repository structure",
|
||||
log: "[Read] README.md",
|
||||
},
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
displayName: "Explore",
|
||||
summary: "Inspect repository structure",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds display model for worktree setup detail", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "paseo_worktree_setup",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/tmp/repo/.paseo/worktrees/repo/branch",
|
||||
branchName: "feature-branch",
|
||||
log: "==> [1/1] Running: npm install\n",
|
||||
commands: [
|
||||
{
|
||||
index: 1,
|
||||
command: "npm install",
|
||||
cwd: "/tmp/repo/.paseo/worktrees/repo/branch",
|
||||
log: "==> [1/1] Running: npm install\n",
|
||||
status: "running",
|
||||
exitCode: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
displayName: "Worktree Setup",
|
||||
summary: "feature-branch",
|
||||
});
|
||||
});
|
||||
|
||||
it("provides errorText for failed calls", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "shell",
|
||||
status: "failed",
|
||||
error: { message: "boom" },
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: null,
|
||||
output: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(display.errorText).toBe('{\n "message": "boom"\n}');
|
||||
});
|
||||
|
||||
it("labels terminal interaction rows without a summary when no command is available", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "terminal",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
icon: "square_terminal",
|
||||
},
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
displayName: "Terminal",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the command as terminal interaction summary when available", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "terminal",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
label: "npm run test",
|
||||
icon: "square_terminal",
|
||||
},
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
displayName: "Terminal",
|
||||
summary: "npm run test",
|
||||
});
|
||||
});
|
||||
|
||||
it("humanizes Paseo MCP tool names (Claude Code format)", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "mcp__paseo__create_agent",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "unknown", input: null, output: null },
|
||||
});
|
||||
expect(display.displayName).toBe("Create Agent");
|
||||
});
|
||||
|
||||
it("humanizes Paseo MCP tool names (Codex format)", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "paseo.create_agent",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "unknown", input: null, output: null },
|
||||
});
|
||||
expect(display.displayName).toBe("Create Agent");
|
||||
});
|
||||
|
||||
it("humanizes list_agents Paseo tool", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "mcp__paseo__list_agents",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "unknown", input: null, output: null },
|
||||
});
|
||||
expect(display.displayName).toBe("List Agents");
|
||||
});
|
||||
|
||||
it("does not override speak tool display name", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "speak",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "unknown", input: null, output: null },
|
||||
});
|
||||
expect(display.displayName).toBe("Speak");
|
||||
});
|
||||
|
||||
it("labels plan detail rows as Plan", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "plan",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plan",
|
||||
text: "### Login Screen\n- Build layout",
|
||||
},
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
displayName: "Plan",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { ToolCallTimelineItem } from "./agent-types.js";
|
||||
import { getPaseoToolLeafName, isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization";
|
||||
import { stripCwdPrefix } from "./path-utils.js";
|
||||
|
||||
export type ToolCallDisplayInput = Pick<
|
||||
ToolCallTimelineItem,
|
||||
"name" | "status" | "error" | "metadata" | "detail"
|
||||
> & {
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
export interface ToolCallDisplayModel {
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
errorText?: string;
|
||||
}
|
||||
|
||||
interface DetailDisplay {
|
||||
displayName?: string;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function humanizeToolName(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
return name;
|
||||
}
|
||||
if (isPaseoToolName(trimmed)) {
|
||||
const leaf = getPaseoToolLeafName(trimmed);
|
||||
if (leaf) {
|
||||
return humanizeToolName(leaf);
|
||||
}
|
||||
}
|
||||
if (/[:./]/.test(trimmed) || trimmed.includes("__")) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.replace(/[._-]+/g, " ")
|
||||
.split(" ")
|
||||
.filter((segment) => segment.length > 0)
|
||||
.map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function formatErrorText(error: unknown): string | undefined {
|
||||
if (error === null || error === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
if (isRecord(error) && typeof error.content === "string") {
|
||||
return error.content;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(error, null, 2);
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
function buildFilePathDisplay(
|
||||
displayName: string,
|
||||
filePath: string,
|
||||
cwd: string | undefined,
|
||||
): DetailDisplay {
|
||||
return {
|
||||
displayName,
|
||||
summary: stripCwdPrefix(filePath, cwd),
|
||||
};
|
||||
}
|
||||
|
||||
function buildCanonicalDetailDisplay(input: ToolCallDisplayInput): DetailDisplay {
|
||||
switch (input.detail.type) {
|
||||
case "shell":
|
||||
return {
|
||||
displayName: "Shell",
|
||||
summary: input.detail.command,
|
||||
};
|
||||
case "read":
|
||||
return buildFilePathDisplay("Read", input.detail.filePath, input.cwd);
|
||||
case "edit":
|
||||
return buildFilePathDisplay("Edit", input.detail.filePath, input.cwd);
|
||||
case "write":
|
||||
return buildFilePathDisplay("Write", input.detail.filePath, input.cwd);
|
||||
case "search":
|
||||
return {
|
||||
displayName: "Search",
|
||||
summary: input.detail.query,
|
||||
};
|
||||
case "fetch":
|
||||
return {
|
||||
displayName: "Fetch",
|
||||
summary: input.detail.url,
|
||||
};
|
||||
case "worktree_setup":
|
||||
return {
|
||||
displayName: "Worktree Setup",
|
||||
summary: input.detail.branchName,
|
||||
};
|
||||
case "sub_agent":
|
||||
return {
|
||||
displayName: readString(input.detail.subAgentType) ?? "Task",
|
||||
summary: readString(input.detail.description),
|
||||
};
|
||||
case "plain_text":
|
||||
return {
|
||||
summary: input.detail.label,
|
||||
};
|
||||
case "plan":
|
||||
return {
|
||||
displayName: "Plan",
|
||||
};
|
||||
case "unknown":
|
||||
return {};
|
||||
default:
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
function buildUnknownDetailOverride(input: ToolCallDisplayInput): DetailDisplay {
|
||||
const lowerName = input.name.trim().toLowerCase();
|
||||
if (input.detail.type === "unknown" && lowerName === "task") {
|
||||
return {
|
||||
displayName: "Task",
|
||||
summary: isRecord(input.metadata) ? readString(input.metadata.subAgentActivity) : undefined,
|
||||
};
|
||||
}
|
||||
if (input.detail.type === "unknown" && lowerName === "thinking") {
|
||||
return {
|
||||
displayName: "Thinking",
|
||||
};
|
||||
}
|
||||
if (lowerName === "terminal") {
|
||||
return {
|
||||
displayName: "Terminal",
|
||||
summary: input.detail.type === "plain_text" ? readString(input.detail.label) : undefined,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCallDisplayModel {
|
||||
const canonicalDisplay = buildCanonicalDetailDisplay(input);
|
||||
const unknownDetailOverride = buildUnknownDetailOverride(input);
|
||||
const displayName =
|
||||
unknownDetailOverride.displayName ??
|
||||
canonicalDisplay.displayName ??
|
||||
humanizeToolName(input.name);
|
||||
const summary = unknownDetailOverride.summary ?? canonicalDisplay.summary;
|
||||
const errorText = input.status === "failed" ? formatErrorText(input.error) : undefined;
|
||||
|
||||
return {
|
||||
displayName,
|
||||
...(summary ? { summary } : {}),
|
||||
...(errorText ? { errorText } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
const TOOL_TOKEN_REGEX = /[a-z0-9]+/g;
|
||||
const STANDARD_NAMESPACE_SEPARATOR_REGEX = /[.:/]/;
|
||||
|
||||
export function normalizeToolName(name: string): string {
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function tokenizeToolName(name: string): string[] {
|
||||
const normalized = normalizeToolName(name);
|
||||
return normalized.match(TOOL_TOKEN_REGEX) ?? [];
|
||||
}
|
||||
|
||||
export function getToolLeafName(name: string): string | null {
|
||||
const tokens = tokenizeToolName(name);
|
||||
return tokens.length > 0 ? tokens[tokens.length - 1] : null;
|
||||
}
|
||||
|
||||
export function isSpeakToolName(name: string): boolean {
|
||||
return getToolLeafName(name) === "speak";
|
||||
}
|
||||
|
||||
export function isLikelyNamespacedToolName(name: string): boolean {
|
||||
const normalized = normalizeToolName(name);
|
||||
if (STANDARD_NAMESPACE_SEPARATOR_REGEX.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (!normalized.includes("__")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Keep `__` handling strict to avoid false positives on arbitrary custom names.
|
||||
const segments = normalized.split("__").filter((segment) => segment.length > 0);
|
||||
if (segments.length >= 3) {
|
||||
return true;
|
||||
}
|
||||
if (segments.length === 2 && segments[1].includes("_")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isPaseoToolName(name: string): boolean {
|
||||
const normalized = normalizeToolName(name);
|
||||
if (isSpeakToolName(normalized)) {
|
||||
return false;
|
||||
}
|
||||
if (normalized.includes("__")) {
|
||||
const segments = normalized.split("__").filter((s) => s.length > 0);
|
||||
return (
|
||||
segments.length >= 3 &&
|
||||
segments[0] === "mcp" &&
|
||||
(segments[1] === "paseo" || segments[1].startsWith("paseo_"))
|
||||
);
|
||||
}
|
||||
if (normalized.includes(".")) {
|
||||
const firstSegment = normalized.split(".")[0];
|
||||
return firstSegment === "paseo" || firstSegment.startsWith("paseo_");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getPaseoToolLeafName(name: string): string | null {
|
||||
const normalized = normalizeToolName(name);
|
||||
if (normalized.includes("__")) {
|
||||
const segments = normalized.split("__").filter((s) => s.length > 0);
|
||||
if (
|
||||
segments.length >= 3 &&
|
||||
segments[0] === "mcp" &&
|
||||
(segments[1] === "paseo" || segments[1].startsWith("paseo_"))
|
||||
) {
|
||||
return segments.slice(2).join("__");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (normalized.includes(".")) {
|
||||
const firstSegment = normalized.split(".")[0];
|
||||
if (firstSegment === "paseo" || firstSegment.startsWith("paseo_")) {
|
||||
return normalized.split(".").slice(1).join(".");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isLikelyExternalToolName(name: string): boolean {
|
||||
const normalized = normalizeToolName(name);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (isSpeakToolName(normalized)) {
|
||||
return true;
|
||||
}
|
||||
return isLikelyNamespacedToolName(normalized);
|
||||
}
|
||||
Reference in New Issue
Block a user