From eeb6e236750da8d63f585ea4aa30af0320dd5ca2 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 7 Nov 2026 16:36:46 +0530 Subject: [PATCH 01/16] fix(web): keep Pi thinking controls visible before selection (#27397) Co-authored-by: maria-rcks --- .../components/chat/ComposerModelEffort.tsx | 6 +++- .../client-runtime/src/modelTraits.test.ts | 36 +++++++++++++++++++ packages/client-runtime/src/modelTraits.ts | 4 +-- 3 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 packages/client-runtime/src/modelTraits.test.ts diff --git a/apps/web/src/components/chat/ComposerModelEffort.tsx b/apps/web/src/components/chat/ComposerModelEffort.tsx index 35fd11c98be7c..10a4f7fa91892 100644 --- a/apps/web/src/components/chat/ComposerModelEffort.tsx +++ b/apps/web/src/components/chat/ComposerModelEffort.tsx @@ -130,7 +130,11 @@ export function describeComposerEffort( ? `${reasoning.label} ${reasoning.currentValue === true ? "On" : "Off"}` : ultrathink ? "Ultrathink" - : getProviderOptionCurrentLabel(reasoning, modelSelection, traits.reportedModelSelection); + : (getProviderOptionCurrentLabel( + reasoning, + modelSelection, + traits.reportedModelSelection, + ) ?? (reasoning.id === "thinking" ? reasoning.label : null)); const fast = display.speedLabel === "Fast" || display.speedLabel === "Ultrafast"; // Agent options such as modes live in the picker; the trigger adds only the context window. const contextWindow = diff --git a/packages/client-runtime/src/modelTraits.test.ts b/packages/client-runtime/src/modelTraits.test.ts new file mode 100644 index 0000000000000..67b22e3099464 --- /dev/null +++ b/packages/client-runtime/src/modelTraits.test.ts @@ -0,0 +1,36 @@ +import { ProviderDriverKind, type ProviderOptionDescriptor } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildTraitsTriggerDisplay, getDescriptorStringValue } from "./modelTraits.ts"; + +describe("buildTraitsTriggerDisplay", () => { + it.each([ + { currentValue: undefined, defaultValue: undefined, value: null, label: "Thinking" }, + { currentValue: undefined, defaultValue: "high", value: "high", label: "High" }, + { currentValue: "low", defaultValue: undefined, value: "low", label: "Low" }, + { currentValue: "max", defaultValue: "high", value: "max", label: "Max" }, + { id: "effort", currentValue: undefined, defaultValue: undefined, value: null, label: "" }, + ])("labels Pi thinking without inventing a selection: $label ($value)", (input) => { + const descriptor: Extract = { + id: input.id ?? "thinking", + label: "Thinking", + type: "select", + options: ["low", "high", "max"].map((id) => ({ + id, + label: id[0]!.toUpperCase() + id.slice(1), + ...(id === input.defaultValue ? { isDefault: true } : {}), + })), + ...(input.currentValue ? { currentValue: input.currentValue } : {}), + }; + + expect( + buildTraitsTriggerDisplay({ + provider: ProviderDriverKind.make("pi"), + descriptors: [descriptor], + primarySelectDescriptorId: descriptor.id, + ultrathinkPromptControlled: false, + }).label, + ).toBe(input.label); + expect(getDescriptorStringValue(descriptor)).toBe(input.value); + }); +}); diff --git a/packages/client-runtime/src/modelTraits.ts b/packages/client-runtime/src/modelTraits.ts index 9a3dc06a15173..3782b79e567f8 100644 --- a/packages/client-runtime/src/modelTraits.ts +++ b/packages/client-runtime/src/modelTraits.ts @@ -169,11 +169,11 @@ export function buildTraitsTriggerDisplay(input: { ? "Ultrathink" : descriptor.type === "boolean" ? `${descriptor.label} ${descriptor.currentValue === true ? "On" : "Off"}` - : getProviderOptionCurrentLabel( + : (getProviderOptionCurrentLabel( descriptor, input.modelSelection, input.reportedModelSelection, - ); + ) ?? (descriptor.id === "thinking" ? descriptor.label : null)); if (typeof label === "string" && label.length > 0) { if (reasoningLabelIndex === -1 && isReasoningSelect(descriptor)) { reasoningLabelIndex = labels.length; From 67fdc53823894f96e5946da80fac5b6c34c0487d Mon Sep 17 00:00:00 2001 From: Peyton Lamb <94505409+not-compromised@users.noreply.github.com> Date: Sat, 7 Nov 2026 05:19:13 -0600 Subject: [PATCH 02/16] fix(desktop): published themes reach the app when it uses this machine's service (#26324) Co-authored-by: notcompromised Co-authored-by: maria-rcks Co-authored-by: Yash Singh --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + .../src/ipc/methods/localEnvironment.test.ts | 29 ++++++++++++- .../src/ipc/methods/localEnvironment.ts | 17 ++++++++ apps/desktop/src/preload.ts | 1 + apps/web/src/appInit.tsx | 2 + apps/web/src/hooks/useDefaultTheme.ts | 14 +++---- .../web/src/hooks/useEnvironmentTheme.test.ts | 2 +- apps/web/src/state/primaryEnvironment.test.ts | 41 +++++++++++++++++++ apps/web/src/state/primaryEnvironment.ts | 37 +++++++++++++++++ apps/web/src/state/server.ts | 17 ++++++-- apps/web/src/themePalette.ts | 4 +- docs/user/appearance.md | 4 +- packages/contracts/src/ipc.ts | 2 + packages/effect-acp/src/protocol.ts | 32 +++++---------- 15 files changed, 168 insertions(+), 37 deletions(-) create mode 100644 apps/web/src/state/primaryEnvironment.test.ts diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 66c558e86fc8d..db26c525c0015 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -6,6 +6,7 @@ import { installNotificationBadge } from "./methods/notificationBadge.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; import { getConnectionCatalog, setConnectionCatalog } from "./methods/connectionCatalog.ts"; import { + getHostEnvironmentId, getLocalEnvironmentEnabled, setLocalEnvironmentEnabled, } from "./methods/localEnvironment.ts"; @@ -100,6 +101,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getLocalEnvironmentBootstraps); yield* ipc.handleSync(getLocalEnvironmentEnabled); yield* ipc.handle(setLocalEnvironmentEnabled); + yield* ipc.handle(getHostEnvironmentId); yield* ipc.handle(getLocalEnvironmentBearerToken); yield* ipc.handle(getClientSettings); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 840e5f1168007..6a149e24ae695 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -38,6 +38,7 @@ export const GET_PREFERRED_SYSTEM_LANGUAGES_CHANNEL = "desktop:get-preferred-sys export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; export const GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:get-local-environment-enabled"; export const SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:set-local-environment-enabled"; +export const GET_HOST_ENVIRONMENT_ID_CHANNEL = "desktop:get-host-environment-id"; export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = "desktop:get-local-environment-bearer-token"; export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; diff --git a/apps/desktop/src/ipc/methods/localEnvironment.test.ts b/apps/desktop/src/ipc/methods/localEnvironment.test.ts index 94bbf085a9921..36e89eb880c15 100644 --- a/apps/desktop/src/ipc/methods/localEnvironment.test.ts +++ b/apps/desktop/src/ipc/methods/localEnvironment.test.ts @@ -1,4 +1,7 @@ import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -10,7 +13,11 @@ import * as ElectronApp from "../../electron/ElectronApp.ts"; import * as ElectronTheme from "../../electron/ElectronTheme.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as DesktopWindow from "../../window/DesktopWindow.ts"; -import { getLocalEnvironmentEnabled, setLocalEnvironmentEnabled } from "./localEnvironment.ts"; +import { + getHostEnvironmentId, + getLocalEnvironmentEnabled, + setLocalEnvironmentEnabled, +} from "./localEnvironment.ts"; const layerUnusedLifecycleRuntime = Layer.mergeAll( DesktopShutdown.layer, @@ -27,6 +34,26 @@ const layerUnusedLifecycleRuntime = Layer.mergeAll( ); describe("local environment IPC", () => { + it.effect("reads the shared home identity without creating one", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateDir = yield* fs.makeTempDirectoryScoped(); + const environment = DesktopEnvironment.DesktopEnvironment.of({ + path, + stateDir, + } as DesktopEnvironment.DesktopEnvironment["Service"]); + const read = getHostEnvironmentId + .handler(undefined) + .pipe(Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment)); + assert.strictEqual(yield* read, null); + assert.isFalse(yield* fs.exists(path.join(stateDir, "environment-id"))); + yield* fs.writeFileString(path.join(stateDir, "environment-id"), " service-id\n"); + assert.strictEqual(yield* read, "service-id"); + yield* fs.writeFileString(path.join(stateDir, "environment-id"), "\n"); + assert.strictEqual(yield* read, null); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); it.effect("relaunches only when the setting changes and keeps other settings", () => { const relaunchReasons: Array = []; const layer = Layer.mergeAll( diff --git a/apps/desktop/src/ipc/methods/localEnvironment.ts b/apps/desktop/src/ipc/methods/localEnvironment.ts index 74cccd04a0c58..3c5c2c63d77da 100644 --- a/apps/desktop/src/ipc/methods/localEnvironment.ts +++ b/apps/desktop/src/ipc/methods/localEnvironment.ts @@ -1,6 +1,8 @@ import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as IpcChannels from "../channels.ts"; @@ -28,3 +30,18 @@ export const setLocalEnvironmentEnabled = makeIpcMethod({ } }), }); + +// A background service sharing this app's T3 home records its environment ID in the same state dir. +export const getHostEnvironmentId = makeIpcMethod({ + channel: IpcChannels.GET_HOST_ENVIRONMENT_ID_CHANNEL, + payload: Schema.Void, + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.localEnvironment.getHostEnvironmentId")(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const contents = yield* fileSystem + .readFileString(environment.path.join(environment.stateDir, "environment-id")) + .pipe(Effect.orElseSucceed(() => "")); + return contents.trim() || null; + }), +}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 2d2bd4f516487..7c44ec5133785 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -126,6 +126,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL) !== false, setLocalEnvironmentEnabled: (enabled) => ipcRenderer.invoke(IpcChannels.SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL, enabled), + getHostEnvironmentId: () => ipcRenderer.invoke(IpcChannels.GET_HOST_ENVIRONMENT_ID_CHANNEL), getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), setClientSettings: (settings) => ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings), diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index 9d77c475155cb..ccb7a8b82000e 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -32,6 +32,7 @@ import { import { startDefaultThemeAdoption } from "./hooks/useDefaultTheme"; import { startEnvironmentThemeRepaint } from "./hooks/useEnvironmentTheme"; import { readLocalStorageValue, subscribeLocalStorageKey } from "./hooks/useLocalStorage"; +import { startHostEnvironmentLookup } from "./state/primaryEnvironment"; import { getClientSettings, startModelPreferencesMigration, @@ -180,6 +181,7 @@ export function startAppServices(): AppRouter { startClientAppearance(); startDocumentTitle(); + startHostEnvironmentLookup(); startEnvironmentThemeRepaint(); startBrowserChromeThemeSync(router); startDefaultThemeAdoption(); diff --git a/apps/web/src/hooks/useDefaultTheme.ts b/apps/web/src/hooks/useDefaultTheme.ts index 5731da6eb53d5..9077142289c03 100644 --- a/apps/web/src/hooks/useDefaultTheme.ts +++ b/apps/web/src/hooks/useDefaultTheme.ts @@ -1,6 +1,6 @@ import { appAtomRegistry } from "../rpc/atomRegistry"; -import { primaryEnvironmentIdAtom } from "../state/primaryEnvironment"; -import { primaryServerSettingsAtom } from "../state/server"; +import { themeEnvironmentIdAtom } from "../state/primaryEnvironment"; +import { themeServerSettingsAtom } from "../state/server"; import { getThemeDefinition, singleAppearanceOf, subscribeToCustomThemes } from "../themePalette"; import { setAppearanceMode, setTheme } from "./useTheme"; @@ -45,9 +45,9 @@ function writeAppliedGeneration(storageKey: string, generation: string): void { function adoptDefaultTheme(): void { if (typeof window === "undefined") return; - const environmentId = appAtomRegistry.get(primaryEnvironmentIdAtom); + const environmentId = appAtomRegistry.get(themeEnvironmentIdAtom); if (environmentId === null) return; - const { defaultTheme, defaultThemeSetAt } = appAtomRegistry.get(primaryServerSettingsAtom); + const { defaultTheme, defaultThemeSetAt } = appAtomRegistry.get(themeServerSettingsAtom); const definition = getThemeDefinition(defaultTheme); const storageKey = `${APPLIED_DEFAULT_THEME_STORAGE_PREFIX}${environmentId}`; const generation = defaultThemeToApply({ @@ -68,15 +68,15 @@ function adoptDefaultTheme(): void { } /** - * Applies the primary environment's default theme once per generation. Start it after the + * Applies the theme environment's default theme once per generation. Start it after the * environment theme sync so a first-run client adopting the environment's own theme finds it * already in the library; it re-checks when published or custom palettes change, so a theme * that resolves late still applies. */ export function startDefaultThemeAdoption(): () => void { const unsubscribes = [ - appAtomRegistry.subscribe(primaryEnvironmentIdAtom, adoptDefaultTheme), - appAtomRegistry.subscribe(primaryServerSettingsAtom, adoptDefaultTheme), + appAtomRegistry.subscribe(themeEnvironmentIdAtom, adoptDefaultTheme), + appAtomRegistry.subscribe(themeServerSettingsAtom, adoptDefaultTheme), subscribeToCustomThemes(adoptDefaultTheme), ]; adoptDefaultTheme(); diff --git a/apps/web/src/hooks/useEnvironmentTheme.test.ts b/apps/web/src/hooks/useEnvironmentTheme.test.ts index 80b8fcbc216a0..855d1a3d5463a 100644 --- a/apps/web/src/hooks/useEnvironmentTheme.test.ts +++ b/apps/web/src/hooks/useEnvironmentTheme.test.ts @@ -9,7 +9,7 @@ const publishedThemes = vi.hoisted(() => ({ vi.mock(import("../state/server"), async () => { const { Atom } = await import("effect/reactivity"); publishedThemes.atom = Atom.make>([]); - return { primaryServerEnvironmentThemesAtom: publishedThemes.atom } as never; + return { themeServerEnvironmentThemesAtom: publishedThemes.atom } as never; }); const refreshTheme = vi.hoisted(() => vi.fn()); vi.mock(import("./useTheme"), () => ({ refreshTheme }) as never); diff --git a/apps/web/src/state/primaryEnvironment.test.ts b/apps/web/src/state/primaryEnvironment.test.ts new file mode 100644 index 0000000000000..2a90209ae463f --- /dev/null +++ b/apps/web/src/state/primaryEnvironment.test.ts @@ -0,0 +1,41 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { Atom, AtomRegistry } from "effect/reactivity"; +import { describe, expect, it } from "vite-plus/test"; + +import { createThemeEnvironmentIdAtom } from "./primaryEnvironment"; + +const PRIMARY = EnvironmentId.make("primary"); +const SERVICE = EnvironmentId.make("service"); +const REMOTE = EnvironmentId.make("remote"); + +function themeEnvironmentId(input: { + primary: EnvironmentId | null; + host: string | null; + catalog: ReadonlyArray; +}) { + const atom = createThemeEnvironmentIdAtom({ + primaryEnvironmentIdAtom: Atom.make(input.primary), + hostEnvironmentIdAtom: Atom.make(input.host), + catalogValueAtom: Atom.make({ entries: new Map(input.catalog.map((id) => [id, null])) }), + }); + return AtomRegistry.make().get(atom); +} + +describe("themeEnvironmentIdAtom", () => { + it("prefers the primary environment over this machine's background service", () => { + expect( + themeEnvironmentId({ primary: PRIMARY, host: "service", catalog: [PRIMARY, SERVICE] }), + ).toBe(PRIMARY); + }); + + it("uses the connected background service sharing the app's T3 home when there is no primary", () => { + expect(themeEnvironmentId({ primary: null, host: "service", catalog: [REMOTE, SERVICE] })).toBe( + SERVICE, + ); + }); + + it("uses no environment when that service is not among the connections", () => { + expect(themeEnvironmentId({ primary: null, host: "service", catalog: [REMOTE] })).toBeNull(); + expect(themeEnvironmentId({ primary: null, host: null, catalog: [REMOTE] })).toBeNull(); + }); +}); diff --git a/apps/web/src/state/primaryEnvironment.ts b/apps/web/src/state/primaryEnvironment.ts index 75739b8ddeffb..92b0b4bb8b3d5 100644 --- a/apps/web/src/state/primaryEnvironment.ts +++ b/apps/web/src/state/primaryEnvironment.ts @@ -1,6 +1,8 @@ +import type { EnvironmentId } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; import { environmentCatalog } from "../connection/catalog"; +import { appAtomRegistry } from "../rpc/atomRegistry"; export const primaryEnvironmentIdAtom = Atom.make((get) => { for (const [environmentId, entry] of get(environmentCatalog.catalogValueAtom).entries) { @@ -10,3 +12,38 @@ export const primaryEnvironmentIdAtom = Atom.make((get) => { } return null; }).pipe(Atom.withLabel("web-primary-environment-id")); + +const hostEnvironmentIdAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("web-host-environment-id"), +); + +// Without a primary, only the service sharing the desktop app's T3 home supplies themes. +export function createThemeEnvironmentIdAtom(input: { + readonly primaryEnvironmentIdAtom: Atom.Atom; + readonly hostEnvironmentIdAtom: Atom.Atom; + readonly catalogValueAtom: Atom.Atom<{ readonly entries: ReadonlyMap }>; +}) { + return Atom.make((get) => { + const primaryEnvironmentId = get(input.primaryEnvironmentIdAtom); + if (primaryEnvironmentId !== null) return primaryEnvironmentId; + const hostEnvironmentId = get(input.hostEnvironmentIdAtom); + for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + if (environmentId === hostEnvironmentId) return environmentId; + } + return null; + }).pipe(Atom.withLabel("web-theme-environment-id")); +} + +export const themeEnvironmentIdAtom = createThemeEnvironmentIdAtom({ + primaryEnvironmentIdAtom, + hostEnvironmentIdAtom, + catalogValueAtom: environmentCatalog.catalogValueAtom, +}); + +export function startHostEnvironmentLookup(): void { + void window.desktopBridge + ?.getHostEnvironmentId?.() + .then((environmentId) => appAtomRegistry.set(hostEnvironmentIdAtom, environmentId)) + .catch(() => {}); +} diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index bced4e959fa8e..8be2ccd51f03e 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -26,7 +26,7 @@ import { isConnectedWithConfig } from "../components/settings/scopedSettings"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { environmentAtomList } from "./environmentAtomList"; -import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; +import { primaryEnvironmentIdAtom, themeEnvironmentIdAtom } from "./primaryEnvironment"; import { environmentSession } from "./session"; import { reuseProvidersIgnoringCheckedAt } from "./serverProviders.logic"; @@ -173,10 +173,19 @@ export const primaryServerAvailableEditorsAtom = Atom.make( const EMPTY_ENVIRONMENT_THEMES: ReadonlyArray = []; -export const primaryServerEnvironmentThemesAtom = Atom.make( +const themeServerConfigAtom = Atom.make((get): ServerConfig | null => { + const environmentId = get(themeEnvironmentIdAtom); + return environmentId === null ? null : get(serverEnvironment.configValueAtom(environmentId)); +}).pipe(Atom.withLabel("web-theme-server-config")); + +export const themeServerEnvironmentThemesAtom = Atom.make( (get): ReadonlyArray => - get(primaryServerConfigAtom)?.environmentThemes ?? EMPTY_ENVIRONMENT_THEMES, -).pipe(Atom.withLabel("web-primary-server-environment-themes")); + get(themeServerConfigAtom)?.environmentThemes ?? EMPTY_ENVIRONMENT_THEMES, +).pipe(Atom.withLabel("web-theme-server-environment-themes")); + +export const themeServerSettingsAtom = Atom.make( + (get): ServerSettings => get(themeServerConfigAtom)?.settings ?? DEFAULT_SERVER_SETTINGS, +).pipe(Atom.withLabel("web-theme-server-settings")); export const providersIgnoringCheckedAtAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get): ReadonlyArray => diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index 3c561e5417eac..309010a383602 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -30,7 +30,7 @@ import { } from "./lib/contextChipAccents"; import { appAtomRegistry, subscribeAtomChanges } from "./rpc/atomRegistry"; -import { primaryServerEnvironmentThemesAtom } from "./state/server"; +import { themeServerEnvironmentThemesAtom } from "./state/server"; export { EMBER_THEME, GROVE_THEME, IRIS_THEME, OCEAN_THEME, T3_CHAT_THEME, THEME_COLOR_ROLES }; export type { ThemeAppearance, ThemeColorRole, ThemeColors, ThemeDefinition, ThemeVariants }; @@ -315,7 +315,7 @@ export function publishedThemeDefinitions( // Every reconnect snapshot delivers a fresh but usually identical array; keeping the // previous one skips regenerating palettes and repainting the document for it. const publishedThemesAtom = Atom.make((get): ReadonlyArray => { - const next = get(primaryServerEnvironmentThemesAtom); + const next = get(themeServerEnvironmentThemesAtom); const previous = Option.getOrNull(get.self>()); return previous !== null && Equal.equals(previous, next) ? previous : next; }).pipe(Atom.withLabel("web-published-environment-themes")); diff --git a/docs/user/appearance.md b/docs/user/appearance.md index 49d8e02d79a82..b27173dd60ff9 100644 --- a/docs/user/appearance.md +++ b/docs/user/appearance.md @@ -57,7 +57,9 @@ change. Export your theme as JSON to share it. ## Environment themes Environment themes and defaults come from the server serving your web app or the desktop app's -main local environment. app.t3.codes and additional connections do not use them. +main local environment. With the desktop app's local environment off, they come from the +[background service](./background-service.md) on the same machine while the app is connected to +it. app.t3.codes and other connections do not use them. Select a published theme in **Settings → Appearance** to follow its palette as the server updates it. **Duplicate** makes an independent copy you can edit. A saved custom theme with the same ID diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 182b238d15590..afb87b14bc91f 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1053,6 +1053,8 @@ export interface DesktopBridge { getLocalEnvironmentBootstraps: () => readonly DesktopEnvironmentBootstrap[]; getLocalEnvironmentEnabled: () => boolean; setLocalEnvironmentEnabled: (enabled: boolean) => Promise; + /** The environment ID recorded in this app's T3 home, such as its own machine's background service. */ + getHostEnvironmentId?: () => Promise; getLocalEnvironmentBearerToken: () => Promise; getClientSettings: () => Promise; setClientSettings: (settings: ClientSettings) => Promise; diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index e385316949e47..c61ee315e3536 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -178,6 +178,13 @@ const makeStrictNdJsonRpcParser = () => { const codec = RpcSerialization.jsonRpc().makeUnsafe(); const decoder = new TextDecoder(); let buffer = ""; + const decodeLine = (line: string): ReadonlyArray => + codec.decode(line).map((message) => ({ + message: normalizeAcpJsonRpcError( + message as RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded, + ), + lineLength: line.length, + })); return { // Every message in a batch line is charged the whole line. decode: (bytes: Uint8Array | string): ReadonlyArray => { @@ -190,21 +197,14 @@ const makeStrictNdJsonRpcParser = () => { } const lines = (buffer + chunk.slice(0, lastNewline)).split("\n"); buffer = chunk.slice(lastNewline + 1); - return lines.flatMap((line) => - codec.decode(line).map((message) => ({ - message: normalizeAcpJsonRpcError( - message as RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded, - ), - lineLength: line.length, - })), - ); + return lines.flatMap(decodeLine); }, // A truncated final line is left to the exit status, which explains a crash better. - end: (): ReadonlyArray => { + end: (): ReadonlyArray => { const line = buffer + decoder.decode(); buffer = ""; try { - return line.trim() === "" ? [] : codec.decode(line); + return line.trim() === "" ? [] : decodeLine(line); } catch { return []; } @@ -807,17 +807,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi ), ), Effect.andThen( - Effect.suspend(() => - Effect.forEach( - ( - parser.end() as ReadonlyArray< - RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded - > - ).map(normalizeAcpJsonRpcError), - routeDecodedMessage, - { discard: true }, - ), - ), + Effect.suspend(() => Effect.forEach(parser.end(), routeDecodedMessage, { discard: true })), ), // Anything that ends the reader, including a defect in a callback it runs, // terminates the connection so pending requests fail instead of hanging. From e3441535afbbdf7af5349f619b7cc96dc2da44ef Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sat, 7 Nov 2026 05:41:03 -0600 Subject: [PATCH 03/16] fix(antigravity): check account access after installation (#32446) --- .../src/provider/providerInstallation.test.ts | 187 +++++++++++++----- .../src/provider/providerInstallation.ts | 80 +++++++- apps/server/src/server.ts | 2 + apps/server/src/ws.test.ts | 2 + apps/server/src/ws.ts | 4 +- 5 files changed, 215 insertions(+), 60 deletions(-) diff --git a/apps/server/src/provider/providerInstallation.test.ts b/apps/server/src/provider/providerInstallation.test.ts index 32d417a1d43d1..8a3d3316ac320 100644 --- a/apps/server/src/provider/providerInstallation.test.ts +++ b/apps/server/src/provider/providerInstallation.test.ts @@ -7,6 +7,8 @@ import { } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -15,8 +17,9 @@ import * as Stream from "effect/Stream"; import * as ServerSettings from "../serverSettings.ts"; import * as CodexInstallation from "./CodexInstallation.ts"; import * as AntigravityInstallation from "./AntigravityInstallation.ts"; +import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; -import { makeProviderInstallation } from "./providerInstallation.ts"; +import * as ProviderInstallation from "./providerInstallation.ts"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "./ProviderRegistry.ts"; @@ -60,69 +63,145 @@ const makeHarness = Effect.fn("providerInstallation.test.makeHarness")(function* } = {}, ) { const calls: string[] = []; + const refreshed = yield* Deferred.make(); + const observed = yield* Deferred.make(); + const listening = yield* Deferred.make(); + const installationState = yield* SubscriptionRef.make(state); let protectedPaths: ReadonlyArray = []; const configured = input.instance ?? instance(); - const router = yield* makeProviderInstallation().pipe( - Effect.provide( - Layer.mergeAll( - ServerSettings.layerTest(input.settings), - Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ - getInstance: (id) => - Effect.succeed(id === configured.instanceId ? configured : undefined), - listInstances: Effect.succeed([configured]), + const dependencies = Layer.mergeAll( + ServerSettings.layerTest(input.settings), + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + getInstance: (id) => Effect.succeed(id === configured.instanceId ? configured : undefined), + listInstances: Effect.succeed([configured]), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + refreshInstance: () => + Effect.sync(() => { + calls.push("refresh"); + return []; + }).pipe(Effect.tap(() => Deferred.succeed(refreshed, undefined))), + }), + Layer.mock(CodexInstallation.CodexInstallation)({ + managedDirectory: "/unused-managed-codex", + start: Effect.sync(() => { + calls.push("codex-start"); + return { ...state, driver: ProviderDriverKind.make("codex") }; + }), + cancel: () => + Effect.sync(() => { + calls.push("codex-cancel"); + return { ...state, driver: ProviderDriverKind.make("codex") }; }), - Layer.mock(ProviderRegistry.ProviderRegistry)({ - refreshInstance: () => - Effect.sync(() => { - calls.push("refresh"); - return []; - }), + state: Effect.succeed({ ...state, driver: ProviderDriverKind.make("codex") }), + changes: Stream.succeed({ ...state, driver: ProviderDriverKind.make("codex") }), + remove: (paths) => + Effect.sync(() => { + protectedPaths = paths ?? []; + calls.push("codex-remove"); }), - Layer.mock(CodexInstallation.CodexInstallation)({ - managedDirectory: "/unused-managed-codex", - start: Effect.sync(() => { - calls.push("codex-start"); - return { ...state, driver: ProviderDriverKind.make("codex") }; - }), - cancel: () => - Effect.sync(() => { - calls.push("codex-cancel"); - return { ...state, driver: ProviderDriverKind.make("codex") }; - }), - state: Effect.succeed({ ...state, driver: ProviderDriverKind.make("codex") }), - changes: Stream.succeed({ ...state, driver: ProviderDriverKind.make("codex") }), - remove: (paths) => - Effect.sync(() => { - protectedPaths = paths ?? []; - calls.push("codex-remove"); - }), + }), + Layer.mock(AntigravityInstallation.AntigravityInstallation)({ + managedDirectory: "/unused-managed-runtime", + start: Effect.sync(() => { + calls.push("start"); + return { ...state, operationId: "install-1", phase: "downloading" as const }; + }).pipe(Effect.tap((started) => SubscriptionRef.set(installationState, started))), + cancel: () => + Effect.sync(() => { + calls.push("cancel"); + return state; }), - Layer.mock(AntigravityInstallation.AntigravityInstallation)({ - managedDirectory: "/unused-managed-runtime", - start: Effect.sync(() => { - calls.push("start"); - return state; - }), - cancel: () => - Effect.sync(() => { - calls.push("cancel"); - return state; - }), - state: Effect.succeed(state), - changes: Stream.succeed(state), - remove: (paths) => - Effect.sync(() => { - protectedPaths = paths ?? []; - calls.push("remove"); - }), + state: Effect.succeed(state), + changes: input.instance?.refreshModels + ? SubscriptionRef.changes(installationState).pipe( + Stream.tap(() => Deferred.succeed(listening, undefined)), + Stream.ensuring(Deferred.succeed(observed, undefined)), + ) + : Stream.succeed(state), + remove: (paths) => + Effect.sync(() => { + protectedPaths = paths ?? []; + calls.push("remove"); }), - ), - ), + }), ); - return { router, calls, protectedPaths: () => protectedPaths }; + const context = yield* Layer.build(ProviderInstallation.layer.pipe(Layer.provide(dependencies))); + const connect = Effect.service(ProviderInstallation.ProviderInstallation).pipe( + Effect.provide(context), + ); + const router = yield* connect; + return { + router, + connect, + listening, + calls, + refreshed, + observed, + installationState, + protectedPaths: () => protectedPaths, + }; }); describe("provider installation routing", () => { + it.effect.each([ + { phase: "succeeded", enabled: true, checkFails: false, expectedChecks: 1 }, + { phase: "succeeded", enabled: true, checkFails: true, expectedChecks: 1 }, + { phase: "succeeded", enabled: false, checkFails: false, expectedChecks: 0 }, + { phase: "cancelled", enabled: true, checkFails: false, expectedChecks: 0 }, + { phase: "failed", enabled: true, checkFails: false, expectedChecks: 0 }, + ] as const)( + "checks account access once after installation is $phase, enabled=$enabled, checkFails=$checkFails", + ({ phase, enabled, checkFails, expectedChecks }) => + Effect.gen(function* () { + let checks = 0; + const harness = yield* makeHarness({ + instance: Object.assign(instance(), { + enabled, + refreshModels: () => + Effect.sync(() => { + checks++; + }).pipe( + Effect.andThen( + checkFails + ? Effect.fail( + new ProviderDriverError({ + driver, + instanceId, + detail: "Sign in required.", + }), + ) + : Effect.void, + ), + ), + }), + }); + yield* Effect.gen(function* () { + const first = yield* harness.connect; + const second = yield* harness.connect; + yield* Effect.all([first.start({ instanceId }), second.start({ instanceId })], { + concurrency: "unbounded", + }); + yield* Deferred.await(harness.listening); + }).pipe(Effect.scoped); + assert.isFalse(yield* Deferred.isDone(harness.observed)); + assert.strictEqual(checks, 0); + yield* SubscriptionRef.set(harness.installationState, { + ...state, + operationId: "install-1", + phase, + installedVersion: phase === "succeeded" ? "1.5.1" : null, + }); + yield* Deferred.await(harness.observed); + if (expectedChecks > 0) yield* Deferred.await(harness.refreshed); + assert.strictEqual(checks, expectedChecks); + assert.deepEqual( + harness.calls, + expectedChecks > 0 ? ["start", "start", "refresh"] : ["start", "start"], + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("keeps external installs manual without hiding shared install status", () => Effect.gen(function* () { const harness = yield* makeHarness({ diff --git a/apps/server/src/provider/providerInstallation.ts b/apps/server/src/provider/providerInstallation.ts index 7b4c0bd478abb..7a628dfa5b318 100644 --- a/apps/server/src/provider/providerInstallation.ts +++ b/apps/server/src/provider/providerInstallation.ts @@ -3,12 +3,18 @@ import { CodexSettings, ProviderDriverKind, type ProviderInstallCancelInput, + type ProviderInstallState, type ProviderInstanceId, ProviderSetupError, type ProviderSetupInput, } from "@t3tools/contracts"; import { resolveCommandPath } from "@t3tools/shared/shell"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -25,12 +31,34 @@ const hasBinaryPath = Schema.is(Schema.Struct({ binaryPath: Schema.String })); const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings); const decodeAntigravitySettings = Schema.decodeUnknownEffect(AntigravitySettings); -export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(function* () { +export class ProviderInstallation extends Context.Service< + ProviderInstallation, + { + readonly start: ( + input: ProviderSetupInput, + ) => Effect.Effect; + readonly cancel: ( + input: ProviderInstallCancelInput, + ) => Effect.Effect; + readonly subscribe: ( + input: ProviderSetupInput, + ) => Stream.Stream; + readonly remove: ( + input: ProviderSetupInput, + ) => Effect.Effect; + } +>()("t3/provider/providerInstallation") {} + +const make = Effect.fn("makeProviderInstallation")(function* () { const antigravityInstallation = yield* AntigravityInstallation.AntigravityInstallation; const codexInstallation = yield* CodexInstallation.CodexInstallation; const instances = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; const providers = yield* ProviderRegistry.ProviderRegistry; const settings = yield* ServerSettings.ServerSettingsService; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const scope = yield* Effect.scope; + const accountCheckOperations = new Map(); const readEntries = Effect.fn("ProviderInstallation.readEntries")(function* ( instanceId: ProviderInstanceId, @@ -106,8 +134,47 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu new ProviderSetupError({ instanceId, operation: error.operation, detail: error.detail }); const start = Effect.fn("ProviderInstallation.start")(function* (input: ProviderSetupInput) { - const { installation } = yield* requireInstance(input.instanceId, "install", true); - return yield* installation.start.pipe(Effect.mapError(failure(input.instanceId))); + const { installation, driver } = yield* requireInstance(input.instanceId, "install", true); + const started = yield* installation.start.pipe(Effect.mapError(failure(input.instanceId))); + if ( + driver === ANTIGRAVITY && + started.operationId !== null && + accountCheckOperations.get(input.instanceId) !== started.operationId + ) { + accountCheckOperations.set(input.instanceId, started.operationId); + // Installation outlives the request and may finish while every client is disconnected. + yield* installation.changes.pipe( + Stream.filter( + (state) => + state.operationId !== started.operationId || + state.phase === "succeeded" || + state.phase === "failed" || + state.phase === "cancelled", + ), + Stream.runHead, + Effect.flatMap( + Effect.fnUntraced(function* (result) { + if ( + Option.isNone(result) || + result.value.operationId !== started.operationId || + result.value.phase !== "succeeded" + ) + return; + const instance = yield* instances.getInstance(input.instanceId); + if ( + instance?.driverKind !== ANTIGRAVITY || + !instance.enabled || + !instance.refreshModels + ) + return; + yield* instance.refreshModels().pipe(Effect.ignore({ log: true })); + yield* providers.refreshInstance(input.instanceId); + }), + ), + Effect.forkIn(scope), + ); + } + return started; }); const cancel = Effect.fn("ProviderInstallation.cancel")(function* ( @@ -142,7 +209,10 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu const binaryPath = entry.config.binaryPath.trim(); const resolved = yield* resolveCommandPath(binaryPath, { env: yield* mergeProviderInstanceEnvironment(entry.environment), - }); + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); return resolved === null ? [binaryPath] : [binaryPath, resolved]; }), ); @@ -160,3 +230,5 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu return { start, cancel, subscribe, remove }; }); + +export const layer = Layer.effect(ProviderInstallation, make()); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e7baa0a91164e..bb4d6bbcbdbd6 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -108,6 +108,7 @@ import * as ManagedPolicy from "./policy/ManagedPolicy.ts"; import * as ProjectEnrichmentService from "./project/ProjectEnrichmentService.ts"; import * as NativeAppIconResolver from "./assets/NativeAppIconResolver.ts"; import * as AntigravityInstallation from "./provider/AntigravityInstallation.ts"; +import * as ProviderInstallation from "./provider/providerInstallation.ts"; import * as CodexInstallation from "./provider/CodexInstallation.ts"; import * as ProviderInstanceRegistry from "./provider/ProviderInstanceRegistry.ts"; import * as ProviderAdapterRegistry from "./orchestration-v2/ProviderAdapterRegistry.ts"; @@ -666,6 +667,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( ).pipe(Layer.provide(DelegatedTaskQuestionReactor.layer), Layer.provide(ProjectionStoreV2.layer)), ProviderUsageLimitsIngestion.layer, layerProviderInstallationRefresh, + ProviderInstallation.layer, ReplayMarkers.layer, Layer.effectDiscard(ProjectSettingsOverrideSweep.sweep), ).pipe( diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 2be9e61c7c916..1a93c1e2c3465 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -51,6 +51,7 @@ import * as AgentSessionImporter from "./project/AgentSessionImporter.ts"; import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as AntigravityInstallation from "./provider/AntigravityInstallation.ts"; +import * as ProviderInstallation from "./provider/providerInstallation.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as NativeChildWorkQuery from "./orchestration-v2/NativeChildWorkQuery.ts"; @@ -802,6 +803,7 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({}), Layer.mock(ProjectStore.ProjectStoreV2)({}), Layer.mock(ProviderAuthService.ProviderAuthService)({}), + Layer.mock(ProviderInstallation.ProviderInstallation)({}), Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({}), Layer.mock(ProviderLatestVersions.ProviderLatestVersions)({}), Layer.mock(ProviderMaintenanceRunner.ProviderMaintenanceRunner)({}), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9b3f244549bc7..1fa81ddc7026e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -178,7 +178,7 @@ import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRe import * as AcpRegistryRuntimeCoordinator from "@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as ProviderAuthService from "./provider/ProviderAuthService.ts"; -import { makeProviderInstallation } from "./provider/providerInstallation.ts"; +import * as ProviderInstallation from "./provider/providerInstallation.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as NpmRelease from "./cloud/npmRelease.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; @@ -1496,7 +1496,7 @@ export const layerWsRpc = ( yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const providerAuth = yield* ProviderAuthService.ProviderAuthService; - const providerInstallation = yield* makeProviderInstallation(); + const providerInstallation = yield* ProviderInstallation.ProviderInstallation; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const npmRelease = yield* NpmRelease.NpmRelease; const config = yield* ServerConfig.ServerConfig; From f016a16b390f0a0237f71c691596fbd7bce43bc7 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:42:18 +0100 Subject: [PATCH 04/16] fix(acp): preserve native form questions and typed answers (#19659) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks Co-authored-by: Yash Singh Co-authored-by: Honey Paptan --- apps/server/scripts/acp-mock-agent.ts | 28 +- .../Adapters/AcpAdapterV2.test.ts | 351 ++++++++++++++++++ .../fixtures/acp_titled_multiselect/input.ts | 21 ++ .../fixtures/acp_titled_multiselect/output.ts | 46 +++ .../registry_transcript.ndjson | 11 + .../testkit/fixtures/index.ts | 18 + .../Drivers/AntigravityDriver.test.ts | 49 ++- .../src/provider/acp/AntigravityAcpSupport.ts | 2 + .../src/state/pendingUserInput.test.ts | 27 ++ .../src/state/pendingUserInput.ts | 6 +- .../src/state/threadRequests.test.ts | 14 + .../src/state/threadRequests.ts | 21 +- packages/effect-acp/scripts/generate.ts | 41 +- .../src/_generated/schema-v1.gen.ts | 18 +- .../effect-acp/src/_generated/schema.gen.ts | 20 +- packages/effect-acp/src/client.test.ts | 54 +++ packages/effect-acp/src/schema.test.ts | 21 ++ packages/provider-acp/src/server/adapter.ts | 76 ++-- .../src/server/elicitationForm.test.ts | 47 +++ .../src/server/elicitationForm.ts | 223 +++++++++++ 20 files changed, 1005 insertions(+), 89 deletions(-) create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/acp_titled_multiselect/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/acp_titled_multiselect/output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/acp_titled_multiselect/registry_transcript.ndjson create mode 100644 packages/provider-acp/src/server/elicitationForm.test.ts create mode 100644 packages/provider-acp/src/server/elicitationForm.ts diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index feb4aa405ae64..76a950d7c479d 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -11,7 +11,7 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as EffectAcpAgent from "effect-acp/agent"; import * as AcpError from "effect-acp/errors"; -import type * as AcpSchema from "effect-acp/schema"; +import * as AcpSchema from "effect-acp/schema"; import type * as AcpCompat from "effect-acp/compat"; import { beginAcpMockPrompt } from "./acpMockCancellationState.ts"; @@ -36,6 +36,12 @@ const emitBackgroundToolDuringAnswer = process.env.T3_ACP_EMIT_BACKGROUND_TOOL_DURING_ANSWER === "1"; const emitMcpToolApprovalElicitation = process.env.T3_ACP_EMIT_MCP_TOOL_APPROVAL_ELICITATION === "1"; +const emitElicitation = process.env.T3_ACP_EMIT_ELICITATION === "1"; +const elicitationSchema = process.env.T3_ACP_ELICITATION_SCHEMA; +const decodeElicitationSchema = Schema.decodeUnknownSync( + Schema.fromJsonString(AcpSchema.ElicitationSchema), +); +const elicitationResponseLogPath = process.env.T3_ACP_ELICITATION_RESPONSE_LOG_PATH; const grokKillTaskShape = process.env.T3_ACP_GROK_KILL_TASK_SHAPE; const emitGrokBackgroundTaskPoll = process.env.T3_ACP_GROK_BACKGROUND_TASK_COMPLETION === "poll"; const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; @@ -1541,21 +1547,25 @@ const program = Effect.gen(function* () { return yield* finishPrompt(requestedSessionId, "end_turn"); } - if (emitMcpToolApprovalElicitation) { - yield* agent.client.elicit({ + if (emitElicitation || emitMcpToolApprovalElicitation) { + const response = yield* agent.client.elicit({ sessionId: requestedSessionId, message: "Approve this request?", mode: "form", - requestedSchema: { - type: "object", - properties: { - approved: { type: "boolean", title: "Approved" }, - }, - }, + requestedSchema: elicitationSchema + ? decodeElicitationSchema(elicitationSchema) + : { + type: "object", + properties: { + approved: { type: "boolean", title: "Approved" }, + }, + }, ...(emitMcpToolApprovalElicitation ? { _meta: { codex_approval_kind: "mcp_tool_call" } } : {}), }); + if (elicitationResponseLogPath) + NodeFS.appendFileSync(elicitationResponseLogPath, `${encodeJson(response)}\n`, "utf8"); return yield* finishPrompt(requestedSessionId, "end_turn"); } diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 807675034189c..f63a220fb5191 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -27,6 +27,7 @@ import { ThreadId, type OrchestrationV2ProviderThread, type OrchestrationV2TurnItem, + type OrchestrationV2UserInputQuestion, type ProviderOptionDescriptor, type ProviderUserInputAnswers, } from "@t3tools/contracts"; @@ -759,6 +760,100 @@ function assertReinjectedInstructions( assert.include(text, `\n${request}\n`); } +const runFormElicitation = (input: { + readonly name: string; + readonly requestedSchema: unknown; + readonly answer: ( + questions: ReadonlyArray, + ) => ProviderUserInputAnswers | undefined; +}) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-form-elicitation-", + }); + const responseLog = path.join(directory, "responses.jsonl"); + const instanceId = ProviderInstanceId.make(`acp-test-form-${input.name}`); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + makeRuntime: makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + environment: { + T3_ACP_EMIT_ELICITATION: "1", + T3_ACP_ELICITATION_SCHEMA: JSON.stringify(input.requestedSchema), + T3_ACP_ELICITATION_RESPONSE_LOG_PATH: responseLog, + }, + }), + }, + selfInvocation: yield* resolveSelfInvocation(), + }); + const threadId = ThreadId.make(`thread-acp-form-${input.name}`); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "approval-required", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make(`provider-session-acp-form-${input.name}`), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy }); + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + }), + ); + const first = Option.getOrThrow( + yield* runtime.events.pipe( + Stream.filter( + (event) => + event.type === "turn.terminal" || + (event.type === "turn_item.updated" && event.turnItem.type === "user_input_request"), + ), + Stream.runHead, + ), + ); + let questions: ReadonlyArray | undefined; + if (first.type === "turn_item.updated" && first.turnItem.type === "user_input_request") { + questions = first.turnItem.questions; + const answers = input.answer(questions); + yield* runtime.respondToRuntimeRequest({ + requestId: first.turnItem.requestId, + ...(answers === undefined ? {} : { answers }), + }); + yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + } + const responses = (yield* fileSystem.readFileString(responseLog)) + .split("\n") + .filter((line) => line.length > 0) + .map((line) => Option.getOrThrow(decodeUnknownJson(line))); + assert.lengthOf(responses, 1); + return { questions, response: responses[0] }; + }); + +/** Submits like the web and mobile clients: an option's `value`, else its label. */ +const optionValues = (question: OrchestrationV2UserInputQuestion | undefined) => + question?.options.map((option) => option.value ?? option.label); + describe("AcpAdapterV2", () => { it.live("reports per-turn usage unavailable on a continuation turn that never prompts", () => Effect.gen(function* () { @@ -6161,6 +6256,262 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.live("asks form fields with their fixed choices and answers in the declared types", () => + Effect.gen(function* () { + const { questions, response } = yield* runFormElicitation({ + name: "typed", + requestedSchema: { + type: "object", + properties: { + color: { type: "string", title: "Color", enum: ["red", "green"] }, + size: { + type: "string", + oneOf: [ + { const: "m", title: "Medium" }, + { const: "l", title: "Large" }, + ], + }, + tags: { type: "array", items: { type: "string", enum: ["a", "b", "c"] } }, + areas: { + type: "array", + items: { + anyOf: [ + { const: "api", title: "API", description: "Server routes" }, + { const: "ui", title: "Interface" }, + ], + }, + }, + confirm: { type: "boolean" }, + count: { type: "integer" }, + ratio: { type: "number" }, + limit: { type: "integer" }, + note: { type: "string", enum: null }, + }, + required: ["color", "tags", "confirm", "count"], + }, + answer: (asked) => { + const byId = new Map(asked.map((question) => [question.id, question])); + return { + color: optionValues(byId.get("color"))?.[1], + size: optionValues(byId.get("size"))?.[1], + tags: ["c", "a", "unknown", "a"], + areas: optionValues(byId.get("areas"))?.toReversed(), + confirm: optionValues(byId.get("confirm"))?.[1], + count: "3", + ratio: " 0.5 ", + // Not an integer: omitted, since the field is optional. + limit: "3.7", + note: "hi", + extra: "not in the form", + }; + }, + }); + assert.deepStrictEqual( + questions?.map((question) => ({ + id: question.id, + labels: question.options.map((option) => option.label), + values: optionValues(question), + multiSelect: question.multiSelect, + allowCustomAnswer: question.allowCustomAnswer, + })), + [ + { + id: "color", + labels: ["red", "green"], + values: ["red", "green"], + multiSelect: undefined, + allowCustomAnswer: false, + }, + { + id: "size", + labels: ["Medium", "Large"], + values: ["m", "l"], + multiSelect: undefined, + allowCustomAnswer: false, + }, + { + id: "tags", + labels: ["a", "b", "c"], + values: ["a", "b", "c"], + multiSelect: true, + allowCustomAnswer: false, + }, + { + id: "areas", + labels: ["API", "Interface"], + values: ["api", "ui"], + multiSelect: true, + allowCustomAnswer: false, + }, + { + id: "confirm", + labels: ["true", "false"], + values: ["true", "false"], + multiSelect: undefined, + allowCustomAnswer: false, + }, + ...["count", "ratio", "limit", "note"].map((id) => ({ + id, + labels: [], + values: [], + multiSelect: undefined, + allowCustomAnswer: undefined, + })), + ], + ); + assert.deepStrictEqual(response, { + action: "accept", + content: { + color: "green", + size: "l", + tags: ["c", "a"], + areas: ["ui", "api"], + confirm: false, + count: 3, + ratio: 0.5, + note: "hi", + }, + }); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + + it.live("keeps explicit empty selections and checks selection counts", () => + Effect.gen(function* () { + const items = { type: "string", enum: ["a", "b", "c"] }; + const requestedSchema = { + type: "object", + properties: { + none: { type: "array", items, default: [] }, + all: { type: "array", items, maxItems: 3 }, + optionalNone: { type: "array", items }, + some: { type: "array", items, minItems: 1 }, + tooMany: { type: "array", items, maxItems: 1 }, + single: { type: "array", items }, + unknownOnly: { type: "array", items }, + omitted: { type: "array", items }, + }, + required: ["none", "all", "some"], + }; + const accepted = yield* runFormElicitation({ + name: "selection-counts", + requestedSchema, + answer: () => ({ + none: [], + all: ["a", "b", "c"], + optionalNone: [], + some: ["b"], + // Optional and not valid selections, so they are left out. + tooMany: ["a", "b"], + single: "a", + unknownOnly: ["z"], + }), + }); + assert.deepStrictEqual(accepted.response, { + action: "accept", + content: { none: [], all: ["a", "b", "c"], optionalNone: [], some: ["b"] }, + }); + const belowMinimum = yield* runFormElicitation({ + name: "selection-below-minimum", + requestedSchema, + answer: () => ({ none: [], all: ["a"], some: [] }), + }); + assert.deepStrictEqual(belowMinimum.response, { action: "cancel" }); + const requiredOmitted = yield* runFormElicitation({ + name: "selection-required-omitted", + requestedSchema, + answer: () => ({ all: ["a"], some: ["a"] }), + }); + assert.deepStrictEqual(requiredOmitted.response, { action: "cancel" }); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + + it.live("answers a field named __proto__ like any other field", () => + Effect.gen(function* () { + const { questions, response } = yield* runFormElicitation({ + name: "proto-field", + requestedSchema: { + type: "object", + properties: { + ["__proto__"]: { type: "string", enum: ["a", "b"] }, + name: { type: "string" }, + }, + required: ["__proto__", "name"], + }, + answer: () => ({ ["__proto__"]: "b", name: "hi" }), + }); + assert.deepStrictEqual( + questions?.map((question) => question.id), + ["__proto__", "name"], + ); + assert.deepStrictEqual(response, { + action: "accept", + content: { ["__proto__"]: "b", name: "hi" }, + }); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + + it.live("declines a form whose selection counts the client cannot answer, without asking", () => + Effect.gen(function* () { + const items = { type: "string", enum: ["a", "b"] }; + for (const [name, bounds] of [ + ["min-above-max", { minItems: 2, maxItems: 1 }], + ["min-above-options", { minItems: 3 }], + ["empty-only", { maxItems: 0 }], + ] as const) { + const { questions, response } = yield* runFormElicitation({ + name, + requestedSchema: { + type: "object", + properties: { tags: { type: "array", items, ...bounds } }, + }, + answer: () => assert.fail("an impossible form must not reach the user"), + }); + assert.isUndefined(questions); + assert.deepStrictEqual(response, { action: "decline" }); + } + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + + it.live("cancels a form whose required answer does not fit, or that is dismissed", () => + Effect.gen(function* () { + const requestedSchema = { + type: "object", + properties: { count: { type: "integer" }, note: { type: "string" } }, + required: ["count"], + }; + const invalid = yield* runFormElicitation({ + name: "invalid-required", + requestedSchema, + answer: () => ({ count: "two", note: "hi" }), + }); + assert.deepStrictEqual(invalid.response, { action: "cancel" }); + const dismissed = yield* runFormElicitation({ + name: "dismissed", + requestedSchema, + answer: () => undefined, + }); + assert.deepStrictEqual(dismissed.response, { action: "cancel" }); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + + it.live("declines a form with a field it cannot ask for, without asking", () => + Effect.gen(function* () { + const { questions, response } = yield* runFormElicitation({ + name: "unsupported", + requestedSchema: { + type: "object", + properties: { + color: { type: "string", enum: ["red"] }, + files: { type: "array", items: { type: "_upload" } }, + }, + }, + answer: () => assert.fail("an unsupported form must not reach the user"), + }); + assert.isUndefined(questions); + assert.deepStrictEqual(response, { action: "decline" }); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.live("auto-approves tagged MCP elicitations under full-access policy", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/acp_titled_multiselect/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/acp_titled_multiselect/input.ts new file mode 100644 index 0000000000000..e1c3b3b4dc96c --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/acp_titled_multiselect/input.ts @@ -0,0 +1,21 @@ +import type { OrchestratorFixtureInput } from "../shared.ts"; + +export const ACP_TITLED_MULTISELECT_PROMPT = + "Ask which areas this fixture should cover, allowing several. After receiving the answer, respond exactly: titled multi-select fixture complete"; + +/** + * An ACP form elicitation whose multi-select lists titled `anyOf` options. + * The client submits option values, including one the form never offered. + */ +export function acpTitledMultiSelectInput(): OrchestratorFixtureInput { + return { + interactionMode: "plan", + steps: [ + { type: "message", text: ACP_TITLED_MULTISELECT_PROMPT }, + { + type: "answer_next_user_input_request", + answers: { fixture_areas: ["ui", "unknown", "schemas"] }, + }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/acp_titled_multiselect/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/acp_titled_multiselect/output.ts new file mode 100644 index 0000000000000..14e4f2ddb793e --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/acp_titled_multiselect/output.ts @@ -0,0 +1,46 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertAllRuntimeRequestsResolved, + assertAssistantTextIncludes, + assertBaseProjection, + assertRuntimeRequestKinds, + assertSemanticProjectionIntegrity, + assertTurnItemTypes, + assertUserMessagesInclude, + projectionFor, +} from "../shared.ts"; +import { ACP_TITLED_MULTISELECT_PROMPT } from "./input.ts"; + +/** The transcript's expected `elicitation/create` response pins the typed answer. */ +export function assertAcpTitledMultiSelectOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] }); + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertRuntimeRequestKinds(projection, ["user_input"]); + assertAllRuntimeRequestsResolved(projection); + assertTurnItemTypes(projection, ["user_message", "user_input_request", "assistant_message"]); + assertUserMessagesInclude(projection, [ACP_TITLED_MULTISELECT_PROMPT]); + assertAssistantTextIncludes(projection, "titled multi-select fixture complete"); + + const requestItem = projection.turnItems.find((item) => item.type === "user_input_request"); + assert.deepStrictEqual(requestItem?.type === "user_input_request" && requestItem.questions, [ + { + id: "fixture_areas", + header: "Areas", + question: "Which areas should this fixture cover?", + options: [ + { label: "Strict schemas", description: "Typed wire shapes", value: "schemas" }, + { label: "UI flexibility", description: "UI flexibility", value: "ui" }, + { label: "Docs", description: "Docs", value: "docs" }, + ], + allowCustomAnswer: false, + multiSelect: true, + }, + ]); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/acp_titled_multiselect/registry_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/acp_titled_multiselect/registry_transcript.ndjson new file mode 100644 index 0000000000000..55cf50b553cb5 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/acp_titled_multiselect/registry_transcript.ndjson @@ -0,0 +1,11 @@ +{"type":"transcript_start","provider":"acpRegistry","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"acp_titled_multiselect","metadata":{"generatedBy":"protocol-semantic-fixture","nativeSessionId":"registry-replay-session-1"}} +{"type":"expect_outbound","label":"initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":2,"info":"","capabilities":"","clientCapabilities":"","clientInfo":""}}} +{"type":"emit_inbound","label":"initialized","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"audio":false,"embeddedContext":true,"image":false}},"authMethods":[{"id":"replay","name":"Replay"}],"agentInfo":{"name":"generic-acp-replay","version":"1"}}}} +{"type":"expect_outbound","label":"session.new","frame":{"kind":"request","method":"session/new","params":{"cwd":"","mcpServers":""}}} +{"type":"emit_inbound","label":"session.created","frame":{"kind":"response","method":"session/new","result":{"sessionId":"registry-replay-session-1"}}} +{"type":"expect_outbound","label":"session.prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"registry-replay-session-1","prompt":[{"type":"text","text":"\n\n\n\n\nAsk which areas this fixture should cover, allowing several. After receiving the answer, respond exactly: titled multi-select fixture complete\n"}]}}} +{"type":"emit_inbound","label":"elicitation.request","frame":{"kind":"request","method":"elicitation/create","params":{"sessionId":"registry-replay-session-1","mode":"form","message":"Choose the fixture areas.","requestedSchema":{"type":"object","title":"Fixture areas","properties":{"fixture_areas":{"type":"array","title":"Areas","description":"Which areas should this fixture cover?","minItems":1,"items":{"anyOf":[{"const":"schemas","title":"Strict schemas","description":"Typed wire shapes"},{"const":"ui","title":"UI flexibility"},{"const":"docs","title":"Docs"}]}}},"required":["fixture_areas"]}}}} +{"type":"expect_outbound","label":"elicitation.response","frame":{"kind":"response","method":"elicitation/create","result":{"action":"accept","content":{"fixture_areas":["ui","schemas"]}}}} +{"type":"emit_inbound","label":"assistant.final","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"registry-replay-session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"titled multi-select fixture complete"}}}}} +{"type":"emit_inbound","label":"prompt.completed","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"end_turn"}}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts index 811598474bac5..055d8d5ce9591 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts @@ -138,6 +138,8 @@ import { } from "./plan_questions/codex_output.ts"; import { assertOpenCodePlanQuestionsOutput } from "./plan_questions/opencode_output.ts"; import { planQuestionsInput } from "./plan_questions/input.ts"; +import { acpTitledMultiSelectInput } from "./acp_titled_multiselect/input.ts"; +import { assertAcpTitledMultiSelectOutput } from "./acp_titled_multiselect/output.ts"; import { assertProposedPlanOutput } from "./proposed_plan/codex_output.ts"; import { assertProposedPlanCursorOutput } from "./proposed_plan/cursor_output.ts"; import { proposedPlanInput } from "./proposed_plan/input.ts"; @@ -788,6 +790,22 @@ export const ORCHESTRATOR_REPLAY_FIXTURES: ReadonlyArray { it.effect.skipIf(windowsHost)( - "refreshes through the selected executable and personal Google ACP", + "refreshes the selected executable and limits form capabilities to chat", () => Effect.gen(function* () { const h = yield* makeHarness(); @@ -336,6 +339,50 @@ it.layer(layerTest)("AntigravityDriver", (it) => { .map((request) => request.params?.mcpServers), ).toEqual([[], []]); yield* h.assertClosed; + yield* h.instance.textGeneration + .generateThreadTitle({ + cwd: h.profileDirectory, + message: "Name this thread", + modelSelection: { instanceId: h.instance.instanceId, model: "gemini-test-low" }, + }) + .pipe(Effect.ignore); + const workspace = yield* h.fs.makeTempDirectoryScoped({ prefix: "t3-ag-chat-" }); + const threadId = ThreadId.make("thread-antigravity-capabilities"); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "approval-required", + interactionMode: "default", + cwd: workspace, + }); + const modelSelection = { instanceId: h.instance.instanceId, model: "gemini-test-low" }; + const session = yield* h.instance.orchestrationAdapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-antigravity-capabilities"), + modelSelection, + runtimePolicy, + }); + yield* session.ensureThread({ threadId, modelSelection, runtimePolicy }); + + const helper = { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + session: { notices: {} }, + }; + expect( + (yield* h.readRequests) + .filter((request) => request.method === "initialize") + .map((request) => request.params?.clientCapabilities), + ).toEqual([ + // Two model refreshes, then thread title generation. + helper, + helper, + helper, + { + fs: { readTextFile: true, writeTextFile: false }, + terminal: false, + session: { notices: {} }, + elicitation: { form: {} }, + }, + ]); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index 0834571785891..cd357a505c53e 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -43,6 +43,7 @@ export interface AntigravityAcpRuntimeInput extends Omit< readonly onAuthorizationUrl?: (url: string) => Effect.Effect; /** Serve `fs/read_text_file` for runtimes whose native reader is not qualified. */ readonly clientFileReads?: boolean; + readonly clientCapabilities?: AcpSessionRuntime.AcpSessionRuntimeOptions["clientCapabilities"]; /** The installed version, known before `initialize`; it picks the file reader and client name. */ readonly runtimeVersion?: string | null; /** Conversational sessions only; helpers and probes leave the system prompt untouched. */ @@ -136,6 +137,7 @@ export const makeAntigravityAcpRuntime = Effect.fn("makeAntigravityAcpRuntime")( sessionMeta: (initializeResult) => antigravitySessionMeta(initializeResult, input.sessionSystemPromptAppend), clientCapabilities: { + ...input.clientCapabilities, // Advertising writes swaps the agent's diff edits for whole-file client_edit_file. fs: { readTextFile: diff --git a/packages/client-runtime/src/state/pendingUserInput.test.ts b/packages/client-runtime/src/state/pendingUserInput.test.ts index 7f5a2b0b84c3d..5baaaac41c83a 100644 --- a/packages/client-runtime/src/state/pendingUserInput.test.ts +++ b/packages/client-runtime/src/state/pendingUserInput.test.ts @@ -178,3 +178,30 @@ describe("buildPendingUserInputAnswers", () => { }); }); }); + +it.each([ + ["single-select", singleSelectQuestion, ["Orchestration-first"], '"Orchestration-first"'], + [ + "multi-select", + { + ...singleSelectQuestion, + multiSelect: true, + options: [{ label: "Server" }, { label: "Web" }], + }, + ["Server", "Web"], + '["Server","Web"]', + ], +])("sends a %s answer to a question with the id __proto__", (_kind, question, selected, json) => { + const answers = buildPendingUserInputAnswers( + [ + { ...question, id: "__proto__" }, + { ...singleSelectQuestion, id: "name" }, + ], + { + ["__proto__"]: { selectedOptionValues: selected }, + name: { selectedOptionValues: ["Orchestration-first"] }, + }, + ); + + expect(JSON.stringify(answers)).toBe(`{"__proto__":${json},"name":"Orchestration-first"}`); +}); diff --git a/packages/client-runtime/src/state/pendingUserInput.ts b/packages/client-runtime/src/state/pendingUserInput.ts index 1c71cdb29b4df..520fb626b5936 100644 --- a/packages/client-runtime/src/state/pendingUserInput.ts +++ b/packages/client-runtime/src/state/pendingUserInput.ts @@ -162,15 +162,15 @@ export function buildPendingUserInputAnswers( questions: ReadonlyArray, draftAnswers: Record, ): Record | null { - const answers: Record = {}; + const entries: Array<[string, PendingUserInputAnswer]> = []; for (const question of questions) { const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); if (answer === null) { return null; } - answers[question.id] = answer; + entries.push([question.id, answer]); } - return answers; + return Object.fromEntries(entries); } diff --git a/packages/client-runtime/src/state/threadRequests.test.ts b/packages/client-runtime/src/state/threadRequests.test.ts index c2e1024e232ae..c8c43f2334087 100644 --- a/packages/client-runtime/src/state/threadRequests.test.ts +++ b/packages/client-runtime/src/state/threadRequests.test.ts @@ -235,6 +235,20 @@ describe("initial user input drafts", () => { customAnswer: " Proposed commit\n\n", }); }); + + it("seeds a question with the id __proto__ as its own draft", () => { + const seeded = seedUserInputDraftAnswers( + [ + { id: "__proto__", initialAnswer: "Proposed" }, + { id: "name", initialAnswer: "Control" }, + ], + {}, + ); + + expect(JSON.stringify(seeded)).toBe( + '{"__proto__":{"customAnswer":"Proposed"},"name":{"customAnswer":"Control"}}', + ); + }); }); const projection: OrchestrationV2ThreadProjection = { ...v2Projection, diff --git a/packages/client-runtime/src/state/threadRequests.ts b/packages/client-runtime/src/state/threadRequests.ts index 6cbad534cba1d..7c3c44c4caa59 100644 --- a/packages/client-runtime/src/state/threadRequests.ts +++ b/packages/client-runtime/src/state/threadRequests.ts @@ -99,18 +99,15 @@ export function seedUserInputDraftAnswers, drafts: Record, ): Record { - let seeded: Record = drafts; - for (const question of questions) { - if ( - question.initialAnswer === undefined || - question.allowCustomAnswer === false || - seeded[question.id] !== undefined - ) - continue; - if (seeded === drafts) seeded = { ...drafts }; - seeded[question.id] = { customAnswer: question.initialAnswer }; - } - return seeded; + // Entries, not assignment, so an id such as `__proto__` becomes an own draft. + const seeds = questions.flatMap((question) => + question.initialAnswer === undefined || + question.allowCustomAnswer === false || + Object.hasOwn(drafts, question.id) + ? [] + : [[question.id, { customAnswer: question.initialAnswer }] as const], + ); + return seeds.length === 0 ? drafts : { ...drafts, ...Object.fromEntries(seeds) }; } /** Keys a user-input draft by thread so a thread's resolved requests can be pruned together. */ diff --git a/packages/effect-acp/scripts/generate.ts b/packages/effect-acp/scripts/generate.ts index 134b64aa0cf3c..8617d16dd4b15 100644 --- a/packages/effect-acp/scripts/generate.ts +++ b/packages/effect-acp/scripts/generate.ts @@ -166,6 +166,37 @@ function isJsonArray(value: Schema.Json | undefined): value is ReadonlyArray Schema.Json, +): Record { + return Object.fromEntries( + Object.entries(value).map(([key, child]) => { + if (DATA_KEYWORDS.has(key)) return [key, child]; + if ( + key === "properties" && + child !== null && + typeof child === "object" && + !isJsonArray(child) + ) { + return [ + key, + Object.fromEntries( + Object.entries(child).map(([name, schema]) => [name, transform(schema)]), + ), + ]; + } + return [key, isJsonArray(child) ? child.map(transform) : transform(child)]; + }), + ); +} + function normalizeSchemaKeywords(value: Schema.Json): Schema.Json { if (isJsonArray(value)) { return value.map(normalizeSchemaKeywords); @@ -174,11 +205,7 @@ function normalizeSchemaKeywords(value: Schema.Json): Schema.Json { return value; } - const normalizedEntries = Object.entries(value).map(([key, child]) => [ - key, - normalizeSchemaKeywords(child), - ]); - const normalizedObject = Object.fromEntries(normalizedEntries) as Record; + const normalizedObject = mapSubschemas(value, normalizeSchemaKeywords); // ACP's forward-compatible variants exclude known discriminator values. // The Effect generator does not support `not`; express this exact exclusion // as a string pattern while preserving validation of the known variants. @@ -316,9 +343,7 @@ function flattenCompositions( ): Schema.Json { if (isJsonArray(value)) return value.map((child) => flattenCompositions(child, definitions)); if (value === null || typeof value !== "object") return value; - const normalized = Object.fromEntries( - Object.entries(value).map(([key, child]) => [key, flattenCompositions(child, definitions)]), - ); + const normalized = mapSubschemas(value, (child) => flattenCompositions(child, definitions)); const { allOf, anyOf, ...base } = normalized; const merge = (left: Schema.Json, right: Schema.Json): Schema.Json => { if ( diff --git a/packages/effect-acp/src/_generated/schema-v1.gen.ts b/packages/effect-acp/src/_generated/schema-v1.gen.ts index 268118dab204e..20a7c26d61c78 100644 --- a/packages/effect-acp/src/_generated/schema-v1.gen.ts +++ b/packages/effect-acp/src/_generated/schema-v1.gen.ts @@ -946,8 +946,22 @@ export const EnumOption = Schema.Struct({ identifier: "EnumOption", }); -export type TitledMultiSelectItems = never; -export const TitledMultiSelectItems = Schema.Never.annotate({ +export type TitledMultiSelectItems = { + readonly anyOf: ReadonlyArray; + readonly _meta?: { readonly [x: string]: Schema.Json } | null; +}; +export const TitledMultiSelectItems = Schema.Struct({ + anyOf: Schema.Array(EnumOption).annotate({ description: "Titled enum options." }), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Json.annotate({ expected: "JSON value" })).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), +}).annotate({ description: "Items definition for titled multi-select enum properties.", identifier: "TitledMultiSelectItems", }); diff --git a/packages/effect-acp/src/_generated/schema.gen.ts b/packages/effect-acp/src/_generated/schema.gen.ts index ad12b7e197e48..eb489ddf45f36 100644 --- a/packages/effect-acp/src/_generated/schema.gen.ts +++ b/packages/effect-acp/src/_generated/schema.gen.ts @@ -1128,8 +1128,24 @@ export const EnumOption = Schema.Struct({ identifier: "EnumOption", }); -export type TitledMultiSelectItems = never; -export const TitledMultiSelectItems = Schema.Never.annotate({ +export type TitledMultiSelectItems = { + readonly anyOf: ReadonlyArray; + readonly _meta?: { readonly [x: string]: Schema.Json } | null; +}; +export const TitledMultiSelectItems = Schema.Struct({ + anyOf: Schema.Array(EnumOption) + .annotate({ description: "Titled enum options. Must contain at least one option." }) + .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Json.annotate({ expected: "JSON value" })).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility)", + }), + Schema.Null, + ]), + ), +}).annotate({ description: "Items definition for titled multi-select enum properties.", identifier: "TitledMultiSelectItems", }); diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts index 0736cb369c994..c5ab6d4b95364 100644 --- a/packages/effect-acp/src/client.test.ts +++ b/packages/effect-acp/src/client.test.ts @@ -49,6 +49,7 @@ const PermissionRequest = jsonRpcRequest( "session/request_permission", AcpSchema.RequestPermissionRequest, ); +const ElicitationResponse = jsonRpcResponse(AcpSchema.CreateElicitationResponse); const PermissionResponse = jsonRpcResponse(AcpSchema.RequestPermissionResponse); const ErrorResponse = Schema.Struct({ id: Schema.String, error: Schema.Unknown }); const DieResponse = Schema.Struct({ @@ -1295,6 +1296,59 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { }), ); + it.effect("hands titled multi-select elicitation fields to the handler", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const scope = yield* Scope.make(); + const acp = yield* AcpClient.make(stdio).pipe(Effect.provideService(Scope.Scope, scope)); + const received = yield* Deferred.make(); + yield* acp.handleElicitation((request) => + Deferred.succeed(received, request).pipe( + Effect.as({ action: "accept" as const, content: { areas: ["ui"] } }), + ), + ); + const requestedSchema = { + type: "object", + properties: { + areas: { + type: "array", + title: "Areas", + items: { + anyOf: [ + { const: "api", title: "API", description: "Server routes" }, + { const: "ui", title: "Interface" }, + ], + }, + }, + }, + required: ["areas"], + }; + + yield* Queue.offer( + input, + yield* encodeJsonl(jsonRpcRequest("elicitation/create", Schema.Unknown), { + jsonrpc: "2.0", + id: "elicitation", + method: "elicitation/create", + params: { + sessionId: "session-1", + mode: "form", + message: "Pick areas", + requestedSchema, + }, + headers: [], + }), + ); + const response = yield* Queue.take(output).pipe( + Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(ElicitationResponse))), + ); + assert.deepEqual(response.result, { action: "accept", content: { areas: ["ui"] } }); + const request = yield* Deferred.await(received); + assert.deepEqual("requestedSchema" in request && request.requestedSchema, requestedSchema); + yield* Scope.close(scope, Exit.void); + }), + ); + it.effect( "routes a standard prompt response after Grok extension notifications in the same batch", () => diff --git a/packages/effect-acp/src/schema.test.ts b/packages/effect-acp/src/schema.test.ts index a066f2a5d1c94..ed8775271b52b 100644 --- a/packages/effect-acp/src/schema.test.ts +++ b/packages/effect-acp/src/schema.test.ts @@ -13,6 +13,27 @@ describe("pinned ACP wire schemas", () => { expect(accepts({ type: "future_content", payload: { value: 1 } })).toBe(true); }); + it("decodes titled multi-select form fields in v1 and v2", () => { + const titled = (anyOf: ReadonlyArray) => ({ + sessionId: "session-1", + mode: "form", + message: "Pick areas", + requestedSchema: { + type: "object", + properties: { areas: { type: "array", minItems: 1, items: { anyOf } } }, + required: ["areas"], + }, + }); + const form = titled([ + { const: "api", title: "API", description: "Server routes" }, + { const: "ui", title: "Interface" }, + ]); + for (const request of [V1.CreateElicitationRequest, V2.CreateElicitationRequest]) { + expect(Schema.decodeUnknownSync(request)(form)).toEqual(form); + expect(Schema.is(request)(titled([{ const: "api" }]))).toBe(false); + } + }); + it("keeps v1 command inputs compatible with native agents", () => { const command = { name: "plan", description: "Make a plan", input: { hint: "Task" } }; const isAvailableCommand = Schema.is(V1.AvailableCommand); diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 3e47a8069ca78..c4fc2f092644a 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -99,6 +99,7 @@ import { makeAcpClientPolicyGrants, unknownRecord, } from "./clientPolicy.ts"; +import { parseElicitationForm } from "./elicitationForm.ts"; import { makeAcpClientTerminals, resolveEmbeddedTerminalContent, @@ -1321,22 +1322,6 @@ function selectAutoApprovedPermissionOption( ); } -function elicitationContent( - answers: ProviderUserInputAnswers, - allowedKeys: ReadonlySet, -): Record { - const content: Record = {}; - for (const [key, value] of Object.entries(answers)) { - if (!allowedKeys.has(key)) continue; - if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { - content[key] = value; - } else if (Array.isArray(value)) { - content[key] = value.filter((entry): entry is string => typeof entry === "string"); - } - } - return content; -} - interface ActiveTextSegment { readonly nativeItemId: string; readonly startedAt: DateTime.Utc; @@ -6754,36 +6739,30 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if (params.mode !== "form" || !("requestedSchema" in params)) { return { action: "decline" } as const; } - const requestedSchema = unknownRecord(params.requestedSchema); - const properties = unknownRecord(requestedSchema?.properties) ?? {}; const nativeForm = flavor.extractElicitationForm?.(params); + const parsedForm = + nativeForm === undefined + ? parseElicitationForm(params.requestedSchema, params.message) + : undefined; + const form = + nativeForm ?? + (parsedForm === undefined + ? undefined + : { + questions: parsedForm.questions, + respond: ( + answers: ProviderUserInputAnswers, + ): EffectAcpSchema.CreateElicitationResponse => { + const content = parsedForm.content(answers); + return content === undefined + ? { action: "cancel" } + : { action: "accept", content }; + }, + }); + if (form === undefined) return { action: "decline" } as const; const elicitationScopeId = "sessionId" in params ? params.sessionId : `request:${params.requestId}`; - const questions = - nativeForm?.questions ?? - Object.entries(properties).map( - ([id, property], index): OrchestrationV2UserInputQuestion => { - const record = unknownRecord(property); - const enumValues = Array.isArray(record?.enum) - ? record.enum.filter((value): value is string => typeof value === "string") - : []; - const options = - enumValues.length > 0 - ? enumValues.map((value) => ({ label: value, description: value })) - : record?.type === "boolean" - ? [ - { label: "true", description: "Yes" }, - { label: "false", description: "No" }, - ] - : []; - return { - id, - header: nonEmptyText(record?.title, `Question ${index + 1}`), - question: nonEmptyText(record?.description, params.message), - options, - }; - }, - ); + const userInput = yield* requestUserInputWithAdmission( handlerGeneration, Effect.gen(function* () { @@ -6795,7 +6774,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( return { nativeItemId: nativeRequestId, nativeRequestId, - questions, + questions: form.questions, }; }), transportRequestId, @@ -6803,14 +6782,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const response = userInput.answers === null ? ({ action: "cancel" } as const) - : (nativeForm?.respond(userInput.answers) ?? - ({ - action: "accept", - content: elicitationContent( - userInput.answers, - new Set(Object.keys(properties)), - ), - } as const)); + : form.respond(userInput.answers); yield* userInput.acknowledgeNativeResponse; return response; }).pipe(holdingAgentRequest(handlerGeneration)), diff --git a/packages/provider-acp/src/server/elicitationForm.test.ts b/packages/provider-acp/src/server/elicitationForm.test.ts new file mode 100644 index 0000000000000..bbc82f036c8b8 --- /dev/null +++ b/packages/provider-acp/src/server/elicitationForm.test.ts @@ -0,0 +1,47 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { parseElicitationForm } from "./elicitationForm.ts"; + +describe("parseElicitationForm", () => { + for (const type of ["string", "array"] as const) { + it.each([ + { values: [" padded ", "", "plain"], labels: ["padded", '""', "plain"] }, + { values: [" a", "a", "b"], labels: ['" a"', '"a"', "b"] }, + { values: [" a", "a", '"a"'], labels: ['" a"', '"a"', '"\\"a\\""'] }, + ])( + `preserves ${type} enum values with distinct readable labels: $values`, + ({ values, labels }) => { + const form = parseElicitationForm( + { + type: "object", + properties: { + answer: + type === "array" + ? { type, items: { type: "string", enum: values } } + : { type, enum: values }, + }, + required: ["answer"], + }, + "Choose", + ); + assert(form !== undefined); + const question = form.questions[0]; + assert(question !== undefined); + assert.deepStrictEqual( + question.options.map((option) => option.label), + labels, + ); + assert.deepStrictEqual( + question.options.map((option) => option.value), + values, + ); + assert.strictEqual(question.allowCustomAnswer, false); + assert.strictEqual(question.multiSelect, type === "array" ? true : undefined); + for (const value of values) { + const answer = type === "array" ? [value] : value; + assert.deepStrictEqual(form.content({ answer }), { answer }); + } + }, + ); + } +}); diff --git a/packages/provider-acp/src/server/elicitationForm.ts b/packages/provider-acp/src/server/elicitationForm.ts new file mode 100644 index 0000000000000..df7c29cab2950 --- /dev/null +++ b/packages/provider-acp/src/server/elicitationForm.ts @@ -0,0 +1,223 @@ +import type { + OrchestrationV2UserInputQuestion, + ProviderUserInputAnswers, +} from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/compat"; + +import { unknownRecord } from "./clientPolicy.ts"; + +type Field = + | { readonly kind: "text" } + | { readonly kind: "number"; readonly integer: boolean } + | { readonly kind: "boolean" } + | { readonly kind: "choice"; readonly values: ReadonlySet } + | { + readonly kind: "multiChoice"; + readonly values: ReadonlySet; + readonly minItems: number; + readonly maxItems: number; + }; + +type Option = OrchestrationV2UserInputQuestion["options"][number]; + +export interface ElicitationForm { + readonly questions: ReadonlyArray; + /** + * Types answers as the schema declares them. Unknown keys and options are + * dropped, as is an answer that does not fit its field. Returns undefined + * when a required field is left without a valid answer. + */ + readonly content: ( + answers: ProviderUserInputAnswers, + ) => Record | undefined; +} + +function text(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +/** All of `entries` as options, or undefined when any entry cannot be one. */ +function choiceOptions( + entries: unknown, + toOption: (entry: unknown) => Option | undefined, +): Option[] | undefined { + if (!Array.isArray(entries) || entries.length === 0) return undefined; + const options = entries.map(toOption); + return options.every((option) => option !== undefined) ? options : undefined; +} + +function enumOptions(entries: unknown): Option[] | undefined { + if ( + !Array.isArray(entries) || + entries.length === 0 || + !entries.every((value): value is string => typeof value === "string") + ) + return undefined; + const counts = new Map(); + for (const value of entries) counts.set(value.trim(), (counts.get(value.trim()) ?? 0) + 1); + const options = entries.map((value) => { + const plain = value.trim(); + const label = plain !== "" && counts.get(plain) === 1 ? plain : JSON.stringify(value); + return { label, description: label, value }; + }); + // A quoted fallback can collide with a literal label; quote every value in that case. + return new Set(options.map((option) => option.label)).size === options.length + ? options + : options.map((option) => { + const label = JSON.stringify(option.value); + return { ...option, label, description: label }; + }); +} + +/** Titled entries (`oneOf` on strings, `anyOf` on array items) carry a title for their `const`. */ +function titledOption(entry: unknown): Option | undefined { + const option = unknownRecord(entry); + const label = text(option?.title) ?? text(option?.const); + return typeof option?.const === "string" && label !== undefined + ? { label, description: text(option.description) ?? label, value: option.const } + : undefined; +} + +function parseField( + property: Readonly>, +): { readonly field: Field; readonly options: Option[] } | undefined { + switch (property.type) { + case "string": { + // ACP treats a null `enum` or `oneOf` like an omitted one. + if ((property.enum ?? property.oneOf ?? undefined) === undefined) { + return { field: { kind: "text" }, options: [] }; + } + const options = Array.isArray(property.oneOf) + ? choiceOptions(property.oneOf, titledOption) + : enumOptions(property.enum); + return options === undefined + ? undefined + : { + field: { kind: "choice", values: new Set(options.map(optionValue)) }, + options, + }; + } + case "array": { + // String items list plain values in `enum`; titled items list `{ const, title }` in `anyOf`. + const items = unknownRecord(property.items); + const options = + items?.type === "string" + ? enumOptions(items.enum) + : items !== undefined && items.type === undefined + ? choiceOptions(items.anyOf, titledOption) + : undefined; + if (options === undefined) return undefined; + // The schema decode already limits counts to non-negative integers. + const values = new Set(options.map(optionValue)); + const minItems = typeof property.minItems === "number" ? property.minItems : 0; + const maxItems = typeof property.maxItems === "number" ? property.maxItems : Infinity; + // Clients require a selection, so empty-only questions cannot be answered. + if (maxItems === 0 || minItems > maxItems || minItems > values.size) return undefined; + return { field: { kind: "multiChoice", values, minItems, maxItems }, options }; + } + case "boolean": + return { + field: { kind: "boolean" }, + options: [ + { label: "true", description: "Yes", value: "true" }, + { label: "false", description: "No", value: "false" }, + ], + }; + case "number": + case "integer": + return { field: { kind: "number", integer: property.type === "integer" }, options: [] }; + default: + return undefined; + } +} + +function optionValue(option: Option): string { + return option.value ?? option.label; +} + +function typedAnswer( + field: Field, + answer: unknown, +): EffectAcpSchema.ElicitationContentValue | undefined { + switch (field.kind) { + case "text": + return typeof answer === "string" ? answer : undefined; + case "boolean": + if (typeof answer === "boolean") return answer; + return answer === "true" ? true : answer === "false" ? false : undefined; + case "number": { + const parsed = + typeof answer === "number" + ? answer + : typeof answer === "string" && answer.trim().length > 0 + ? Number(answer.trim()) + : Number.NaN; + if (!Number.isFinite(parsed)) return undefined; + return field.integer && !Number.isInteger(parsed) ? undefined : parsed; + } + case "choice": + return typeof answer === "string" && field.values.has(answer) ? answer : undefined; + case "multiChoice": { + if (!Array.isArray(answer)) return undefined; + const selected = [ + ...new Set( + answer.filter( + (entry): entry is string => typeof entry === "string" && field.values.has(entry), + ), + ), + ]; + // `[]` is an explicit empty selection, but a selection of only unknown + // options is not one. + if (selected.length === 0 && answer.length > 0) return undefined; + return selected.length >= field.minItems && selected.length <= field.maxItems + ? selected + : undefined; + } + } +} + +// Decline unaskable forms; validate types and selection counts, leaving other constraints to the agent. +export function parseElicitationForm( + requestedSchema: unknown, + message: string, +): ElicitationForm | undefined { + const schema = unknownRecord(requestedSchema); + const required = new Set( + Array.isArray(schema?.required) + ? schema.required.filter((key): key is string => typeof key === "string") + : [], + ); + const fields = new Map(); + const questions: OrchestrationV2UserInputQuestion[] = []; + for (const [id, value] of Object.entries(unknownRecord(schema?.properties) ?? {})) { + const property = unknownRecord(value); + const parsed = property === undefined ? undefined : parseField(property); + // Question ids must survive the contract's trim to round-trip answers. + if (property === undefined || parsed === undefined || text(id) !== id) return undefined; + fields.set(id, parsed.field); + questions.push({ + id, + header: text(property.title) ?? `Question ${questions.length + 1}`, + question: text(property.description) ?? message, + options: parsed.options, + ...(parsed.field.kind === "text" || parsed.field.kind === "number" + ? {} + : { allowCustomAnswer: false }), + ...(parsed.field.kind === "multiChoice" ? { multiSelect: true } : {}), + }); + } + return { + questions, + content: (answers) => { + const entries: Array<[string, EffectAcpSchema.ElicitationContentValue]> = []; + for (const [id, field] of fields) { + const typed = Object.hasOwn(answers, id) ? typedAnswer(field, answers[id]) : undefined; + if (typed !== undefined) entries.push([id, typed]); + else if (required.has(id)) return undefined; + } + // Own properties for every field, `__proto__` included; assigning to + // `{}` would hit its inherited setter instead. + return Object.fromEntries(entries); + }, + }; +} From 9f00ced8da5c6cd65d7a05492185154924c3705a Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:42:51 +0100 Subject: [PATCH 05/16] fix(antigravity): explain missing SOCKS dependency during refresh (#19660) Co-authored-by: Claude Opus 5.5 Co-authored-by: Yash Singh --- apps/server/scripts/acp-mock-agent.ts | 11 +++ .../Drivers/AntigravityDriver.test.ts | 74 ++++++++++++++++++- .../src/provider/Drivers/AntigravityDriver.ts | 21 +++++- 3 files changed, 100 insertions(+), 6 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 76a950d7c479d..342f2b0fa937d 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -64,6 +64,14 @@ const failLoadSessionAfterConfigReplay = process.env.T3_ACP_FAIL_LOAD_SESSION_AFTER_CONFIG_REPLAY === "1"; const emitLoadReplay = process.env.T3_ACP_EMIT_LOAD_REPLAY === "1"; const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1"; +// JSON for the `data` of an "Internal error" response to `session/new`, as +// Antigravity sends when its runtime cannot reach its local harness. +const newSessionErrorData = + process.env.T3_ACP_NEW_SESSION_ERROR_DATA === undefined + ? undefined + : Schema.decodeSync(Schema.fromJsonString(Schema.Unknown))( + process.env.T3_ACP_NEW_SESSION_ERROR_DATA, + ); const failPromptOrdinal = Number(process.env.T3_ACP_FAIL_PROMPT_ORDINAL); const compactionPromptOrdinals = new Set( (process.env.T3_ACP_COMPACTION_PROMPT_ORDINALS ?? "").split(",").map(Number), @@ -594,6 +602,9 @@ const program = Effect.gen(function* () { yield* agent.handleCreateSession(() => Effect.gen(function* () { yield* requireAuthentication(); + if (newSessionErrorData !== undefined) { + return yield* AcpError.AcpRequestError.internalError("Internal error", newSessionErrorData); + } if (antigravityProfile) { yield* publishAntigravityCommands(sessionId); } diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index 6c58e22649782..eee164e01950a 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -77,7 +77,11 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( const makeExecutable = Effect.fn("AntigravityDriverTest.makeExecutable")(function* ( name: string, - loginRequired = false, + options: { + readonly loginRequired?: boolean; + /** `data` of the agent's "Internal error" response to `session/new`. */ + readonly newSessionErrorData?: unknown; + } = {}, ) { const directory = path.join(root, name); const executablePath = path.join(directory, "agy_acp_server.par"); @@ -89,9 +93,14 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( executablePath, [ "#!/bin/sh", - ...(loginRequired + ...(options.loginRequired ? [`printf '%s\\n' ${shellQuote(ANTIGRAVITY_AUTH_STDOUT_PREFIX + authorizationUrl)}`] : []), + ...(options.newSessionErrorData === undefined + ? [] + : [ + `export T3_ACP_NEW_SESSION_ERROR_DATA=${shellQuote(JSON.stringify(options.newSessionErrorData))}`, + ]), `exec ${shellQuote(nodePath)} ${shellQuote(mockAgentPath)} "$@"`, "", ].join("\n"), @@ -110,7 +119,7 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( const first = yield* makeExecutable("runtime 'one"); const second = yield* makeExecutable("runtime two"); - const signedOut = yield* makeExecutable("runtime signed-out", true); + const signedOut = yield* makeExecutable("runtime signed-out", { loginRequired: true }); const controls = { selected: first, failResolution: false, beforeAcquire: Effect.void }; const acquisitions: Array<{ binaryPath: string | undefined; path: string | undefined }> = []; const releases: Array = []; @@ -229,6 +238,7 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( return { instance, refresh, + makeExecutable, fs, path, profileDirectory, @@ -423,6 +433,64 @@ it.layer(layerTest)("AntigravityDriver", (it) => { }).pipe(Effect.scoped), ); + it.effect.skipIf(windowsHost)( + "reports the missing SOCKS dependency when session creation fails and keeps the catalog", + () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.refresh(); + const before = yield* h.instance.snapshot.getSnapshot; + h.controls.selected = yield* h.makeExecutable("runtime socks", { + newSessionErrorData: { details: "python-socks is required to use a SOCKS proxy" }, + }); + const error = yield* h.refresh().pipe(Effect.flip); + expect(error.detail).toBe( + "Could not refresh Antigravity models. The previous model list is unchanged. Antigravity reported: python-socks is required to use a SOCKS proxy", + ); + const after = yield* h.instance.snapshot.getSnapshot; + expect(after.models).toEqual(before.models); + expect(after.auth).toEqual(before.auth); + yield* h.assertClosed; + }).pipe(Effect.scoped), + ); + + it.effect.skipIf(windowsHost)( + "never copies unrecognized agent error details into the refresh error", + () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.refresh(); + const before = yield* h.instance.snapshot.getSnapshot; + const generic = + "Could not refresh Antigravity models. The previous model list is unchanged."; + const secret = "https://accounts.example.test/o/oauth2?code=sk-secret-123"; + h.controls.selected = yield* h.makeExecutable("runtime socks with extra", { + newSessionErrorData: { + details: `Traceback (most recent call last):\n proxy ${secret}\npython-socks is required to use a SOCKS proxy`, + }, + }); + const known = yield* h.refresh().pipe(Effect.flip); + expect(known.detail).toBe( + `${generic} Antigravity reported: python-socks is required to use a SOCKS proxy`, + ); + + for (const [name, data] of [ + ["runtime unknown details", { details: `Token refresh failed for ${secret}` }], + ["runtime object details", { details: { stack: "secret frame", env: { HOME: "/" } } }], + ["runtime no details", ["python-socks is required to use a SOCKS proxy"]], + ] as const) { + h.controls.selected = yield* h.makeExecutable(name, { newSessionErrorData: data }); + const error = yield* h.refresh().pipe(Effect.flip); + expect(error.detail).toBe(generic); + } + const after = yield* h.instance.snapshot.getSnapshot; + expect(after.models).toEqual(before.models); + expect(after.auth).toEqual(before.auth); + expect(JSON.stringify(after)).not.toContain("sk-secret-123"); + yield* h.assertClosed; + }).pipe(Effect.scoped), + ); + it.effect.skipIf(windowsHost)( "removes runtime temp directories left by a previous server on create", () => diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index cad62882e6813..01fba514086a2 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -23,7 +23,7 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { HttpClient } from "effect/http"; -import type { AcpError } from "effect-acp/errors"; +import * as AcpErrors from "effect-acp/errors"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { @@ -76,6 +76,21 @@ import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./Antigra const DRIVER = ProviderDriverKind.make("antigravity"); const decodeSettings = Schema.decodeSync(AntigravitySettings); const isNodeRuntimeUnavailableError = Schema.is(NodeRuntimeUnavailableError); +const isAcpRequestError = Schema.is(AcpErrors.AcpRequestError); +const isAcpErrorDetails = Schema.is(Schema.Struct({ details: Schema.String })); +const REFRESH_FAILED = + "Could not refresh Antigravity models. The previous model list is unchanged."; +const PYTHON_SOCKS_REQUIRED = "python-socks is required to use a SOCKS proxy"; + +// Keep upstream diagnostics in cause; only recognized, fixed text reaches clients. +function refreshFailureDetail(cause: unknown) { + return isAcpRequestError(cause) && + cause.operation === "receive-response" && + isAcpErrorDetails(cause.data) && + cause.data.details.includes(PYTHON_SOCKS_REQUIRED) + ? `${REFRESH_FAILED} Antigravity reported: ${PYTHON_SOCKS_REQUIRED}` + : REFRESH_FAILED; +} export type AntigravityDriverEnv = | AntigravityInstallation.AntigravityInstallation @@ -185,7 +200,7 @@ export const AntigravityDriver: ProviderDriver< input: Omit, ): Effect.fn.Return< AcpSessionRuntime.AcpSessionRuntime["Service"], - AcpError | ProviderSetupError, + AcpErrors.AcpError | ProviderSetupError, Scope.Scope > { if (authConfigIssue !== null) { @@ -513,7 +528,7 @@ export const AntigravityDriver: ProviderDriver< : cause._tag === "ProviderSetupError" && (cause.operation === "configure" || cause.operation === "start") ? cause.detail - : "Could not refresh Antigravity models. The previous model list is unchanged.", + : refreshFailureDetail(cause), cause, }), ), From 4c3a9d58184f20bd76833bd599c3544d2c09e3eb Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sat, 7 Nov 2026 05:53:17 -0600 Subject: [PATCH 06/16] fix(cursor): use the SDK account for usage data (#32448) Remove Cursor CLI credential and macOS Keychain fallbacks for account usage and limits, including their settings and web/mobile prompts. Preserve usage through the Cursor SDK sign-in, CURSOR_API_KEY exchange, and explicit auth tokens for limits. Verified focused Cursor tests and full CI on the final head. The scope and intended loss of usage for CLI-only environments were explicitly approved by the maintainer. Model: GPT 6.1 Sol. Harness: Codex through T3 Code. --- .../src/features/usage/UsageLimitsPooled.tsx | 12 +- .../src/features/usage/UsageLimitsSection.tsx | 9 +- .../src/features/usage/UsageRouteScreen.tsx | 166 +---------- .../src/features/usage/usageLimitsRefresh.ts | 3 +- apps/mobile/src/state/usage.ts | 12 +- ...viderOrchestrationAdapterInfrastructure.ts | 3 - apps/server/src/server.ts | 5 +- apps/server/src/usage/UsageService.test.ts | 140 ++------- apps/server/src/usage/UsageService.ts | 4 +- .../settings/ProviderSettingsPanel.tsx | 17 -- .../settings/SettingsSidebarNav.tsx | 5 +- .../settings/UsageProviderSettings.tsx | 42 --- .../settings/settingsSearch.test.ts | 4 - .../src/components/settings/settingsSearch.ts | 11 - .../useAvailableSettingsSearchItems.ts | 14 +- apps/web/src/components/usage/UsageLimits.tsx | 6 +- .../components/usage/UsageLimitsPooled.tsx | 13 +- apps/web/src/components/usage/UsagePage.tsx | 189 +----------- apps/web/src/state/usage.ts | 12 +- docs/user/usage.md | 17 +- packages/client-runtime/src/state/server.ts | 1 - .../client-runtime/src/state/usage.test.ts | 96 +----- packages/client-runtime/src/state/usage.ts | 44 +-- packages/contracts/src/settings.ts | 4 - packages/contracts/src/usage.ts | 1 - packages/provider-core/src/server/usage.ts | 1 - packages/provider-cursor/package.json | 5 - .../src/server/CursorAccountReader.ts | 16 +- .../src/server/CursorKeychain.ts | 102 ------- .../src/server/CursorUsageAccounts.ts | 90 +----- .../src/server/accountCache.ts | 5 +- .../src/server/accountUsage.test.ts | 102 +++---- .../src/server/accountUsage.ts | 95 +----- .../provider-cursor/src/server/driver.test.ts | 6 - packages/provider-cursor/src/server/driver.ts | 23 +- .../src/server/usageLimits.test.ts | 276 +++++------------- .../provider-cursor/src/server/usageLimits.ts | 53 ---- packages/shared/src/usageFormat.test.ts | 3 +- packages/shared/src/usageFormat.ts | 9 +- pnpm-lock.yaml | 3 - 40 files changed, 185 insertions(+), 1434 deletions(-) delete mode 100644 packages/provider-cursor/src/server/CursorKeychain.ts diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 27f6912bc7c67..a9780d9954ae7 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -408,13 +408,11 @@ export function UsageLimitsSection({ now, failedLabels, selectedEnvironmentIds, - cursorPrompt, wide = false, }: { readonly now: number; readonly failedLabels: readonly string[]; readonly selectedEnvironmentIds: ReadonlySet | null; - readonly cursorPrompt?: ReactNode; readonly wide?: boolean; }) { const presentations = useAtomValue(environmentPresentations.presentationsWithProvidersAtom); @@ -426,17 +424,11 @@ export function UsageLimitsSection({ const notices = collectLimitNotices(selected); const externalLinks = collectExternalUsageLinks(selected); const colors = useProviderColors(); - const cursorPromptAt = - Math.max( - pools.findIndex((pool) => pool.driver === "codex"), - pools.findIndex((pool) => pool.driver === "claudeAgent"), - ) + 1; return ( {pools.length === 0 && notices.length === 0 && failedLabels.length === 0 && - !cursorPrompt && externalLinks.length === 0 ? ( {selected.size === 0 @@ -444,11 +436,10 @@ export function UsageLimitsSection({ : "No provider on the selected environments reports subscription limits."} ) : null} - {pools.map((pool, index) => { + {pools.map((pool) => { const windows = displayLimitWindows(pool); return ( - {index === cursorPromptAt ? cursorPrompt : null} @@ -478,7 +469,6 @@ export function UsageLimitsSection({ ); })} - {cursorPromptAt === pools.length ? cursorPrompt : null} {externalLinks.map((link) => ( {link.label} diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index 7f9c6bf28ff3c..214e64fab628b 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -345,18 +345,16 @@ export function useRefreshLimits( .filter(([environmentId]) => failedIds.has(environmentId)) .map(([, presentation]) => presentation.entry.target.label); - const refresh = (afterPending: boolean) => + const refresh = () => Promise.all( - connectedIds.map((environmentId) => - refreshEnvironmentLimits(registry, environmentId, { afterPending }), - ), + connectedIds.map((environmentId) => refreshEnvironmentLimits(registry, environmentId)), ); // Android RefreshControl needs a true-to-false cycle to stop, even with nothing to probe. const refreshManually = async () => { if (refreshingRef.current) return; refreshingRef.current = true; setRefreshing(true); - await refresh(false).finally(() => { + await refresh().finally(() => { refreshingRef.current = false; setRefreshing(false); }); @@ -366,6 +364,5 @@ export function useRefreshLimits( refreshing, failedLabels, refresh: refreshManually, - refreshAfterEnable: () => refresh(true), }; } diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 032ab7e23fb02..7e794ac089b9c 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -1,10 +1,8 @@ -import { Button } from "../../components/ui/Button"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { ChatGptUsageSummary } from "./ChatGptUsageSummary"; import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; import { EnvironmentId, USAGE_CONTRACT_VERSION, type UsageProviderKind } from "@t3tools/contracts"; import { type RouteProp, useIsFocused, useRoute } from "@react-navigation/native"; -import { cursorKeychainAccessEnvironments } from "@t3tools/client-runtime/state/usage"; import { updatingProvidersLabel, usageEnvironmentProgress, @@ -38,12 +36,9 @@ import { SegmentedControl } from "../../components/ui/SegmentedControl"; import { useReduceMotionEnabled } from "../../lib/appState"; import { AppText as Text } from "../../components/ui/AppText"; import { useWorkspaceSettledContentWidth } from "../layout/workspace-content-width"; -import { ProviderIcon } from "../../components/ProviderIcon"; import { cn } from "../../lib/cn"; import { SettingsScreen } from "../settings/components/SettingsScreen"; import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; -import { serverEnvironment } from "../../state/server"; -import { useAtomCommand } from "../../state/use-atom-command"; import { SettingsSection } from "../settings/components/SettingsSection"; import { UsageDailyChart } from "./UsageDailyChart"; import { toggleUsageEnvironment } from "./usageEnvironmentSelection"; @@ -72,7 +67,6 @@ const WIDE_CHART_HEIGHT = 260; const WIDE_WIDTH = 600; const MAX_CONTENT_WIDTH = 1080; const providerLabel = (provider: UsageProviderKind) => PROVIDER_LABEL[provider]; -const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain."; export function UsageRouteScreen(props: { readonly workspace?: boolean }) { const route = useRoute>(); @@ -114,11 +108,6 @@ function UsageScreen(props: { ); const isFocused = useIsFocused(); const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && view === "limits"); - const cursorAccessEnvironments = cursorKeychainAccessEnvironments(selectedEnvironments); - const refreshAfterCursorEnable = () => { - void refresh(); - void limits.refreshAfterEnable(); - }; const sourceMessages = usageSourceMessages( selectedEnvironments.flatMap((environment) => environment.summary?.sources ?? []), ); @@ -183,16 +172,7 @@ function UsageScreen(props: { isPast24Hours, timeZone: window.timeZone, }; - const providers = ( - 1} - onCursorEnabled={refreshAfterCursorEnable} - compact={wide} - /> - ); + const providers = ; const showEnvironmentFilter = environments.length > 0 || selectedEnvironmentIds !== null; const selectEnvironment = (value: string) => { @@ -291,14 +271,6 @@ function UsageScreen(props: { failedLabels={limits.failedLabels} selectedEnvironmentIds={selectedEnvironmentIds} wide={wide} - cursorPrompt={ - cursorAccessEnvironments.length > 0 ? ( - - ) : null - } /> ) : ( <> @@ -429,105 +401,6 @@ function UsageUpdating({ ); } -function CursorEnableAction({ - environmentId, - label, - onEnabled, - buttonText = "Enable", -}: { - readonly environmentId: EnvironmentId; - readonly label: string; - readonly onEnabled: () => void; - readonly buttonText?: string; -}) { - const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { - label: "enable Cursor account usage", - }); - const [pending, setPending] = useState(false); - const enable = async () => { - setPending(true); - const result = await updateSettings({ - environmentId, - input: { patch: { cursorKeychainUsageEnabled: true } }, - }).finally(() => setPending(false)); - if (result._tag === "Success") onEnabled(); - }; - return ( - - ); - if (!tooltip) return button; - return ( - - - {CURSOR_KEYCHAIN_COPY} - - ); -} - -function CursorEnableRow({ - environmentId, - label, - showEnvironment, - onEnabled, -}: { - readonly environmentId: EnvironmentId; - readonly label: string; - readonly showEnvironment: boolean; - readonly onEnabled: () => void; -}) { - return ( -
- - - - Cursor{showEnvironment ? ` · ${label}` : ""} - - -
- ); -} - -function CursorEnableLimits({ - environments, - onEnabled, -}: { - readonly environments: readonly EnvironmentUsageStatus[]; - readonly onEnabled: () => void; -}) { - return ( -
-

- - Cursor -

-
-

{CURSOR_KEYCHAIN_COPY}

-
- {environments.map((environment) => ( - 1 ? `Enable on ${environment.label}` : "Enable"} - onEnabled={onEnabled} - tooltip={false} - /> - ))} -
-
-
- ); -} - function ProviderMark({ provider, className, diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index f47a65cd08268..bf5de2e330a3f 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -8,11 +8,7 @@ import { type UsageProviderKind, type UsageSummaryInput, } from "@t3tools/contracts"; -import { - needsCursorKeychainAccess, - refreshUsage, - usageSummaryInput, -} from "@t3tools/client-runtime/state/usage"; +import { refreshUsage, usageSummaryInput } from "@t3tools/client-runtime/state/usage"; import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access"; import { AsyncResult, Atom } from "effect/reactivity"; import { useState } from "react"; @@ -31,7 +27,6 @@ export interface EnvironmentUsageStatus { readonly isConnected: boolean; readonly error: string | null; readonly summary: UsageSummary | null; - readonly needsCursorKeychainAccess: boolean; } const usageByWindowAtom = Atom.family((windowKey: string) => @@ -55,7 +50,6 @@ const usageByWindowAtom = Atom.family((windowKey: string) => isConnected, ...access, summary: null, - needsCursorKeychainAccess: false, }); continue; } @@ -77,10 +71,6 @@ const usageByWindowAtom = Atom.family((windowKey: string) => isConnected, error: result._tag === "Failure" ? "This environment could not report usage." : null, summary, - needsCursorKeychainAccess: needsCursorKeychainAccess( - summary, - get(serverEnvironment.providersValueAtom(environmentId)), - ), }); } return statuses; diff --git a/docs/user/usage.md b/docs/user/usage.md index d25bdba81f00a..d7e0dad901bef 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -25,13 +25,9 @@ databases, including T3-managed profiles. Set `OPENCODE_DATA_DIR` or `ANTIGRAVIT server to read a different data directory; comma-separated paths read multiple directories. Cursor reads account usage from Cursor's API for the account you signed in with in T3 or set as -`CURSOR_API_KEY`, or otherwise the CLI login saved on the server. -This includes headless T3 sessions and desktop usage across machines; the same account counts -once across connected environments. Without any of these, T3 shows a -notice instead of incomplete local totals. T3 does not estimate missing tokens from conversation text. -On macOS without a T3 sign-in, choose **Enable Cursor usage** on Usage to allow T3 to read your -existing CLI login from Keychain. You can turn it off in **Settings → Providers → Usage providers**. macOS may ask -you to allow access on the server Mac. +`CURSOR_API_KEY`. This includes headless T3 sessions and desktop usage across machines; the same +account counts once across connected environments. T3 does not estimate missing tokens from +conversation text. Usage includes each configured account's history, including disabled accounts. Custom homes follow the account's home setting or its `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, or `GROK_HOME` environment @@ -161,10 +157,9 @@ in OpenCode with an API key. A Console browser sign-in alone does not report the your Go API key as `OPENCODE_API_KEY` in the OpenCode instance's **Environment variables**, then refresh provider status. Cursor reports its monthly allowance, including separate Auto and API usage, for `CURSOR_AUTH_TOKEN` when it is set, -otherwise for the account you signed in with in T3 or set as `CURSOR_API_KEY`, otherwise for the CLI -login. On macOS, this includes the default Keychain login after you enable Cursor usage. Sign-in, API key, -and Keychain login report limits only with Cursor's default API endpoint. If you configure a custom -Cursor endpoint, use an explicit token or file-based CLI login for limits. +otherwise for the account you signed in with in T3 or set as `CURSOR_API_KEY`. +Sign-in and API keys report limits only with Cursor's default API endpoint. If you configure a custom +Cursor endpoint, use an explicit `CURSOR_AUTH_TOKEN` for limits. Grok reports the remaining subscription allowance and reset time for its current billing period after signing in with `grok login`. Explicit `XAI_API_KEY` connections and custom authentication diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index e4d6ab7d14f37..d3229468ab41b 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1132,7 +1132,6 @@ export function createServerEnvironmentAtoms( Object.keys(aliases) .sort() .map((model) => [model, aliases[model]]), - settings?.cursorKeychainUsageEnabled ?? false, ]); }).pipe(Atom.withLabel(`environment-data:server:usage-scan-settings:${environmentId}`)), ); diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 0650b1f2093c7..380e2de731b5d 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -1,10 +1,7 @@ import { EnvironmentId, - ProviderDriverKind, - ProviderInstanceId, UsageDay, USAGE_CONTRACT_VERSION, - type ServerProvider, type UsageModelFilter, type UsageSummary, type UsageSummaryInput, @@ -14,12 +11,7 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { EnvironmentPresentation } from "../connection/presentation.ts"; -import { - cursorKeychainAccessEnvironments, - needsCursorKeychainAccess, - refreshUsage, - refreshUsageLimits, -} from "./usage.ts"; +import { refreshUsage, refreshUsageLimits } from "./usage.ts"; const input = { sinceDay: UsageDay.make("2026-09-05"), @@ -199,91 +191,17 @@ describe("manual usage refresh", () => { }); describe("limits refresh cooldown", () => { - it("runs a fresh check after an in-flight check when settings change", async () => { - const id = EnvironmentId.make("limits-after-enable"); + it("shares an in-flight check and skips duplicate automatic refreshes", async () => { + const id = EnvironmentId.make("limits-in-flight"); const oldCheck = Promise.withResolvers(); const first = refreshUsageLimits(id, () => oldCheck.promise, true); const newCheck = vi.fn(async () => "new limits"); - const afterEnable = refreshUsageLimits(id, newCheck, false, true); + const manual = refreshUsageLimits(id, newCheck); + expect(await refreshUsageLimits(id, newCheck, true)).toBeUndefined(); expect(newCheck).not.toHaveBeenCalled(); oldCheck.resolve("old limits"); expect(await first).toBe("old limits"); - expect(await afterEnable).toBe("new limits"); - expect(newCheck).toHaveBeenCalledTimes(1); - }); -}); - -describe("needsCursorKeychainAccess", () => { - const cursorPrompt: UsageSummary = { - ...summary, - sources: [ - { - fingerprint: { - hostId: "host", - provider: "cursor", - resolvedHomePath: "/Users/me/.cursor/auth.json", - volumeId: "volume", - }, - status: "ok", - scannedFiles: 0, - skippedFiles: 0, - malformedRecords: 0, - distinctSessions: 0, - message: "Cursor account usage is off on this environment.", - action: "enableCursorKeychain", - }, - ], - }; - const cursor = (status: ServerProvider["status"]): ServerProvider => ({ - instanceId: ProviderInstanceId.make("cursor"), - driver: ProviderDriverKind.make("cursor"), - enabled: status !== "disabled", - installed: status === "ready", - version: null, - status, - auth: { status: "unknown" }, - checkedAt: "2026-09-05T12:00:00.000Z", - models: [], - slashCommands: [], - skills: [], - }); - - it("offers access only when Cursor is ready on that environment", () => { - expect(needsCursorKeychainAccess(cursorPrompt, [cursor("ready")])).toBe(true); - expect(needsCursorKeychainAccess(cursorPrompt, [cursor("error")])).toBe(false); - expect(needsCursorKeychainAccess(cursorPrompt, [cursor("disabled")])).toBe(false); - expect(needsCursorKeychainAccess(cursorPrompt, [])).toBe(false); - expect(needsCursorKeychainAccess(cursorPrompt, null)).toBe(false); - expect(needsCursorKeychainAccess(summary, [cursor("ready")])).toBe(false); - }); - - it("stops offering access once any environment reads the Cursor account", () => { - const off = { summary: cursorPrompt, needsCursorKeychainAccess: true }; - const account: UsageSummary = { - ...summary, - sources: [ - { - fingerprint: { - hostId: "cursor.com", - provider: "cursor", - resolvedHomePath: "cursor-account:abc", - volumeId: "abc", - }, - status: "ok", - scannedFiles: 1, - skippedFiles: 0, - malformedRecords: 0, - distinctSessions: 1, - message: null, - }, - ], - }; - expect(cursorKeychainAccessEnvironments([off, off])).toEqual([off, off]); - expect( - cursorKeychainAccessEnvironments([ - off, - { summary: account, needsCursorKeychainAccess: false }, - ]), - ).toEqual([]); + expect(await manual).toBe("old limits"); + expect(newCheck).not.toHaveBeenCalled(); }); }); diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 2dea725be1f14..f302ffbdfd85c 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -1,10 +1,4 @@ -import type { - EnvironmentId, - ServerProvider, - UsageModelFilter, - UsageSummary, - UsageSummaryInput, -} from "@t3tools/contracts"; +import type { EnvironmentId, UsageModelFilter, UsageSummaryInput } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import type { AtomRegistry } from "effect/reactivity"; @@ -15,35 +9,6 @@ import type { createServerEnvironmentAtoms } from "./server.ts"; const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError); -export function needsCursorKeychainAccess( - summary: UsageSummary | null, - providers: readonly ServerProvider[] | null, -): boolean { - return ( - summary?.sources.some((source) => source.action === "enableCursorKeychain") === true && - providers?.some((provider) => provider.driver === "cursor" && provider.status === "ready") === - true - ); -} - -/** - * Any environment reading a Cursor account already reports that account's - * history from every machine, so the prompt only adds duplicates there. - */ -export function cursorKeychainAccessEnvironments< - E extends { readonly summary: UsageSummary | null; readonly needsCursorKeychainAccess: boolean }, ->(environments: readonly E[]): readonly E[] { - const hasCursorAccount = environments.some((environment) => - environment.summary?.sources.some( - (source) => - source.fingerprint.provider === "cursor" && source.fingerprint.hostId === "cursor.com", - ), - ); - return hasCursorAccount - ? [] - : environments.filter((environment) => environment.needsCursorKeychainAccess); -} - /** The Usage tab asks each environment with that environment's configured model filter. */ export function usageSummaryInput( input: UsageSummaryInput, @@ -59,16 +24,9 @@ export async function refreshUsageLimits( environmentId: EnvironmentId, refresh: () => Promise, automatic = false, - afterPending = false, ): Promise { const pending = limitsRefreshes.get(environmentId); if (pending !== undefined) { - if (afterPending) { - try { - await pending; - } catch {} - return refreshUsageLimits(environmentId, refresh, false, true); - } return automatic ? undefined : ((await pending) as A); } const refreshAfter = limitsRefreshAfter.get(environmentId) ?? 0; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index b98ebe0fd7e16..f6409a87074fd 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1449,9 +1449,6 @@ export const ServerSettings = Schema.Struct({ secretProviders: Schema.Record(SecretProviderId, SecretProviderConfig).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), - cursorKeychainUsageEnabled: Schema.Boolean.pipe( - Schema.withDecodingDefault(Effect.succeed(false)), - ), usagePriceOverrides: Schema.Record(TrimmedNonEmptyString, UsageModelPriceOverride).pipe( Schema.withDecodingDefault(Effect.succeed({})), @@ -1806,7 +1803,6 @@ export const ServerSettingsPatch = Schema.Struct({ secretProviders: Schema.optionalKey( Schema.Record(SecretProviderId, Schema.NullOr(SecretProviderConfigWrite)), ), - cursorKeychainUsageEnabled: Schema.optionalKey(Schema.Boolean), usagePriceOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, Schema.NullOr(UsageModelPriceOverride)), diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 7f5a609eee20a..b473a7264b1a4 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -91,7 +91,6 @@ export const UsageSource = Schema.Struct({ /** Total this count; bucket counts double-count sessions spanning days or models. */ distinctSessions: NonNegativeInt, message: Schema.NullOr(TrimmedNonEmptyString), - action: Schema.optionalKey(Schema.Literal("enableCursorKeychain")), /** Cached response during refresh; repeat with awaitRefresh for fresh data. */ refreshing: Schema.optionalKey(Schema.Literal(true)), }); diff --git a/packages/provider-core/src/server/usage.ts b/packages/provider-core/src/server/usage.ts index 509d890808987..3915ce7b2d9fa 100644 --- a/packages/provider-core/src/server/usage.ts +++ b/packages/provider-core/src/server/usage.ts @@ -89,7 +89,6 @@ export interface ProviderUsageScan { readonly hostId?: string; readonly status?: UsageSource["status"]; readonly message?: string; - readonly action?: UsageSource["action"]; readonly files: | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[] | null; diff --git a/packages/provider-cursor/package.json b/packages/provider-cursor/package.json index b8e38d3b778cd..f073e7c921e35 100644 --- a/packages/provider-cursor/package.json +++ b/packages/provider-cursor/package.json @@ -31,10 +31,6 @@ "types": "./src/server/CursorAccountReader.ts", "import": "./src/server/CursorAccountReader.ts" }, - "./server/CursorKeychain": { - "types": "./src/server/CursorKeychain.ts", - "import": "./src/server/CursorKeychain.ts" - }, "./server/CursorUsageAccounts": { "types": "./src/server/CursorUsageAccounts.ts", "import": "./src/server/CursorUsageAccounts.ts" @@ -50,7 +46,6 @@ }, "dependencies": { "@cursor/sdk": "1.2.0", - "@napi-rs/keyring": "^1.3.0", "@t3tools/contracts": "workspace:*", "@t3tools/provider-core": "workspace:*", "@t3tools/shared": "workspace:*", diff --git a/packages/provider-cursor/src/server/CursorAccountReader.ts b/packages/provider-cursor/src/server/CursorAccountReader.ts index 183819b05c8c8..f6e9b384a40d8 100644 --- a/packages/provider-cursor/src/server/CursorAccountReader.ts +++ b/packages/provider-cursor/src/server/CursorAccountReader.ts @@ -1,13 +1,11 @@ import * as Context from "effect/Context"; import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as HttpClient from "effect/http/HttpClient"; import * as Layer from "effect/Layer"; import type { CursorCredentialSource } from "./accountCache.ts"; import { readCursorAccountUsage, type CursorAccountUsageReadResult } from "./accountUsage.ts"; -import * as CursorKeychain from "./CursorKeychain.ts"; export class CursorAccountReader extends Context.Service< CursorAccountReader, @@ -23,18 +21,12 @@ export class CursorAccountReader extends Context.Service< export const layer = Layer.effect( CursorAccountReader, Effect.gen(function* () { - const keychain = yield* CursorKeychain.CursorKeychain; - const context = yield* Effect.context< - FileSystem.FileSystem | Digest.Digest | HttpClient.HttpClient - >(); + const context = yield* Effect.context(); return CursorAccountReader.of({ read: Effect.fn("CursorAccountReader.read")(function (credentialSource, sinceMs, untilMs) { - return readCursorAccountUsage( - credentialSource, - sinceMs, - untilMs, - keychain.accessToken, - ).pipe(Effect.provideContext(context)); + return readCursorAccountUsage(credentialSource, sinceMs, untilMs).pipe( + Effect.provideContext(context), + ); }), }); }), diff --git a/packages/provider-cursor/src/server/CursorKeychain.ts b/packages/provider-cursor/src/server/CursorKeychain.ts deleted file mode 100644 index 20947971814ac..0000000000000 --- a/packages/provider-cursor/src/server/CursorKeychain.ts +++ /dev/null @@ -1,102 +0,0 @@ -// Remote clients cannot answer macOS prompts; time out reads but retain the in-flight request for reuse. -import * as NodeModule from "node:module"; - -import * as Clock from "effect/Clock"; -import * as Context from "effect/Context"; -import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; - -const CACHE_MS = 5 * 60_000; -const DEFAULT_TIMEOUT = Duration.seconds(30); - -const requireForKeyring = NodeModule.createRequire(import.meta.url); - -export class CursorKeychainTimeoutError extends Schema.TaggedError()( - "CursorKeychainTimeoutError", - {}, -) { - override get message(): string { - return "Timed out waiting for Keychain access."; - } -} - -export const isCursorKeychainTimeoutError = Schema.is(CursorKeychainTimeoutError); - -export class CursorKeychainReadError extends Schema.TaggedError()( - "CursorKeychainReadError", - { cause: Schema.Defect() }, -) { - override get message(): string { - return "Cursor Keychain credentials could not be read."; - } -} - -export class CursorKeychain extends Context.Service< - CursorKeychain, - { - readonly accessToken: Effect.Effect< - string | null, - CursorKeychainTimeoutError | CursorKeychainReadError - >; - } ->()("@t3tools/provider-cursor/server/CursorKeychain") {} - -const readKeychainEntry = Effect.tryPromise({ - try: async () => { - const { AsyncEntry } = requireForKeyring( - "@napi-rs/keyring", - ) as typeof import("@napi-rs/keyring"); - return (await new AsyncEntry("cursor-access-token", "cursor-user").getPassword()) ?? null; - }, - catch: (cause) => new CursorKeychainReadError({ cause }), -}); - -const make = ( - read: Effect.Effect = readKeychainEntry, - timeout: Duration.Input = DEFAULT_TIMEOUT, -): CursorKeychain["Service"] => { - let cached: { readonly token: string; readonly untilMs: number } | null = null; - let pending: Deferred.Deferred | null = null; - - const startRead = Effect.uninterruptible( - Effect.gen(function* () { - if (pending !== null) return pending; - const done = Deferred.makeUnsafe(); - pending = done; - yield* read.pipe( - Effect.tap((token) => - Clock.currentTimeMillis.pipe( - Effect.map((nowMs) => { - cached = token ? { token, untilMs: nowMs + CACHE_MS } : null; - }), - ), - ), - Effect.onExit((exit) => - Effect.suspend(() => { - pending = null; - return Deferred.done(done, exit); - }), - ), - Effect.forkDetach, - ); - return done; - }), - ); - - const accessToken = Effect.gen(function* () { - const nowMs = yield* Clock.currentTimeMillis; - if (cached !== null && cached.untilMs > nowMs) return cached.token; - const done = yield* startRead; - const token = yield* Deferred.await(done).pipe(Effect.timeoutOption(timeout)); - if (Option.isNone(token)) return yield* new CursorKeychainTimeoutError(); - return token.value; - }); - - return CursorKeychain.of({ accessToken }); -}; - -export const layer = Layer.sync(CursorKeychain, () => make()); diff --git a/packages/provider-cursor/src/server/CursorUsageAccounts.ts b/packages/provider-cursor/src/server/CursorUsageAccounts.ts index 0bb9550ba9d71..21ab0e62e8948 100644 --- a/packages/provider-cursor/src/server/CursorUsageAccounts.ts +++ b/packages/provider-cursor/src/server/CursorUsageAccounts.ts @@ -2,7 +2,6 @@ import type { ProviderInstanceId } from "@t3tools/contracts"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import type { ProviderUsageScan } from "@t3tools/provider-core/server/usage"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Digest from "effect/crypto/Digest"; @@ -44,7 +43,6 @@ export class CursorUsageAccounts extends Context.Service< readonly instanceId: ProviderInstanceId; readonly environment: NodeJS.ProcessEnv; }>; - readonly keychainUsageEnabled: boolean; readonly windowStartMs: number; readonly retentionCutoffMs: number; readonly awaitRefresh: boolean; @@ -58,8 +56,6 @@ const make = Effect.gen(function* () { const path = yield* Path.Path; const host = yield* ProviderHost.ProviderHost; const accountReader = yield* CursorAccountReader.CursorAccountReader; - const hostEnvironment = yield* HostProcess.Environment; - const platform = yield* HostProcess.Platform; const digest = yield* Digest.Digest; const cachePath = path.join(host.paths.stateDir, CURSOR_ACCOUNT_CACHE_FILE_NAME); @@ -190,34 +186,12 @@ const make = Effect.gen(function* () { const accountSource = Effect.fn("CursorUsageAccounts.accountSource")(function* ( credential: CursorCredentialSource, - authPath: string, windowStartMs: number, retentionStartMs: number, awaitRefresh: boolean, ) { - // A Keychain login outlives an uninstalled Cursor, so it also needs Cursor's local directory. - const localLogin = - typeof credential === "string" - ? credential - : credential.kind === "keychain" - ? path.dirname(authPath) - : null; - if ( - localLogin !== null && - !(yield* fileSystem.exists(localLogin).pipe(Effect.orElseSucceed(() => true))) - ) { - return null; - } yield* ensureLoaded; - const credentialKey = - typeof credential === "string" - ? credential - : credential.kind === "apiKey" - ? // A fingerprint, so a replaced key never reads the previous account's cache. - `api-key:${Hex.encode( - yield* digest.digest(Digest.sha256, new TextEncoder().encode(credential.apiKey)), - )}` - : "keychain"; + const credentialKey = `api-key:${Hex.encode(yield* digest.digest(Digest.sha256, new TextEncoder().encode(credential.apiKey)))}`; const nowMs = yield* Clock.currentTimeMillis; const recentFailure = failures.get(credentialKey); let refreshing = false; @@ -236,8 +210,7 @@ const make = Effect.gen(function* () { if (failureMessage === null) return null; if (cache === undefined) { return { - dir: authPath, - // Never combine a local fallback with another server's account-wide history. + dir: "cursor-account", ...(refreshing ? { files: [], refreshing: true } : { files: null, message: failureMessage ?? CURSOR_ACCOUNT_READ_ERROR }), @@ -263,30 +236,7 @@ const make = Effect.gen(function* () { }); const scan: CursorUsageAccounts["Service"]["scan"] = Effect.fn("CursorUsageAccounts.scan")( - function* ({ - instances, - keychainUsageEnabled, - windowStartMs, - retentionCutoffMs, - awaitRefresh, - }) { - const home = yield* HostProcess.HomeDirectory; - const userHome = - (platform === "win32" ? hostEnvironment["USERPROFILE"] : hostEnvironment["HOME"]) || home; - const configHome = hostEnvironment["XDG_CONFIG_HOME"]?.trim(); - const cursorHome = - platform === "darwin" - ? path.join(userHome, "Library", "Application Support") - : platform === "win32" - ? hostEnvironment["APPDATA"] || path.join(userHome, "AppData", "Roaming") - : configHome && path.isAbsolute(configHome) - ? configHome - : path.join(userHome, ".config"); - const authPath = - platform === "darwin" - ? path.join(userHome, ".cursor", "auth.json") - : path.join(cursorHome, platform === "win32" ? "Cursor" : "cursor", "auth.json"); - // T3's Cursor sign-in and CURSOR_API_KEY name the account the agent uses, so they win. + function* ({ instances, windowStartMs, retentionCutoffMs, awaitRefresh }) { const apiKeys = yield* Effect.forEach(instances, ({ instanceId, environment }) => environment.CURSOR_API_KEY?.trim() ? Effect.succeed(environment.CURSOR_API_KEY.trim()) @@ -302,7 +252,6 @@ const make = Effect.gen(function* () { (apiKey) => accountSource( { kind: "apiKey", apiKey }, - authPath, windowStartMs, retentionCutoffMs, awaitRefresh, @@ -323,38 +272,7 @@ const make = Effect.gen(function* () { } return [...byAccount.values()]; } - const credentialStore = hostEnvironment["AGENT_CLI_CREDENTIAL_STORE"]; - const loginUnavailable = - Boolean(hostEnvironment["CURSOR_AUTH_TOKEN"]?.trim()) || credentialStore === "memory"; - const useKeychain = platform === "darwin" && credentialStore !== "file"; - if (useKeychain && !loginUnavailable && !keychainUsageEnabled) { - return [ - { - dir: authPath, - volumeId: "", - files: null, - message: "Cursor account usage is off on this environment.", - action: "enableCursorKeychain", - }, - ]; - } - if (loginUnavailable) { - return [ - { - dir: authPath, - files: null, - message: "Cursor account history needs a Cursor CLI login on this server.", - }, - ]; - } - const source = yield* accountSource( - useKeychain ? { kind: "keychain" } : authPath, - authPath, - windowStartMs, - retentionCutoffMs, - awaitRefresh, - ); - return source === null ? [] : [source]; + return []; }, ); diff --git a/packages/provider-cursor/src/server/accountCache.ts b/packages/provider-cursor/src/server/accountCache.ts index b2ff2fbd6b3a2..ffbfcc6400533 100644 --- a/packages/provider-cursor/src/server/accountCache.ts +++ b/packages/provider-cursor/src/server/accountCache.ts @@ -2,10 +2,7 @@ import { cursorRateModel } from "./accountUsage.ts"; import type { UsageRecord } from "@t3tools/provider-core/server/usage"; -export type CursorCredentialSource = - | string - | { readonly kind: "keychain" } - | { readonly kind: "apiKey"; readonly apiKey: string }; +export type CursorCredentialSource = { readonly kind: "apiKey"; readonly apiKey: string }; export const CURSOR_ACCOUNT_TTL_MS = 60 * 1000; diff --git a/packages/provider-cursor/src/server/accountUsage.test.ts b/packages/provider-cursor/src/server/accountUsage.test.ts index a2ccc42cc7502..d907c46f9ac3f 100644 --- a/packages/provider-cursor/src/server/accountUsage.test.ts +++ b/packages/provider-cursor/src/server/accountUsage.test.ts @@ -5,27 +5,14 @@ import { assert, describe, it } from "@effect/vitest"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; import * as FetchHttpClient from "effect/http/FetchHttpClient"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import * as Path from "effect/Path"; import * as TestClock from "effect/testing/TestClock"; -import * as CursorKeychain from "./CursorKeychain.ts"; import { readCursorAccountUsage } from "./accountUsage.ts"; -const writeAuth = (accessToken: string) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "usage-reader-test-" }); - const authPath = path.join(dir, "auth.json"); - yield* fileSystem.writeFileString(authPath, JSON.stringify({ accessToken })); - return { dir, authPath }; - }); - interface DashboardRequest { readonly url: string; readonly init: RequestInit; @@ -40,6 +27,15 @@ const dashboard = (answer: (request: DashboardRequest) => Response | Promise Response | Promise, - keychainToken?: Parameters[3], ) => - readCursorAccountUsage(credentialSource, sinceMs, endDate, keychainToken).pipe( + readCursorAccountUsage(credentialSource, sinceMs, endDate).pipe( Effect.provideService(HttpClient.HttpClient, dashboard(answer)), ); @@ -72,15 +67,18 @@ describe("Cursor account history", () => { () => Effect.gen(function* () { const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; - const { authPath } = yield* writeAuth(accessToken); + const credential = { kind: "apiKey", apiKey: accessToken } as const; const pages: number[] = []; const signals: AbortSignal[] = []; const request = ({ url, init, headers, page }: DashboardRequest) => { - assert.strictEqual(url, "https://cursor.com/api/dashboard/get-filtered-usage-events"); + assert.strictEqual( + url, + "https://api2.cursor.sh/aiserver.v1.DashboardService/GetFilteredUsageEvents", + ); assert.strictEqual(init.redirect, "error"); - assert.strictEqual(headers.get("origin"), "https://cursor.com"); + assert.strictEqual(headers.get("connect-protocol-version"), "1"); assert.strictEqual(headers.get("content-type"), "application/json"); - assert.include(headers.get("cookie") ?? "", "WorkosCursorSessionToken=demo%3A%3A"); + assert.strictEqual(headers.get("authorization"), `Bearer ${accessToken}`); const body = { page }; pages.push(body.page); if (init.signal) signals.push(init.signal); @@ -102,7 +100,7 @@ describe("Cursor account history", () => { })), }); }; - const result = yield* readAccountUsage(authPath, 0, 1781000000000, request); + const result = yield* readAccountUsage(credential, 0, 1781000000000, request); assert.isNull(result.error); assert.deepStrictEqual(pages, [1, 2]); assert.lengthOf(signals, 2); @@ -124,10 +122,10 @@ describe("Cursor account history", () => { it.effect("reads Cursor account history beyond 100 pages", () => Effect.gen(function* () { const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - const { authPath } = yield* writeAuth(accessToken); + const credential = { kind: "apiKey", apiKey: accessToken } as const; const fullPage = Array.from({ length: 1000 }, () => ({ tokenUsage: null })); let requests = 0; - const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () => { + const result = yield* readAccountUsage(credential, 0, 1781000000000, async () => { requests += 1; return Response.json({ totalUsageEventsCount: 100_001, @@ -143,13 +141,13 @@ describe("Cursor account history", () => { it.effect("accepts confirmed empty Cursor usage but rejects error envelopes", () => Effect.gen(function* () { const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - const { authPath } = yield* writeAuth(accessToken); + const credential = { kind: "apiKey", apiKey: accessToken } as const; for (const body of [ {}, { totalUsageEventsCount: 0 }, { totalUsageEventsCount: 0, usageEventsDisplay: [] }, ]) { - const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () => + const result = yield* readAccountUsage(credential, 0, 1781000000000, async () => Response.json(body), ); assert.isNull(result.error); @@ -165,7 +163,7 @@ describe("Cursor account history", () => { "invalid", 0, ]) { - const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () => + const result = yield* readAccountUsage(credential, 0, 1781000000000, async () => Response.json(body), ); assert.isNotNull(result.error); @@ -177,9 +175,9 @@ describe("Cursor account history", () => { it.effect("requires a terminal Cursor page after a full page reaches the reported count", () => Effect.gen(function* () { const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - const { authPath } = yield* writeAuth(accessToken); + const credential = { kind: "apiKey", apiKey: accessToken } as const; let requests = 0; - const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () => { + const result = yield* readAccountUsage(credential, 0, 1781000000000, async () => { requests++; return Response.json( requests === 1 @@ -205,7 +203,7 @@ describe("Cursor account history", () => { () => Effect.gen(function* () { const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - const { authPath } = yield* writeAuth(accessToken); + const credential = { kind: "apiKey", apiKey: accessToken } as const; const event = (index: number) => ({ timestamp: String(1780000000000 + index), model: "gpt-5", @@ -213,7 +211,7 @@ describe("Cursor account history", () => { }); for (const total of [2000, 2001]) { let requests = 0; - const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () => { + const result = yield* readAccountUsage(credential, 0, 1781000000000, async () => { requests++; return Response.json({ totalUsageEventsCount: total, @@ -236,7 +234,7 @@ describe("Cursor account history", () => { assert.strictEqual(new Set(result.records.map((record) => record.dedupeKey)).size, total); } let requests = 0; - const inconsistent = yield* readAccountUsage(authPath, 0, 1781000000000, async () => { + const inconsistent = yield* readAccountUsage(credential, 0, 1781000000000, async () => { requests++; return Response.json({ totalUsageEventsCount: 1001, @@ -256,14 +254,14 @@ describe("Cursor account history", () => { () => Effect.gen(function* () { const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; - const { dir, authPath } = yield* writeAuth(accessToken); - const truncated = yield* readAccountUsage(authPath, 0, 1781000000000, async () => + const credential = { kind: "apiKey", apiKey: accessToken } as const; + const truncated = yield* readAccountUsage(credential, 0, 1781000000000, async () => Response.json({ totalUsageEventsCount: 101, usageEventsDisplay: [] }), ); assert.isNotNull(truncated.error); assert.deepStrictEqual(truncated.records, []); const denied = yield* readAccountUsage( - authPath, + credential, 0, 1781000000000, async () => new Response(accessToken, { status: 401 }), @@ -271,18 +269,6 @@ describe("Cursor account history", () => { assert.isNotNull(denied.error); assert.isFalse(denied.error?.includes(accessToken) ?? true); assert.deepStrictEqual(denied.records, []); - let requested = false; - const missing = yield* readAccountUsage( - (yield* Path.Path).join(dir, "missing.json"), - 0, - 1781000000000, - async () => { - requested = true; - return Response.json({}); - }, - ); - assert.isTrue(missing.missing); - assert.isFalse(requested); }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); }); @@ -336,33 +322,13 @@ describe("readCursorAccountUsage", () => { }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); - it.effect("asks for Keychain approval when the prompt goes unanswered", () => - Effect.gen(function* () { - const result = yield* readAccountUsage( - { kind: "keychain" }, - 0, - 1, - () => { - throw new Error("no network expected"); - }, - Effect.fail(new CursorKeychain.CursorKeychainTimeoutError()), - ); - assert.deepStrictEqual(result, { - accountKey: null, - records: [], - missing: false, - error: "Allow Keychain access on the Mac running T3 Code, then refresh.", - }); - }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), - ); - it.effect("reads the pages behind the first together and keeps them in page order", () => Effect.gen(function* () { const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; let inFlight = 0; let mostInFlight = 0; const result = yield* readAccountUsage( - { kind: "keychain" }, + { kind: "apiKey", apiKey: accessToken }, 0, 1781000000000, async ({ page }) => { @@ -379,7 +345,6 @@ describe("readCursorAccountUsage", () => { })), }); }, - Effect.succeed(accessToken), ); assert.isNull(result.error); assert.strictEqual(mostInFlight, 6); @@ -406,7 +371,7 @@ describe("readCursorAccountUsage", () => { ] as const) { let open = 0; const result = yield* readAccountUsage( - { kind: "keychain" }, + { kind: "apiKey", apiKey: accessToken }, 0, 1781000000000, ({ init, page }) => { @@ -421,7 +386,6 @@ describe("readCursorAccountUsage", () => { }); }); }, - Effect.succeed(accessToken), ); assert.strictEqual(result.error, error); assert.deepStrictEqual(result.records, []); diff --git a/packages/provider-cursor/src/server/accountUsage.ts b/packages/provider-cursor/src/server/accountUsage.ts index c26d691ad16ee..6d28410f7c831 100644 --- a/packages/provider-cursor/src/server/accountUsage.ts +++ b/packages/provider-cursor/src/server/accountUsage.ts @@ -5,17 +5,14 @@ import * as Effect from "effect/Effect"; import * as Base64Url from "effect/encoding/Base64Url"; import * as Hex from "effect/encoding/Hex"; import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; import * as FetchHttpClient from "effect/http/FetchHttpClient"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; -import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import type { CursorCredentialSource } from "./accountCache.ts"; import { DEFAULT_CURSOR_API_ENDPOINT, exchangeCursorApiKey } from "./apiKey.ts"; -import * as CursorKeychain from "./CursorKeychain.ts"; function object(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) @@ -87,84 +84,25 @@ function boundaryOverlap(previous: readonly string[], current: readonly string[] return lengths.at(-1) ?? 0; } -const DASHBOARD_USAGE_URL = "https://cursor.com/api/dashboard/get-filtered-usage-events"; - -/** The session cookie must never follow a redirect off cursor.com. */ +/** Never forward the access token through a redirect. */ const NO_REDIRECT: RequestInit = { redirect: "error" }; export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(function* ( credentialSource: CursorCredentialSource, sinceMs: number, endDate: number, - keychainToken: Effect.Effect< - string | null, - | CursorKeychain.CursorKeychainTimeoutError - | CursorKeychain.CursorKeychainReadError - | CursorAccountUsageInvalidError - > = invalid("No Keychain reader"), -): Effect.fn.Return< - CursorAccountUsageReadResult, - never, - FileSystem.FileSystem | Digest.Digest | HttpClient.HttpClient -> { - const fileSystem = yield* FileSystem.FileSystem; +): Effect.fn.Return { const httpClient = yield* HttpClient.HttpClient; - const credential = yield* Effect.result< - unknown, - | PlatformError.PlatformError - | Schema.SchemaError - | CursorKeychain.CursorKeychainTimeoutError - | CursorKeychain.CursorKeychainReadError - | CursorAccountUsageInvalidError, - never - >( - typeof credentialSource === "string" - ? fileSystem.readFileString(credentialSource).pipe( - Effect.flatMap(decodeJson), - Effect.map((credentials) => object(credentials).accessToken), - ) - : credentialSource.kind === "apiKey" - ? exchangeCursorApiKey(credentialSource.apiKey).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.mapError( - () => new CursorAccountUsageInvalidError({ reason: "Invalid API key" }), - ), - ) - : keychainToken, - ); + const credential = yield* Effect.result(exchangeCursorApiKey(credentialSource.apiKey)); if (Result.isFailure(credential)) { - const cause = credential.failure; - const missing = - typeof credentialSource === "string" && - PlatformError.isPlatformError(cause) && - cause.reason._tag === "NotFound"; return { accountKey: null, records: [], - missing, - error: missing - ? null - : typeof credentialSource === "string" - ? "Cursor credentials could not be read." - : credentialSource.kind === "apiKey" - ? "Cursor account usage could not be read." - : CursorKeychain.isCursorKeychainTimeoutError(cause) - ? "Allow Keychain access on the Mac running T3 Code, then refresh." - : "Cursor Keychain credentials could not be read.", + missing: false, + error: "Cursor account usage could not be read.", }; } const accessToken = credential.success; - if (typeof accessToken !== "string" || !accessToken) { - return { - accountKey: null, - records: [], - missing: true, - error: - typeof credentialSource === "string" - ? null - : "Cursor account history needs a macOS Keychain CLI login on this server.", - }; - } let accountKey: string | null = null; const read = Effect.gen(function* () { const payload = Base64Url.decodeString(accessToken.split(".")[1] ?? ""); @@ -175,26 +113,15 @@ export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(functi ), ).sub; if (typeof subject !== "string" || !subject) return yield* invalid("Invalid authentication"); - const userId = subject.split("|").at(-1); - if (!userId) return yield* invalid("Invalid authentication"); accountKey = yield* accountHash(subject); if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate) return yield* invalid("Invalid date window"); - // The web dashboard rejects API-key tokens; Cursor's RPC API accepts them. - const pageRequest = - typeof credentialSource !== "string" && credentialSource.kind === "apiKey" - ? HttpClientRequest.post( - `${DEFAULT_CURSOR_API_ENDPOINT}/aiserver.v1.DashboardService/GetFilteredUsageEvents`, - ).pipe( - HttpClientRequest.bearerToken(accessToken), - HttpClientRequest.setHeader("connect-protocol-version", "1"), - ) - : HttpClientRequest.post(DASHBOARD_USAGE_URL).pipe( - HttpClientRequest.setHeaders({ - Origin: "https://cursor.com", - Cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${userId}::${accessToken}`)}`, - }), - ); + const pageRequest = HttpClientRequest.post( + `${DEFAULT_CURSOR_API_ENDPOINT}/aiserver.v1.DashboardService/GetFilteredUsageEvents`, + ).pipe( + HttpClientRequest.bearerToken(accessToken), + HttpClientRequest.setHeader("connect-protocol-version", "1"), + ); const records: UsageRecord[] = []; const occurrences = new Map(); const pages: unknown[][] = []; diff --git a/packages/provider-cursor/src/server/driver.test.ts b/packages/provider-cursor/src/server/driver.test.ts index d273e4b278194..164118085d233 100644 --- a/packages/provider-cursor/src/server/driver.test.ts +++ b/packages/provider-cursor/src/server/driver.test.ts @@ -34,7 +34,6 @@ import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import { CursorDriver } from "./driver.ts"; import * as CursorAgentSdk from "./CursorAgentSdk.ts"; import * as CursorSdk from "./CursorSdk.ts"; -import * as CursorKeychain from "./CursorKeychain.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; @@ -52,11 +51,6 @@ const layerTest = TestProviderHost.layer({ runBackgroundWork: false }).pipe( open: () => Effect.die("Maintenance resolution must not open a Cursor session"), }), ), - Layer.provideMerge( - Layer.succeed(CursorKeychain.CursorKeychain, { - accessToken: Effect.die("The driver test must not read the Keychain"), - }), - ), Layer.provideMerge( Layer.succeed( ProviderEventLoggers.ProviderEventLoggers, diff --git a/packages/provider-cursor/src/server/driver.ts b/packages/provider-cursor/src/server/driver.ts index 088c97c685c58..ef50abb0a93b4 100644 --- a/packages/provider-cursor/src/server/driver.ts +++ b/packages/provider-cursor/src/server/driver.ts @@ -37,7 +37,6 @@ import { makeCursorAuth } from "./auth.ts"; import * as CursorCredentialStore from "./credentialStore.ts"; import * as CursorAgentSdk from "./CursorAgentSdk.ts"; import * as CursorSdk from "./CursorSdk.ts"; -import * as CursorKeychain from "./CursorKeychain.ts"; import * as CursorUsageAccounts from "./CursorUsageAccounts.ts"; const decodeCursorSettings = Schema.decodeSync(CursorSettings); @@ -56,8 +55,7 @@ export type CursorDriverEnv = | Path.Path | HttpClient.HttpClient | CursorSdk.CursorSdk - | ProviderHost.ProviderHost - | CursorKeychain.CursorKeychain; + | ProviderHost.ProviderHost; export const CursorDriver: ProviderDriver< CursorSettings, @@ -74,12 +72,11 @@ export const CursorDriver: ProviderDriver< usage: { kind: "scan", provider: "cursor", - scan: ({ instances, settings, windowStartMs, retentionCutoffMs, awaitRefresh }) => + scan: ({ instances, windowStartMs, retentionCutoffMs, awaitRefresh }) => CursorUsageAccounts.CursorUsageAccounts.pipe( Effect.flatMap((accounts) => accounts.scan({ instances, - keychainUsageEnabled: settings.cursorKeychainUsageEnabled, windowStartMs, retentionCutoffMs, awaitRefresh, @@ -99,7 +96,6 @@ export const CursorDriver: ProviderDriver< const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; - const keychain = yield* CursorKeychain.CursorKeychain; const sdkRunner = yield* CursorAgentSdk.CursorAgentSdkRunner; const processEnv = yield* mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ @@ -233,22 +229,15 @@ export const CursorDriver: ProviderDriver< snapshot.auth.status === "authenticated" ), (snapshot) => - host.settings.get.pipe( - Effect.flatMap((settings) => - readCursorUsageLimits( - effectiveConfig, - { ...processEnv, CURSOR_API_KEY: apiKey }, - settings.cursorKeychainUsageEnabled, - ), - ), - Effect.map((usageLimits) => ({ ...snapshot, usageLimits })), - ), + readCursorUsageLimits(effectiveConfig, { + ...processEnv, + CURSOR_API_KEY: apiKey, + }).pipe(Effect.map((usageLimits) => ({ ...snapshot, usageLimits }))), ), ), ), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), - Effect.provideService(CursorKeychain.CursorKeychain, keychain), Effect.map(stampSnapshot), Effect.tap((provider) => Effect.sync(() => { diff --git a/packages/provider-cursor/src/server/usageLimits.test.ts b/packages/provider-cursor/src/server/usageLimits.test.ts index 617616d14bc25..0b30313c46058 100644 --- a/packages/provider-cursor/src/server/usageLimits.test.ts +++ b/packages/provider-cursor/src/server/usageLimits.test.ts @@ -1,25 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; -import { NodeServices } from "@effect/platform-node"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import * as CursorKeychain from "./CursorKeychain.ts"; import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./usageLimits.ts"; -const withNodeServices = ( - effect: Effect.Effect, - accessToken: CursorKeychain.CursorKeychain["Service"]["accessToken"] = Effect.die( - "must not read Keychain", - ), -) => - effect.pipe( - Effect.provideService(CursorKeychain.CursorKeychain, { accessToken }), - Effect.provide(NodeServices.layer), - ); - describe("Cursor usage limits", () => { const checkedAt = "2026-09-16T00:00:00.000Z"; @@ -70,227 +54,105 @@ describe("Cursor usage limits", () => { ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 100 }]); }); - it.effect("reads the instance's credentials and endpoint even when usage enabled is false", () => - withNodeServices( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fs.makeTempDirectoryScoped(); - yield* fs.makeDirectory(path.join(directory, "cursor")); - yield* fs.writeFileString( - path.join(directory, "cursor", "auth.json"), - '{"accessToken":"instance-token"}', - ); - const client = HttpClient.make((request) => { - expect(request.url).toBe( - "https://cursor.example/aiserver.v1.DashboardService/GetCurrentPeriodUsage", - ); - expect(request.method).toBe("POST"); - expect(request.headers.authorization).toBe("Bearer instance-token"); - expect(request.headers["connect-protocol-version"]).toBe("1"); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ enabled: false, planUsage: { totalPercentUsed: 42 } }), - ), - ); - }); - yield* fs.makeDirectory(path.join(directory, ".cursor")); - yield* fs.writeFileString( - path.join(directory, ".cursor", "auth.json"), - '{"accessToken":"instance-token"}', - ); - for (const platform of ["linux", "darwin"] as const) { - const limits = yield* readCursorUsageLimits( - { apiEndpoint: "https://cursor.example/" }, - { XDG_CONFIG_HOME: directory, HOME: directory, AGENT_CLI_CREDENTIAL_STORE: "file" }, - ).pipe( - Effect.provideService(HostProcess.Platform, platform), - Effect.provideService(HttpClient.HttpClient, client), - ); - expect(limits.windows[0]?.usedPercent).toBe(42); - } - }).pipe(Effect.scoped), - ), - ); - - it.effect( - "never reads stale files for keychain or memory logins, but accepts an explicit auth token", - () => - Effect.gen(function* () { - for (const platform of ["linux", "darwin"] as const) { - for (const token of [undefined, "explicit-token"]) { - const limits = yield* withNodeServices( - readCursorUsageLimits( - { apiEndpoint: "" }, - { - AGENT_CLI_CREDENTIAL_STORE: platform === "linux" ? "memory" : "default", - ...(token ? { CURSOR_AUTH_TOKEN: token } : {}), - }, - false, - ).pipe( - Effect.provideService(HostProcess.Platform, platform), - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: () => Effect.die("must not read an unrelated credential file"), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - expect(token).toBe("explicit-token"); - expect(request.headers.authorization).toBe("Bearer explicit-token"); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ planUsage: { totalPercentUsed: 10 } }), - ), - ); - }), - ), - ), - ); - if (token) expect(limits.windows[0]?.usedPercent).toBe(10); - else expect(limits.unavailable?.reason).toBe("unsupported"); - } - } - }), - ); - - it.effect("reports failed requests without exposing credentials or response bodies", () => + it.effect("requires explicit credentials and accepts an auth token", () => Effect.gen(function* () { - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe( + for (const token of [undefined, "explicit-token"]) { + const limits = yield* readCursorUsageLimits( + { apiEndpoint: "" }, + token ? { CURSOR_AUTH_TOKEN: token } : {}, + ).pipe( Effect.provideService( HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed( + HttpClient.make((request) => { + expect(token).toBe("explicit-token"); + expect(request.headers.authorization).toBe("Bearer explicit-token"); + return Effect.succeed( HttpClientResponse.fromWeb( request, - new Response("private response", { status: 401 }), + Response.json({ planUsage: { totalPercentUsed: 10 } }), ), - ), - ), - ), - ), - ); - expect(limits.unavailable).toEqual({ - reason: "probeFailed", - message: "Cursor could not read usage limits.", - }); - }), - ); - - it.effect("reads an API key's own account instead of a stored login", () => - Effect.gen(function* () { - for (const platform of ["linux", "darwin"] as const) { - const requests: Array = []; - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_API_KEY: "api-key" }, true).pipe( - Effect.provideService(HostProcess.Platform, platform), - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: () => Effect.die("must not read a stored login"), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - requests.push([request.url, request.headers.authorization]); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json( - request.url.endsWith("/auth/exchange_user_api_key") - ? { accessToken: "api-key-access-token" } - : { planUsage: { totalPercentUsed: 37 } }, - ), - ), - ); - }), - ), + ); + }), ), ); - expect(requests).toEqual([ - ["https://api2.cursor.sh/auth/exchange_user_api_key", "Bearer api-key"], - [ - "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", - "Bearer api-key-access-token", - ], - ]); - expect(limits.windows[0]?.usedPercent).toBe(37); + if (token) expect(limits.windows[0]?.usedPercent).toBe(10); + else expect(limits.unavailable?.reason).toBe("unsupported"); } }), ); - it.effect("reads the default macOS Cursor login from Keychain for limits", () => + it.effect("reports failed requests without exposing credentials or response bodies", () => Effect.gen(function* () { - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint: "" }, {}, true).pipe( - Effect.provideService(HostProcess.Platform, "darwin"), - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: () => Effect.die("must not read a stale credential file"), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - expect(request.headers.authorization).toBe("Bearer keychain-token"); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ planUsage: { totalPercentUsed: 42 } }), - ), - ); - }), + const limits = yield* readCursorUsageLimits( + { apiEndpoint: "" }, + { CURSOR_AUTH_TOKEN: "private-token" }, + ).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("private response", { status: 401 }), + ), + ), ), ), - Effect.succeed("keychain-token"), ); - expect(limits.windows[0]?.usedPercent).toBe(42); + expect(limits.unavailable).toEqual({ + reason: "probeFailed", + message: "Cursor could not read usage limits.", + }); }), ); - it.effect("reports a Keychain initialization failure without failing the provider refresh", () => + it.effect("exchanges an API key to read its account limits", () => Effect.gen(function* () { - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint: "" }, {}, true).pipe( - Effect.provideService(HostProcess.Platform, "darwin"), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("must not request limits without a login")), - ), - ), - Effect.fail( - new CursorKeychain.CursorKeychainReadError({ - cause: new Error("Keychain initialization failed"), + const requests: Array = []; + const limits = yield* readCursorUsageLimits( + { apiEndpoint: "" }, + { CURSOR_API_KEY: "api-key" }, + ).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests.push([request.url, request.headers.authorization]); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json( + request.url.endsWith("/auth/exchange_user_api_key") + ? { accessToken: "api-key-access-token" } + : { planUsage: { totalPercentUsed: 37 } }, + ), + ), + ); }), ), ); - expect(limits.unavailable?.reason).toBe("probeFailed"); + expect(requests).toEqual([ + ["https://api2.cursor.sh/auth/exchange_user_api_key", "Bearer api-key"], + [ + "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", + "Bearer api-key-access-token", + ], + ]); + expect(limits.windows[0]?.usedPercent).toBe(37); }), ); - it.effect("does not send a Keychain login or API key to a custom endpoint", () => + it.effect("does not send an API key to a custom endpoint", () => Effect.gen(function* () { for (const [apiEndpoint, environment] of [ - ["http://localhost:3000", {}], - ["", { CURSOR_API_ENDPOINT: "http://localhost:3000" }], - ["https://cursor-proxy.example", {}], - ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example" }], + ["http://localhost:3000", { CURSOR_API_KEY: "api-key" }], + ["", { CURSOR_API_ENDPOINT: "http://localhost:3000", CURSOR_API_KEY: "api-key" }], + ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example", CURSOR_API_KEY: "api-key" }], ["https://cursor-proxy.example", { CURSOR_API_KEY: "api-key" }], ] as const) { - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint }, environment, true).pipe( - Effect.provideService(HostProcess.Platform, "darwin"), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("must not send a Keychain credential to a proxy")), - ), + const limits = yield* readCursorUsageLimits({ apiEndpoint }, environment).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("must not send an API key to a proxy")), ), ); expect(limits.unavailable?.reason).toBe("unsupported"); diff --git a/packages/provider-cursor/src/server/usageLimits.ts b/packages/provider-cursor/src/server/usageLimits.ts index 87fcecef4e97f..a0746c3e013bf 100644 --- a/packages/provider-cursor/src/server/usageLimits.ts +++ b/packages/provider-cursor/src/server/usageLimits.ts @@ -1,12 +1,9 @@ import type { ServerProviderUsageWindow } from "@t3tools/contracts"; import type { CursorSettings } from "../settings.ts"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import { CURSOR_USAGE_WINDOWS } from "@t3tools/shared/usageLimits"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; -import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; @@ -17,10 +14,7 @@ import { makeUsageLimits, } from "@t3tools/provider-core/server/usageLimits"; import { DEFAULT_CURSOR_API_ENDPOINT, exchangeCursorApiKey } from "./apiKey.ts"; -import * as CursorKeychain from "./CursorKeychain.ts"; -const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) }); -const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials)); const CursorUsageResponse = Schema.Struct({ billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])), planUsage: Schema.optional( @@ -64,13 +58,9 @@ export function cursorUsageResponseToLimits( export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function* ( settings: Pick, environment: NodeJS.ProcessEnv = process.env, - allowKeychain = false, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); return yield* Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const platform = yield* HostProcess.Platform; const endpoint = ( settings.apiEndpoint?.trim() || environment.CURSOR_API_ENDPOINT?.trim() || @@ -78,8 +68,6 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function ).replace(/\/$/, ""); let token = environment.CURSOR_AUTH_TOKEN?.trim(); const apiKey = environment.CURSOR_API_KEY?.trim(); - const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE; - // An API key can name a different account from the stored login, so read the key's own. if (!token && apiKey) { if (endpoint !== DEFAULT_CURSOR_API_ENDPOINT) { return makeUnavailableUsageLimits({ @@ -89,47 +77,6 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function }); } token = yield* exchangeCursorApiKey(apiKey); - } else if (!token && credentialStore === "memory") { - return makeUnavailableUsageLimits({ - checkedAt, - reason: "unsupported", - message: "Cursor usage requires a CLI login or CURSOR_AUTH_TOKEN.", - }); - } else if (!token && platform === "darwin" && credentialStore !== "file") { - if (!allowKeychain) { - return makeUnavailableUsageLimits({ - checkedAt, - reason: "unsupported", - message: "Enable Cursor account usage in T3 Code to read its Keychain login.", - }); - } - if (endpoint !== DEFAULT_CURSOR_API_ENDPOINT) { - return makeUnavailableUsageLimits({ - checkedAt, - reason: "unsupported", - message: "Cursor account usage requires the default Cursor endpoint when using Keychain.", - }); - } - const keychain = yield* CursorKeychain.CursorKeychain; - token = (yield* keychain.accessToken)?.trim(); - } else if (!token) { - const home = - (platform === "win32" ? environment.USERPROFILE : environment.HOME) || - (yield* HostProcess.HomeDirectory); - const directory = - platform === "win32" - ? path.join(environment.APPDATA || path.join(home, "AppData", "Roaming"), "Cursor") - : platform === "darwin" - ? path.join(home, ".cursor") - : path.join(environment.XDG_CONFIG_HOME || path.join(home, ".config"), "cursor"); - const credentials = yield* fs.readFileString(path.join(directory, "auth.json")).pipe( - Effect.catchTags({ - PlatformError: (error) => - error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), - }), - Effect.flatMap(decodeCredentials), - ); - token = credentials.accessToken?.trim(); } if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); const client = yield* HttpClient.HttpClient; diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index 6682a194ab278..c847f323a2065 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -116,12 +116,11 @@ describe("usage source messages", () => { ]); }); - it("omits empty missing sources, healthy sources, and diagnostics handled by an action", () => { + it("omits empty missing sources and healthy sources", () => { expect( usageSourceMessages([ malformedSource(0, { status: "missing" }), malformedSource(1, { status: "ok" }), - malformedSource(1, { action: "enableCursorKeychain" }), ]), ).toEqual([]); expect(usageSourceMessages([])).toEqual([]); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index 51398982e04c8..1c6ba7b8fd75a 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -26,11 +26,10 @@ export function usageSourceMessages(sources: readonly UsageSource[]): readonly s const malformedBySource = new Map(); for (const source of sources) { if ( - source.action || - (source.status !== "partial" && - source.status !== "failed" && - source.fingerprint.provider !== "cursor" && - !(source.status === "missing" && source.malformedRecords > 0)) + source.status !== "partial" && + source.status !== "failed" && + source.fingerprint.provider !== "cursor" && + !(source.status === "missing" && source.malformedRecords > 0) ) { continue; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f56b9fb3f0a7b..697191c410823 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1709,9 +1709,6 @@ importers: '@cursor/sdk': specifier: 1.2.0 version: 1.2.0(zod@4.6.5) - '@napi-rs/keyring': - specifier: ^1.3.0 - version: 1.3.0 '@t3tools/contracts': specifier: workspace:* version: link:../contracts From 917ce994e4c64864bad3eed39044607b86896320 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 7 Nov 2026 04:38:39 -0800 Subject: [PATCH 07/16] fix(web): agent sign-in defaults to Auto access (#32186) --- .../auth/ConnectAgentSurface.logic.test.ts | 13 +++++++++++++ .../components/auth/ConnectAgentSurface.logic.ts | 16 ++++++++++++++++ .../src/components/auth/ConnectAgentSurface.tsx | 7 ++++++- docs/user/outside-agents.md | 5 +++-- 4 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/auth/ConnectAgentSurface.logic.test.ts create mode 100644 apps/web/src/components/auth/ConnectAgentSurface.logic.ts diff --git a/apps/web/src/components/auth/ConnectAgentSurface.logic.test.ts b/apps/web/src/components/auth/ConnectAgentSurface.logic.test.ts new file mode 100644 index 0000000000000..b5d7d8ebc9900 --- /dev/null +++ b/apps/web/src/components/auth/ConnectAgentSurface.logic.test.ts @@ -0,0 +1,13 @@ +import { expect, it } from "vite-plus/test"; +import { RuntimeMode } from "@t3tools/contracts"; + +import { resolveMcpAccess } from "./ConnectAgentSurface.logic"; + +it.each(RuntimeMode.literals)("keeps MCP access within the %s policy ceiling", (maxRuntimeMode) => { + const ceiling = { organization: "Example", maxRuntimeMode }; + expect(resolveMcpAccess("auto", ceiling)).toBe( + maxRuntimeMode === "full-access" ? "auto" : maxRuntimeMode, + ); + expect(resolveMcpAccess("read-only", ceiling)).toBe("read-only"); + expect(resolveMcpAccess("approval-required", ceiling)).toBe("approval-required"); +}); diff --git a/apps/web/src/components/auth/ConnectAgentSurface.logic.ts b/apps/web/src/components/auth/ConnectAgentSurface.logic.ts new file mode 100644 index 0000000000000..351716b8e9682 --- /dev/null +++ b/apps/web/src/components/auth/ConnectAgentSurface.logic.ts @@ -0,0 +1,16 @@ +import { + type AuthMcpApprovalDetails, + type AuthMcpClientAccess, + isRuntimeModeAbove, +} from "@t3tools/contracts"; + +export function resolveMcpAccess( + access: AuthMcpClientAccess, + ceiling: AuthMcpApprovalDetails["runtimeModeCeiling"], +) { + return access !== "read-only" && + ceiling !== undefined && + isRuntimeModeAbove(access, ceiling.maxRuntimeMode) + ? ceiling.maxRuntimeMode + : access; +} diff --git a/apps/web/src/components/auth/ConnectAgentSurface.tsx b/apps/web/src/components/auth/ConnectAgentSurface.tsx index f741b5506818f..ec6aa5b3abc6d 100644 --- a/apps/web/src/components/auth/ConnectAgentSurface.tsx +++ b/apps/web/src/components/auth/ConnectAgentSurface.tsx @@ -18,6 +18,7 @@ import { type ReactNode, useEffect, useState } from "react"; import { PrimaryEnvironmentHttpClient } from "~/environments/primary/httpClient"; import { runPrimaryHttp } from "~/lib/runtime"; import { cn } from "~/lib/utils"; +import { resolveMcpAccess } from "./ConnectAgentSurface.logic"; import { runtimeModeConfig } from "../chat/runtimeModeConfig"; import { Alert, AlertDescription } from "../ui/alert"; import { Button } from "../ui/button"; @@ -92,7 +93,11 @@ function oneClickApproves(details: AuthMcpApprovalDetails, access: AuthMcpClient export function ConnectAgentSurface() { const [authorization] = useState(readAuthorizationRequest); const [loaded, setLoaded] = useState({ status: "loading" }); - const [access, setAccess] = useState("read-only"); + const [requestedAccess, setAccess] = useState("auto"); + const access = resolveMcpAccess( + requestedAccess, + loaded.status === "ready" ? loaded.details.runtimeModeCeiling : undefined, + ); const [pairingCode, setPairingCode] = useState(""); const [errorMessage, setErrorMessage] = useState(""); const [pending, setPending] = useState<"approve" | "deny" | null>(null); diff --git a/docs/user/outside-agents.md b/docs/user/outside-agents.md index f040f2218058d..ebc477d712fca 100644 --- a/docs/user/outside-agents.md +++ b/docs/user/outside-agents.md @@ -45,9 +45,10 @@ Only approve a sign-in you just started. To approve, enter a pairing code from browser already signed in to the environment with permission to manage access can approve without a code. -Then choose what the agent may do: +Then choose what the agent may do. **Auto** is selected by default, or the +closest lower mode allowed by your organization’s policy: -- **Read only** is the default. The agent can read projects and threads and see +- **Read only** lets the agent read projects and threads and see the available providers and models. It cannot change anything. - **Supervised** through **Full access** also let it start, message and stop threads in every project. A thread it starts or steers cannot run with more From a601caaffd45ba7527bf2e8dcb1e8b966c70fa92 Mon Sep 17 00:00:00 2001 From: Felix Laga Date: Sat, 7 Nov 2026 21:40:49 +0900 Subject: [PATCH 08/16] fix(gitlab): `glab mr accept` counts as a shell merge (#32400) Co-authored-by: Claude Opus 5.5 --- .../source-control-gitlab/src/shared/definition.test.ts | 9 +++++++++ packages/source-control-gitlab/src/shared/definition.ts | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/source-control-gitlab/src/shared/definition.test.ts b/packages/source-control-gitlab/src/shared/definition.test.ts index 9cd95fadfafcc..a67cfeda25cd8 100644 --- a/packages/source-control-gitlab/src/shared/definition.test.ts +++ b/packages/source-control-gitlab/src/shared/definition.test.ts @@ -100,6 +100,15 @@ describe("GitLab change request URLs", () => { }); }); +describe("GitLab CLI commands", () => { + it("recognizes shell merges and closes, including glab's `accept` alias", () => { + for (const command of ["glab mr merge 7 --yes", "glab mr accept 7 --yes", "glab mr close 7"]) { + expect(definition.changeRequestCloseCommand.test(command), command).toBe(true); + } + expect(definition.changeRequestCloseCommand.test("glab mr view 7")).toBe(false); + }); +}); + describe("GitLab remotes", () => { const recognize = (url: string) => recognizeRemoteWith(definition, url); diff --git a/packages/source-control-gitlab/src/shared/definition.ts b/packages/source-control-gitlab/src/shared/definition.ts index 2ec891cae34b8..16d5bb8cf0efa 100644 --- a/packages/source-control-gitlab/src/shared/definition.ts +++ b/packages/source-control-gitlab/src/shared/definition.ts @@ -62,7 +62,8 @@ export const definition = defineSourceControl({ "delete-branch", ] as const), checkoutCommand: ({ number }) => `glab mr checkout ${number}`, - changeRequestCloseCommand: /\bglab\s+mr\s+(?:merge|close)\b/u, + // `accept` is glab's alias for `merge`. + changeRequestCloseCommand: /\bglab\s+mr\s+(?:merge|accept|close)\b/u, changeRequestCreateCommand: /\bglab\s+mr\s+create\b/u, // Bot users, unlike GitHub's, have real profile pages. authorProfileUrl: (login, repositoryUrl) => From 97d04c3bb791f47d9893da5a33c51e94a9011345 Mon Sep 17 00:00:00 2001 From: Felix Laga Date: Sat, 7 Nov 2026 21:41:03 +0900 Subject: [PATCH 09/16] fix(forgejo): tea's `pull` and `m` aliases count as shell merges (#32401) Co-authored-by: Claude Opus 5.5 --- .../src/shared/definition.test.ts | 15 +++++++++++++++ .../src/shared/definition.ts | 3 ++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/source-control-forgejo/src/shared/definition.test.ts b/packages/source-control-forgejo/src/shared/definition.test.ts index 19628b44d3cdf..139d21c37f5ca 100644 --- a/packages/source-control-forgejo/src/shared/definition.test.ts +++ b/packages/source-control-forgejo/src/shared/definition.test.ts @@ -120,6 +120,21 @@ describe("Forgejo remotes", () => { }); }); +describe("Forgejo CLI commands", () => { + it("recognizes shell merges and closes under every spelling tea accepts", () => { + for (const command of [ + "tea pulls merge 7", + "tea pull merge 7", + "tea pr m 7", + "tea pull close 7", + "fj pr close 7", + ]) { + expect(definition.changeRequestCloseCommand.test(command), command).toBe(true); + } + expect(definition.changeRequestCloseCommand.test("tea pull checkout 7")).toBe(false); + }); +}); + describe("Forgejo change request host", () => { it("keeps an HTTP remote's port, because a server's identity includes it", () => { const project = identity("http://forge.example:3000/git/team/repo.git"); diff --git a/packages/source-control-forgejo/src/shared/definition.ts b/packages/source-control-forgejo/src/shared/definition.ts index c75b071c2ab80..f78805fee1c60 100644 --- a/packages/source-control-forgejo/src/shared/definition.ts +++ b/packages/source-control-forgejo/src/shared/definition.ts @@ -93,7 +93,8 @@ export const definition = defineSourceControl({ repositoryUrl ? `git fetch '${repositoryUrl.replaceAll("'", "'\\''")}' ${changeRequestHeadRef(number)} && git checkout -B pulls/${number} FETCH_HEAD` : null, - changeRequestCloseCommand: /\b(?:tea\s+(?:pr|pulls)|fj\s+pr)\s+(?:merge|close)\b/u, + // tea also takes `pull` for `pulls` and `m` for `merge`. + changeRequestCloseCommand: /\b(?:tea\s+(?:pr|pulls?)|fj\s+pr)\s+(?:merge|m|close)\b/u, authorProfileUrl: () => null, hardLineBreaks: true, // Forgejo refuses a change request review without a summary, even with inline comments. From e455200077ce60806a01c8f28435b58860dcfc74 Mon Sep 17 00:00:00 2001 From: Abdullah Al Zawad Date: Sat, 7 Nov 2026 18:43:07 +0600 Subject: [PATCH 10/16] fix: side chats start with the parent's permission mode and reasoning level (#32315) --- .../src/features/threads/useStartSideChat.ts | 36 +++++++++++++++-- .../src/state/thread-task-permissions.test.ts | 34 +++++++++++++++- .../src/state/use-thread-composer-state.ts | 40 +++++++++++++------ apps/web/src/components/ChatView.tsx | 4 ++ apps/web/src/components/chat/useSideChat.ts | 32 +++++++++++++-- docs/user/thread-sidebar.md | 7 ++-- 6 files changed, 127 insertions(+), 26 deletions(-) diff --git a/apps/mobile/src/features/threads/useStartSideChat.ts b/apps/mobile/src/features/threads/useStartSideChat.ts index b9c6287a25775..d329cc519039c 100644 --- a/apps/mobile/src/features/threads/useStartSideChat.ts +++ b/apps/mobile/src/features/threads/useStartSideChat.ts @@ -5,7 +5,13 @@ import { squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; -import { MessageId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { + MessageId, + ThreadId, + type ModelSelection, + type RuntimeMode, + type ScopedThreadRef, +} from "@t3tools/contracts"; import * as Dialog from "../../components/ui/Dialog"; import { uuidv4 } from "../../lib/uuid"; @@ -17,11 +23,13 @@ import { useOpenLinkedThread } from "./useOpenLinkedThread"; /** * Forks `source` at its latest stable point into a side chat, sends `prompt` there, and opens - * it like any linked thread. Resolves false when the side chat did not start. + * it like any linked thread. Resolves false when the side chat did not start. `settings` are + * what the parent's composer would send, which its thread gets only on its next send. */ export function useStartSideChat() { const forkSideChat = useAtomCommand(threadEnvironment.forkSideChat, { reportFailure: false }); const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); + const setRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { reportFailure: false }); const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); const openLinkedThread = useOpenLinkedThread(); @@ -34,7 +42,11 @@ export function useStartSideChat() { Dialog.alert("Side chat not started", error instanceof Error ? error.message : fallback); }; - return async (source: ScopedThreadRef, prompt: string): Promise => { + return async ( + source: ScopedThreadRef, + prompt: string, + settings: { readonly runtimeMode: RuntimeMode; readonly modelSelection: ModelSelection }, + ): Promise => { const targetThreadId = ThreadId.make(uuidv4()); const fork = await forkSideChat({ environmentId: source.environmentId, @@ -70,6 +82,21 @@ export function useStartSideChat() { discard(); return false; } + if (settings.runtimeMode !== sideChat.runtimeMode) { + const mode = await setRuntimeMode({ + environmentId: source.environmentId, + input: { + threadId: targetThreadId, + runtimeMode: settings.runtimeMode, + createdAt: new Date().toISOString(), + }, + }); + if (mode._tag === "Failure") { + alertFailure(mode, "Could not set the side chat's permission mode."); + discard(); + return false; + } + } const turn = await startTurn({ environmentId: source.environmentId, input: { @@ -80,8 +107,9 @@ export function useStartSideChat() { text: prompt, attachments: [], }, + modelSelection: settings.modelSelection, titleSeed: deriveThreadTitleSeed({ text: prompt, attachments: [] }), - runtimeMode: sideChat.runtimeMode, + runtimeMode: settings.runtimeMode, interactionMode: "default", createdAt: new Date().toISOString(), }, diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts index 76c485531c837..1bebfb373e327 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -10,6 +10,7 @@ import { TurnItemId, type ModelSelection, type OrchestrationV2ThreadProjection, + type RuntimeMode, type ServerConfig, } from "@t3tools/contracts"; import { @@ -30,6 +31,7 @@ const state = vi.hoisted(() => ({ text: "Keep my draft", attachments: [] as DraftComposerImageAttachment[], modelSelection: undefined as ModelSelection | undefined, + runtimeMode: undefined as RuntimeMode | undefined, }, serverConfig: null as ServerConfig | null, projection: null as OrchestrationV2ThreadProjection | null, @@ -185,6 +187,7 @@ vi.mock("../lib/commandMetadata", () => ({ vi.mock("../lib/copyTextWithHaptic", () => ({ copyTextWithHaptic: vi.fn() })); vi.mock("./use-remote-environment-registry", () => ({ setPendingConnectionError: vi.fn() })); +import { Alert } from "react-native"; import { useThreadComposerState } from "./use-thread-composer-state"; import { useSelectedThreadRequests } from "./use-selected-thread-requests"; @@ -193,7 +196,12 @@ const NO_SIDE_CHATS = { startSideChat: async () => false }; beforeEach(() => { state.grantedEnvironments = new Set(["primary"]); state.connectionState = "connected"; - state.draft = { text: "Keep my draft", attachments: [], modelSelection: undefined }; + state.draft = { + text: "Keep my draft", + attachments: [], + modelSelection: undefined, + runtimeMode: undefined, + }; state.freshPending = false; state.compactDue = false; state.thread.latestRun = null; @@ -345,7 +353,18 @@ describe("mobile task permissions", () => { ...state.serverConfig, environment: { capabilities: { sideChats: true } }, } as ServerConfig; - state.draft = { ...state.draft, text: "/side why does this fail?" }; + // The thread gets the composer's mode and model only on its next send. + const selection: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }; + state.draft = { + ...state.draft, + text: "/side why does this fail?", + modelSelection: selection, + runtimeMode: "approval-required", + }; const startSideChat = vi.fn(async () => true); const composer = useThreadComposerState({ startSideChat }); expect(composer.sideChatUnavailableReason).toBeNull(); @@ -353,6 +372,7 @@ describe("mobile task permissions", () => { expect(startSideChat).toHaveBeenCalledExactlyOnceWith( { environmentId: "secondary", threadId: "thread" }, "why does this fail?", + { runtimeMode: "approval-required", modelSelection: selection }, ); expect(state.enqueue).not.toHaveBeenCalled(); expect(state.clearDraft).toHaveBeenCalled(); @@ -370,6 +390,16 @@ describe("mobile task permissions", () => { expect(await useThreadComposerState({ startSideChat }).onSendMessage()).toBeNull(); expect(startSideChat).toHaveBeenCalledOnce(); expect(state.clearDraft).not.toHaveBeenCalled(); + // A model the send would refuse does not start a side chat either. + vi.clearAllMocks(); + state.serverConfig = { + ...state.serverConfig, + providers: [{ instanceId: "codex", driver: "codex", enabled: false }], + } as unknown as ServerConfig; + state.draft = { ...state.draft, attachments: [] }; + expect(await useThreadComposerState({ startSideChat }).onSendMessage()).toBeNull(); + expect(startSideChat).not.toHaveBeenCalled(); + expect(Alert.alert).toHaveBeenCalledOnce(); }); it("rechecks a retained send callback before clearing or queueing the draft", async () => { diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 4700aac8a1bb4..e19009fcc16ba 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -27,6 +27,7 @@ import { type EnvironmentId, type ModelSelection, type ProviderInteractionMode, + type RuntimeMode, type ScopedThreadRef, type ServerProvider, type ThreadId, @@ -197,7 +198,11 @@ export function updateThreadDraftModelSelection( export function useThreadComposerState(input: { /** Forks the thread into a side chat that gets `prompt`; resolves false when it did not start. */ - readonly startSideChat: (source: ScopedThreadRef, prompt: string) => Promise; + readonly startSideChat: ( + source: ScopedThreadRef, + prompt: string, + settings: { readonly runtimeMode: RuntimeMode; readonly modelSelection: ModelSelection }, + ) => Promise; }) { const { selectedThread: selectedThreadShell, @@ -564,6 +569,19 @@ export function useThreadComposerState(input: { if (text.length === 0 && attachments.length === 0) { return null; } + const modelSelection = draft.modelSelection ?? thread.modelSelection; + const serverConfig = selectedEnvironmentRuntime?.serverConfig; + const modelUnavailable = + selectedEnvironmentRuntime?.connectionState === "connected" && + isModelSelectionUnavailable(serverConfig, modelSelection, thread.projectId); + const alertModelUnavailable = () => { + const { title, detail } = describeUnavailableModelSelection( + serverConfig, + modelSelection, + thread.projectId, + ); + Dialog.alert(title, detail); + }; // A redirect's text answers its approval, so `/side` there is that text, not the command. const sideChatCommand = beforeDispatch === undefined ? parseSideChatCommand(text) : null; if (sideChatCommand) { @@ -576,12 +594,18 @@ export function useThreadComposerState(input: { ); } else if (sideChatCommand.prompt.length === 0) { Dialog.alert("Ask a side question", "Type the question after /side, then send it."); + } else if (modelUnavailable) { + alertModelUnavailable(); } else if (!startingSideChatRef.current) { startingSideChatRef.current = true; const started = await input .startSideChat( { environmentId: thread.environmentId, threadId: thread.id }, sideChatCommand.prompt, + { + runtimeMode: draft.runtimeMode ?? thread.runtimeMode, + modelSelection, + }, ) .finally(() => { startingSideChatRef.current = false; @@ -627,18 +651,8 @@ export function useThreadComposerState(input: { return null; } - const modelSelection = draft.modelSelection ?? thread.modelSelection; - const serverConfig = selectedEnvironmentRuntime?.serverConfig; - if ( - selectedEnvironmentRuntime?.connectionState === "connected" && - isModelSelectionUnavailable(serverConfig, modelSelection, thread.projectId) - ) { - const { title, detail } = describeUnavailableModelSelection( - serverConfig, - modelSelection, - thread.projectId, - ); - Dialog.alert(title, detail); + if (modelUnavailable) { + alertModelUnavailable(); return null; } const provider = serverConfig?.providers.find( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 76ad543630fb2..ff4a642b5c31d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2794,6 +2794,10 @@ export default function ChatView(props: ChatViewProps) { serverConfig?.environment.capabilities.sideChats === true; const sideChat = useSideChat({ threadRef: sideChatsOffered && isServerThread ? activeThreadRef : null, + readSettings: () => ({ + runtimeMode, + modelSelection: composerRef.current?.getSendContext().selectedModelSelection, + }), onError: (message) => { if (activeThread) setThreadError(activeThread.id, message); }, diff --git a/apps/web/src/components/chat/useSideChat.ts b/apps/web/src/components/chat/useSideChat.ts index 1587526528987..1083ca819c6dc 100644 --- a/apps/web/src/components/chat/useSideChat.ts +++ b/apps/web/src/components/chat/useSideChat.ts @@ -1,4 +1,4 @@ -import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { ModelSelection, RuntimeMode, ScopedThreadRef } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations"; import { @@ -9,6 +9,7 @@ import { import { useRef } from "react"; import { newMessageId, newThreadId } from "~/lib/utils"; +import { useComposerDraftStore } from "../../composerDraftStore"; import { useStableCallback } from "../../hooks/useStableCallback"; import { useRightPanelStore } from "../../rightPanelStore"; import { readThreadShell, waitForThreadShell } from "../../state/entities"; @@ -18,16 +19,24 @@ import { useOrchestrationCommand } from "../../state/use-orchestration-command"; /** * Starts side chats from `threadRef`, or offers none when it is null. `open` forks the - * thread's latest stable point, sends `prompt` there, and shows the side chat's tab. It - * resolves false when the side chat did not start, or another one is still starting. + * thread's latest stable point, sends `prompt` there with `readSettings()`, the parent + * composer's settings, and shows the tab. It resolves false when the side chat did not + * start, or another one is still starting. */ export function useSideChat(input: { readonly threadRef: ScopedThreadRef | null; + readonly readSettings: () => { + readonly runtimeMode: RuntimeMode; + readonly modelSelection: ModelSelection | undefined; + }; readonly onError: (message: string) => void; }) { const pendingRef = useRef(false); const forkSideChat = useAtomCommand(threadEnvironment.forkSideChat, { reportFailure: false }); const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); + const setThreadRuntimeMode = useOrchestrationCommand(threadEnvironment.setRuntimeMode, { + reportFailure: false, + }); const startThreadTurn = useOrchestrationCommand(threadEnvironment.startTurn, { reportFailure: false, }); @@ -40,6 +49,7 @@ export function useSideChat(input: { input.onError(error instanceof Error ? error.message : fallback); }; const start = async (threadRef: ScopedThreadRef, prompt: string | undefined) => { + const { runtimeMode, modelSelection } = input.readSettings(); const targetThreadId = newThreadId(); const fork = await forkSideChat({ environmentId: threadRef.environmentId, @@ -67,6 +77,17 @@ export function useSideChat(input: { discard(); return false; } + if (runtimeMode !== sideChat.runtimeMode) { + const mode = await setThreadRuntimeMode({ + environmentId: threadRef.environmentId, + input: { threadId: targetThreadId, runtimeMode, createdAt: new Date().toISOString() }, + }); + if (mode._tag === "Failure") { + reportFailure(mode, "Could not set the side chat's permission mode."); + discard(); + return false; + } + } const text = prompt?.trim(); // Send before showing the tab: closing a tab whose side chat has no run deletes it. if (text) { @@ -75,8 +96,9 @@ export function useSideChat(input: { input: { threadId: targetThreadId, message: { messageId: newMessageId(), role: "user", text, attachments: [] }, + ...(modelSelection === undefined ? {} : { modelSelection }), titleSeed: deriveThreadTitleSeed({ text, attachments: [] }), - runtimeMode: sideChat.runtimeMode, + runtimeMode, interactionMode: "default", createdAt: new Date().toISOString(), }, @@ -86,6 +108,8 @@ export function useSideChat(input: { discard(); return false; } + } else if (modelSelection !== undefined) { + useComposerDraftStore.getState().setModelSelection(targetRef, modelSelection); } useRightPanelStore.getState().openSideChat(threadRef, targetThreadId); return true; diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 94c9cc03c8de9..8de252f90c35e 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -304,9 +304,10 @@ You can also choose **Side chat** from the **+** menu in the right panel. The si chat opens as a tab there, with its own composer. Side chats do not show in the sidebar. Find one again under **Side chats** in thread details. -A side chat works in the same folder as the thread and inherits its permissions -when created, including for its first reply. Change permissions for an individual -side chat in its composer. Later changes to the parent do not change existing side chats. +A side chat works in the same folder as the thread and starts with the permissions, +model, and reasoning level shown in the thread's composer, including for its first +reply. Change them for an individual side chat in its composer. Later changes to the +parent do not change existing side chats. To use what you learned, choose **Bring back**. The thread gets the side chat with your next message there. Bring back is available while the thread is idle. From 25bf84ab07f53f141c20239c63e383823a9d5ea0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 7 Nov 2026 09:19:49 -0800 Subject: [PATCH 11/16] fix(mobile): center the Choose project empty-state buttons (#27450) --- .../features/threads/NewTaskRouteScreen.tsx | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 8cbc7bd7d9058..d904c3c67c543 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -402,21 +402,27 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps {Platform.OS === "android" ? ( <> -