fix: make worktreeRoot backward-compatible for old clients/daemons

worktreeRoot was added as a required field in 9154f8fc, which breaks
old clients parsing new daemon responses and new clients parsing old
daemon responses. Made it .optional() with .transform() fallbacks.

Also added a critical rule to CLAUDE.md: schema changes must always
be backward-compatible in both directions.
This commit is contained in:
Mohamed Boudra
2026-04-04 20:29:44 +07:00
parent 018ebd5f29
commit 1e9b7f1157
3 changed files with 115 additions and 27 deletions
+42 -27
View File
@@ -1607,35 +1607,50 @@ export const ArtifactMessageSchema = z.object({
}),
});
export const ProjectCheckoutLiteNotGitPayloadSchema = z.object({
cwd: z.string(),
isGit: z.literal(false),
currentBranch: z.null(),
remoteUrl: z.null(),
worktreeRoot: z.null(),
isPaseoOwnedWorktree: z.literal(false),
mainRepoRoot: z.null(),
});
export const ProjectCheckoutLiteNotGitPayloadSchema = z
.object({
cwd: z.string(),
isGit: z.literal(false),
currentBranch: z.null(),
remoteUrl: z.null(),
worktreeRoot: z.null().optional(),
isPaseoOwnedWorktree: z.literal(false),
mainRepoRoot: z.null(),
})
.transform((value) => ({
...value,
worktreeRoot: null,
}));
export const ProjectCheckoutLiteGitNonPaseoPayloadSchema = z.object({
cwd: z.string(),
isGit: z.literal(true),
currentBranch: z.string().nullable(),
remoteUrl: z.string().nullable(),
worktreeRoot: z.string(),
isPaseoOwnedWorktree: z.literal(false),
mainRepoRoot: z.null(),
});
export const ProjectCheckoutLiteGitNonPaseoPayloadSchema = z
.object({
cwd: z.string(),
isGit: z.literal(true),
currentBranch: z.string().nullable(),
remoteUrl: z.string().nullable(),
worktreeRoot: z.string().optional(),
isPaseoOwnedWorktree: z.literal(false),
mainRepoRoot: z.null(),
})
.transform((value) => ({
...value,
worktreeRoot: value.worktreeRoot ?? value.cwd,
}));
export const ProjectCheckoutLiteGitPaseoPayloadSchema = z.object({
cwd: z.string(),
isGit: z.literal(true),
currentBranch: z.string().nullable(),
remoteUrl: z.string().nullable(),
worktreeRoot: z.string(),
isPaseoOwnedWorktree: z.literal(true),
mainRepoRoot: z.string(),
});
export const ProjectCheckoutLiteGitPaseoPayloadSchema = z
.object({
cwd: z.string(),
isGit: z.literal(true),
currentBranch: z.string().nullable(),
remoteUrl: z.string().nullable(),
worktreeRoot: z.string().optional(),
isPaseoOwnedWorktree: z.literal(true),
mainRepoRoot: z.string(),
})
.transform((value) => ({
...value,
worktreeRoot: value.worktreeRoot ?? value.cwd,
}));
export const ProjectCheckoutLitePayloadSchema = z.union([
ProjectCheckoutLiteNotGitPayloadSchema,
@@ -50,4 +50,71 @@ describe("workspace message schemas", () => {
expect(result.success).toBe(false);
});
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");
});
});