diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts index 1490140c1e6e2..8cb9e8993ce17 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts @@ -73,6 +73,7 @@ const AntigravityProviderCapabilitiesV2 = { export interface AntigravityAdapterV2Options { readonly instanceId: ProviderInstanceId; readonly selfInvocation: SelfInvocation; + readonly fileLinkInstructions?: string | undefined; readonly makeRuntime: ( input: Omit, ) => Effect.Effect< @@ -300,6 +301,7 @@ export const makeAntigravityAdapterV2 = Effect.fn("makeAntigravityAdapterV2")(fu instanceId: options.instanceId, flavor: makeAntigravityAcpAdapterFlavor({ ...options, ...services }), selfInvocation: options.selfInvocation, + fileLinkInstructions: options.fileLinkInstructions, ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }), ...(options.continuationRequests === undefined ? {} diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 7b63b7efc0b65..ac7d710dd5d4f 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1887,7 +1887,10 @@ export function makeClaudeQueryOptions(input: { type: "preset" as const, preset: "claude_code" as const, append: [ - buildRuntimeInstructions({ harness: "Claude Code" }) + + buildRuntimeInstructions({ + harness: "Claude Code", + fileLinkInstructions: input.settings?.fileLinkInstructions, + }) + (input.mcpServers === undefined ? "" : T3_CODE_ORCHESTRATION_INSTRUCTIONS + diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 038266ba93426..4c817ce1ee8ae 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -766,6 +766,7 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur readonly codexInput: ReadonlyArray; readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; readonly modelSelection: ModelSelection; + readonly fileLinkInstructions?: string | undefined; readonly hasT3Mcp?: boolean; readonly browserToolsAvailable?: boolean; readonly deviceToolsAvailable?: boolean; @@ -801,7 +802,11 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur const t3Context = input.hasT3Mcp === true ? buildCodexAdditionalContext( - { model: input.modelSelection.model, reasoningEffort: effort ?? undefined }, + { + model: input.modelSelection.model, + reasoningEffort: effort ?? undefined, + fileLinkInstructions: input.fileLinkInstructions, + }, { browser: input.browserToolsAvailable ?? true, device: input.deviceToolsAvailable ?? false, @@ -6899,6 +6904,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( codexInput, runtimePolicy: turnInput.runtimePolicy, modelSelection: turnInput.modelSelection, + fileLinkInstructions: adapterOptions.settings.fileLinkInstructions, hasT3Mcp: mcpSession !== undefined, browserToolsAvailable: mcpSession?.browserToolsAvailable ?? true, deviceToolsAvailable: mcpSession?.capabilities?.has("device") ?? false, diff --git a/apps/server/src/provider/CodexDeveloperInstructions.test.ts b/apps/server/src/provider/CodexDeveloperInstructions.test.ts index 5f4a28e886b6a..2e71082ee097e 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.test.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.test.ts @@ -1,5 +1,6 @@ import * as NodeAssert from "node:assert/strict"; +import { FILE_LINK_INSTRUCTIONS_MAX_LENGTH } from "@t3tools/contracts"; import { T3_CODE_DEVICE_TOOL_INSTRUCTIONS } from "@t3tools/provider-core/server/orchestrationInstructions"; import { describe, it } from "vite-plus/test"; @@ -11,6 +12,12 @@ import { describe("buildCodexAdditionalContext", () => { describe.each([ { model: "custom-model", modelName: "Custom 🦊 Model", reasoningEffort: "xhigh" }, + { + model: "custom-model-longest-file-link-instructions", + modelName: "Custom 🦊 Model", + reasoningEffort: "xhigh", + fileLinkInstructions: "界".repeat(FILE_LINK_INSTRUCTIONS_MAX_LENGTH), + }, ])("with runtime $model", (runtime) => { it.each([undefined, { browser: true, device: true }])( "keeps every entry within Codex's per-entry cap with tools %j", diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index cbfd514eb11a6..e50774150faba 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -35,6 +35,7 @@ export interface CodexRuntimeInfo { readonly model: string; readonly modelName?: string | undefined; readonly reasoningEffort?: string | undefined; + readonly fileLinkInstructions?: string | undefined; } /** Keep this outside collaboration mode; model-catalog mode text overrides client developer_instructions. */ diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 7bb1f25b67ce7..c6923a08c1bde 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -459,6 +459,7 @@ export const AntigravityDriver: ProviderDriver< const orchestrationAdapter = yield* makeAntigravityAdapterV2({ instanceId, selfInvocation, + fileLinkInstructions: settings.fileLinkInstructions, makeRuntime, withProcess: authFlow.withProcess, defaultModel, diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index e998332d3e92f..959838d825654 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -12,6 +12,7 @@ import { EnvironmentId, type ClaudeSettings, type CodexSettings, + DEFAULT_FILE_LINK_INSTRUCTIONS, ProviderDriverKind, type ProviderInstanceConfigMap, type ResolvedProviderInstanceEnvironment, @@ -100,6 +101,7 @@ const makeCodexConfig = (overrides: Partial): CodexSettings => ({ homePath: "", shadowHomePath: "", launchArgs: "", + fileLinkInstructions: DEFAULT_FILE_LINK_INSTRUCTIONS, customModels: [], ...overrides, }); @@ -110,6 +112,7 @@ const makeClaudeConfig = (overrides: Partial): ClaudeSettings => homePath: "", customModels: [], launchArgs: "", + fileLinkInstructions: DEFAULT_FILE_LINK_INSTRUCTIONS, autoCompactWindow: "", idleCompaction: "default", ...overrides, diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index f0040802a2e5e..69abe689129fb 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -17,7 +17,9 @@ import * as Arr from "effect/Array"; import * as Result from "effect/Result"; import { Fragment, useState, type ReactElement, type ReactNode } from "react"; import { + FileLinkInstructionsSetting, isProviderDriverKind, + makeProviderSettingsSchema, resolveProviderInstanceEnabled, type ProviderInstanceConfig, type ProviderInstanceEnvironmentVariable, @@ -80,6 +82,13 @@ import { const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; +// Connect with ChatGPT owns Codex's launch settings, but not the guidance its agents get. +const CODEX_MANAGED_SETTINGS_FORM = { + settingsSchema: makeProviderSettingsSchema({ + fileLinkInstructions: FileLinkInstructionsSetting, + }), +}; + function ProviderStatusDiagnostic({ detail, children, @@ -1338,7 +1347,21 @@ export function ProviderInstanceCard({ title="Runtime" headerPlacement="outside" > - {runtime ?? runtimeFields} + {runtime === undefined ? ( + runtimeFields + ) : ( + <> + {runtime} + + + )} ) : !driverOption || deriveProviderSettingsFields(driverOption).length > 0 ? ( diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index d59eb5c1238b9..2db5dec209c29 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + DEFAULT_FILE_LINK_INSTRUCTIONS, + FILE_LINK_INSTRUCTIONS_MAX_LENGTH, makeProviderSettingsSchema, ProviderDriverKind, SettingsSecretValue, @@ -52,6 +54,40 @@ describe("ProviderSettingsForm helpers", () => { expect(next).toEqual({ forkOwned: 1 }); }); + it("keeps file link instructions unset until edited, within Codex's limit, and resettable", () => { + const claude = providerClients.get(ProviderDriverKind.make("claudeAgent")); + const field = deriveProviderSettingsFields(claude!).find( + ({ key }) => key === "fileLinkInstructions", + ); + expect(field).toMatchObject({ + control: "textarea", + defaultStringValue: DEFAULT_FILE_LINK_INSTRUCTIONS, + maxLength: FILE_LINK_INSTRUCTIONS_MAX_LENGTH, + }); + + expect(nextProviderConfigWithFieldValue(undefined, field!, "Cite files as URLs.")).toEqual({ + fileLinkInstructions: "Cite files as URLs.", + }); + expect(nextProviderConfigWithFieldValue({ launchArgs: "--chrome" }, field!, "")).toEqual({ + launchArgs: "--chrome", + fileLinkInstructions: "", + }); + expect( + nextProviderConfigWithFieldValue( + { launchArgs: "--chrome", fileLinkInstructions: "" }, + field!, + DEFAULT_FILE_LINK_INSTRUCTIONS, + ), + ).toEqual({ launchArgs: "--chrome" }); + }); + + it("offers file link instructions for local ACP agents", () => { + const acpRegistry = providerClients.get(ProviderDriverKind.make("acpRegistry")); + expect( + deriveProviderSettingsFields(acpRegistry!, { source: "local" }).map(({ key }) => key), + ).toEqual(["source", "commandPath", "fileLinkInstructions"]); + }); + it("stores false boolean fields when true is the default", () => { const next = nextProviderConfigWithFieldValue( undefined, diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index 646dfd80edc20..277a894e434dc 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -28,7 +28,7 @@ import type { ProviderClientDefinition } from "@t3tools/provider-core/client"; export type SettingsFormDefinition = Pick & { readonly driverKind?: ProviderClientDefinition["driverKind"]; }; -import { SettingsRow } from "./settingsLayout"; +import { SettingResetButton, SettingsRow } from "./settingsLayout"; export interface ProviderSettingsFieldModel { readonly key: string; @@ -36,12 +36,14 @@ export interface ProviderSettingsFieldModel { readonly label: string; readonly description?: string | undefined; readonly placeholder?: string | undefined; + readonly maxLength?: number | undefined; readonly clearWhenEmpty: "omit" | "persist"; /** Kept in the server's secret store: a saved value arrives as `SECRET_REDACTED` and never shows. */ readonly secret?: boolean | undefined; /** Typed `SettingsSecretValue`: may name a secret provider reference instead of a value. */ readonly acceptsSecretReference?: boolean | undefined; readonly defaultBooleanValue?: boolean | undefined; + readonly defaultStringValue?: string | undefined; readonly options?: ReadonlyArray | undefined; } @@ -89,6 +91,16 @@ function readFieldBooleanDefault( return Option.isSome(decoded) && typeof decoded.value === "boolean" ? decoded.value : undefined; } +function readFieldTextDefault( + fieldSchema: ProviderClientDefinition["settingsSchema"]["fields"][string], +): string | undefined { + const decodeDefault = Schema.decodeUnknownOption(fieldSchema as Schema.Decoder); + const decoded = decodeDefault(undefined); + return Option.isSome(decoded) && typeof decoded.value === "string" && decoded.value.length > 0 + ? decoded.value + : undefined; +} + export function deriveProviderSettingsFields( definition: SettingsFormDefinition, value?: unknown, @@ -114,7 +126,14 @@ export function deriveProviderSettingsFields( const fieldSchema = definition.settingsSchema.fields[key]!; const formAnnotation = readProviderSettingsFormAnnotation(fieldSchema); if (formAnnotation.hidden) return []; - if (isLocalAcp && key !== "source" && key !== "commandPath") return []; + if ( + isLocalAcp && + key !== "source" && + key !== "commandPath" && + key !== "fileLinkInstructions" + ) { + return []; + } const annotatedTitle = readFieldAnnotationString(fieldSchema, "title"); const annotatedDescription = readFieldAnnotationString(fieldSchema, "description"); @@ -136,12 +155,18 @@ export function deriveProviderSettingsFields( : formAnnotation.placeholder !== undefined ? { placeholder: formAnnotation.placeholder } : {}), + ...(formAnnotation.maxLength !== undefined + ? { maxLength: formAnnotation.maxLength } + : {}), clearWhenEmpty: formAnnotation.clearWhenEmpty ?? "omit", ...(isSecretSetting(fieldSchema) ? { secret: true } : {}), ...(acceptsSecretReference(fieldSchema) ? { acceptsSecretReference: true } : {}), ...(formAnnotation.control === "switch" ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) } : {}), + ...(formAnnotation.control === "textarea" + ? { defaultStringValue: readFieldTextDefault(fieldSchema) } + : {}), ...(formAnnotation.control === "select" && formAnnotation.options ? { options: formAnnotation.options } : {}), @@ -284,10 +309,10 @@ function ProviderCommandArguments({ ); } -function readProviderConfigString(config: unknown, key: string): string { - if (config === null || typeof config !== "object") return ""; +function readProviderConfigString(config: unknown, key: string, defaultValue = ""): string { + if (config === null || typeof config !== "object") return defaultValue; const value = (config as Record)[key]; - return typeof value === "string" ? value : ""; + return typeof value === "string" ? value : defaultValue; } function readProviderConfigBoolean(config: unknown, key: string, defaultValue = false): boolean { @@ -320,7 +345,10 @@ export function nextProviderConfigWithFieldValue( } const trimmed = value.trim(); - if (field.clearWhenEmpty === "omit" && trimmed.length === 0) { + if ( + (field.clearWhenEmpty === "omit" && trimmed.length === 0) || + trimmed === field.defaultStringValue + ) { delete base[field.key]; } else { base[field.key] = value; @@ -434,6 +462,34 @@ function ProviderSettingsSelect({ ); } +function ProviderSettingsTextarea({ + field, + value, + onChange, + ...rest +}: Pick & { + readonly id: string; + readonly className?: string | undefined; + readonly "aria-describedby"?: string | undefined; +}) { + const text = readProviderConfigString(value, field.key, field.defaultStringValue); + return ( +