diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 60a7312eea3..78f62ac2123 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -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"; @@ -24,7 +25,11 @@ import { } from "./CursorProvider.ts"; const runNode = ( - effect: Effect.Effect, + effect: Effect.Effect< + A, + E, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path + >, ): Promise => Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer))); const resolveMockAgentPath = Effect.fn("resolveMockAgentPath")(function* () { @@ -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", () => { @@ -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", @@ -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, @@ -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([ @@ -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([ @@ -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)); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 1f38549e080..8afdd78eb11 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -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, @@ -605,6 +612,14 @@ function joinProviderMessages(...messages: ReadonlyArray): 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; @@ -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)}.`, }, }); @@ -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) { diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index bf869d25c66..d826eca0063 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -208,19 +208,17 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( ); return ( -
{ - if (isResponding) return; handleOptionSelection(activeQuestion.id, option.label); }} className={className} > {content} -
+ ); })} diff --git a/packages/client-runtime/src/shellSnapshotReducer.test.ts b/packages/client-runtime/src/shellSnapshotReducer.test.ts index 69ae5e5d69f..6e5b8eab5aa 100644 --- a/packages/client-runtime/src/shellSnapshotReducer.test.ts +++ b/packages/client-runtime/src/shellSnapshotReducer.test.ts @@ -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 = { diff --git a/packages/client-runtime/src/shellSnapshotReducer.ts b/packages/client-runtime/src/shellSnapshotReducer.ts index a30eedb769b..0274e6539dd 100644 --- a/packages/client-runtime/src/shellSnapshotReducer.ts +++ b/packages/client-runtime/src/shellSnapshotReducer.ts @@ -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) diff --git a/packages/shared/src/remote.test.ts b/packages/shared/src/remote.test.ts index 54c78907421..24e78757009 100644 --- a/packages/shared/src/remote.test.ts +++ b/packages/shared/src/remote.test.ts @@ -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(() => diff --git a/packages/shared/src/remote.ts b/packages/shared/src/remote.ts index 703811609b8..7347dbc74a1 100644 --- a/packages/shared/src/remote.ts +++ b/packages/shared/src/remote.ts @@ -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); @@ -18,7 +19,10 @@ export class RemoteBackendUrlMissingError extends Schema.TaggedErrorClass()( "RemotePairingUrlInvalidError", - { cause: Schema.Defect() }, + { + cause: Schema.optional(Schema.Defect()), + protocol: Schema.optional(Schema.String), + }, ) { override get message(): string { return "Pairing URL is invalid."; @@ -29,7 +33,8 @@ export class RemoteBackendUrlInvalidError extends Schema.TaggedErrorClass + SUPPORTED_REMOTE_BACKEND_PROTOCOLS.has(url.protocol); + const normalizeRemoteBaseUrl = ( rawValue: string, source: RemoteBackendUrlInvalidError["source"], @@ -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 = ""; @@ -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(