test(server): pin wire compatibility regressions
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
import pino from "pino";
|
||||
import { z } from "zod";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { CLIENT_CAPS } from "./client-capabilities.js";
|
||||
import {
|
||||
AgentTimelineItemPayloadSchema,
|
||||
FetchAgentTimelineResponseMessageSchema,
|
||||
SessionInboundMessageSchema,
|
||||
type SessionOutboundMessage,
|
||||
} from "./messages.js";
|
||||
import { Session, type SessionOptions } from "../server/session.js";
|
||||
import type { AgentTimelineRow } from "../server/agent/agent-manager.js";
|
||||
import { handleCreatePaseoWorktreeRequest } from "../server/worktree-session.js";
|
||||
|
||||
const LegacyTimelineEntryPayloadSchema = z.object({
|
||||
provider: z.enum(["claude", "codex", "opencode"]),
|
||||
item: AgentTimelineItemPayloadSchema,
|
||||
timestamp: z.string(),
|
||||
seqStart: z.number().int().nonnegative(),
|
||||
seqEnd: z.number().int().nonnegative(),
|
||||
sourceSeqRanges: z.array(
|
||||
z.object({
|
||||
startSeq: z.number().int().nonnegative(),
|
||||
endSeq: z.number().int().nonnegative(),
|
||||
}),
|
||||
),
|
||||
// Copied from v0.1.65-beta.3: no reasoning_merge on the wire yet.
|
||||
collapsed: z.array(z.enum(["assistant_merge", "tool_lifecycle"])),
|
||||
});
|
||||
|
||||
const LegacyFetchAgentTimelineResponseMessageSchema = z.object({
|
||||
type: z.literal("fetch_agent_timeline_response"),
|
||||
payload: FetchAgentTimelineResponseMessageSchema.shape.payload.extend({
|
||||
entries: z.array(LegacyTimelineEntryPayloadSchema),
|
||||
}),
|
||||
});
|
||||
|
||||
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(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
interface SessionInternals {
|
||||
buildAgentPayload: (snapshot: unknown) => Promise<null>;
|
||||
handleFetchAgentTimelineRequest: (
|
||||
message: Extract<
|
||||
z.infer<typeof SessionInboundMessageSchema>,
|
||||
{ type: "fetch_agent_timeline_request" }
|
||||
>,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
function createSessionForWireCompatTest(options?: {
|
||||
clientCapabilities?: Record<string, unknown> | null;
|
||||
messages?: SessionOutboundMessage[];
|
||||
}): Session {
|
||||
const messages = options?.messages ?? [];
|
||||
const snapshot = { id: "agent-1", provider: "codex" };
|
||||
const rows: AgentTimelineRow[] = [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: "2026-05-02T00:00:00.000Z",
|
||||
item: { type: "reasoning", text: "Step " },
|
||||
},
|
||||
{
|
||||
seq: 2,
|
||||
timestamp: "2026-05-02T00:00:00.100Z",
|
||||
item: { type: "reasoning", text: "by step" },
|
||||
},
|
||||
{
|
||||
seq: 3,
|
||||
timestamp: "2026-05-02T00:00:00.200Z",
|
||||
item: { type: "assistant_message", text: "done" },
|
||||
},
|
||||
];
|
||||
|
||||
const session = new Session({
|
||||
clientId: "wire-compat-client",
|
||||
clientCapabilities: options?.clientCapabilities ?? null,
|
||||
onMessage: (message) => messages.push(message),
|
||||
logger: pino({ level: "silent" }),
|
||||
downloadTokenStore: {} as SessionOptions["downloadTokenStore"],
|
||||
pushTokenStore: {} as SessionOptions["pushTokenStore"],
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
agentManager: {
|
||||
getAgent: vi.fn(() => snapshot),
|
||||
fetchTimeline: vi.fn(() => ({
|
||||
epoch: "epoch-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: 3, nextSeq: 4 },
|
||||
rows,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
})),
|
||||
listAgents: vi.fn(() => []),
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
} as unknown as SessionOptions["agentManager"],
|
||||
agentStorage: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
} as unknown as SessionOptions["agentStorage"],
|
||||
projectRegistry: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
get: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
archive: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
existsOnDisk: vi.fn(),
|
||||
} as unknown as SessionOptions["projectRegistry"],
|
||||
workspaceRegistry: {
|
||||
get: vi.fn(),
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as SessionOptions["workspaceRegistry"],
|
||||
chatService: {} as SessionOptions["chatService"],
|
||||
scheduleService: {} as SessionOptions["scheduleService"],
|
||||
loopService: {} as SessionOptions["loopService"],
|
||||
checkoutDiffManager: {
|
||||
scheduleRefreshForCwd: vi.fn(),
|
||||
} as unknown as SessionOptions["checkoutDiffManager"],
|
||||
github: {
|
||||
invalidate: vi.fn(),
|
||||
searchIssuesAndPrs: vi.fn(),
|
||||
createPullRequest: vi.fn(),
|
||||
} as unknown as SessionOptions["github"],
|
||||
workspaceGitService: {
|
||||
getCheckoutDiff: vi.fn(),
|
||||
getSnapshot: vi.fn(),
|
||||
suggestBranchesForCwd: vi.fn(),
|
||||
listStashes: vi.fn(),
|
||||
peekSnapshot: vi.fn(),
|
||||
validateBranchRef: vi.fn(),
|
||||
hasLocalBranch: vi.fn(),
|
||||
resolveRepoRemoteUrl: vi.fn(),
|
||||
getWorkspaceGitMetadata: vi.fn(),
|
||||
} as unknown as SessionOptions["workspaceGitService"],
|
||||
daemonConfigStore: {
|
||||
get: vi.fn(() => ({
|
||||
mcp: { injectIntoAgents: false },
|
||||
providers: {},
|
||||
})),
|
||||
onChange: vi.fn(() => () => {}),
|
||||
} as unknown as SessionOptions["daemonConfigStore"],
|
||||
stt: null,
|
||||
tts: null,
|
||||
terminalManager: null,
|
||||
});
|
||||
|
||||
const internals = session as unknown as SessionInternals;
|
||||
internals.buildAgentPayload = vi.fn(async () => null);
|
||||
return session;
|
||||
}
|
||||
|
||||
async function emitTimelineResponse(
|
||||
clientCapabilities?: Record<string, unknown> | null,
|
||||
): Promise<Extract<SessionOutboundMessage, { type: "fetch_agent_timeline_response" }>> {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWireCompatTest({ clientCapabilities, messages });
|
||||
const internals = session as unknown as SessionInternals;
|
||||
|
||||
await internals.handleFetchAgentTimelineRequest({
|
||||
type: "fetch_agent_timeline_request",
|
||||
requestId: "req-timeline",
|
||||
agentId: "agent-1",
|
||||
projection: "projected",
|
||||
});
|
||||
|
||||
const response = messages[0];
|
||||
expect(response?.type).toBe("fetch_agent_timeline_response");
|
||||
if (!response || response.type !== "fetch_agent_timeline_response") {
|
||||
throw new Error("Expected fetch_agent_timeline_response");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
describe("wire compatibility", () => {
|
||||
test("downgrades reasoning_merge for clients that do not declare the capability", async () => {
|
||||
const response = await emitTimelineResponse();
|
||||
|
||||
const currentParsed = FetchAgentTimelineResponseMessageSchema.parse(response);
|
||||
expect(currentParsed.payload.entries[0]?.collapsed).not.toContain("reasoning_merge");
|
||||
|
||||
const legacyParsed = LegacyFetchAgentTimelineResponseMessageSchema.parse(response);
|
||||
expect(legacyParsed.payload.entries[0]?.collapsed).toEqual([]);
|
||||
});
|
||||
|
||||
test("preserves reasoning_merge for clients that declare the capability", async () => {
|
||||
const response = await emitTimelineResponse({
|
||||
[CLIENT_CAPS.reasoningMergeEnum]: true,
|
||||
});
|
||||
|
||||
const currentParsed = FetchAgentTimelineResponseMessageSchema.parse(response);
|
||||
expect(currentParsed.payload.entries[0]?.collapsed).toContain("reasoning_merge");
|
||||
});
|
||||
|
||||
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("legacy worktree request shape normalizes to the same internal input as the new shape", async () => {
|
||||
const captured: unknown[] = [];
|
||||
|
||||
const dependencies = {
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
describeWorkspaceRecord: async () =>
|
||||
({
|
||||
id: "ws-1",
|
||||
projectId: "proj-1",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
projectKind: "directory",
|
||||
workspaceKind: "checkout",
|
||||
name: "repo",
|
||||
cwd: "/tmp/repo",
|
||||
status: "ready",
|
||||
activityAt: null,
|
||||
scripts: [],
|
||||
}) as never,
|
||||
emit: vi.fn(),
|
||||
sessionLogger: pino({ level: "silent" }),
|
||||
createPaseoWorktreeWorkflow: async (input: unknown) => {
|
||||
captured.push(input);
|
||||
return {} as never;
|
||||
},
|
||||
};
|
||||
|
||||
const legacyRequest = SessionInboundMessageSchema.parse({
|
||||
type: "create_paseo_worktree_request",
|
||||
requestId: "req-legacy",
|
||||
cwd: "/tmp/repo",
|
||||
worktreeSlug: "legacy-worktree",
|
||||
nameContext: "Investigate flaky test",
|
||||
attachments: [
|
||||
{
|
||||
type: "github_issue",
|
||||
mimeType: "application/github-issue",
|
||||
number: 55,
|
||||
title: "Improve startup error details",
|
||||
url: "https://github.com/getpaseo/paseo/issues/55",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const newRequest = SessionInboundMessageSchema.parse({
|
||||
type: "create_paseo_worktree_request",
|
||||
requestId: "req-new",
|
||||
cwd: "/tmp/repo",
|
||||
worktreeSlug: "legacy-worktree",
|
||||
firstAgentContext: {
|
||||
prompt: "Investigate flaky test",
|
||||
attachments: [
|
||||
{
|
||||
type: "github_issue",
|
||||
mimeType: "application/github-issue",
|
||||
number: 55,
|
||||
title: "Improve startup error details",
|
||||
url: "https://github.com/getpaseo/paseo/issues/55",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (legacyRequest.type !== "create_paseo_worktree_request") {
|
||||
throw new Error("Expected legacy worktree request");
|
||||
}
|
||||
if (newRequest.type !== "create_paseo_worktree_request") {
|
||||
throw new Error("Expected new worktree request");
|
||||
}
|
||||
|
||||
await handleCreatePaseoWorktreeRequest(dependencies, legacyRequest);
|
||||
await handleCreatePaseoWorktreeRequest(dependencies, newRequest);
|
||||
|
||||
expect(captured).toHaveLength(2);
|
||||
expect(captured[0]).toEqual(captured[1]);
|
||||
expect(captured[0]).toEqual({
|
||||
cwd: "/tmp/repo",
|
||||
worktreeSlug: "legacy-worktree",
|
||||
firstAgentContext: {
|
||||
prompt: "Investigate flaky test",
|
||||
attachments: [
|
||||
{
|
||||
type: "github_issue",
|
||||
mimeType: "application/github-issue",
|
||||
number: 55,
|
||||
title: "Improve startup error details",
|
||||
url: "https://github.com/getpaseo/paseo/issues/55",
|
||||
},
|
||||
],
|
||||
},
|
||||
refName: undefined,
|
||||
action: undefined,
|
||||
githubPrNumber: undefined,
|
||||
runSetup: false,
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user