Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 44 additions & 10 deletions apps/server/src/provider/Layers/CursorProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import type * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
import { describe, expect, it } from "vite-plus/test";
import type * as EffectAcpSchema from "effect-acp/schema";
import type { CursorSettings } from "@t3tools/contracts";
Expand All @@ -24,7 +25,11 @@ import {
} from "./CursorProvider.ts";

const runNode = <A, E>(
effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>,
effect: Effect.Effect<
A,
E,
ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
>,
): Promise<A> => Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer)));

const resolveMockAgentPath = Effect.fn("resolveMockAgentPath")(function* () {
Expand Down Expand Up @@ -293,6 +298,18 @@ const baseCursorSettings: CursorSettings = {
apiEndpoint: "",
customModels: [],
};
const cursorAcpDiscoveryFailedMessage = [
"Cursor ACP model discovery failed.",
"Cursor CLI setup may be incomplete; install or enable the Cursor CLI, restart T3 Code, and try again.",
"See https://cursor.com/docs/cli/installation.",
"Check server logs for ACP details.",
].join(" ");
const missingCursorBinaryPath = "/definitely/not/installed/t3-cursor-agent";
const cursorCliCommandMissingMessage = [
`Cursor CLI command \`${missingCursorBinaryPath}\` was not found.`,
`Install or enable the Cursor CLI, make sure \`${missingCursorBinaryPath}\` is on PATH, then restart T3 Code.`,
"See https://cursor.com/docs/cli/installation.",
].join(" ");

describe("getCursorFallbackModels", () => {
it("does not publish any built-in cursor models before ACP discovery", () => {
Expand Down Expand Up @@ -338,12 +355,11 @@ describe("buildCursorProviderSnapshot", () => {
auth: { status: "unauthenticated" },
message: "Cursor Agent is not authenticated. Run `agent login` and try again.",
},
discoveryWarning: "Cursor ACP model discovery failed. Check server logs for details.",
discoveryWarning: cursorAcpDiscoveryFailedMessage,
}),
).toMatchObject({
status: "error",
message:
"Cursor Agent is not authenticated. Run `agent login` and try again. Cursor ACP model discovery failed. Check server logs for details.",
message: `Cursor Agent is not authenticated. Run \`agent login\` and try again. ${cursorAcpDiscoveryFailedMessage}`,
models: [
{
slug: "claude-sonnet-4-6",
Expand Down Expand Up @@ -411,10 +427,28 @@ describe("buildCursorCapabilitiesFromConfigOptions", () => {
});

describe("checkCursorProviderStatus", () => {
it("reports the install docs when the Cursor CLI command is missing", async () => {
const provider = await runNode(
checkCursorProviderStatus({
enabled: true,
binaryPath: missingCursorBinaryPath,
apiEndpoint: "",
customModels: [],
}),
);

expect(provider).toMatchObject({
installed: false,
status: "error",
auth: { status: "unknown" },
message: cursorCliCommandMissingMessage,
});
});

it("passes the injected environment to ACP model discovery", async () => {
const { requestLogPath, wrapperPath } = await runNode(makeProviderStatusEnvFixture());

const provider = await Effect.runPromise(
const provider = await runNode(
checkCursorProviderStatus(
{
enabled: true,
Expand All @@ -426,7 +460,7 @@ describe("checkCursorProviderStatus", () => {
...process.env,
T3_ACP_REQUEST_LOG_PATH: requestLogPath,
},
).pipe(Effect.provide(NodeServices.layer)),
),
);

expect(provider.models.map((model) => model.slug)).toEqual([
Expand All @@ -443,13 +477,13 @@ describe("discoverCursorModelsViaAcp", () => {
it("keeps the ACP probe runtime alive long enough to discover models", async () => {
const wrapperPath = await runNode(makeMockAgentWrapper());

const models = await Effect.runPromise(
const models = await runNode(
discoverCursorModelsViaAcp({
enabled: true,
binaryPath: wrapperPath,
apiEndpoint: "",
customModels: [],
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
}).pipe(Effect.scoped),
);

expect(models.map((model) => model.slug)).toEqual([
Expand All @@ -465,13 +499,13 @@ describe("discoverCursorModelsViaAcp", () => {
makeExitLogFixture("cursor-provider-exit-log-"),
);

await Effect.runPromise(
await runNode(
discoverCursorModelsViaAcp({
enabled: true,
binaryPath: wrapperPath,
apiEndpoint: "",
customModels: [],
}).pipe(Effect.provide(NodeServices.layer)),
}),
);

const exitLog = await runNode(waitForFileContent(exitLogPath));
Expand Down
19 changes: 17 additions & 2 deletions apps/server/src/provider/Layers/CursorProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({

const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000;
const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08;
const CURSOR_CLI_INSTALLATION_DOCS_URL = "https://cursor.com/docs/cli/installation";
const CURSOR_ACP_MODEL_DISCOVERY_FAILED_MESSAGE = [
"Cursor ACP model discovery failed.",
"Cursor CLI setup may be incomplete; install or enable the Cursor CLI, restart T3 Code, and try again.",
`See ${CURSOR_CLI_INSTALLATION_DOCS_URL}.`,
"Check server logs for ACP details.",
].join(" ");
export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = {
_meta: {
parameterizedModelPicker: true,
Expand Down Expand Up @@ -605,6 +612,14 @@ function joinProviderMessages(...messages: ReadonlyArray<string | undefined>): s
return parts.length > 0 ? parts.join(" ") : undefined;
}

function buildCursorCliCommandMissingMessage(binaryPath: string): string {
return [
`Cursor CLI command \`${binaryPath}\` was not found.`,
`Install or enable the Cursor CLI, make sure \`${binaryPath}\` is on PATH, then restart T3 Code.`,
`See ${CURSOR_CLI_INSTALLATION_DOCS_URL}.`,
].join(" ");
}

export function buildCursorProviderSnapshot(input: {
readonly checkedAt: string;
readonly cursorSettings: CursorSettings;
Expand Down Expand Up @@ -1014,7 +1029,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(
status: "error",
auth: { status: "unknown" },
message: isCommandMissingCause(error)
? "Cursor Agent CLI (`agent`) is not installed or not on PATH."
? buildCursorCliCommandMissingMessage(cursorSettings.binaryPath)
: `Failed to execute Cursor Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`,
},
});
Expand Down Expand Up @@ -1073,7 +1088,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(
yield* Effect.logWarning("Cursor ACP model discovery failed", {
cause: Cause.pretty(discoveryExit.cause),
});
discoveryWarning = "Cursor ACP model discovery failed. Check server logs for details.";
discoveryWarning = CURSOR_ACP_MODEL_DISCOVERY_FAILED_MESSAGE;
} else if (Option.isNone(discoveryExit.value)) {
discoveryWarning = `Cursor ACP model discovery timed out after ${CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`;
} else if (discoveryExit.value.value.length === 0) {
Expand Down
10 changes: 4 additions & 6 deletions apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,19 +208,17 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
</>
);
return (
<div
<button
key={`${activeQuestion.id}:${option.label}`}
role="button"
tabIndex={isResponding ? -1 : 0}
aria-disabled={isResponding}
type="button"
disabled={isResponding}
onClick={() => {
if (isResponding) return;
handleOptionSelection(activeQuestion.id, option.label);
}}
className={className}
>
{content}
</div>
</button>
);
})}
</div>
Expand Down
20 changes: 20 additions & 0 deletions packages/client-runtime/src/shellSnapshotReducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,26 @@ const stubThread = {
} as const;

describe("applyShellStreamEvent", () => {
it("ignores stale project upserts without mutating the snapshot", () => {
const snapshotWithProject: OrchestrationShellSnapshot = {
...baseSnapshot,
snapshotSequence: 4,
projects: [stubProject],
};

for (const sequence of [3, 4]) {
const next = applyShellStreamEvent(snapshotWithProject, {
kind: "project-upserted",
sequence,
project: { ...stubProject, title: "Stale Title" },
});

expect(next).toBe(snapshotWithProject);
expect(next.snapshotSequence).toBe(4);
expect(next.projects[0]?.title).toBe("Test Project");
}
});

describe("project-upserted", () => {
it("adds a new project", () => {
const event: OrchestrationShellStreamEvent = {
Expand Down
2 changes: 2 additions & 0 deletions packages/client-runtime/src/shellSnapshotReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export function applyShellStreamEvent(
snapshot: OrchestrationShellSnapshot,
event: OrchestrationShellStreamEvent,
): OrchestrationShellSnapshot {
if (event.sequence <= snapshot.snapshotSequence) return snapshot;

switch (event.kind) {
case "project-upserted": {
const projects = snapshot.projects.some((p) => p.id === event.project.id)
Expand Down
47 changes: 47 additions & 0 deletions packages/shared/src/remote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,53 @@ describe("remote", () => {
});
});

it("rejects unsupported direct pairing URL protocols", () => {
let pairingUrlError: unknown;
try {
resolveRemotePairingTarget({
pairingUrl: "ftp://remote.example.com/pair#token=pairing-token",
});
} catch (cause) {
pairingUrlError = cause;
}

expect(pairingUrlError).toBeInstanceOf(RemotePairingUrlInvalidError);
expect(pairingUrlError).toMatchObject({ protocol: "ftp:" });
expect((pairingUrlError as RemotePairingUrlInvalidError).cause).toBeUndefined();
});

it("rejects unsupported hosted pairing backend protocols", () => {
let hostError: unknown;
try {
resolveRemotePairingTarget({
pairingUrl:
"https://app.t3.codes/pair?host=ftp%3A%2F%2Fremote.example.com#token=pairing-token",
});
} catch (cause) {
hostError = cause;
}

expect(hostError).toBeInstanceOf(RemoteBackendUrlInvalidError);
expect(hostError).toMatchObject({ source: "hosted-pairing-host", protocol: "ftp:" });
expect((hostError as RemoteBackendUrlInvalidError).cause).toBeUndefined();
});

it("rejects unsupported direct host protocols", () => {
let hostError: unknown;
try {
resolveRemotePairingTarget({
host: "ftp://remote.example.com",
pairingCode: "pairing-token",
});
} catch (cause) {
hostError = cause;
}

expect(hostError).toBeInstanceOf(RemoteBackendUrlInvalidError);
expect(hostError).toMatchObject({ source: "direct-host", protocol: "ftp:" });
expect((hostError as RemoteBackendUrlInvalidError).cause).toBeUndefined();
});

it("uses distinct structural errors for missing pairing inputs", () => {
expect(() => resolveRemotePairingTarget({})).toThrowError(RemoteBackendUrlMissingError);
expect(() =>
Expand Down
23 changes: 21 additions & 2 deletions packages/shared/src/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema";
const PAIRING_TOKEN_PARAM = "token";
const HOSTED_PAIRING_HOST_PARAM = "host";
const HOSTED_PAIRING_LABEL_PARAM = "label";
const SUPPORTED_REMOTE_BACKEND_PROTOCOLS = new Set(["http:", "https:", "ws:", "wss:"]);

const readHashParams = (url: URL): URLSearchParams =>
new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash);
Expand All @@ -18,7 +19,10 @@ export class RemoteBackendUrlMissingError extends Schema.TaggedErrorClass<Remote

export class RemotePairingUrlInvalidError extends Schema.TaggedErrorClass<RemotePairingUrlInvalidError>()(
"RemotePairingUrlInvalidError",
{ cause: Schema.Defect() },
{
cause: Schema.optional(Schema.Defect()),
protocol: Schema.optional(Schema.String),
},
) {
override get message(): string {
return "Pairing URL is invalid.";
Expand All @@ -29,7 +33,8 @@ export class RemoteBackendUrlInvalidError extends Schema.TaggedErrorClass<Remote
"RemoteBackendUrlInvalidError",
{
source: Schema.Literals(["direct-host", "hosted-pairing-host"]),
cause: Schema.Defect(),
cause: Schema.optional(Schema.Defect()),
protocol: Schema.optional(Schema.String),
},
) {
override get message(): string {
Expand Down Expand Up @@ -64,6 +69,9 @@ export const RemotePairingTargetError = Schema.Union([
]);
export type RemotePairingTargetError = typeof RemotePairingTargetError.Type;

const hasSupportedRemoteBackendProtocol = (url: URL): boolean =>
SUPPORTED_REMOTE_BACKEND_PROTOCOLS.has(url.protocol);

const normalizeRemoteBaseUrl = (
rawValue: string,
source: RemoteBackendUrlInvalidError["source"],
Expand All @@ -83,6 +91,12 @@ const normalizeRemoteBaseUrl = (
} catch (cause) {
throw new RemoteBackendUrlInvalidError({ source, cause });
}
if (!hasSupportedRemoteBackendProtocol(url)) {
throw new RemoteBackendUrlInvalidError({
source,
protocol: url.protocol,
});
}
url.pathname = "/";
url.search = "";
url.hash = "";
Expand Down Expand Up @@ -184,6 +198,11 @@ export const resolveRemotePairingTarget = (input: {
} catch (cause) {
throw new RemotePairingUrlInvalidError({ cause });
}
if (!hasSupportedRemoteBackendProtocol(url)) {
throw new RemotePairingUrlInvalidError({
protocol: url.protocol,
});
}
const hostedPairingRequest = readHostedPairingRequest(url);
if (hostedPairingRequest) {
const hostedBackendUrl = normalizeRemoteBaseUrl(
Expand Down
Loading