Skip to content

Commit 630cc29

Browse files
authored
Preserve unknown fields in generated Zod schemas (#92)
Switch generated object schemas to `z.looseObject()` so known requests and notifications keep unrecognized properties during validation.
1 parent 3ebb56d commit 630cc29

File tree

3 files changed

+300
-177
lines changed

3 files changed

+300
-177
lines changed

scripts/generate.js

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,11 @@ async function main() {
4444

4545
const zodPath = "./src/schema/zod.gen.ts";
4646
const zodSrc = await fs.readFile(zodPath, "utf8");
47-
await fs.writeFile(
48-
zodPath,
47+
const zod = await prettier.format(
4948
updateDocs(
5049
zodSrc
5150
.replace(`from "zod"`, `from "zod/v4"`)
51+
.replaceAll(/z\.object\(/g, "z.looseObject(")
5252
// Weird type issue
5353
.replaceAll(
5454
/z\.record\((?!z\.string\(\),\s*)([^)]+)\)/g,
@@ -63,19 +63,22 @@ async function main() {
6363
"z.number()",
6464
),
6565
),
66+
{ parser: "typescript" },
6667
);
68+
await fs.writeFile(zodPath, zod);
6769

6870
const tsPath = "./src/schema/types.gen.ts";
6971
const tsSrc = await fs.readFile(tsPath, "utf8");
70-
await fs.writeFile(
71-
tsPath,
72+
const ts = await prettier.format(
7273
updateDocs(
7374
tsSrc.replace(
7475
`export type ClientOptions`,
7576
`// eslint-disable-next-line @typescript-eslint/no-unused-vars\ntype ClientOptions`,
7677
),
7778
),
79+
{ parser: "typescript" },
7880
);
81+
await fs.writeFile(tsPath, ts);
7982

8083
const meta = await prettier.format(
8184
`export const AGENT_METHODS = ${JSON.stringify(metadata.agentMethods, null, 2)} as const;

src/acp.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,126 @@ describe("Connection", () => {
538538
expect(response.authMethods?.[0].id).toBe("oauth");
539539
});
540540

541+
it("preserves unknown properties on known incoming params", async () => {
542+
let receivedInitializeParams: Record<string, unknown> | undefined;
543+
let receivedSessionUpdate: Record<string, unknown> | undefined;
544+
545+
class TestClient implements Client {
546+
async writeTextFile(
547+
_: WriteTextFileRequest,
548+
): Promise<WriteTextFileResponse> {
549+
return {};
550+
}
551+
async readTextFile(
552+
_: ReadTextFileRequest,
553+
): Promise<ReadTextFileResponse> {
554+
return { content: "test" };
555+
}
556+
async requestPermission(
557+
_: RequestPermissionRequest,
558+
): Promise<RequestPermissionResponse> {
559+
return {
560+
outcome: {
561+
outcome: "selected",
562+
optionId: "allow",
563+
},
564+
};
565+
}
566+
async sessionUpdate(params: SessionNotification): Promise<void> {
567+
receivedSessionUpdate = params as unknown as Record<string, unknown>;
568+
}
569+
}
570+
571+
class TestAgent implements Agent {
572+
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
573+
receivedInitializeParams = params as unknown as Record<string, unknown>;
574+
return {
575+
protocolVersion: PROTOCOL_VERSION,
576+
agentCapabilities: { loadSession: false },
577+
authMethods: [],
578+
};
579+
}
580+
async newSession(_: NewSessionRequest): Promise<NewSessionResponse> {
581+
return { sessionId: "test-session" };
582+
}
583+
async loadSession(_: LoadSessionRequest): Promise<LoadSessionResponse> {
584+
return {};
585+
}
586+
async authenticate(_: AuthenticateRequest): Promise<void> {
587+
// no-op
588+
}
589+
async prompt(_: PromptRequest): Promise<PromptResponse> {
590+
return { stopReason: "end_turn" };
591+
}
592+
async cancel(_: CancelNotification): Promise<void> {
593+
// no-op
594+
}
595+
}
596+
597+
const agentConnection = new ClientSideConnection(
598+
() => new TestClient(),
599+
ndJsonStream(clientToAgent.writable, agentToClient.readable),
600+
);
601+
602+
const clientConnection = new AgentSideConnection(
603+
() => new TestAgent(),
604+
ndJsonStream(agentToClient.writable, clientToAgent.readable),
605+
);
606+
607+
await agentConnection.initialize({
608+
protocolVersion: PROTOCOL_VERSION,
609+
clientCapabilities: {
610+
fs: {
611+
readTextFile: false,
612+
writeTextFile: false,
613+
experimentalFs: true,
614+
},
615+
customCapability: {
616+
enabled: true,
617+
},
618+
},
619+
extraTopLevel: "keep me",
620+
} as any);
621+
622+
await clientConnection.sessionUpdate({
623+
sessionId: "test-session",
624+
update: {
625+
sessionUpdate: "agent_message_chunk",
626+
content: {
627+
type: "text",
628+
text: "Hello from agent",
629+
},
630+
extraUpdateField: {
631+
keep: true,
632+
},
633+
},
634+
extraNotificationField: "keep this too",
635+
} as any);
636+
637+
await new Promise((resolve) => setTimeout(resolve, 50));
638+
639+
expect(receivedInitializeParams).toMatchObject({
640+
extraTopLevel: "keep me",
641+
clientCapabilities: {
642+
customCapability: {
643+
enabled: true,
644+
},
645+
fs: {
646+
experimentalFs: true,
647+
},
648+
},
649+
});
650+
651+
expect(receivedSessionUpdate).toMatchObject({
652+
extraNotificationField: "keep this too",
653+
update: {
654+
extraUpdateField: {
655+
keep: true,
656+
},
657+
},
658+
});
659+
});
660+
541661
it("handles extension methods and notifications", async () => {
542662
const extensionLog: string[] = [];
543663

0 commit comments

Comments
 (0)