Files
oh-my-pi/packages/ai/test/cursor-on-payload.test.ts
T
ranxianglei 574a83f5f5 fix(pi-ai): honor onPayload replacement payloads in openai-completions, bedrock and cursor
The onPayload hook contract (README, docs/extensions.md) is that a non-undefined
return replaces the provider request payload, and every provider except these
three implements it (anthropic, openai-responses family, google, ollama — see
the earlier fix for the responses providers). openai-completions, amazon-bedrock
and cursor invoked the hook fire-and-forget and sent the original payload, so
extensions hooking before_provider_request could never transform the wire body
on these providers.

- openai-completions: await the hook and apply a non-undefined replacement to
  the params used for the request body, raw request dump and error-path
  fallback state
- amazon-bedrock: same for the ConverseStream command input
- cursor: await the hook for the AgentRunRequest; buildGrpcRequest becomes
  async and is exported for direct testing (transport is HTTP/2)
- devin-agent intentionally unchanged: it does not fire the hook at all (its
  payload is a protobuf object), which is a feature gap rather than a dropped
  replacement; documented in README/docs instead
- regression tests: captured wire body reflects async/sync replacement, and an
  undefined return keeps the original payload (completions + bedrock over a
  mocked fetch; cursor by decoding the serialized run request)
2026-08-16 19:22:49 +08:00

68 lines
2.4 KiB
TypeScript

// Regression: cursor ignored the onPayload replacement return value
// (fire-and-forget), so the hook could never change the request actually sent
// upstream. The replacement contract matches anthropic / openai-responses /
// google: await the hook and use its non-undefined return as the request.
// buildGrpcRequest is exercised directly (the transport is HTTP/2), and the
// serialized run request is decoded back from the wire bytes.
import { describe, expect, it } from "bun:test";
import { fromBinary } from "@bufbuild/protobuf";
import { buildGrpcRequest } from "@oh-my-pi/pi-ai/providers/cursor";
import { AgentClientMessageSchema } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
import type { Context, Model } from "@oh-my-pi/pi-ai/types";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
const model: Model<"cursor-agent"> = buildModel({
id: "cursor-composer-2.5",
name: "Cursor Composer 2.5",
api: "cursor-agent",
provider: "cursor",
baseUrl: "https://api2.cursor.sh",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 32_000,
});
const context: Context = {
messages: [{ role: "user", content: "Say hello", timestamp: 0 }],
};
function decodeRunRequest(requestBytes: Uint8Array): { case: string; value: Record<string, any> } {
const decoded = fromBinary(AgentClientMessageSchema, requestBytes);
return decoded.message as unknown as { case: string; value: Record<string, any> };
}
describe("cursor onPayload replacement", () => {
it("sends an async onPayload replacement body", async () => {
const { requestBytes } = await buildGrpcRequest(
model,
context,
{
onPayload: async payload => ({
...(payload as Record<string, unknown>),
customSystemPrompt: "replacement",
}),
},
{ conversationId: "conv-1", blobStore: new Map() },
);
const message = decodeRunRequest(requestBytes);
expect(message.case).toBe("runRequest");
expect(message.value.customSystemPrompt).toBe("replacement");
});
it("keeps the original body when onPayload returns undefined", async () => {
const { requestBytes } = await buildGrpcRequest(
model,
context,
{ onPayload: async () => undefined },
{ conversationId: "conv-1", blobStore: new Map() },
);
const message = decodeRunRequest(requestBytes);
expect(message.case).toBe("runRequest");
expect(message.value.customSystemPrompt).toBeUndefined();
});
});