mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 01:32:40 +00:00
* fix(protocol): keep 0.7.2 clients working with 0.8 hosts Gate plugin timeline rows and blocked workspace setup statuses per client capability so app-store version lag does not make existing agents unloadable. Preserve canonical pagination and document the intended Hub provider-mode authority. * test(protocol): clarify compatibility test preconditions
392 lines
11 KiB
TypeScript
392 lines
11 KiB
TypeScript
import { describe, expect, test } from "vitest";
|
|
import { z } from "zod";
|
|
import {
|
|
AgentSnapshotPayloadSchema,
|
|
AgentTimelineItemPayloadSchema,
|
|
ServerInfoStatusPayloadSchema,
|
|
SessionOutboundMessageSchema,
|
|
WSHelloMessageSchema,
|
|
WorkspaceSetupSnapshotSchema,
|
|
WorkspaceSetupProgressMessageSchema,
|
|
AgentTimelineEntryPayloadSchema,
|
|
} from "./messages.js";
|
|
|
|
test("terminal listings accept older rows and retain new per-terminal directories", () => {
|
|
const response = {
|
|
type: "list_terminals_response",
|
|
payload: {
|
|
requestId: "terminal-list",
|
|
cwd: "/workspace",
|
|
terminals: [{ id: "terminal", name: "Shell", workspaceId: "workspace" }],
|
|
},
|
|
};
|
|
expect(SessionOutboundMessageSchema.parse(response)).toEqual(response);
|
|
const withDirectory = {
|
|
...response,
|
|
payload: {
|
|
...response.payload,
|
|
terminals: [{ ...response.payload.terminals[0], cwd: "/workspace/subdirectory" }],
|
|
},
|
|
};
|
|
expect(SessionOutboundMessageSchema.parse(withDirectory)).toEqual(withDirectory);
|
|
});
|
|
|
|
const LegacySubAgentToolCallSchema = z.object({
|
|
type: z.literal("tool_call"),
|
|
callId: z.string(),
|
|
name: z.string(),
|
|
status: z.enum(["running", "completed", "failed", "canceled"]),
|
|
error: z.unknown().nullable(),
|
|
detail: z.object({
|
|
type: z.literal("sub_agent"),
|
|
subAgentType: z.string().optional(),
|
|
description: z.string().optional(),
|
|
log: z.string(),
|
|
// Copied from v0.1.65-beta.3: actions was required even though the UI ignored it.
|
|
actions: z.array(
|
|
z.object({
|
|
index: z.number().int().positive(),
|
|
toolName: z.string(),
|
|
summary: z.string().optional(),
|
|
}),
|
|
),
|
|
}),
|
|
});
|
|
|
|
const LegacyAgentCapabilityFlagsSchema = z.object({
|
|
supportsStreaming: z.boolean(),
|
|
supportsSessionPersistence: z.boolean(),
|
|
supportsDynamicModes: z.boolean(),
|
|
supportsMcpServers: z.boolean(),
|
|
supportsReasoningStream: z.boolean(),
|
|
supportsToolInvocations: z.boolean(),
|
|
});
|
|
|
|
const LegacyAgentSnapshotPayloadSchema = AgentSnapshotPayloadSchema.extend({
|
|
capabilities: LegacyAgentCapabilityFlagsSchema,
|
|
});
|
|
|
|
describe("wire schema compatibility", () => {
|
|
test("hello parses with and without the project update capability", () => {
|
|
const legacy = WSHelloMessageSchema.parse({
|
|
type: "hello",
|
|
clientId: "legacy-client",
|
|
clientType: "mobile",
|
|
protocolVersion: 1,
|
|
});
|
|
const capable = WSHelloMessageSchema.parse({
|
|
type: "hello",
|
|
clientId: "capable-client",
|
|
clientType: "mobile",
|
|
protocolVersion: 1,
|
|
capabilities: { project_updates: true },
|
|
});
|
|
|
|
expect([legacy, capable]).toEqual([
|
|
{
|
|
type: "hello",
|
|
clientId: "legacy-client",
|
|
clientType: "mobile",
|
|
protocolVersion: 1,
|
|
},
|
|
{
|
|
type: "hello",
|
|
clientId: "capable-client",
|
|
clientType: "mobile",
|
|
protocolVersion: 1,
|
|
capabilities: { project_updates: true },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("timeline replacement invalidation is opt-in and carries no timeline rows", () => {
|
|
expect(
|
|
WSHelloMessageSchema.parse({
|
|
type: "hello",
|
|
clientId: "capable-client",
|
|
clientType: "mobile",
|
|
protocolVersion: 1,
|
|
capabilities: { timeline_replacement_invalidation: true },
|
|
}).capabilities,
|
|
).toEqual({ timeline_replacement_invalidation: true });
|
|
|
|
expect(
|
|
SessionOutboundMessageSchema.parse({
|
|
type: "agent.timeline.replacement",
|
|
payload: { agentId: "agent-1", epoch: "epoch-2" },
|
|
}),
|
|
).toEqual({
|
|
type: "agent.timeline.replacement",
|
|
payload: { agentId: "agent-1", epoch: "epoch-2" },
|
|
});
|
|
});
|
|
|
|
test("server info strips unknown legacy features while accepting former turn identity", () => {
|
|
const parsed = ServerInfoStatusPayloadSchema.parse({
|
|
status: "server_info",
|
|
serverId: "legacy-server",
|
|
features: {
|
|
workspaceGithubClone: true,
|
|
agentTurnIdentity: true,
|
|
},
|
|
});
|
|
|
|
expect(parsed).toEqual({
|
|
status: "server_info",
|
|
serverId: "legacy-server",
|
|
hostname: null,
|
|
version: null,
|
|
features: { agentTurnIdentity: true },
|
|
});
|
|
});
|
|
|
|
test("assistant timeline message ids are optional on the wire", () => {
|
|
expect(
|
|
AgentTimelineItemPayloadSchema.parse({
|
|
type: "assistant_message",
|
|
text: "old daemon shape",
|
|
}),
|
|
).toEqual({
|
|
type: "assistant_message",
|
|
text: "old daemon shape",
|
|
});
|
|
expect(
|
|
AgentTimelineItemPayloadSchema.parse({
|
|
type: "assistant_message",
|
|
text: "new daemon shape",
|
|
messageId: "msg-1",
|
|
}),
|
|
).toEqual({
|
|
type: "assistant_message",
|
|
text: "new daemon shape",
|
|
messageId: "msg-1",
|
|
});
|
|
});
|
|
|
|
test("task progress fields are optional on the wire", () => {
|
|
expect(
|
|
AgentTimelineItemPayloadSchema.parse({
|
|
type: "todo",
|
|
items: [{ text: "Legacy task", completed: false }],
|
|
}),
|
|
).toEqual({ type: "todo", items: [{ text: "Legacy task", completed: false }] });
|
|
expect(
|
|
AgentTimelineItemPayloadSchema.parse({
|
|
type: "todo",
|
|
items: [
|
|
{
|
|
id: "task-1",
|
|
text: "Current task",
|
|
activeForm: "Working on current task",
|
|
status: "in_progress",
|
|
completed: false,
|
|
},
|
|
],
|
|
}),
|
|
).toEqual({
|
|
type: "todo",
|
|
items: [
|
|
{
|
|
id: "task-1",
|
|
text: "Current task",
|
|
activeForm: "Working on current task",
|
|
status: "in_progress",
|
|
completed: false,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("sub_agent tool-call payload still parses against the v0.1.65-beta.3 schema", () => {
|
|
const parsed = LegacySubAgentToolCallSchema.parse({
|
|
type: "tool_call",
|
|
callId: "call-sub-agent-1",
|
|
name: "Task",
|
|
status: "completed",
|
|
error: null,
|
|
detail: {
|
|
type: "sub_agent",
|
|
subAgentType: "Explore",
|
|
description: "Inspect repository structure",
|
|
childSessionId: "child-session-1",
|
|
log: "[Read] README.md",
|
|
actions: [],
|
|
},
|
|
});
|
|
|
|
expect(parsed.detail.actions).toEqual([]);
|
|
});
|
|
|
|
test("old clients parse agent snapshots with rewind capabilities", () => {
|
|
const parsed = LegacyAgentSnapshotPayloadSchema.parse({
|
|
id: "agent-1",
|
|
provider: "claude",
|
|
cwd: "/tmp/project",
|
|
model: null,
|
|
thinkingOptionId: null,
|
|
effectiveThinkingOptionId: null,
|
|
createdAt: "2026-05-23T00:00:00.000Z",
|
|
updatedAt: "2026-05-23T00:00:00.000Z",
|
|
lastUserMessageAt: null,
|
|
status: "idle",
|
|
capabilities: {
|
|
supportsStreaming: true,
|
|
supportsSessionPersistence: true,
|
|
supportsDynamicModes: true,
|
|
supportsMcpServers: true,
|
|
supportsReasoningStream: true,
|
|
supportsToolInvocations: true,
|
|
supportsRewindConversation: true,
|
|
supportsRewindFiles: true,
|
|
supportsRewindBoth: true,
|
|
},
|
|
currentModeId: null,
|
|
availableModes: [],
|
|
pendingPermissions: [],
|
|
persistence: null,
|
|
title: null,
|
|
labels: {},
|
|
});
|
|
|
|
expect(parsed.capabilities).toEqual({
|
|
supportsStreaming: true,
|
|
supportsSessionPersistence: true,
|
|
supportsDynamicModes: true,
|
|
supportsMcpServers: true,
|
|
supportsReasoningStream: true,
|
|
supportsToolInvocations: true,
|
|
});
|
|
});
|
|
|
|
test("new clients parse agent snapshots without rewind capabilities", () => {
|
|
const parsed = AgentSnapshotPayloadSchema.parse({
|
|
id: "agent-1",
|
|
provider: "claude",
|
|
cwd: "/tmp/project",
|
|
model: null,
|
|
thinkingOptionId: null,
|
|
effectiveThinkingOptionId: null,
|
|
createdAt: "2026-05-23T00:00:00.000Z",
|
|
updatedAt: "2026-05-23T00: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);
|
|
});
|
|
|
|
test("notification timeline items parse their level and message", () => {
|
|
expect(
|
|
AgentTimelineItemPayloadSchema.parse({
|
|
type: "notification",
|
|
level: "warning",
|
|
message: "Command blocked by user",
|
|
}),
|
|
).toEqual({
|
|
type: "notification",
|
|
level: "warning",
|
|
message: "Command blocked by user",
|
|
});
|
|
});
|
|
});
|
|
|
|
test("0.8 timeline and setup capabilities remain optional in the hello", () => {
|
|
const hello = { type: "hello", clientId: "compat", clientType: "mobile", protocolVersion: 1 };
|
|
expect(WSHelloMessageSchema.safeParse(hello).success).toBe(true);
|
|
expect(
|
|
WSHelloMessageSchema.parse({
|
|
...hello,
|
|
capabilities: { plugin_timeline_items: true, workspace_setup_blocked: true },
|
|
}).capabilities,
|
|
).toEqual({ plugin_timeline_items: true, workspace_setup_blocked: true });
|
|
});
|
|
|
|
test("plugin rows and identity merges require a capable receiver", () => {
|
|
const item = {
|
|
type: "plugin",
|
|
id: "task",
|
|
pluginId: "tasks",
|
|
kind: "tasks",
|
|
version: 1,
|
|
data: { text: "Working" },
|
|
};
|
|
expect(AgentTimelineItemPayloadSchema.parse(item)).toEqual(item);
|
|
const legacyItems = z.object({
|
|
type: z.enum([
|
|
"user_message",
|
|
"assistant_message",
|
|
"reasoning",
|
|
"tool_call",
|
|
"todo",
|
|
"error",
|
|
"notification",
|
|
"compaction",
|
|
]),
|
|
});
|
|
expect(legacyItems.safeParse(item).success).toBe(false);
|
|
const entry = {
|
|
provider: "codex",
|
|
item,
|
|
timestamp: "2026-09-07T00:00:00.000Z",
|
|
seqStart: 1,
|
|
seqEnd: 2,
|
|
sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }],
|
|
collapsed: ["identity"],
|
|
};
|
|
expect(AgentTimelineEntryPayloadSchema.parse(entry)).toEqual(entry);
|
|
const legacyCollapsed = z.array(z.enum(["assistant_merge", "reasoning_merge", "tool_lifecycle"]));
|
|
expect(legacyCollapsed.safeParse(entry.collapsed).success).toBe(false);
|
|
});
|
|
|
|
test("blocked setup preserves the legacy failed shape and optional provenance", () => {
|
|
const snapshot = {
|
|
status: "blocked",
|
|
error: null,
|
|
detail: {
|
|
type: "worktree_setup",
|
|
worktreePath: "/workspace",
|
|
branchName: "fork",
|
|
log: "",
|
|
commands: [],
|
|
},
|
|
blockedSource: {
|
|
kind: "change_request",
|
|
forge: "github",
|
|
number: 42,
|
|
headRepository: "contributor/project",
|
|
},
|
|
};
|
|
expect(WorkspaceSetupSnapshotSchema.parse(snapshot)).toEqual(snapshot);
|
|
const legacyStatus = z.enum(["running", "completed", "failed"]);
|
|
const legacySnapshot = WorkspaceSetupSnapshotSchema.omit({ blockedSource: true }).extend({
|
|
status: legacyStatus,
|
|
});
|
|
expect(legacySnapshot.safeParse(snapshot).success).toBe(false);
|
|
const failed = { ...snapshot, status: "failed", error: "Update Paseo to review and run setup." };
|
|
expect(legacySnapshot.safeParse(failed).success).toBe(true);
|
|
const progress = {
|
|
type: "workspace_setup_progress",
|
|
payload: { ...failed, workspaceId: "workspace" },
|
|
};
|
|
expect(WorkspaceSetupProgressMessageSchema.parse(progress)).toEqual(progress);
|
|
expect(WorkspaceSetupSnapshotSchema.parse(legacySnapshot.parse(failed))).toEqual(
|
|
legacySnapshot.parse(failed),
|
|
);
|
|
});
|