From ae815ad9b99836b3f6a6e829ad8df0ecaa51856a Mon Sep 17 00:00:00 2001 From: doramirdor Date: Thu, 5 Nov 2026 21:36:12 -0500 Subject: [PATCH 01/16] feat: Nadir model routing before each user turn Adds an opt-in "Nadir model routing" setting (Settings > Providers > Advanced, and the mobile Threads page). When on, the server asks Nadir's /v1/bucket which model from the selected provider instance's menu should run each new user turn, then runs that model. The thread's model selection updates, so the picker shows the model that ran. - The user's pick is the ceiling: Nadir never routes above it, and the server remembers it per thread so a routed-down pick does not become the next turn's ceiling. A model picked by hand becomes the new one. - Covers both client entry points (message.dispatch and launchThread). Steering, continuations, compactions, and scheduled, delegated or guest sends keep their model. - Sends the thread's context size so Nadir prices the cache rewrite of a mid-thread switch, and keeps the warm model when switching costs more. - Fails open: any error, a 2.5 s timeout, or an unqualified answer keeps the user's pick. Optional NADIR_API_KEY on the host; keyless works. - Web: after a send, the composer drops its draft model pick so the picker follows the thread's model. Co-Authored-By: Claude Opus 5.5 --- .../SettingsServerControlsRouteScreen.tsx | 15 ++ .../src/orchestration-v2/NadirRouting.test.ts | 104 +++++++++ .../src/orchestration-v2/NadirRouting.ts | 218 ++++++++++++++++++ .../orchestration-v2/ThreadMessageIntake.ts | 69 +++++- .../src/components/chat/useComposerSend.ts | 5 + .../settings/ProviderSettingsPanel.tsx | 3 +- .../components/settings/SettingsPanels.tsx | 36 +++ .../src/components/settings/settingsSearch.ts | 7 + apps/web/src/composerDraftStore.ts | 15 ++ packages/contracts/src/settings.ts | 3 + 10 files changed, 472 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/orchestration-v2/NadirRouting.test.ts create mode 100644 apps/server/src/orchestration-v2/NadirRouting.ts diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 793e3ae75412c..fa01e4e676655 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -394,6 +394,21 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { onValueChange={(value) => write({ enableAgentBrowserAccess: value })} /> + + write({ nadirRouting: value })} + /> + 0} /> ) : null} diff --git a/apps/server/src/orchestration-v2/NadirRouting.test.ts b/apps/server/src/orchestration-v2/NadirRouting.test.ts new file mode 100644 index 0000000000000..6568083811169 --- /dev/null +++ b/apps/server/src/orchestration-v2/NadirRouting.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "@effect/vitest"; +import { ProviderInstanceId, type ModelSelection } from "@t3tools/contracts"; + +import { + baselineFor, + forgetChoices, + nadirRequestBody, + pickModel, + rememberChoice, +} from "./NadirRouting.ts"; + +const menu = ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"]; +const context = { baseline: "claude-opus-4-6", current: "claude-opus-4-6", menu }; +const routed = (model: string, extra: Record = {}) => ({ + selected_model: model, + coverage_held: false, + input_truncated: false, + policy: { advised_model: model, suppressed: false }, + ...extra, +}); + +describe("pickModel", () => { + it("takes Nadir's qualified pick from the menu", () => { + expect(pickModel(routed("claude-haiku-4-5"), context)).toBe("claude-haiku-4-5"); + }); + + it.each([ + ["no answer", null], + ["a model outside the menu", routed("gpt-5.5")], + [ + "a suppressed saving", + routed("claude-haiku-4-5", { + policy: { advised_model: "claude-haiku-4-5", suppressed: true }, + }), + ], + [ + "advice that disagrees with the pick", + routed("claude-haiku-4-5", { + policy: { advised_model: "claude-sonnet-4-6", suppressed: false }, + }), + ], + ["a truncated prompt", routed("claude-haiku-4-5", { input_truncated: true })], + ["held coverage", routed("claude-haiku-4-5", { coverage_held: true })], + [ + "a warm cache worth keeping", + routed("claude-haiku-4-5", { cache_advice: { decision: "stay_warm" } }), + ], + ])("keeps the user's pick on %s", (_label, answer) => { + expect(pickModel(answer, context)).toBe("claude-opus-4-6"); + }); + + it("keeps a cheaper model that already holds the cache when Nadir picks it again", () => { + const answer = routed("claude-haiku-4-5", { + policy: { advised_model: "claude-haiku-4-5", suppressed: true }, + }); + expect(pickModel(answer, { ...context, current: "claude-haiku-4-5" })).toBe("claude-haiku-4-5"); + }); + + it("follows an organization's governance model when the menu has it", () => { + expect(pickModel({ governance_model: "claude-sonnet-4-6" }, context)).toBe("claude-sonnet-4-6"); + expect(pickModel({ governance_model: "gpt-5.5" }, context)).toBe("claude-opus-4-6"); + }); +}); + +describe("baselineFor", () => { + const instanceId = ProviderInstanceId.make("claudeAgent"); + const opus: ModelSelection = { instanceId, model: "claude-opus-4-6", options: [] }; + const haiku: ModelSelection = { instanceId, model: "claude-haiku-4-5" }; + + it("keeps the user's pick as the ceiling after Nadir routes down", () => { + forgetChoices(); + rememberChoice("thread-1", opus, haiku); + // The picker now shows Haiku; sending again must not make Haiku the ceiling. + expect(baselineFor("thread-1", haiku)).toBe(opus); + }); + + it("treats a model the user picks by hand as the new ceiling", () => { + forgetChoices(); + rememberChoice("thread-1", opus, haiku); + const sonnet: ModelSelection = { instanceId, model: "claude-sonnet-4-6" }; + expect(baselineFor("thread-1", sonnet)).toBe(sonnet); + expect(baselineFor("thread-2", haiku)).toBe(haiku); + }); +}); + +describe("nadirRequestBody", () => { + it("never lets Nadir route above the user's pick and prices the warm cache", () => { + const body = nadirRequestBody({ + text: "Rename foo to bar in format.ts", + baseline: "claude-opus-4-6", + menu, + driver: "claudeAgent", + threadId: "thread-1", + cache: { model: "claude-opus-4-6", contextTokens: 120_000 }, + }); + expect(body).toMatchObject({ + requested_model: "claude-opus-4-6", + menu, + policy: { allow_route_up: false }, + agent_session: { client: "claude-code", session_id: "thread-1" }, + cache: { model: "claude-opus-4-6", current_input_tokens: 120_000 }, + }); + }); +}); diff --git a/apps/server/src/orchestration-v2/NadirRouting.ts b/apps/server/src/orchestration-v2/NadirRouting.ts new file mode 100644 index 0000000000000..f29579194a9e1 --- /dev/null +++ b/apps/server/src/orchestration-v2/NadirRouting.ts @@ -0,0 +1,218 @@ +import type { ModelSelection, ServerProvider } from "@t3tools/contracts"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import * as Option from "effect/Option"; + +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as ServerSettings from "../serverSettings.ts"; + +/** + * Nadir routing: before a user's turn starts, ask Nadir (getnadir.com) which model from the + * selected provider instance's menu should run it, and run that one. The model the user picked + * stays the ceiling: Nadir never routes above it. Any failure keeps the user's pick. + */ + +const NADIR_BUCKET_URL = "https://api.getnadir.com/v1/bucket"; +const MAX_PROMPT_CHARS = 40_000; +// A short follow-up ("yes", "do it") depends on context Nadir does not see. +const MIN_PROMPT_WORDS = 4; + +// ponytail: per-process memory. A restart forgets the ceiling and treats the thread's current +// model as the user's pick; persist it on the thread if that ever matters. +const MAX_TRACKED_THREADS = 1_000; +const ceilings = new Map(); + +const sameModel = (left: ModelSelection, right: ModelSelection) => + left.instanceId === right.instanceId && left.model === right.model; + +/** The user's pick: the incoming selection, unless it is the model Nadir chose last turn. */ +export function baselineFor(threadId: string, incoming: ModelSelection): ModelSelection { + const prior = ceilings.get(threadId); + return prior !== undefined && sameModel(prior.chosen, incoming) ? prior.baseline : incoming; +} + +export function rememberChoice( + threadId: string, + baseline: ModelSelection, + chosen: ModelSelection, +): void { + ceilings.delete(threadId); + ceilings.set(threadId, { baseline, chosen }); + if (ceilings.size > MAX_TRACKED_THREADS) { + const oldest = ceilings.keys().next().value; + if (oldest !== undefined) ceilings.delete(oldest); + } +} + +export function forgetChoices(): void { + ceilings.clear(); +} + +/** Models the user can pick for this instance, the menu Nadir chooses from. */ +export function modelMenu(provider: ServerProvider | undefined): ReadonlyArray { + return ( + provider?.models.filter((model) => model.isLegacy !== true).map((model) => model.slug) ?? [] + ); +} + +export interface NadirCacheState { + /** The model holding the thread's warm prompt cache. */ + readonly model: string; + /** Tokens in the thread's current context window. */ + readonly contextTokens?: number | undefined; +} + +export function nadirRequestBody(input: { + readonly text: string; + readonly baseline: string; + readonly menu: ReadonlyArray; + readonly driver: string | undefined; + readonly threadId: string; + readonly cache?: NadirCacheState | undefined; +}) { + const client = + input.driver === "codex" ? "codex" : input.driver === "claudeAgent" ? "claude-code" : null; + return { + prompt: input.text, + source: "t3code", + role: "main", + requested_model: input.baseline, + menu: input.menu, + axes: ["coding", "agentic"], + agent_policy: { main: "auto" }, + policy: { allow_route_up: false, min_savings_pct: 0.05 }, + ...(client === null ? {} : { agent_session: { client, session_id: input.threadId } }), + // Omitting cached_tokens makes Nadir assume the whole context is cached: the costly reading. + ...(input.cache === undefined + ? {} + : { + cache: { + model: input.cache.model, + expected_remaining_turns: 1, + ...(input.cache.contextTokens === undefined + ? {} + : { current_input_tokens: input.cache.contextTokens }), + }, + }), + }; +} + +const record = (value: unknown): Record | undefined => + typeof value === "object" && value !== null ? (value as Record) : undefined; + +/** The model to run, from Nadir's answer. Anything incomplete or unqualified keeps the baseline. */ +export function pickModel( + response: unknown, + context: { + readonly baseline: string; + readonly current: string; + readonly menu: ReadonlyArray; + }, +): string { + const answer = record(response); + if (answer === undefined) return context.baseline; + // An organization policy on the account names the model to use. + const governed = answer.governance_model; + if (typeof governed === "string") { + return context.menu.includes(governed) ? governed : context.baseline; + } + const selected = answer.selected_model; + const policy = record(answer.policy); + if ( + typeof selected !== "string" || + !context.menu.includes(selected) || + answer.coverage_held === true || + answer.input_truncated === true || + policy?.advised_model !== selected + ) { + return context.baseline; + } + if (selected === context.baseline || selected === context.current) return selected; + if (policy.suppressed !== false) return context.baseline; + // A warm cache can make the cheaper model the costlier turn; never stay on a cheaper last-turn + // model for a task Nadir rates harder, so fall back to the user's pick. + if (record(answer.cache_advice)?.decision === "stay_warm") return context.baseline; + return selected; +} + +/** True when Nadir routing is on in Settings. */ +export const isEnabled = ServerSettings.ServerSettingsService.pipe( + Effect.flatMap((service) => service.getSettings), + Effect.map((settings) => settings.nadirRouting), + Effect.orElseSucceed(() => false), +); + +const askNadir = Effect.fn("NadirRouting.askNadir")(function* ( + client: HttpClient.HttpClient, + body: unknown, +) { + // Optional: without a key Nadir answers on its free anonymous tier. + const apiKey = (yield* Config.String("NADIR_API_KEY").pipe(Config.withDefault(""))).trim(); + const request = HttpClientRequest.post(NADIR_BUCKET_URL).pipe( + apiKey ? HttpClientRequest.setHeader("X-API-Key", apiKey) : (request) => request, + HttpClientRequest.bodyJsonUnsafe(body), + ); + return yield* client.execute(request).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.timeout("2500 millis"), + ); +}); + +/** + * The selection a user's turn should run on. Returns `selection` unchanged when routing is off, + * the prompt is too short or long to judge, or Nadir is slow, down or unsure. + */ +export const chooseModelSelection = Effect.fn("NadirRouting.chooseModelSelection")( + function* (input: { + readonly threadId: string; + readonly text: string; + readonly selection: ModelSelection; + readonly cache?: NadirCacheState | undefined; + }) { + // Optional so callers outside the client intake (MCP tools, tests) need not provide them. + const client = yield* Effect.serviceOption(HttpClient.HttpClient); + const registry = yield* Effect.serviceOption(ProviderRegistry.ProviderRegistry); + if (Option.isNone(client) || Option.isNone(registry) || !(yield* isEnabled)) { + return input.selection; + } + const baseline = baselineFor(input.threadId, input.selection); + const text = input.text.trim(); + const judgeable = + text.length <= MAX_PROMPT_CHARS && + !text.startsWith("/") && + text.split(/\s+/).length >= MIN_PROMPT_WORDS; + const providers = yield* registry.value.getProviders; + const provider = providers.find((candidate) => candidate.instanceId === baseline.instanceId); + const menu = modelMenu(provider); + if (!judgeable || !menu.includes(baseline.model)) { + rememberChoice(input.threadId, baseline, baseline); + return baseline; + } + const current = input.cache?.model ?? input.selection.model; + const answer = yield* askNadir( + client.value, + nadirRequestBody({ + text, + baseline: baseline.model, + menu, + driver: provider?.driver, + threadId: input.threadId, + cache: input.cache, + }), + ).pipe( + Effect.tapError((cause) => Effect.logDebug("Nadir routing skipped", { cause })), + Effect.orElseSucceed(() => null), + ); + const model = pickModel(answer, { baseline: baseline.model, current, menu }); + // Options such as effort belong to the user's model; the routed model uses its own defaults. + const chosen: ModelSelection = + model === baseline.model ? baseline : { instanceId: baseline.instanceId, model }; + rememberChoice(input.threadId, baseline, chosen); + if (model !== baseline.model) { + yield* Effect.logInfo("Nadir routed turn", { from: baseline.model, to: model }); + } + return chosen; + }, +); diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts index 3e8922c3ba706..e4b37e916a1b7 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts @@ -8,6 +8,7 @@ import * as ServerConfig from "../config.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as AttachmentClaims from "./AttachmentClaims.ts"; +import * as NadirRouting from "./NadirRouting.ts"; import * as ThreadLaunch from "./ThreadLaunchService.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; @@ -82,11 +83,60 @@ const withGuestAttachments = Effect.fn("ThreadMessageIntake.withGuestAttachments return { ...command, attachments: message?.attachments ?? [] }; }); +/** + * A user's own new turn gets Nadir's model pick. Steering into a running turn, continuations, + * compactions, and scheduled, delegated or guest sends keep the model they came with. + */ +const withNadirModel = Effect.fn("ThreadMessageIntake.withNadirModel")(function* ( + command: Extract, +) { + const plainUserTurn = + command.guestMessageId === undefined && + command.scheduledTaskId === undefined && + command.senderThreadId === undefined && + command.delegatedCompletion === undefined && + command.idleCompaction === undefined && + command.restartContinuationOfRunId === undefined && + command.usageLimitContinuationOfRunId === undefined && + command.manualContinuationOfRunId === undefined && + command.deliveryIntent !== "steer" && + command.deliveryIntent !== "restart" && + command.dispatchMode.type === "start_immediately"; + if (!plainUserTurn || !(yield* NadirRouting.isEnabled)) return command; + const threads = yield* ThreadManagement.ThreadManagementService; + const shell = yield* threads + .getThreadShell(command.threadId) + .pipe(Effect.orElseSucceed(() => null)); + // A message sent during a run steers it, and a run cannot change model mid-turn. + if (shell === null || shell.activeRunId !== null) return command; + const usage = yield* threads.getThreadSnapshotWindow(command.threadId, { rowLimit: 1 }).pipe( + Effect.map( + ({ projection }) => + projection.providerThreads.find((thread) => thread.id === shell.activeProviderThreadId) + ?.contextUsage ?? null, + ), + Effect.orElseSucceed(() => null), + ); + const selection = command.modelSelection ?? shell.modelSelection; + const chosen = yield* NadirRouting.chooseModelSelection({ + threadId: command.threadId, + text: command.text, + selection, + cache: + usage === null + ? undefined + : { model: shell.modelSelection.model, contextTokens: usage.usedTokens }, + }); + return chosen === selection ? command : { ...command, modelSelection: chosen }; +}); + export const dispatchCommand = Effect.fn("ThreadMessageIntake.dispatchCommand")(function* ( incoming: OrchestrationV2Command, ) { const command = - incoming.type === "message.dispatch" ? yield* withGuestAttachments(incoming) : incoming; + incoming.type === "message.dispatch" + ? yield* withGuestAttachments(incoming).pipe(Effect.flatMap(withNadirModel)) + : incoming; const threads = yield* ThreadManagement.ThreadManagementService; if (command.type === "runtime-request.respond" && command.attachmentsByQuestionId) { const config = yield* ServerConfig.ServerConfig; @@ -208,8 +258,23 @@ export const sendToThread = Effect.fn("ThreadMessageIntake.sendToThread")(functi }); export const launchThread = Effect.fn("ThreadMessageIntake.launchThread")(function* ( - input: ThreadLaunch.ThreadLaunchInput, + launchInput: ThreadLaunch.ThreadLaunchInput, ) { + // A new thread has no warm cache, so its first turn is the cheapest place to pick a model. + const input = + launchInput.initialMessage === undefined || + launchInput.threadId === undefined || + launchInput.importedNativeThread !== undefined || + launchInput.delegatedFrom !== undefined + ? launchInput + : { + ...launchInput, + modelSelection: yield* NadirRouting.chooseModelSelection({ + threadId: launchInput.threadId, + text: launchInput.initialMessage.text, + selection: launchInput.modelSelection, + }), + }; const launches = yield* ThreadLaunch.ThreadLaunchService; yield* AttachmentClaims.validateAttachmentLimits(input.initialMessage?.attachments ?? []); if (!input.initialMessage?.attachments.some(AttachmentClaims.attachmentIsPendingUpload)) { diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index 7e0b410134c5e..b3e1209f40b2d 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -149,6 +149,7 @@ import type { useWorktreeSetup } from "./useWorktreeSetup"; const ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE = 3; const selectPlanModeEnabled = (settings: UnifiedSettings) => settings.planModeEnabled; +const selectNadirRouting = (settings: UnifiedSettings) => settings.nadirRouting; function readEnvironmentPresentation(environmentId: EnvironmentId) { return appAtomRegistry.get(environmentPresentations.presentationsAtom).get(environmentId); @@ -344,6 +345,8 @@ export function useComposerSend(input: ComposerSendInput) { const navigate = useNavigate(); const clientSettingsHydrated = useClientSettingsHydrated(); const planModeEnabled = useEnvironmentSettings(environmentId, selectPlanModeEnabled); + const nadirRouting = useEnvironmentSettings(environmentId, selectNadirRouting); + const releaseModelPick = useComposerDraftStore((store) => store.releaseModelPick); const startThreadTurn = useOrchestrationCommand(threadEnvironment.startTurn, { reportFailure: false, }); @@ -1413,6 +1416,8 @@ export function useComposerSend(input: ComposerSendInput) { failure = startResult; } else { turnStartSucceeded = true; + // Nadir may run the turn on another model; the picker then shows the one that ran. + if (nadirRouting) releaseModelPick(scopeThreadRef(environmentId, threadIdForSend)); setKeepFullHistory(routeThreadKey, false); if (freshArm !== undefined) disarmFreshSession(routeThreadKey, freshArm); clearUsageLimitsFor(routeThreadKey); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index fca8b88925e96..311f3a739dab0 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -103,7 +103,7 @@ import { CodexSetupSection, CodexManagedRuntimeFields } from "./CodexSetupSectio import { readCodexSetupMode } from "./CodexSetupSection.logic"; import { providerClients } from "./providerDriverMeta"; import { searchableSetting } from "./settingsSearch"; -import { CompactIdleThreadsRow } from "./SettingsPanels"; +import { CompactIdleThreadsRow, NadirRoutingRow } from "./SettingsPanels"; import { backgroundActivityOverrideSettings, buildProviderInstanceUpdatePatch, @@ -1174,6 +1174,7 @@ export function EnvironmentProviderSettings({ } /> + {isAddInstanceDialogOpen && !readOnly ? ( diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d408d6c8d5d94..ef73654859d6c 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1433,6 +1433,42 @@ export function DiffsSettingsPanel() { ); } +export function NadirRoutingRow() { + return useOptionalSettingsScope() === null ? null : ; +} + +function ScopedNadirRoutingRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + const mixed = useScopedSettingsMixed(["nadirRouting"]); + + return ( + updateSettings({ nadirRouting: DEFAULT_UNIFIED_SETTINGS.nadirRouting })} + /> + ) : null + } + control={ + updateSettings({ nadirRouting: Boolean(checked) })} + aria-label="Nadir model routing" + /> + } + /> + ); +} + /** The Providers panel can render without a settings scope, as its tests do; this row needs one. */ export function CompactIdleThreadsRow() { return useOptionalSettingsScope() === null ? null : ; diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 4c890c05aa4e9..7ce7171616e9a 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -608,6 +608,13 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "environment-defaults", searchTerms: ["claude context compaction idle cache tokens usage summary resume"], }, + { + id: "nadir-routing", + title: "Nadir model routing", + to: "/settings/providers", + scope: "environment-defaults", + searchTerms: ["auto model picker cheaper haiku sonnet opus router cost savings getnadir"], + }, { id: "auto-settle-merged-threads", title: "Auto-settle merged threads", diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index aef9e88a38572..136ca637596ad 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -626,6 +626,8 @@ interface ComposerDraftStoreState { attachments: PersistedComposerImageAttachment[], ) => Promise; clearComposerContent: (threadRef: ComposerThreadTarget) => void; + /** Drops the draft's model pick so the picker follows the thread's model again. */ + releaseModelPick: (threadRef: ComposerThreadTarget) => void; /** Clears text and attachments, preserving terminal/element contexts, preview annotations, and review comments. */ clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void; /** Keeps attachments over destination limits, other environments' uploads, terminal/element contexts, annotations, and review comments in source. */ @@ -4035,6 +4037,19 @@ const composerDraftStore = create()( await Promise.resolve(); verifyPersistedAttachments(threadKey, attachments, set); }, + releaseModelPick: (threadRef) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current || current.activeProvider === null) return state; + const { modelSelectionExplicit: _explicit, ...retained } = current; + const draft = { ...retained, activeProvider: null, modelSelectionByProvider: {} }; + const drafts = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(draft)) delete drafts[threadKey]; + else drafts[threadKey] = draft; + return { draftsByThreadKey: drafts }; + }); + }, clearComposerContent: (threadRef) => { const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; if (threadKey.length === 0) { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 8a4b25589121c..7373fbc3a213a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1247,6 +1247,8 @@ export const ServerSettings = Schema.Struct({ continueThreadsAfterServerUpdate: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), ), + /** Before each user turn, Nadir picks the model from the selected instance's menu, never above the user's pick. */ + nadirRouting: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), enableAgentBrowserAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), speechModelId: Schema.String.pipe( @@ -1667,6 +1669,7 @@ export const ServerSettingsPatch = Schema.Struct({ responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), + nadirRouting: Schema.optionalKey(Schema.Boolean), enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), speechModelId: Schema.optionalKey(Schema.String), speechAcceleration: Schema.optionalKey(SpeechAcceleration), From 026b8a5e57ac7a54875700e640cfb925b88a116f Mon Sep 17 00:00:00 2001 From: doramirdor Date: Fri, 6 Nov 2026 10:57:15 -0500 Subject: [PATCH 02/16] fix: keep the user's effort when Nadir routes a turn A routed turn used to drop the selection's options, so the routed model ran at its own default effort (Sonnet 5.5 at High after an Opus 5.5 Medium pick). Carry the values the user's pick ran with, defaults included, wherever the routed model offers the same choice. An effort-only change on the routed model now counts as a new pick. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/NadirRouting.test.ts | 81 ++++++++++++++++++- .../src/orchestration-v2/NadirRouting.ts | 47 +++++++++-- 2 files changed, 119 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration-v2/NadirRouting.test.ts b/apps/server/src/orchestration-v2/NadirRouting.test.ts index 6568083811169..c336eea628c13 100644 --- a/apps/server/src/orchestration-v2/NadirRouting.test.ts +++ b/apps/server/src/orchestration-v2/NadirRouting.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "@effect/vitest"; -import { ProviderInstanceId, type ModelSelection } from "@t3tools/contracts"; +import { + ProviderDriverKind, + ProviderInstanceId, + type ModelSelection, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; import { baselineFor, @@ -7,6 +13,7 @@ import { nadirRequestBody, pickModel, rememberChoice, + routedSelection, } from "./NadirRouting.ts"; const menu = ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"]; @@ -80,6 +87,78 @@ describe("baselineFor", () => { const sonnet: ModelSelection = { instanceId, model: "claude-sonnet-4-6" }; expect(baselineFor("thread-1", sonnet)).toBe(sonnet); expect(baselineFor("thread-2", haiku)).toBe(haiku); + // Changing only the effort of the model Nadir chose is a pick too. + const haikuHigh: ModelSelection = { ...haiku, options: [{ id: "effort", value: "high" }] }; + expect(baselineFor("thread-1", haikuHigh)).toBe(haikuHigh); + }); +}); + +describe("routedSelection", () => { + const instanceId = ProviderInstanceId.make("claudeAgent"); + const model = (slug: string, efforts: string[], defaultEffort: string, fast = false) => + ({ + slug, + name: slug, + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Effort", + type: "select", + options: efforts.map((id) => ({ id, label: id, isDefault: id === defaultEffort })), + }, + ...(fast ? [{ id: "fastMode", label: "Fast", type: "boolean" as const }] : []), + ], + }, + }) satisfies ServerProviderModel; + const provider: ServerProvider = { + instanceId, + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + installed: true, + version: "0.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-11-06T00:00:00.000Z", + models: [ + model("opus", ["low", "medium", "high", "max"], "medium", true), + model("sonnet", ["medium", "high", "max"], "high"), + model("haiku", ["low", "medium", "high"], "medium"), + ], + slashCommands: [], + skills: [], + }; + + it.each([ + [ + "the effort the picker showed, not the routed model's default", + undefined, + "sonnet", + [{ id: "effort", value: "medium" }], + ], + [ + "a stated effort, dropping options the routed model lacks", + [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ], + "sonnet", + [{ id: "effort", value: "max" }], + ], + [ + "the routed model's default when it lacks the level", + [{ id: "effort", value: "max" }], + "haiku", + [{ id: "effort", value: "medium" }], + ], + ])("keeps %s", (_label, options, target, expected) => { + const baseline: ModelSelection = { instanceId, model: "opus", ...(options ? { options } : {}) }; + expect(routedSelection(baseline, target, provider)).toEqual({ + instanceId, + model: target, + options: expected, + }); }); }); diff --git a/apps/server/src/orchestration-v2/NadirRouting.ts b/apps/server/src/orchestration-v2/NadirRouting.ts index f29579194a9e1..a1ba1f0eb67e2 100644 --- a/apps/server/src/orchestration-v2/NadirRouting.ts +++ b/apps/server/src/orchestration-v2/NadirRouting.ts @@ -1,4 +1,11 @@ import type { ModelSelection, ServerProvider } from "@t3tools/contracts"; +import { + buildExplicitProviderOptionSelectionsFromDescriptors, + buildProviderOptionSelectionsFromDescriptors, + createModelSelection, + draftModelPickMatchesThread, + getProviderOptionDescriptors, +} from "@t3tools/shared/model"; import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; @@ -23,13 +30,12 @@ const MIN_PROMPT_WORDS = 4; const MAX_TRACKED_THREADS = 1_000; const ceilings = new Map(); -const sameModel = (left: ModelSelection, right: ModelSelection) => - left.instanceId === right.instanceId && left.model === right.model; - -/** The user's pick: the incoming selection, unless it is the model Nadir chose last turn. */ +/** The user's pick: the incoming selection, unless it is what Nadir chose last turn. */ export function baselineFor(threadId: string, incoming: ModelSelection): ModelSelection { const prior = ceilings.get(threadId); - return prior !== undefined && sameModel(prior.chosen, incoming) ? prior.baseline : incoming; + return prior !== undefined && draftModelPickMatchesThread(incoming, prior.chosen) + ? prior.baseline + : incoming; } export function rememberChoice( @@ -56,6 +62,33 @@ export function modelMenu(provider: ServerProvider | undefined): ReadonlyArray + provider?.models.find((candidate) => candidate.slug === slug)?.capabilities ?? undefined; + const from = capabilitiesOf(baseline.model); + const to = capabilitiesOf(model); + if (to === undefined) return createModelSelection(baseline.instanceId, model); + const picked = + from === undefined + ? baseline.options + : buildProviderOptionSelectionsFromDescriptors( + getProviderOptionDescriptors({ caps: from, selections: baseline.options }), + ); + const options = buildExplicitProviderOptionSelectionsFromDescriptors( + getProviderOptionDescriptors({ caps: to, selections: picked }), + picked, + ); + return createModelSelection(baseline.instanceId, model, options); +} + export interface NadirCacheState { /** The model holding the thread's warm prompt cache. */ readonly model: string; @@ -206,9 +239,7 @@ export const chooseModelSelection = Effect.fn("NadirRouting.chooseModelSelection Effect.orElseSucceed(() => null), ); const model = pickModel(answer, { baseline: baseline.model, current, menu }); - // Options such as effort belong to the user's model; the routed model uses its own defaults. - const chosen: ModelSelection = - model === baseline.model ? baseline : { instanceId: baseline.instanceId, model }; + const chosen = model === baseline.model ? baseline : routedSelection(baseline, model, provider); rememberChoice(input.threadId, baseline, chosen); if (model !== baseline.model) { yield* Effect.logInfo("Nadir routed turn", { from: baseline.model, to: model }); From 66fbc371417beee3536cf800a245a7cac9940c4a Mon Sep 17 00:00:00 2001 From: doramirdor Date: Fri, 6 Nov 2026 11:43:16 -0500 Subject: [PATCH 03/16] feat: Nadir compaction setting for Claude threads Adds an opt-in "Nadir compaction" setting next to Nadir model routing (Settings > Providers > Advanced, and the mobile Threads page). When on, new Claude threads run the compaction hooks of an installed Nadir (npx nadir-labs, or the nadir-route Claude Code plugin). They shrink bulky Read and Bash output on this machine and keep an exact archive the agent can read back. No network or model calls, and the plugin's routing hooks never run from this setting. - The hooks go into the Claude SDK flag settings after the user's own, in exec form so no shell parses the script path. - When Claude Code already runs Nadir's compaction from the user settings it loads, T3 leaves it to Claude Code, so it never runs twice. - The script pins itself at session start and leaves resumed sessions untouched, so turning this on mid-thread rewrites nothing. Co-Authored-By: Claude Opus 5.5 --- .../SettingsServerControlsRouteScreen.tsx | 15 ++- .../Adapters/ClaudeAdapterV2.test.ts | 6 + .../Adapters/ClaudeAdapterV2.ts | 38 ++++++- .../orchestration-v2/NadirCompaction.test.ts | 89 +++++++++++++++ .../src/orchestration-v2/NadirCompaction.ts | 106 ++++++++++++++++++ .../src/orchestration-v2/NadirRouting.ts | 2 +- .../settings/ProviderSettingsPanel.tsx | 3 +- .../components/settings/SettingsPanels.tsx | 50 ++++++--- .../src/components/settings/settingsSearch.ts | 7 ++ packages/contracts/src/settings.ts | 3 + 10 files changed, 302 insertions(+), 17 deletions(-) create mode 100644 apps/server/src/orchestration-v2/NadirCompaction.test.ts create mode 100644 apps/server/src/orchestration-v2/NadirCompaction.ts diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index fa01e4e676655..dbe41adaf5c92 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -394,7 +394,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { onValueChange={(value) => write({ enableAgentBrowserAccess: value })} /> - + write({ nadirRouting: value })} /> + write({ nadirCompaction: value })} + /> 0} /> diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index d7d03c779b4bb..191234ebf4556 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -367,11 +367,17 @@ describe("ClaudeAdapterV2 runtime query policy", () => { cwd: "/workspace", sdkSettings: { env: { OTHER: "kept" } }, toolSearchOverride: "true", + extraHooks: { + PostToolUse: [{ matcher: "Read", hooks: [{ type: "command", command: "x" }] }], + }, }); assert.deepEqual((options.settings as { env?: Record }).env, { OTHER: "kept", ENABLE_TOOL_SEARCH: "true", }); + assert.deepEqual((options.settings as { hooks?: Record }).hooks?.PostToolUse, [ + { matcher: "Read", hooks: [{ type: "command", command: "x" }] }, + ]); }); it("projects AskUserQuestion input with question text as the answer key", () => { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 10edb77c5011a..8ac42414dc535 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -182,6 +182,11 @@ import { mergeClaudeProcessEnvironment, resolveClaudeHomePath, } from "../../provider/Drivers/ClaudeHome.ts"; +import { + findNadirCompactionScript, + nadirCompactionHooks, + withExtraHooks, +} from "../NadirCompaction.ts"; import { BUNDLED_CLAUDE_MODEL_CATALOG, resolveClaudeCatalogContextWindowTokens, @@ -1775,6 +1780,8 @@ export function makeClaudeQueryOptions(input: { readonly appendSystemPromptFileText?: string; // Flag settings, because a user's settings.json env beats the process env in Claude Code. readonly toolSearchOverride?: "true"; + /** Hooks to run beside the user's own, such as Nadir compaction. */ + readonly extraHooks?: ClaudeSdkSettings["hooks"]; }): ClaudeAgentSdkQueryOptions { const compiledSelection = compileClaudeModelSelection(input.modelSelection, input.modelCatalog); const { @@ -1823,13 +1830,17 @@ export function makeClaudeQueryOptions(input: { autoCompactWindow: Number(input.settings.autoCompactWindow), } as ClaudeSdkSettings); // A settings file path cannot take extra keys, so it goes through unchanged. - const effectiveQuerySettings = + const toolSearchQuerySettings = input.toolSearchOverride === undefined || typeof compactQuerySettings === "string" ? compactQuerySettings : ({ ...compactQuerySettings, env: { ...compactQuerySettings?.env, ENABLE_TOOL_SEARCH: input.toolSearchOverride }, } as ClaudeSdkSettings); + const effectiveQuerySettings = + input.extraHooks === undefined || typeof toolSearchQuerySettings === "string" + ? toolSearchQuerySettings + : withExtraHooks(toolSearchQuerySettings, input.extraHooks); const deviceInstructions = input.mcpServers !== undefined && input.deviceToolsAvailable === true; const options: ClaudeAgentSdkQueryOptions = { model: compiledSelection.apiModelId, @@ -4890,6 +4901,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ).pipe(Effect.provideService(Path.Path, path)); const hostEnvironment = yield* HostProcess.Environment; const hostPlatform = yield* HostProcess.Platform; + const hostHome = yield* HostProcess.HomeDirectory; + const nadirStateDir = path.join(hostHome, ".nadir", "context"); const customHome = adapterOptions.settings.homePath.trim().length > 0 || adapterOptions.environment.CLAUDE_CONFIG_DIR !== hostEnvironment.CLAUDE_CONFIG_DIR; @@ -5003,6 +5016,27 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (path.dirname(directory) === directory) return cwd; } }); + const resolveNadirCompactionHooks = Effect.gen(function* () { + if (Option.isNone(serverSettings)) return undefined; + const enabled = yield* serverSettings.value.getSettings.pipe( + Effect.map((settings) => settings.nadirCompaction), + Effect.orElseSucceed(() => false), + ); + if (!enabled) return undefined; + const script = yield* findNadirCompactionScript({ + fileSystem, + path, + claudeHome: homePath, + hostHome, + userSettingsLoaded: !customHome, + }); + if (script === undefined) { + yield* Effect.logDebug("Nadir compaction skipped: not installed, or Claude Code runs it"); + return undefined; + } + return nadirCompactionHooks(script, nadirStateDir); + }); + const settingsFilesByPrecedence = Effect.fn("ClaudeAdapterV2.settingsFilesByPrecedence")( function* (cwd: string | null) { const localDirectory = cwd === null ? null : yield* localSettingsDirectory(cwd); @@ -10914,6 +10948,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( turnInput.runtimePolicy.cwd, launchArgSettings, ); + const extraHooks = yield* resolveNadirCompactionHooks; const queryOptions = makeClaudeQueryOptions({ modelSelection: turnInput.modelSelection, modelCatalog, @@ -10954,6 +10989,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( supportedDialogKinds: ["resume_return"], ...(appendSystemPromptFileText === undefined ? {} : { appendSystemPromptFileText }), ...(toolSearchOverride === undefined ? {} : { toolSearchOverride }), + ...(extraHooks === undefined ? {} : { extraHooks }), }); const resolvedTurnCwd = yield* fileSystem .realPath(turnInput.runtimePolicy.cwd ?? ".") diff --git a/apps/server/src/orchestration-v2/NadirCompaction.test.ts b/apps/server/src/orchestration-v2/NadirCompaction.test.ts new file mode 100644 index 0000000000000..57a9c485228de --- /dev/null +++ b/apps/server/src/orchestration-v2/NadirCompaction.test.ts @@ -0,0 +1,89 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { + findNadirCompactionScript, + nadirCompactionHooks, + withExtraHooks, +} from "./NadirCompaction.ts"; + +it.layer(NodeServices.layer)("NadirCompaction", (it) => { + describe("findNadirCompactionScript", () => { + it.effect("finds an installed script unless the loaded Claude settings already run it", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const claudeHome = yield* fileSystem.makeTempDirectoryScoped(); + const hostHome = yield* fileSystem.makeTempDirectoryScoped(); + const find = (userSettingsLoaded = true) => + findNadirCompactionScript({ fileSystem, path, claudeHome, hostHome, userSettingsLoaded }); + const touch = (file: string) => + fileSystem + .makeDirectory(path.dirname(file), { recursive: true }) + .pipe(Effect.andThen(fileSystem.writeFileString(file, ""))); + const writeJson = (file: string, value: unknown) => + fileSystem.writeFileString(file, JSON.stringify(value)); + expect(yield* find()).toBeUndefined(); + + const installPath = path.join(claudeHome, "plugins", "cache", "nadir-route", "1.0.0"); + const pluginScript = path.join(installPath, "scripts", "context_session.py"); + yield* touch(pluginScript); + yield* writeJson(path.join(claudeHome, "plugins", "installed_plugins.json"), { + plugins: { "nadir-route@nadir": [{ scope: "user", installPath }] }, + }); + expect(yield* find()).toBe(pluginScript); + + // npx nadir-labs installs the script beside the user's Claude settings. + const labsScript = path.join(claudeHome, "nadir", "context_session.py"); + yield* touch(labsScript); + expect(yield* find()).toBe(labsScript); + + yield* writeJson(path.join(claudeHome, "settings.json"), { + hooks: { + PostToolUse: [ + { + matcher: "Read|Bash", + hooks: [{ type: "command", command: `python3 ${labsScript}` }], + }, + ], + }, + }); + expect(yield* find()).toBeUndefined(); + // A separate T3 Claude home skips user settings, so T3 must add the hooks itself. + expect(yield* find(false)).toBe(labsScript); + + yield* writeJson(path.join(claudeHome, "settings.json"), { + enabledPlugins: { "nadir-route@nadir": true }, + }); + expect(yield* find()).toBeUndefined(); + }).pipe(Effect.scoped), + ); + }); + + it("adds Nadir's hooks after the user's own", () => { + const own = { type: "command" as const, command: "own-hook" }; + const settings = withExtraHooks( + { env: { KEPT: "1" }, hooks: { PostToolUse: [{ matcher: "Write", hooks: [own] }] } }, + nadirCompactionHooks("/plugin/scripts/context_session.py", "/home/.nadir/context"), + ); + expect(settings.env).toEqual({ KEPT: "1" }); + expect(settings.hooks?.PostToolUse?.map((entry) => entry.matcher)).toEqual([ + "Write", + "Read|Bash", + ]); + expect(settings.hooks?.SessionStart?.[0]?.hooks[0]).toMatchObject({ + command: "python3", + args: [ + "/plugin/scripts/context_session.py", + "--state-dir", + "/home/.nadir/context", + "--max-chars", + "8000", + "--no-policy", + ], + }); + }); +}); diff --git a/apps/server/src/orchestration-v2/NadirCompaction.ts b/apps/server/src/orchestration-v2/NadirCompaction.ts new file mode 100644 index 0000000000000..ba40f89d691ec --- /dev/null +++ b/apps/server/src/orchestration-v2/NadirCompaction.ts @@ -0,0 +1,106 @@ +import type { Settings as ClaudeSdkSettings } from "@anthropic-ai/claude-agent-sdk"; +import * as Effect from "effect/Effect"; +import type * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import type * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +// Nadir compaction: Claude turns run an installed Nadir's compaction hooks, which shrink bulky +// Read and Bash output locally and keep an exact archive. Its routing hooks never run from here. +// The script pins at session start, so resumed sessions stay untouched. + +const PLUGIN_ID = "nadir-route@nadir"; + +type ClaudeHooks = NonNullable; + +const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); + +const readJson = (fileSystem: FileSystem.FileSystem, file: string) => + fileSystem.readFileString(file).pipe( + Effect.map((text) => Option.getOrUndefined(decodeJson(text))), + Effect.orElseSucceed(() => undefined), + ); + +const field = (value: unknown, key: string): unknown => + typeof value === "object" && value !== null ? (value as Record)[key] : undefined; + +const SCRIPT = "context_session.py"; + +/** Whether Claude Code settings already run Nadir's compaction, as `npx nadir-labs` installs it. */ +function runsNadirCompaction(settings: unknown): boolean { + if (field(field(settings, "enabledPlugins"), PLUGIN_ID) === true) return true; + const hooks = field(settings, "hooks"); + return ( + typeof hooks === "object" && + hooks !== null && + Object.values(hooks) + .flat() + .some((matcher) => JSON.stringify(field(matcher, "hooks") ?? []).includes(SCRIPT)) + ); +} + +/** + * The compaction script to run, or undefined when Nadir is not installed or Claude Code already + * runs it from the user settings it loads, so the hooks never run twice. Looks in the + * `npx nadir-labs` install first, then the `nadir-route` plugin. + */ +export const findNadirCompactionScript = Effect.fn("NadirCompaction.findScript")(function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly claudeHome: string; + readonly hostHome: string; + /** False when T3 runs Claude with its own home, which skips the user's settings. */ + readonly userSettingsLoaded: boolean; +}) { + const { fileSystem, path, claudeHome } = input; + if (input.userSettingsLoaded) { + const userSettings = yield* readJson(fileSystem, path.join(claudeHome, "settings.json")); + if (runsNadirCompaction(userSettings)) return undefined; + } + const installed = yield* readJson( + fileSystem, + path.join(claudeHome, "plugins", "installed_plugins.json"), + ); + const entries = field(field(installed, "plugins"), PLUGIN_ID); + const pluginPath = Array.isArray(entries) ? field(entries[0], "installPath") : undefined; + const candidates = [ + path.join(claudeHome, "nadir", SCRIPT), + path.join(input.hostHome, ".claude", "nadir", SCRIPT), + ...(typeof pluginPath === "string" ? [path.join(pluginPath, "scripts", SCRIPT)] : []), + ]; + for (const script of candidates) { + if (yield* fileSystem.exists(script).pipe(Effect.orElseSucceed(() => false))) return script; + } + return undefined; +}); + +/** The plugin's own compaction hook entries, in exec form so no shell parses the paths. */ +export function nadirCompactionHooks(script: string, stateDir: string): ClaudeHooks { + const hooks = [ + { + type: "command" as const, + command: "python3", + args: [script, "--state-dir", stateDir, "--max-chars", "8000", "--no-policy"], + timeout: 5, + }, + ]; + return { + SessionStart: [{ matcher: "startup|clear|resume|compact", hooks }], + UserPromptSubmit: [{ hooks }], + SubagentStart: [{ matcher: ".*", hooks }], + PreToolUse: [{ matcher: "Read", hooks }], + PostToolUse: [{ matcher: "Read|Bash", hooks }], + }; +} + +/** Adds hook entries after any the settings already carry. */ +export function withExtraHooks( + settings: ClaudeSdkSettings | undefined, + extra: ClaudeHooks, +): ClaudeSdkSettings { + const hooks: ClaudeHooks = { ...settings?.hooks }; + for (const [event, matchers] of Object.entries(extra)) { + hooks[event] = [...(hooks[event] ?? []), ...matchers]; + } + return { ...settings, hooks }; +} diff --git a/apps/server/src/orchestration-v2/NadirRouting.ts b/apps/server/src/orchestration-v2/NadirRouting.ts index a1ba1f0eb67e2..de091ba211ea3 100644 --- a/apps/server/src/orchestration-v2/NadirRouting.ts +++ b/apps/server/src/orchestration-v2/NadirRouting.ts @@ -56,7 +56,7 @@ export function forgetChoices(): void { } /** Models the user can pick for this instance, the menu Nadir chooses from. */ -export function modelMenu(provider: ServerProvider | undefined): ReadonlyArray { +function modelMenu(provider: ServerProvider | undefined): ReadonlyArray { return ( provider?.models.filter((model) => model.isLegacy !== true).map((model) => model.slug) ?? [] ); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 311f3a739dab0..f8b6a6db488a1 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -103,7 +103,7 @@ import { CodexSetupSection, CodexManagedRuntimeFields } from "./CodexSetupSectio import { readCodexSetupMode } from "./CodexSetupSection.logic"; import { providerClients } from "./providerDriverMeta"; import { searchableSetting } from "./settingsSearch"; -import { CompactIdleThreadsRow, NadirRoutingRow } from "./SettingsPanels"; +import { CompactIdleThreadsRow, NadirCompactionRow, NadirRoutingRow } from "./SettingsPanels"; import { backgroundActivityOverrideSettings, buildProviderInstanceUpdatePatch, @@ -1175,6 +1175,7 @@ export function EnvironmentProviderSettings({ /> + {isAddInstanceDialogOpen && !readOnly ? ( diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index ef73654859d6c..cbd38ede60942 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1433,36 +1433,60 @@ export function DiffsSettingsPanel() { ); } +const NADIR_SETTING_ROWS = { + nadirRouting: { + label: "Nadir model routing", + searchId: "nadir-routing", + description: + "Before each message you send, Nadir picks the model from the selected provider's list, never above the one you chose, and the model picker shows what ran. Sends the message text to getnadir.com. Set NADIR_API_KEY on the host to use your Nadir account.", + }, + nadirCompaction: { + label: "Nadir compaction", + searchId: "nadir-compaction", + description: + "New Claude threads shrink bulky file reads and command output with Nadir's compaction, keeping an exact copy the agent can read back. Runs on this machine with no network calls. Needs Nadir installed (npx nadir-labs); when Claude Code already runs it, T3 leaves it to Claude Code.", + }, +} as const; + export function NadirRoutingRow() { - return useOptionalSettingsScope() === null ? null : ; + return useOptionalSettingsScope() === null ? null : ( + + ); +} + +export function NadirCompactionRow() { + return useOptionalSettingsScope() === null ? null : ( + + ); } -function ScopedNadirRoutingRow() { +function ScopedNadirSettingRow({ settingKey }: { settingKey: keyof typeof NADIR_SETTING_ROWS }) { + const { label, searchId, description } = NADIR_SETTING_ROWS[settingKey]; const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); - const mixed = useScopedSettingsMixed(["nadirRouting"]); + const mixed = useScopedSettingsMixed([settingKey]); return ( updateSettings({ nadirRouting: DEFAULT_UNIFIED_SETTINGS.nadirRouting })} + label={label} + onClick={() => updateSettings({ [settingKey]: DEFAULT_UNIFIED_SETTINGS[settingKey] })} /> ) : null } control={ updateSettings({ nadirRouting: Boolean(checked) })} - aria-label="Nadir model routing" + settingKeys={[settingKey]} + checked={settings[settingKey]} + onCheckedChange={(checked) => updateSettings({ [settingKey]: Boolean(checked) })} + aria-label={label} /> } /> diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 7ce7171616e9a..752abdcf8bfcc 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -615,6 +615,13 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "environment-defaults", searchTerms: ["auto model picker cheaper haiku sonnet opus router cost savings getnadir"], }, + { + id: "nadir-compaction", + title: "Nadir compaction", + to: "/settings/providers", + scope: "environment-defaults", + searchTerms: ["context tool output read bash shrink tokens claude plugin getnadir"], + }, { id: "auto-settle-merged-threads", title: "Auto-settle merged threads", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7373fbc3a213a..0475b642ef512 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1249,6 +1249,8 @@ export const ServerSettings = Schema.Struct({ ), /** Before each user turn, Nadir picks the model from the selected instance's menu, never above the user's pick. */ nadirRouting: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + /** New Claude sessions run the compaction hooks of Nadir's installed Claude Code plugin. */ + nadirCompaction: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), enableAgentBrowserAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), speechModelId: Schema.String.pipe( @@ -1670,6 +1672,7 @@ export const ServerSettingsPatch = Schema.Struct({ enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), nadirRouting: Schema.optionalKey(Schema.Boolean), + nadirCompaction: Schema.optionalKey(Schema.Boolean), enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), speechModelId: Schema.optionalKey(Schema.String), speechAcceleration: Schema.optionalKey(SpeechAcceleration), From c79afcd9dce0785da5a3d91d63607d2e92927a84 Mon Sep 17 00:00:00 2001 From: doramirdor Date: Fri, 6 Nov 2026 12:14:28 -0500 Subject: [PATCH 04/16] fix: address review on Nadir routing and compaction - Persist each thread's routing ceiling in a file-backed store under the server state directory, so a restart or a long-running server can no longer turn a routed-down model into the thread's ceiling. The entry exists only while the thread runs below its ceiling. - Find the nadir-route plugin through the host's own Claude home as well, so a separate T3 Claude home still finds it. - Release the composer's model pick after a routed send only when it is still the pick the send used, so a pick made while the send was in flight is kept. Co-Authored-By: Claude Opus 5.5 --- .../orchestration-v2/NadirCompaction.test.ts | 18 +++ .../src/orchestration-v2/NadirCompaction.ts | 24 ++-- .../src/orchestration-v2/NadirRouting.test.ts | 60 +++++++--- .../src/orchestration-v2/NadirRouting.ts | 105 +++++++++++++----- apps/server/src/server.ts | 2 + .../src/components/chat/useComposerSend.ts | 8 +- apps/web/src/composerDraftStore.test.ts | 14 +++ apps/web/src/composerDraftStore.ts | 23 +++- 8 files changed, 193 insertions(+), 61 deletions(-) diff --git a/apps/server/src/orchestration-v2/NadirCompaction.test.ts b/apps/server/src/orchestration-v2/NadirCompaction.test.ts index 57a9c485228de..4996daef2fe66 100644 --- a/apps/server/src/orchestration-v2/NadirCompaction.test.ts +++ b/apps/server/src/orchestration-v2/NadirCompaction.test.ts @@ -55,6 +55,24 @@ it.layer(NodeServices.layer)("NadirCompaction", (it) => { // A separate T3 Claude home skips user settings, so T3 must add the hooks itself. expect(yield* find(false)).toBe(labsScript); + // A plugin installed only under the host's own Claude home is found from a separate one. + const separateHome = yield* fileSystem.makeTempDirectoryScoped(); + const hostPluginPath = path.join(hostHome, ".claude", "plugins", "cache", "nadir-route"); + const hostPluginScript = path.join(hostPluginPath, "scripts", "context_session.py"); + yield* touch(hostPluginScript); + yield* writeJson(path.join(hostHome, ".claude", "plugins", "installed_plugins.json"), { + plugins: { "nadir-route@nadir": [{ scope: "user", installPath: hostPluginPath }] }, + }); + expect( + yield* findNadirCompactionScript({ + fileSystem, + path, + claudeHome: separateHome, + hostHome, + userSettingsLoaded: false, + }), + ).toBe(hostPluginScript); + yield* writeJson(path.join(claudeHome, "settings.json"), { enabledPlugins: { "nadir-route@nadir": true }, }); diff --git a/apps/server/src/orchestration-v2/NadirCompaction.ts b/apps/server/src/orchestration-v2/NadirCompaction.ts index ba40f89d691ec..3f041cf58ad04 100644 --- a/apps/server/src/orchestration-v2/NadirCompaction.ts +++ b/apps/server/src/orchestration-v2/NadirCompaction.ts @@ -57,17 +57,19 @@ export const findNadirCompactionScript = Effect.fn("NadirCompaction.findScript") const userSettings = yield* readJson(fileSystem, path.join(claudeHome, "settings.json")); if (runsNadirCompaction(userSettings)) return undefined; } - const installed = yield* readJson( - fileSystem, - path.join(claudeHome, "plugins", "installed_plugins.json"), - ); - const entries = field(field(installed, "plugins"), PLUGIN_ID); - const pluginPath = Array.isArray(entries) ? field(entries[0], "installPath") : undefined; - const candidates = [ - path.join(claudeHome, "nadir", SCRIPT), - path.join(input.hostHome, ".claude", "nadir", SCRIPT), - ...(typeof pluginPath === "string" ? [path.join(pluginPath, "scripts", SCRIPT)] : []), - ]; + const homes = [claudeHome, path.join(input.hostHome, ".claude")]; + const pluginScripts: string[] = []; + for (const home of homes) { + const installed = yield* readJson( + fileSystem, + path.join(home, "plugins", "installed_plugins.json"), + ); + const entries = field(field(installed, "plugins"), PLUGIN_ID); + const pluginPath = Array.isArray(entries) ? field(entries[0], "installPath") : undefined; + if (typeof pluginPath === "string") + pluginScripts.push(path.join(pluginPath, "scripts", SCRIPT)); + } + const candidates = [...homes.map((home) => path.join(home, "nadir", SCRIPT)), ...pluginScripts]; for (const script of candidates) { if (yield* fileSystem.exists(script).pipe(Effect.orElseSucceed(() => false))) return script; } diff --git a/apps/server/src/orchestration-v2/NadirRouting.test.ts b/apps/server/src/orchestration-v2/NadirRouting.test.ts index c336eea628c13..90eef436c859a 100644 --- a/apps/server/src/orchestration-v2/NadirRouting.test.ts +++ b/apps/server/src/orchestration-v2/NadirRouting.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import { ProviderDriverKind, ProviderInstanceId, @@ -9,7 +12,9 @@ import { import { baselineFor, - forgetChoices, + layerCeilingsAt, + layerCeilingsMemory, + NadirCeilings, nadirRequestBody, pickModel, rememberChoice, @@ -74,23 +79,44 @@ describe("baselineFor", () => { const opus: ModelSelection = { instanceId, model: "claude-opus-4-6", options: [] }; const haiku: ModelSelection = { instanceId, model: "claude-haiku-4-5" }; - it("keeps the user's pick as the ceiling after Nadir routes down", () => { - forgetChoices(); - rememberChoice("thread-1", opus, haiku); - // The picker now shows Haiku; sending again must not make Haiku the ceiling. - expect(baselineFor("thread-1", haiku)).toBe(opus); - }); + it.effect("keeps the user's pick as the ceiling after Nadir routes down", () => + Effect.gen(function* () { + const ceilings = yield* NadirCeilings; + yield* rememberChoice(ceilings, "thread-1", opus, haiku); + // The picker now shows Haiku; sending again must not make Haiku the ceiling. + expect(yield* baselineFor(ceilings, "thread-1", haiku)).toEqual(opus); + // Kept only while the thread runs below its ceiling. + yield* rememberChoice(ceilings, "thread-1", opus, opus); + expect(yield* ceilings.has("thread-1")).toBe(false); + }).pipe(Effect.provide(layerCeilingsMemory)), + ); - it("treats a model the user picks by hand as the new ceiling", () => { - forgetChoices(); - rememberChoice("thread-1", opus, haiku); - const sonnet: ModelSelection = { instanceId, model: "claude-sonnet-4-6" }; - expect(baselineFor("thread-1", sonnet)).toBe(sonnet); - expect(baselineFor("thread-2", haiku)).toBe(haiku); - // Changing only the effort of the model Nadir chose is a pick too. - const haikuHigh: ModelSelection = { ...haiku, options: [{ id: "effort", value: "high" }] }; - expect(baselineFor("thread-1", haikuHigh)).toBe(haikuHigh); - }); + it.effect("keeps the ceiling across a server restart", () => + Effect.gen(function* () { + const directory = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped(); + yield* Effect.gen(function* () { + yield* rememberChoice(yield* NadirCeilings, "thread-1", opus, haiku); + }).pipe(Effect.provide(layerCeilingsAt(directory))); + // A fresh store on the same directory is what the next server process opens. + const restarted = yield* Effect.gen(function* () { + return yield* baselineFor(yield* NadirCeilings, "thread-1", haiku); + }).pipe(Effect.provide(layerCeilingsAt(directory))); + expect(restarted).toEqual(opus); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("treats a model the user picks by hand as the new ceiling", () => + Effect.gen(function* () { + const ceilings = yield* NadirCeilings; + yield* rememberChoice(ceilings, "thread-1", opus, haiku); + const sonnet: ModelSelection = { instanceId, model: "claude-sonnet-4-6" }; + expect(yield* baselineFor(ceilings, "thread-1", sonnet)).toBe(sonnet); + expect(yield* baselineFor(ceilings, "thread-2", haiku)).toBe(haiku); + // Changing only the effort of the model Nadir chose is a pick too. + const haikuHigh: ModelSelection = { ...haiku, options: [{ id: "effort", value: "high" }] }; + expect(yield* baselineFor(ceilings, "thread-1", haikuHigh)).toBe(haikuHigh); + }).pipe(Effect.provide(layerCeilingsMemory)), + ); }); describe("routedSelection", () => { diff --git a/apps/server/src/orchestration-v2/NadirRouting.ts b/apps/server/src/orchestration-v2/NadirRouting.ts index de091ba211ea3..3469497dfa0fc 100644 --- a/apps/server/src/orchestration-v2/NadirRouting.ts +++ b/apps/server/src/orchestration-v2/NadirRouting.ts @@ -1,4 +1,4 @@ -import type { ModelSelection, ServerProvider } from "@t3tools/contracts"; +import { ModelSelection, type ServerProvider } from "@t3tools/contracts"; import { buildExplicitProviderOptionSelectionsFromDescriptors, buildProviderOptionSelectionsFromDescriptors, @@ -7,9 +7,16 @@ import { getProviderOptionDescriptors, } from "@t3tools/shared/model"; import * as Config from "effect/Config"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as KeyValueStore from "effect/persistence/KeyValueStore"; +import * as Schema from "effect/Schema"; + +import * as ServerConfig from "../config.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -25,35 +32,71 @@ const MAX_PROMPT_CHARS = 40_000; // A short follow-up ("yes", "do it") depends on context Nadir does not see. const MIN_PROMPT_WORDS = 4; -// ponytail: per-process memory. A restart forgets the ceiling and treats the thread's current -// model as the user's pick; persist it on the thread if that ever matters. -const MAX_TRACKED_THREADS = 1_000; -const ceilings = new Map(); +const Ceiling = Schema.Struct({ baseline: ModelSelection, chosen: ModelSelection }); + +/** + * A thread's routed turn: the user's pick (the ceiling) and the model Nadir ran instead. It + * lives on disk so a restart cannot turn a routed-down model into the thread's ceiling. + */ +export class NadirCeilings extends Context.Service< + NadirCeilings, + KeyValueStore.SchemaStore +>()("t3/orchestration-v2/NadirRouting/NadirCeilings") {} + +const layerFromStore = Layer.effect( + NadirCeilings, + Effect.gen(function* () { + return KeyValueStore.toSchemaStore(yield* KeyValueStore.KeyValueStore, Ceiling); + }), +); + +export const layerCeilingsMemory = layerFromStore.pipe(Layer.provide(KeyValueStore.layerMemory)); + +export const layerCeilingsAt = (directory: string) => + layerFromStore.pipe(Layer.provide(KeyValueStore.layerFileSystem(directory))); + +// ponytail: one small file per thread while it runs below its ceiling; a thread deleted in that +// state leaves its file. Prune with thread deletion if the directory ever grows. +export const layerCeilings = Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const path = yield* Path.Path; + return layerCeilingsAt(path.join(config.stateDir, "nadir-ceilings")).pipe( + Layer.catch(() => + Layer.effectDiscard( + Effect.logWarning("Nadir ceiling directory unavailable; ceilings reset on restart"), + ).pipe(Layer.provideMerge(layerCeilingsMemory)), + ), + ); + }), +); /** The user's pick: the incoming selection, unless it is what Nadir chose last turn. */ -export function baselineFor(threadId: string, incoming: ModelSelection): ModelSelection { - const prior = ceilings.get(threadId); - return prior !== undefined && draftModelPickMatchesThread(incoming, prior.chosen) - ? prior.baseline - : incoming; -} +export const baselineFor = ( + ceilings: NadirCeilings["Service"], + threadId: string, + incoming: ModelSelection, +) => + ceilings.get(threadId).pipe( + Effect.map((prior) => + Option.isSome(prior) && draftModelPickMatchesThread(incoming, prior.value.chosen) + ? prior.value.baseline + : incoming, + ), + Effect.orElseSucceed(() => incoming), + ); -export function rememberChoice( +/** Keeps the ceiling only while the thread runs below it. */ +export const rememberChoice = ( + ceilings: NadirCeilings["Service"], threadId: string, baseline: ModelSelection, chosen: ModelSelection, -): void { - ceilings.delete(threadId); - ceilings.set(threadId, { baseline, chosen }); - if (ceilings.size > MAX_TRACKED_THREADS) { - const oldest = ceilings.keys().next().value; - if (oldest !== undefined) ceilings.delete(oldest); - } -} - -export function forgetChoices(): void { - ceilings.clear(); -} +) => + (baseline === chosen + ? ceilings.remove(threadId) + : ceilings.set(threadId, { baseline, chosen }) + ).pipe(Effect.catch((cause) => Effect.logWarning("Nadir ceiling not saved", { cause }))); /** Models the user can pick for this instance, the menu Nadir chooses from. */ function modelMenu(provider: ServerProvider | undefined): ReadonlyArray { @@ -207,10 +250,16 @@ export const chooseModelSelection = Effect.fn("NadirRouting.chooseModelSelection // Optional so callers outside the client intake (MCP tools, tests) need not provide them. const client = yield* Effect.serviceOption(HttpClient.HttpClient); const registry = yield* Effect.serviceOption(ProviderRegistry.ProviderRegistry); - if (Option.isNone(client) || Option.isNone(registry) || !(yield* isEnabled)) { + const ceilings = yield* Effect.serviceOption(NadirCeilings); + if ( + Option.isNone(client) || + Option.isNone(registry) || + Option.isNone(ceilings) || + !(yield* isEnabled) + ) { return input.selection; } - const baseline = baselineFor(input.threadId, input.selection); + const baseline = yield* baselineFor(ceilings.value, input.threadId, input.selection); const text = input.text.trim(); const judgeable = text.length <= MAX_PROMPT_CHARS && @@ -220,7 +269,7 @@ export const chooseModelSelection = Effect.fn("NadirRouting.chooseModelSelection const provider = providers.find((candidate) => candidate.instanceId === baseline.instanceId); const menu = modelMenu(provider); if (!judgeable || !menu.includes(baseline.model)) { - rememberChoice(input.threadId, baseline, baseline); + yield* rememberChoice(ceilings.value, input.threadId, baseline, baseline); return baseline; } const current = input.cache?.model ?? input.selection.model; @@ -240,7 +289,7 @@ export const chooseModelSelection = Effect.fn("NadirRouting.chooseModelSelection ); const model = pickModel(answer, { baseline: baseline.model, current, menu }); const chosen = model === baseline.model ? baseline : routedSelection(baseline, model, provider); - rememberChoice(input.threadId, baseline, chosen); + yield* rememberChoice(ceilings.value, input.threadId, baseline, chosen); if (model !== baseline.model) { yield* Effect.logInfo("Nadir routed turn", { from: baseline.model, to: model }); } diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 5c9ab9c433ffc..80da9c1ddfc7b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -6,6 +6,7 @@ import * as AssetSigner from "./assets/AssetSigner.ts"; import * as DatabaseStorage from "./persistence/DatabaseStorage.ts"; import * as StorageCleanup from "./storageCleanup.ts"; import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts"; +import * as NadirRouting from "./orchestration-v2/NadirRouting.ts"; import * as PullRequestWatchReactor from "./orchestration-v2/PullRequestWatchReactor.ts"; import * as DelegatedTaskQuestionReactor from "./orchestration-v2/DelegatedTaskQuestionReactor.ts"; // Effect wraps this raw Node HTTP server after its startup and write-error guards attach. @@ -1202,6 +1203,7 @@ const layerMakeServer = Layer.unwrap( Layer.provideMerge(layerHttpServer), Layer.provide(layerApplicationObservability), Layer.provideMerge(FetchHttpClient.layer), + Layer.provideMerge(NadirRouting.layerCeilings), Layer.provide(VcsProcess.layer), Layer.provideMerge(AgentScopeLive.layer), Layer.provideMerge(layerPlatformServices), diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index b3e1209f40b2d..e28704f92a339 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -72,6 +72,7 @@ import { markPromotedDraftThreadByRef, restoreFailedDraftThread, composerDraftHasUserContent, + draftModelPick, type ComposerFileAttachment, type ComposerImageAttachment, type DraftThreadEnvMode, @@ -860,6 +861,9 @@ export function useComposerSend(input: ComposerSendInput) { return; } const threadIdForSend = activeThread.id; + const modelPickAtSend = draftModelPick( + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), + ); const sendSnapshot = snapshotComposerSend({ prompt: promptForSend, images: composerImages, @@ -1417,7 +1421,9 @@ export function useComposerSend(input: ComposerSendInput) { } else { turnStartSucceeded = true; // Nadir may run the turn on another model; the picker then shows the one that ran. - if (nadirRouting) releaseModelPick(scopeThreadRef(environmentId, threadIdForSend)); + if (nadirRouting) { + releaseModelPick(scopeThreadRef(environmentId, threadIdForSend), modelPickAtSend); + } setKeepFullHistory(routeThreadKey, false); if (freshArm !== undefined) disarmFreshSession(routeThreadKey, freshArm); clearUsageLimitsFor(routeThreadKey); diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index cd0e74d231aac..8b07081bee708 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1560,6 +1560,20 @@ describe("composerDraftStore modelSelection", () => { expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined(); }); + it("releases the pick a send used but keeps one changed while it was in flight", () => { + const store = useComposerDraftStore.getState(); + const sent = modelSelection(CODEX_DRIVER, "gpt-5.5"); + const next = modelSelection(CODEX_DRIVER, "gpt-5.4"); + store.setModelSelection(threadRef, sent, { explicit: true }); + store.setModelSelection(threadRef, next, { explicit: true }); + store.releaseModelPick(threadRef, sent); + expect( + draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[next.instanceId], + ).toEqual(next); + store.releaseModelPick(threadRef, next); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined(); + }); + it("releases an inactive thread pick only after its queued model becomes durable", () => { const store = useComposerDraftStore.getState(); const pick = modelSelection(CODEX_DRIVER, "gpt-5.5", { fastMode: false }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 136ca637596ad..22af22c4cdc0b 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -626,8 +626,11 @@ interface ComposerDraftStoreState { attachments: PersistedComposerImageAttachment[], ) => Promise; clearComposerContent: (threadRef: ComposerThreadTarget) => void; - /** Drops the draft's model pick so the picker follows the thread's model again. */ - releaseModelPick: (threadRef: ComposerThreadTarget) => void; + /** + * Drops the draft's model pick so the picker follows the thread's model again, unless the + * pick changed from `sentPick` while the send was in flight. + */ + releaseModelPick: (threadRef: ComposerThreadTarget, sentPick: ModelSelection | undefined) => void; /** Clears text and attachments, preserving terminal/element contexts, preview annotations, and review comments. */ clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void; /** Keeps attachments over destination limits, other environments' uploads, terminal/element contexts, annotations, and review comments in source. */ @@ -1395,6 +1398,16 @@ function normalizeComposerTarget( return target; } +/** The model the draft's picker holds for its active provider, if the user picked one. */ +export function draftModelPick( + draft: + | Pick + | null + | undefined, +): ModelSelection | undefined { + return draft?.activeProvider ? draft.modelSelectionByProvider[draft.activeProvider] : undefined; +} + export function resolveComposerDraftKey( state: ComposerThreadLookupState, target: ComposerThreadTarget, @@ -4037,11 +4050,13 @@ const composerDraftStore = create()( await Promise.resolve(); verifyPersistedAttachments(threadKey, attachments, set); }, - releaseModelPick: (threadRef) => { + releaseModelPick: (threadRef, sentPick) => { const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; set((state) => { const current = state.draftsByThreadKey[threadKey]; - if (!current || current.activeProvider === null) return state; + const pick = draftModelPick(current); + if (!current || pick === undefined || sentPick === undefined) return state; + if (!draftModelPickMatchesThread(pick, sentPick)) return state; const { modelSelectionExplicit: _explicit, ...retained } = current; const draft = { ...retained, activeProvider: null, modelSelectionByProvider: {} }; const drafts = { ...state.draftsByThreadKey }; From 8afe550f98bb2245e78c55f691974fd6f9136e73 Mon Sep 17 00:00:00 2001 From: doramirdor Date: Fri, 6 Nov 2026 12:29:41 -0500 Subject: [PATCH 05/16] fix: address second review on Nadir routing and compaction - A short follow-up or slash command keeps the thread's model instead of jumping back to the ceiling after a routed-down turn. - A retried commandId skips routing, so a replayed receipt cannot record a model the thread never ran. - Compaction needs a regular script file and python3 on PATH; otherwise no hooks are added. - The routing setting text names the organization-pinned model exception. - Plan follow-ups on web and immediate sends from mobile release the composer's model pick after a routed send, as the main web send does. - Tests: intake routing gates (one eligible send, seven excluded ones), user hooks kept ahead of Nadir's in the adapter, and the host-home and malformed-entry cases of the script lookup. Co-Authored-By: Claude Opus 5.5 --- .../SettingsServerControlsRouteScreen.tsx | 2 +- apps/mobile/src/state/threadOutboxDelivery.ts | 12 ++ apps/mobile/src/state/use-composer-drafts.ts | 12 ++ .../Adapters/ClaudeAdapterV2.test.ts | 8 +- .../Adapters/ClaudeAdapterV2.ts | 7 + .../orchestration-v2/NadirCompaction.test.ts | 16 +- .../src/orchestration-v2/NadirCompaction.ts | 6 +- .../src/orchestration-v2/NadirRouting.ts | 12 +- .../ThreadMessageIntake.test.ts | 141 +++++++++++++++++- .../orchestration-v2/ThreadMessageIntake.ts | 10 ++ .../components/chat/useProposedPlanSend.ts | 14 +- .../components/settings/SettingsPanels.tsx | 2 +- 12 files changed, 229 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index dbe41adaf5c92..98477e88d785f 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -402,7 +402,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { "nadirRouting", projectSelected ? "Environment-wide setting. Select All projects to change it." - : "Nadir picks each turn's model from the selected provider's list, never above yours. Sends the message text to getnadir.com.", + : "Nadir picks each turn's model from the selected provider's list, never above yours unless your Nadir organization pins one. Sends the message text to getnadir.com.", )} value={uniform("nadirRouting")} disabled={disabledFor("nadirRouting")} diff --git a/apps/mobile/src/state/threadOutboxDelivery.ts b/apps/mobile/src/state/threadOutboxDelivery.ts index 9b2da676eee44..b245658ddfeaf 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.ts @@ -78,6 +78,7 @@ import { type ComposerDraft, getComposerDraftSnapshot, mergeComposerDraftContent, + releaseComposerModelPick, replaceComposerDraftAttachments, removeDeliveredCloudQueuedMessage, undoComposerDraftMerge, @@ -946,6 +947,17 @@ export function createThreadOutboxDelivery(): { return restoreQueuedMessage(persistedMessage, failure.message); } acknowledgedExistingThreadMessageIds.add(persistedMessage.messageId); + // Nadir may run a new turn on another model; the picker then shows the one that ran. + if ( + currentConfig.settings?.nadirRouting === true && + (queuedMessage.dispatchMode === undefined || queuedMessage.dispatchMode === "auto") && + sendSettings.modelSelection !== undefined + ) { + releaseComposerModelPick( + scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId), + sendSettings.modelSelection, + ); + } const delivered = (await completeQueuedMessageDelivery( persistedMessage, diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index ed7a79c1f7a11..d33fc4339c4cc 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1327,6 +1327,18 @@ function updateComposerDrafts( schedulePersistComposerState(); } +/** Drops a thread draft's model pick that a send used, so the picker follows the thread's model. */ +export function releaseComposerModelPick(draftKey: string, sentPick: ModelSelection): void { + updateComposerDrafts((current) => { + const draft = current[draftKey]; + if (!draft?.modelSelection || !draftModelPickMatchesThread(draft.modelSelection, sentPick)) { + return current; + } + const { modelSelection: _modelSelection, ...rest } = draft; + return withComposerDraft(current, draftKey, rest); + }); +} + export function setStickyComposerModelSelection(modelSelection: ModelSelection): void { appAtomRegistry.set(stickyComposerModelSelectionAtom, modelSelection); schedulePersistComposerState(); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 191234ebf4556..eb603ae2fd333 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -365,7 +365,12 @@ describe("ClaudeAdapterV2 runtime query policy", () => { nativeThreadId: "native-tool-search", resume: false, cwd: "/workspace", - sdkSettings: { env: { OTHER: "kept" } }, + sdkSettings: { + env: { OTHER: "kept" }, + hooks: { + PostToolUse: [{ matcher: "Write", hooks: [{ type: "command", command: "user-hook" }] }], + }, + }, toolSearchOverride: "true", extraHooks: { PostToolUse: [{ matcher: "Read", hooks: [{ type: "command", command: "x" }] }], @@ -376,6 +381,7 @@ describe("ClaudeAdapterV2 runtime query policy", () => { ENABLE_TOOL_SEARCH: "true", }); assert.deepEqual((options.settings as { hooks?: Record }).hooks?.PostToolUse, [ + { matcher: "Write", hooks: [{ type: "command", command: "user-hook" }] }, { matcher: "Read", hooks: [{ type: "command", command: "x" }] }, ]); }); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 8ac42414dc535..8fdf4abb172dc 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -83,6 +83,7 @@ import { } from "@t3tools/shared/mcpApp"; import { AgentScope } from "@t3tools/shared/AgentScope"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model"; import { CLAUDE_RESUME_COMPACTION_COMPACT_ANSWER, @@ -5034,6 +5035,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* Effect.logDebug("Nadir compaction skipped: not installed, or Claude Code runs it"); return undefined; } + // Every hook would fail on a host without python3, so skip rather than add them. + const resolveExecutable = yield* SpawnExecutableResolution; + if (resolveExecutable("python3", hostPlatform, adapterOptions.environment) === undefined) { + yield* Effect.logDebug("Nadir compaction skipped: python3 is not on PATH"); + return undefined; + } return nadirCompactionHooks(script, nadirStateDir); }); diff --git a/apps/server/src/orchestration-v2/NadirCompaction.test.ts b/apps/server/src/orchestration-v2/NadirCompaction.test.ts index 4996daef2fe66..80cb55853fc91 100644 --- a/apps/server/src/orchestration-v2/NadirCompaction.test.ts +++ b/apps/server/src/orchestration-v2/NadirCompaction.test.ts @@ -27,6 +27,11 @@ it.layer(NodeServices.layer)("NadirCompaction", (it) => { const writeJson = (file: string, value: unknown) => fileSystem.writeFileString(file, JSON.stringify(value)); expect(yield* find()).toBeUndefined(); + // A directory at the script's path is not a script. + const labsScript = path.join(claudeHome, "nadir", "context_session.py"); + yield* fileSystem.makeDirectory(labsScript, { recursive: true }); + expect(yield* find()).toBeUndefined(); + yield* fileSystem.remove(labsScript, { recursive: true }); const installPath = path.join(claudeHome, "plugins", "cache", "nadir-route", "1.0.0"); const pluginScript = path.join(installPath, "scripts", "context_session.py"); @@ -36,8 +41,17 @@ it.layer(NodeServices.layer)("NadirCompaction", (it) => { }); expect(yield* find()).toBe(pluginScript); + // The host's own nadir-labs install outranks a plugin, and a malformed entry is ignored. + const hostLabsScript = path.join(hostHome, ".claude", "nadir", "context_session.py"); + yield* touch(hostLabsScript); + expect(yield* find()).toBe(hostLabsScript); + yield* writeJson(path.join(claudeHome, "plugins", "installed_plugins.json"), { + plugins: { "nadir-route@nadir": [{ installPath: 42 }] }, + }); + expect(yield* find()).toBe(hostLabsScript); + yield* fileSystem.remove(hostLabsScript); + // npx nadir-labs installs the script beside the user's Claude settings. - const labsScript = path.join(claudeHome, "nadir", "context_session.py"); yield* touch(labsScript); expect(yield* find()).toBe(labsScript); diff --git a/apps/server/src/orchestration-v2/NadirCompaction.ts b/apps/server/src/orchestration-v2/NadirCompaction.ts index 3f041cf58ad04..2b5f827628856 100644 --- a/apps/server/src/orchestration-v2/NadirCompaction.ts +++ b/apps/server/src/orchestration-v2/NadirCompaction.ts @@ -71,7 +71,11 @@ export const findNadirCompactionScript = Effect.fn("NadirCompaction.findScript") } const candidates = [...homes.map((home) => path.join(home, "nadir", SCRIPT)), ...pluginScripts]; for (const script of candidates) { - if (yield* fileSystem.exists(script).pipe(Effect.orElseSucceed(() => false))) return script; + const isFile = yield* fileSystem.stat(script).pipe( + Effect.map((info) => info.type === "File"), + Effect.orElseSucceed(() => false), + ); + if (isFile) return script; } return undefined; }); diff --git a/apps/server/src/orchestration-v2/NadirRouting.ts b/apps/server/src/orchestration-v2/NadirRouting.ts index 3469497dfa0fc..afbdcbe6d445f 100644 --- a/apps/server/src/orchestration-v2/NadirRouting.ts +++ b/apps/server/src/orchestration-v2/NadirRouting.ts @@ -189,6 +189,7 @@ export function pickModel( const answer = record(response); if (answer === undefined) return context.baseline; // An organization policy on the account names the model to use. + // An organization's pinned model outranks the user's pick; the settings text says so. const governed = answer.governance_model; if (typeof governed === "string") { return context.menu.includes(governed) ? governed : context.baseline; @@ -237,8 +238,9 @@ const askNadir = Effect.fn("NadirRouting.askNadir")(function* ( }); /** - * The selection a user's turn should run on. Returns `selection` unchanged when routing is off, - * the prompt is too short or long to judge, or Nadir is slow, down or unsure. + * The selection a user's turn should run on. Returns `selection` unchanged when routing is off or + * the prompt is too short or long to judge. When Nadir is slow, down or unsure, the turn runs on + * the user's pick. */ export const chooseModelSelection = Effect.fn("NadirRouting.chooseModelSelection")( function* (input: { @@ -268,10 +270,8 @@ export const chooseModelSelection = Effect.fn("NadirRouting.chooseModelSelection const providers = yield* registry.value.getProviders; const provider = providers.find((candidate) => candidate.instanceId === baseline.instanceId); const menu = modelMenu(provider); - if (!judgeable || !menu.includes(baseline.model)) { - yield* rememberChoice(ceilings.value, input.threadId, baseline, baseline); - return baseline; - } + // A short follow-up or a slash command continues on the thread's model; the ceiling stays. + if (!judgeable || !menu.includes(baseline.model)) return input.selection; const current = input.cache?.model ?? input.selection.model; const answer = yield* askNadir( client.value, diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts index ead417400e10f..15a3b0a02c650 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts @@ -3,31 +3,41 @@ import * as DocumentText from "../provider/DocumentText.ts"; import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; +import { describe, expect, it } from "@effect/vitest"; import { ChatAttachmentId, CommandId, EventId, GuestMessageId, MessageId, + ProviderDriverKind, + ProviderInstanceId, + RunId, RuntimeRequestId, + ScheduledTaskId, ThreadId, TurnItemId, type OrchestrationV2Command, type OrchestrationV2ServerCommand, type OrchestrationV2StoredEvent, + type ServerProvider, type UserInputAttachments, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import { HttpClient, HttpClientResponse } from "effect/http"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import { createAttachmentId, createPendingAttachmentId, resolveAttachmentPath, } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; +import * as CommandReceiptStore from "./CommandReceiptStore.ts"; +import { layerCeilingsMemory } from "./NadirRouting.ts"; import { OrchestratorCommandPreviouslyRejectedError, OrchestratorDispatchError, @@ -426,3 +436,132 @@ it.effect("sends a guest message with the guest's files, never ones the client n expect(command.attachments).toEqual([guestFile]); }).pipe(Effect.provide(layerIntakeTest)), ); + +describe("Nadir routing at intake", () => { + const instanceId = ProviderInstanceId.make("claudeAgent"); + const opus = { instanceId, model: "claude-opus-5-5" }; + const send = ( + overrides: Partial> = {}, + ): OrchestrationV2Command => ({ + type: "message.dispatch", + commandId: CommandId.make("send-routed"), + threadId: ThreadId.make("thread-routed"), + messageId: MessageId.make("message-routed"), + text: "Rename the helper and update every caller", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + ...overrides, + }); + const provider: ServerProvider = { + instanceId, + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + installed: true, + version: "0.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-11-06T00:00:00.000Z", + models: ["claude-opus-5-5", "claude-haiku-5-5"].map((slug) => ({ + slug, + name: slug, + isCustom: false, + capabilities: null, + })), + slashCommands: [], + skills: [], + }; + + const route = (command: OrchestrationV2Command, shell: { activeRunId: RunId | null }) => + Effect.gen(function* () { + let requests = 0; + const captured: OrchestrationV2ServerCommand[] = []; + yield* dispatchCommand(command).pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ThreadManagementService.ThreadManagementService)({ + dispatch: layerFailingDispatchHandler(captured), + // Only these shell fields are read. + getThreadShell: () => + Effect.succeed({ + ...shell, + modelSelection: opus, + activeProviderThreadId: null, + } as unknown as Effect.Success< + ReturnType + >), + getThreadSnapshotWindow: () => + Effect.succeed({ projection: { providerThreads: [] } } as unknown as Effect.Success< + ReturnType< + ThreadManagementService.ThreadManagementServiceShape["getThreadSnapshotWindow"] + > + >), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([provider]), + }), + Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({ + getByCommandId: (commandId) => + Effect.succeed( + commandId === "send-replayed" ? Option.some({} as never) : Option.none(), + ), + }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests += 1; + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + selected_model: "claude-haiku-5-5", + policy: { advised_model: "claude-haiku-5-5", suppressed: false }, + coverage_held: false, + input_truncated: false, + }), + ), + ); + }), + ), + layerCeilingsMemory, + ServerSettings.layerTest({ nadirRouting: true }), + ), + ), + Effect.result, + ); + const dispatched = captured[0]; + return { + requests, + model: dispatched?.type === "message.dispatch" ? dispatched.modelSelection?.model : null, + }; + }); + + it.effect("routes a user's new turn and leaves every other send alone", () => + Effect.gen(function* () { + expect(yield* route(send(), { activeRunId: null })).toEqual({ + requests: 1, + model: "claude-haiku-5-5", + }); + // Each case differs from the routed send by one field, so no other guard hides it. + const unrouted: ReadonlyArray = [ + ["a queued send", send({ dispatchMode: { type: "queue_after_active" } }), null], + ["a steer", send({ deliveryIntent: "steer" }), null], + ["a scheduled task", send({ scheduledTaskId: ScheduledTaskId.make("task-1") }), null], + ["a delegated send", send({ senderThreadId: ThreadId.make("thread-parent") }), null], + [ + "a restart continuation", + send({ restartContinuationOfRunId: RunId.make("run-earlier") }), + null, + ], + ["a retried command", send({ commandId: CommandId.make("send-replayed") }), null], + ["a send during a run", send(), RunId.make("run-active")], + ]; + for (const [label, command, activeRunId] of unrouted) { + const result = yield* route(command, { activeRunId }); + expect(result.requests, label).toBe(0); + expect(result.model, label).toBeUndefined(); + } + }).pipe(Effect.provide(layerIntakeTest)), + ); +}); diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts index e4b37e916a1b7..432cd6733cc01 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts @@ -2,12 +2,14 @@ import { remapComposerContextAttachments } from "@t3tools/shared/composerContext import { appendUserInputAttachmentPaths } from "../provider/userInputAttachments.ts"; import type { ChatAttachment, OrchestrationV2Command } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { resolveAttachmentPath } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as AttachmentClaims from "./AttachmentClaims.ts"; +import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import * as NadirRouting from "./NadirRouting.ts"; import * as ThreadLaunch from "./ThreadLaunchService.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; @@ -103,6 +105,14 @@ const withNadirModel = Effect.fn("ThreadMessageIntake.withNadirModel")(function* command.deliveryIntent !== "restart" && command.dispatchMode.type === "start_immediately"; if (!plainUserTurn || !(yield* NadirRouting.isEnabled)) return command; + // A retried command replays its first receipt; routing again would record a model it never ran. + const receipts = yield* Effect.serviceOption(CommandReceiptStore.CommandReceiptStoreV2); + if (Option.isSome(receipts)) { + const prior = yield* receipts.value + .getByCommandId(command.commandId) + .pipe(Effect.orElseSucceed(() => Option.none())); + if (Option.isSome(prior)) return command; + } const threads = yield* ThreadManagement.ThreadManagementService; const shell = yield* threads .getThreadShell(command.threadId) diff --git a/apps/web/src/components/chat/useProposedPlanSend.ts b/apps/web/src/components/chat/useProposedPlanSend.ts index eb945e3e056a4..2debd1552c9e5 100644 --- a/apps/web/src/components/chat/useProposedPlanSend.ts +++ b/apps/web/src/components/chat/useProposedPlanSend.ts @@ -4,6 +4,7 @@ import { type ModelSelection, type ProviderInteractionMode, type RuntimeMode, + type UnifiedSettings, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { @@ -21,7 +22,8 @@ import { truncate } from "@t3tools/shared/String"; import { useNavigate } from "@tanstack/react-router"; import type { Dispatch, SetStateAction } from "react"; import { newMessageId, newThreadId } from "~/lib/utils"; -import { useComposerDraftStore } from "../../composerDraftStore"; +import { draftModelPick, useComposerDraftStore } from "../../composerDraftStore"; +import { useEnvironmentSettings } from "../../hooks/useSettings"; import type { ComposerHandleRef } from "../../composerHandleContext"; import type { buildMessageContext } from "../../lib/composerContextRecords"; import { @@ -77,6 +79,8 @@ interface PlanFollowUpSendInput extends PlanSendBase { * Answers a proposed plan on this thread: refine it in plan mode, or implement it here * (which links the turn to the plan). Resolves whether the turn started. */ +const selectNadirRouting = (settings: UnifiedSettings) => settings.nadirRouting; + export function usePlanFollowUpSend(input: PlanFollowUpSendInput) { const { environmentId, @@ -101,6 +105,8 @@ export function usePlanFollowUpSend(input: PlanFollowUpSendInput) { const setComposerDraftInteractionMode = useComposerDraftStore( (store) => store.setInteractionMode, ); + const releaseModelPick = useComposerDraftStore((store) => store.releaseModelPick); + const nadirRouting = useEnvironmentSettings(environmentId, selectNadirRouting); const { sendInFlight: sendInFlightRef } = draftSendState(routeThreadKey); return async function submitPlanFollowUp({ @@ -133,6 +139,10 @@ export function usePlanFollowUpSend(input: PlanFollowUpSendInput) { } const threadIdForSend = activeThread.id; + const threadRefForSend = scopeThreadRef(activeThread.environmentId, threadIdForSend); + const modelPickAtSend = draftModelPick( + useComposerDraftStore.getState().getComposerDraft(threadRefForSend), + ); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ @@ -212,6 +222,8 @@ export function usePlanFollowUpSend(input: PlanFollowUpSendInput) { } if (failure === null) { + // Nadir may run the turn on another model; the picker then shows the one that ran. + if (nadirRouting) releaseModelPick(threadRefForSend, modelPickAtSend); sendInFlightRef.current = false; resetLocalDispatch(); return true; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index cbd38ede60942..84c409298b7b3 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1438,7 +1438,7 @@ const NADIR_SETTING_ROWS = { label: "Nadir model routing", searchId: "nadir-routing", description: - "Before each message you send, Nadir picks the model from the selected provider's list, never above the one you chose, and the model picker shows what ran. Sends the message text to getnadir.com. Set NADIR_API_KEY on the host to use your Nadir account.", + "Before each message you send, Nadir picks the model from the selected provider's list, never above the one you chose unless your Nadir organization pins a model, and the model picker shows what ran. Sends the message text to getnadir.com. Set NADIR_API_KEY on the host to use your Nadir account.", }, nadirCompaction: { label: "Nadir compaction", From 477b8514acaa1eff1bcacd8296cc65aaf875419a Mon Sep 17 00:00:00 2001 From: doramirdor Date: Fri, 6 Nov 2026 12:36:21 -0500 Subject: [PATCH 06/16] fix: keep Nadir ceilings through failed saves and concurrent sends - The ceiling service keeps an in-memory copy beside the file store, so a save the disk refuses still holds the ceiling for the process. - Each thread's read, Nadir call and write run one at a time behind a per-thread lock, so concurrent sends cannot overwrite each other. - Plugin discovery checks every nadir-route install, not only the first. Co-Authored-By: Claude Opus 5.5 --- .../orchestration-v2/NadirCompaction.test.ts | 8 +- .../src/orchestration-v2/NadirCompaction.ts | 9 +- .../src/orchestration-v2/NadirRouting.test.ts | 49 +++++- .../src/orchestration-v2/NadirRouting.ts | 139 ++++++++++++------ 4 files changed, 154 insertions(+), 51 deletions(-) diff --git a/apps/server/src/orchestration-v2/NadirCompaction.test.ts b/apps/server/src/orchestration-v2/NadirCompaction.test.ts index 80cb55853fc91..94defbb1e8c5b 100644 --- a/apps/server/src/orchestration-v2/NadirCompaction.test.ts +++ b/apps/server/src/orchestration-v2/NadirCompaction.test.ts @@ -74,8 +74,14 @@ it.layer(NodeServices.layer)("NadirCompaction", (it) => { const hostPluginPath = path.join(hostHome, ".claude", "plugins", "cache", "nadir-route"); const hostPluginScript = path.join(hostPluginPath, "scripts", "context_session.py"); yield* touch(hostPluginScript); + // The first install is stale; the second still has the script. yield* writeJson(path.join(hostHome, ".claude", "plugins", "installed_plugins.json"), { - plugins: { "nadir-route@nadir": [{ scope: "user", installPath: hostPluginPath }] }, + plugins: { + "nadir-route@nadir": [ + { scope: "user", installPath: path.join(hostHome, "gone") }, + { scope: "project", installPath: hostPluginPath }, + ], + }, }); expect( yield* findNadirCompactionScript({ diff --git a/apps/server/src/orchestration-v2/NadirCompaction.ts b/apps/server/src/orchestration-v2/NadirCompaction.ts index 2b5f827628856..63f7ed6e2367d 100644 --- a/apps/server/src/orchestration-v2/NadirCompaction.ts +++ b/apps/server/src/orchestration-v2/NadirCompaction.ts @@ -65,9 +65,12 @@ export const findNadirCompactionScript = Effect.fn("NadirCompaction.findScript") path.join(home, "plugins", "installed_plugins.json"), ); const entries = field(field(installed, "plugins"), PLUGIN_ID); - const pluginPath = Array.isArray(entries) ? field(entries[0], "installPath") : undefined; - if (typeof pluginPath === "string") - pluginScripts.push(path.join(pluginPath, "scripts", SCRIPT)); + // Every install counts: an earlier one can be stale. + for (const entry of Array.isArray(entries) ? entries : []) { + const pluginPath = field(entry, "installPath"); + if (typeof pluginPath === "string") + pluginScripts.push(path.join(pluginPath, "scripts", SCRIPT)); + } } const candidates = [...homes.map((home) => path.join(home, "nadir", SCRIPT)), ...pluginScripts]; for (const script of candidates) { diff --git a/apps/server/src/orchestration-v2/NadirRouting.test.ts b/apps/server/src/orchestration-v2/NadirRouting.test.ts index 90eef436c859a..0002a459157f4 100644 --- a/apps/server/src/orchestration-v2/NadirRouting.test.ts +++ b/apps/server/src/orchestration-v2/NadirRouting.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as TestClock from "effect/testing/TestClock"; import { ProviderDriverKind, ProviderInstanceId, @@ -87,7 +90,7 @@ describe("baselineFor", () => { expect(yield* baselineFor(ceilings, "thread-1", haiku)).toEqual(opus); // Kept only while the thread runs below its ceiling. yield* rememberChoice(ceilings, "thread-1", opus, opus); - expect(yield* ceilings.has("thread-1")).toBe(false); + expect(Option.isNone(yield* ceilings.get("thread-1"))).toBe(true); }).pipe(Effect.provide(layerCeilingsMemory)), ); @@ -105,6 +108,50 @@ describe("baselineFor", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("keeps the ceiling in memory when the disk cannot save it", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped(); + yield* fileSystem.chmod(directory, 0o500); + const routedAgain = yield* Effect.gen(function* () { + const ceilings = yield* NadirCeilings; + yield* rememberChoice(ceilings, "thread-1", opus, haiku); + return yield* baselineFor(ceilings, "thread-1", haiku); + }).pipe(Effect.provide(layerCeilingsAt(directory))); + yield* fileSystem.chmod(directory, 0o700); + expect(routedAgain).toEqual(opus); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("runs one routing turn at a time for each thread", () => + Effect.gen(function* () { + const ceilings = yield* NadirCeilings; + const order: string[] = []; + const first = yield* ceilings + .lock("thread-1") + .withPermits(1)( + Effect.sync(() => order.push("first started")).pipe( + Effect.andThen(Effect.sleep("1 second")), + Effect.andThen(Effect.sync(() => order.push("first finished"))), + ), + ) + .pipe(Effect.forkChild); + const second = yield* ceilings + .lock("thread-1") + .withPermits(1)(Effect.sync(() => order.push("second"))) + .pipe(Effect.forkChild); + const other = yield* ceilings + .lock("thread-2") + .withPermits(1)(Effect.sync(() => order.push("other thread"))) + .pipe(Effect.forkChild); + yield* TestClock.adjust("1 second"); + yield* Fiber.join(first); + yield* Fiber.join(second); + yield* Fiber.join(other); + expect(order).toEqual(["first started", "other thread", "first finished", "second"]); + }).pipe(Effect.provide(layerCeilingsMemory)), + ); + it.effect("treats a model the user picks by hand as the new ceiling", () => Effect.gen(function* () { const ceilings = yield* NadirCeilings; diff --git a/apps/server/src/orchestration-v2/NadirRouting.ts b/apps/server/src/orchestration-v2/NadirRouting.ts index afbdcbe6d445f..ffe45da008c28 100644 --- a/apps/server/src/orchestration-v2/NadirRouting.ts +++ b/apps/server/src/orchestration-v2/NadirRouting.ts @@ -15,6 +15,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as KeyValueStore from "effect/persistence/KeyValueStore"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as ServerConfig from "../config.ts"; @@ -33,6 +34,7 @@ const MAX_PROMPT_CHARS = 40_000; const MIN_PROMPT_WORDS = 4; const Ceiling = Schema.Struct({ baseline: ModelSelection, chosen: ModelSelection }); +type Ceiling = typeof Ceiling.Type; /** * A thread's routed turn: the user's pick (the ceiling) and the model Nadir ran instead. It @@ -40,13 +42,53 @@ const Ceiling = Schema.Struct({ baseline: ModelSelection, chosen: ModelSelection */ export class NadirCeilings extends Context.Service< NadirCeilings, - KeyValueStore.SchemaStore + { + readonly get: (threadId: string) => Effect.Effect>; + /** Undefined clears the thread's record. */ + readonly save: (threadId: string, ceiling: Ceiling | undefined) => Effect.Effect; + /** Holds one turn's read, Nadir call and write at a time for each thread. */ + readonly lock: (threadId: string) => Semaphore.Semaphore; + } >()("t3/orchestration-v2/NadirRouting/NadirCeilings") {} +// ponytail: the memory copy and the locks hold one small entry per routed thread for the life of +// the process. The memory copy keeps a ceiling the disk failed to save. const layerFromStore = Layer.effect( NadirCeilings, Effect.gen(function* () { - return KeyValueStore.toSchemaStore(yield* KeyValueStore.KeyValueStore, Ceiling); + const store = KeyValueStore.toSchemaStore(yield* KeyValueStore.KeyValueStore, Ceiling); + const memory = new Map(); + const locks = new Map(); + return { + get: Effect.fn("NadirCeilings.get")(function* (threadId: string) { + if (memory.has(threadId)) return Option.fromUndefinedOr(memory.get(threadId)); + return yield* store.get(threadId).pipe( + Effect.tap((found) => + Effect.sync(() => memory.set(threadId, Option.getOrUndefined(found))), + ), + Effect.orElseSucceed(() => Option.none()), + ); + }), + save: Effect.fn("NadirCeilings.save")(function* ( + threadId: string, + ceiling: Ceiling | undefined, + ) { + memory.set(threadId, ceiling); + yield* (ceiling === undefined ? store.remove(threadId) : store.set(threadId, ceiling)).pipe( + Effect.catch((cause) => + Effect.logWarning("Nadir ceiling kept in memory only", { cause }), + ), + ); + }), + lock: (threadId) => { + let lock = locks.get(threadId); + if (lock === undefined) { + lock = Semaphore.makeUnsafe(1); + locks.set(threadId, lock); + } + return lock; + }, + }; }), ); @@ -77,14 +119,15 @@ export const baselineFor = ( threadId: string, incoming: ModelSelection, ) => - ceilings.get(threadId).pipe( - Effect.map((prior) => - Option.isSome(prior) && draftModelPickMatchesThread(incoming, prior.value.chosen) - ? prior.value.baseline - : incoming, - ), - Effect.orElseSucceed(() => incoming), - ); + ceilings + .get(threadId) + .pipe( + Effect.map((prior) => + Option.isSome(prior) && draftModelPickMatchesThread(incoming, prior.value.chosen) + ? prior.value.baseline + : incoming, + ), + ); /** Keeps the ceiling only while the thread runs below it. */ export const rememberChoice = ( @@ -92,11 +135,7 @@ export const rememberChoice = ( threadId: string, baseline: ModelSelection, chosen: ModelSelection, -) => - (baseline === chosen - ? ceilings.remove(threadId) - : ceilings.set(threadId, { baseline, chosen }) - ).pipe(Effect.catch((cause) => Effect.logWarning("Nadir ceiling not saved", { cause }))); +) => ceilings.save(threadId, baseline === chosen ? undefined : { baseline, chosen }); /** Models the user can pick for this instance, the menu Nadir chooses from. */ function modelMenu(provider: ServerProvider | undefined): ReadonlyArray { @@ -261,38 +300,46 @@ export const chooseModelSelection = Effect.fn("NadirRouting.chooseModelSelection ) { return input.selection; } - const baseline = yield* baselineFor(ceilings.value, input.threadId, input.selection); - const text = input.text.trim(); - const judgeable = - text.length <= MAX_PROMPT_CHARS && - !text.startsWith("/") && - text.split(/\s+/).length >= MIN_PROMPT_WORDS; - const providers = yield* registry.value.getProviders; - const provider = providers.find((candidate) => candidate.instanceId === baseline.instanceId); - const menu = modelMenu(provider); - // A short follow-up or a slash command continues on the thread's model; the ceiling stays. - if (!judgeable || !menu.includes(baseline.model)) return input.selection; - const current = input.cache?.model ?? input.selection.model; - const answer = yield* askNadir( - client.value, - nadirRequestBody({ - text, - baseline: baseline.model, - menu, - driver: provider?.driver, - threadId: input.threadId, - cache: input.cache, + const store = ceilings.value; + return yield* store.lock(input.threadId).withPermits(1)( + Effect.gen(function* () { + const baseline = yield* baselineFor(store, input.threadId, input.selection); + const text = input.text.trim(); + const judgeable = + text.length <= MAX_PROMPT_CHARS && + !text.startsWith("/") && + text.split(/\s+/).length >= MIN_PROMPT_WORDS; + const providers = yield* registry.value.getProviders; + const provider = providers.find( + (candidate) => candidate.instanceId === baseline.instanceId, + ); + const menu = modelMenu(provider); + // A short follow-up or a slash command continues on the thread's model; the ceiling stays. + if (!judgeable || !menu.includes(baseline.model)) return input.selection; + const current = input.cache?.model ?? input.selection.model; + const answer = yield* askNadir( + client.value, + nadirRequestBody({ + text, + baseline: baseline.model, + menu, + driver: provider?.driver, + threadId: input.threadId, + cache: input.cache, + }), + ).pipe( + Effect.tapError((cause) => Effect.logDebug("Nadir routing skipped", { cause })), + Effect.orElseSucceed(() => null), + ); + const model = pickModel(answer, { baseline: baseline.model, current, menu }); + const chosen = + model === baseline.model ? baseline : routedSelection(baseline, model, provider); + yield* rememberChoice(store, input.threadId, baseline, chosen); + if (model !== baseline.model) { + yield* Effect.logInfo("Nadir routed turn", { from: baseline.model, to: model }); + } + return chosen; }), - ).pipe( - Effect.tapError((cause) => Effect.logDebug("Nadir routing skipped", { cause })), - Effect.orElseSucceed(() => null), ); - const model = pickModel(answer, { baseline: baseline.model, current, menu }); - const chosen = model === baseline.model ? baseline : routedSelection(baseline, model, provider); - yield* rememberChoice(ceilings.value, input.threadId, baseline, chosen); - if (model !== baseline.model) { - yield* Effect.logInfo("Nadir routed turn", { from: baseline.model, to: model }); - } - return chosen; }, ); From cfcf776193a878892c1c9f59e73797a939fe822d Mon Sep 17 00:00:00 2001 From: doramirdor Date: Fri, 6 Nov 2026 12:52:52 -0500 Subject: [PATCH 07/16] fix: log Nadir failures by tag and cover every routing gate - Routing and ceiling-save logs carry only the failure's tag, never its message or the whole error. - Intake tests cover every dispatch gate (guest, delegated completion, idle compaction, restart, usage-limit and manual continuations added) and the imported and delegated launch gates. Each case must still reach dispatch or launch, so the gate, not an error, skips Nadir. - The failed-save test uses a store whose writes fail instead of a read-only directory, and checks the write failed. Co-Authored-By: Claude Opus 5.5 --- .../orchestration-v2/NadirCompaction.test.ts | 1 - .../src/orchestration-v2/NadirRouting.test.ts | 48 +++++--- .../src/orchestration-v2/NadirRouting.ts | 20 +++- .../ThreadMessageIntake.test.ts | 112 ++++++++++++++---- 4 files changed, 141 insertions(+), 40 deletions(-) diff --git a/apps/server/src/orchestration-v2/NadirCompaction.test.ts b/apps/server/src/orchestration-v2/NadirCompaction.test.ts index 94defbb1e8c5b..8d1a2bb38f730 100644 --- a/apps/server/src/orchestration-v2/NadirCompaction.test.ts +++ b/apps/server/src/orchestration-v2/NadirCompaction.test.ts @@ -27,7 +27,6 @@ it.layer(NodeServices.layer)("NadirCompaction", (it) => { const writeJson = (file: string, value: unknown) => fileSystem.writeFileString(file, JSON.stringify(value)); expect(yield* find()).toBeUndefined(); - // A directory at the script's path is not a script. const labsScript = path.join(claudeHome, "nadir", "context_session.py"); yield* fileSystem.makeDirectory(labsScript, { recursive: true }); expect(yield* find()).toBeUndefined(); diff --git a/apps/server/src/orchestration-v2/NadirRouting.test.ts b/apps/server/src/orchestration-v2/NadirRouting.test.ts index 0002a459157f4..e6213069be2af 100644 --- a/apps/server/src/orchestration-v2/NadirRouting.test.ts +++ b/apps/server/src/orchestration-v2/NadirRouting.test.ts @@ -3,7 +3,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as KeyValueStore from "effect/persistence/KeyValueStore"; import * as TestClock from "effect/testing/TestClock"; import { ProviderDriverKind, @@ -16,6 +18,7 @@ import { import { baselineFor, layerCeilingsAt, + layerCeilingsFromStore, layerCeilingsMemory, NadirCeilings, nadirRequestBody, @@ -108,20 +111,37 @@ describe("baselineFor", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("keeps the ceiling in memory when the disk cannot save it", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const directory = yield* fileSystem.makeTempDirectoryScoped(); - yield* fileSystem.chmod(directory, 0o500); - const routedAgain = yield* Effect.gen(function* () { - const ceilings = yield* NadirCeilings; - yield* rememberChoice(ceilings, "thread-1", opus, haiku); - return yield* baselineFor(ceilings, "thread-1", haiku); - }).pipe(Effect.provide(layerCeilingsAt(directory))); - yield* fileSystem.chmod(directory, 0o700); - expect(routedAgain).toEqual(opus); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); + it.effect("keeps the ceiling in memory when the disk cannot save it", () => { + let failedSaves = 0; + const layerFailingSaves = Layer.effect( + KeyValueStore.KeyValueStore, + Effect.gen(function* () { + const memory = yield* KeyValueStore.KeyValueStore; + return KeyValueStore.make({ + get: memory.get, + getUint8Array: memory.getUint8Array, + remove: memory.remove, + clear: memory.clear, + size: memory.size, + keys: memory.keys, + set: (key) => + Effect.sync(() => (failedSaves += 1)).pipe( + Effect.andThen( + Effect.fail( + new KeyValueStore.KeyValueStoreError({ method: "set", key, message: "full" }), + ), + ), + ), + }); + }), + ).pipe(Layer.provide(KeyValueStore.layerMemory)); + return Effect.gen(function* () { + const ceilings = yield* NadirCeilings; + yield* rememberChoice(ceilings, "thread-1", opus, haiku); + expect(failedSaves).toBe(1); + expect(yield* baselineFor(ceilings, "thread-1", haiku)).toEqual(opus); + }).pipe(Effect.provide(layerCeilingsFromStore.pipe(Layer.provide(layerFailingSaves)))); + }); it.effect("runs one routing turn at a time for each thread", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/NadirRouting.ts b/apps/server/src/orchestration-v2/NadirRouting.ts index ffe45da008c28..329bd5d18a94c 100644 --- a/apps/server/src/orchestration-v2/NadirRouting.ts +++ b/apps/server/src/orchestration-v2/NadirRouting.ts @@ -33,6 +33,12 @@ const MAX_PROMPT_CHARS = 40_000; // A short follow-up ("yes", "do it") depends on context Nadir does not see. const MIN_PROMPT_WORDS = 4; +/** A bounded label for a failure; its message can carry arbitrary text. */ +const failureKind = (cause: unknown): string => + typeof cause === "object" && cause !== null && "_tag" in cause && typeof cause._tag === "string" + ? cause._tag + : "unknown"; + const Ceiling = Schema.Struct({ baseline: ModelSelection, chosen: ModelSelection }); type Ceiling = typeof Ceiling.Type; @@ -53,7 +59,7 @@ export class NadirCeilings extends Context.Service< // ponytail: the memory copy and the locks hold one small entry per routed thread for the life of // the process. The memory copy keeps a ceiling the disk failed to save. -const layerFromStore = Layer.effect( +export const layerCeilingsFromStore = Layer.effect( NadirCeilings, Effect.gen(function* () { const store = KeyValueStore.toSchemaStore(yield* KeyValueStore.KeyValueStore, Ceiling); @@ -76,7 +82,7 @@ const layerFromStore = Layer.effect( memory.set(threadId, ceiling); yield* (ceiling === undefined ? store.remove(threadId) : store.set(threadId, ceiling)).pipe( Effect.catch((cause) => - Effect.logWarning("Nadir ceiling kept in memory only", { cause }), + Effect.logWarning("Nadir ceiling kept in memory only", { reason: failureKind(cause) }), ), ); }), @@ -92,10 +98,12 @@ const layerFromStore = Layer.effect( }), ); -export const layerCeilingsMemory = layerFromStore.pipe(Layer.provide(KeyValueStore.layerMemory)); +export const layerCeilingsMemory = layerCeilingsFromStore.pipe( + Layer.provide(KeyValueStore.layerMemory), +); export const layerCeilingsAt = (directory: string) => - layerFromStore.pipe(Layer.provide(KeyValueStore.layerFileSystem(directory))); + layerCeilingsFromStore.pipe(Layer.provide(KeyValueStore.layerFileSystem(directory))); // ponytail: one small file per thread while it runs below its ceiling; a thread deleted in that // state leaves its file. Prune with thread deletion if the directory ever grows. @@ -328,7 +336,9 @@ export const chooseModelSelection = Effect.fn("NadirRouting.chooseModelSelection cache: input.cache, }), ).pipe( - Effect.tapError((cause) => Effect.logDebug("Nadir routing skipped", { cause })), + Effect.tapError((cause) => + Effect.logDebug("Nadir routing skipped", { reason: failureKind(cause) }), + ), Effect.orElseSucceed(() => null), ); const model = pickModel(answer, { baseline: baseline.model, current, menu }); diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts index 15a3b0a02c650..51d0907d2b7ea 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts @@ -43,7 +43,8 @@ import { OrchestratorDispatchError, } from "./Orchestrator.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; -import { dispatchCommand } from "./ThreadMessageIntake.ts"; +import * as ThreadLaunch from "./ThreadLaunchService.ts"; +import { dispatchCommand, launchThread } from "./ThreadMessageIntake.ts"; const layerIntakeTest = ServerConfig.layerTest(process.cwd(), { prefix: "t3-question-intake-", @@ -440,6 +441,8 @@ it.effect("sends a guest message with the guest's files, never ones the client n describe("Nadir routing at intake", () => { const instanceId = ProviderInstanceId.make("claudeAgent"); const opus = { instanceId, model: "claude-opus-5-5" }; + const guestMessageId = GuestMessageId.make("guest-message:routed"); + const prompt = "Rename the helper and update every caller"; const send = ( overrides: Partial> = {}, ): OrchestrationV2Command => ({ @@ -447,13 +450,21 @@ describe("Nadir routing at intake", () => { commandId: CommandId.make("send-routed"), threadId: ThreadId.make("thread-routed"), messageId: MessageId.make("message-routed"), - text: "Rename the helper and update every caller", + text: prompt, attachments: [], dispatchMode: { type: "start_immediately" }, createdBy: "user", creationSource: "web", ...overrides, }); + // Only the fields the launch path reads; the launch service is a stub. + const launch = (overrides: Record = {}) => + ({ + threadId: ThreadId.make("thread-launched"), + modelSelection: opus, + initialMessage: { text: prompt, attachments: [] }, + ...overrides, + }) as unknown as ThreadLaunch.ThreadLaunchInput; const provider: ServerProvider = { instanceId, driver: ProviderDriverKind.make("claudeAgent"), @@ -473,31 +484,56 @@ describe("Nadir routing at intake", () => { skills: [], }; - const route = (command: OrchestrationV2Command, shell: { activeRunId: RunId | null }) => + const run = (effect: Effect.Effect, activeRunId: RunId | null) => Effect.gen(function* () { let requests = 0; - const captured: OrchestrationV2ServerCommand[] = []; - yield* dispatchCommand(command).pipe( + const dispatched: Array = []; + const record = (model: string | undefined) => { + dispatched.push(model); + return Effect.fail( + new OrchestratorDispatchError({ + commandId: CommandId.make("stop"), + commandType: "message.dispatch", + cause: "Stop after intake", + }), + ); + }; + yield* effect.pipe( Effect.provide( Layer.mergeAll( Layer.mock(ThreadManagementService.ThreadManagementService)({ - dispatch: layerFailingDispatchHandler(captured), - // Only these shell fields are read. + dispatch: (command) => + record( + command.type === "message.dispatch" ? command.modelSelection?.model : undefined, + ), + // Only these shell and snapshot fields are read. getThreadShell: () => Effect.succeed({ - ...shell, + activeRunId, modelSelection: opus, activeProviderThreadId: null, } as unknown as Effect.Success< ReturnType >), getThreadSnapshotWindow: () => - Effect.succeed({ projection: { providerThreads: [] } } as unknown as Effect.Success< + Effect.succeed({ + projection: { + providerThreads: [], + thread: { + guestShare: { + messages: [{ id: guestMessageId, status: "pending", attachments: [] }], + }, + }, + }, + } as unknown as Effect.Success< ReturnType< ThreadManagementService.ThreadManagementServiceShape["getThreadSnapshotWindow"] > >), }), + Layer.mock(ThreadLaunch.ThreadLaunchService)({ + launch: (input) => record(input.modelSelection.model) as never, + }), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([provider]), }), @@ -530,37 +566,73 @@ describe("Nadir routing at intake", () => { ), Effect.result, ); - const dispatched = captured[0]; - return { - requests, - model: dispatched?.type === "message.dispatch" ? dispatched.modelSelection?.model : null, - }; + return { requests, dispatched }; }); it.effect("routes a user's new turn and leaves every other send alone", () => Effect.gen(function* () { - expect(yield* route(send(), { activeRunId: null })).toEqual({ + expect(yield* run(dispatchCommand(send()), null)).toEqual({ requests: 1, - model: "claude-haiku-5-5", + dispatched: ["claude-haiku-5-5"], }); - // Each case differs from the routed send by one field, so no other guard hides it. + // Each case differs from the routed send by one field, and still reaches dispatch, so the + // gate under test is what skips Nadir. const unrouted: ReadonlyArray = [ ["a queued send", send({ dispatchMode: { type: "queue_after_active" } }), null], ["a steer", send({ deliveryIntent: "steer" }), null], + ["a restart", send({ deliveryIntent: "restart" }), null], + ["a guest message", send({ guestMessageId }), null], ["a scheduled task", send({ scheduledTaskId: ScheduledTaskId.make("task-1") }), null], ["a delegated send", send({ senderThreadId: ThreadId.make("thread-parent") }), null], + [ + "a delegated completion", + send({ + delegatedCompletion: { + parentRunId: RunId.make("run-parent"), + generation: 1 as never, + taskIds: [], + }, + }), + null, + ], + ["an idle compaction", send({ idleCompaction: "onReturn" }), null], [ "a restart continuation", send({ restartContinuationOfRunId: RunId.make("run-earlier") }), null, ], + [ + "a usage-limit continuation", + send({ usageLimitContinuationOfRunId: RunId.make("run-limited") }), + null, + ], + [ + "a manual continuation", + send({ manualContinuationOfRunId: RunId.make("run-manual") }), + null, + ], ["a retried command", send({ commandId: CommandId.make("send-replayed") }), null], ["a send during a run", send(), RunId.make("run-active")], ]; for (const [label, command, activeRunId] of unrouted) { - const result = yield* route(command, { activeRunId }); - expect(result.requests, label).toBe(0); - expect(result.model, label).toBeUndefined(); + const result = yield* run(dispatchCommand(command), activeRunId); + expect(result, label).toEqual({ requests: 0, dispatched: [undefined] }); + } + }).pipe(Effect.provide(layerIntakeTest)), + ); + + it.effect("routes a new thread's first message but not imported or delegated launches", () => + Effect.gen(function* () { + expect(yield* run(launchThread(launch()), null)).toEqual({ + requests: 1, + dispatched: ["claude-haiku-5-5"], + }); + for (const [label, input] of [ + ["an imported thread", launch({ importedNativeThread: {} })], + ["a delegated thread", launch({ delegatedFrom: {} })], + ] as const) { + const result = yield* run(launchThread(input), null); + expect(result, label).toEqual({ requests: 0, dispatched: ["claude-opus-5-5"] }); } }).pipe(Effect.provide(layerIntakeTest)), ); From cab2f559a5e5fa1c45c32925e2eae91b5d1cfdf1 Mon Sep 17 00:00:00 2001 From: doramirdor Date: Fri, 6 Nov 2026 12:58:21 -0500 Subject: [PATCH 08/16] fix: keep Nadir routing off retries and mobile steers - The production runtime now exposes the command receipt store, so intake sees a retried command's receipt; a failed lookup keeps the turn's model instead of routing. - Each command's routing decision is reused within the thread lock, so a retry, even a concurrent one, gets the first answer and asks Nadir once. - Mobile hands the model pick back only after a new turn: an "auto" send into an active run steers it and keeps the pick. - The module comment names the organization-pinned model exception. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/state/thread-outbox-model.ts | 16 ++++++++++++ apps/mobile/src/state/thread-outbox.test.ts | 12 +++++++++ apps/mobile/src/state/threadOutboxDelivery.ts | 10 ++++--- .../src/orchestration-v2/NadirRouting.ts | 23 +++++++++++++++- .../ThreadMessageIntake.test.ts | 26 ++++++++++++++++--- .../orchestration-v2/ThreadMessageIntake.ts | 11 +++++--- .../src/orchestration-v2/runtimeLayer.ts | 2 ++ 7 files changed, 89 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 4796ec19f27ec..02ece2e0ae66f 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -175,6 +175,22 @@ export function threadOutboxRetryDelayMs(attempt: number): number { export type ThreadOutboxDeliveryAction = "wait" | "remove" | "send"; +/** + * Whether a delivered send hands the composer's model pick back to the thread: only a new turn, + * which Nadir may route. An "auto" send into an active run steers it and keeps the model. + */ +export function releasesComposerModelPick(input: { + readonly nadirRouting: boolean; + readonly dispatchMode: ComposerDispatchMode | undefined; + readonly activeRunId: string | null; +}): boolean { + return ( + input.nadirRouting && + (input.dispatchMode === undefined || input.dispatchMode === "auto") && + input.activeRunId === null + ); +} + export function resolveThreadOutboxDeliveryAction(input: { readonly isCreation: boolean; readonly threadExists: boolean; diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index dbd621004b0d6..f5a7d2884f485 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -79,6 +79,7 @@ import { decodeQueuedThreadMessage, encodeQueuedThreadMessage, isQueuedThreadCreationSendable, + releasesComposerModelPick, resolveThreadOutboxDeliveryAction, resolveThreadOutboxDispatchStep, resolveThreadOutboxFailureAction, @@ -1096,6 +1097,17 @@ describe("thread outbox", () => { ).toEqual({ step: "send" }); }); + it("hands the model pick back only after a new turn Nadir may route", () => { + const newTurn = { nadirRouting: true, dispatchMode: "auto", activeRunId: null } as const; + expect(releasesComposerModelPick(newTurn)).toBe(true); + expect(releasesComposerModelPick({ ...newTurn, dispatchMode: undefined })).toBe(true); + // An "auto" send into an active run steers it, and the pick is still the next turn's. + expect(releasesComposerModelPick({ ...newTurn, activeRunId: "run-active" })).toBe(false); + expect(releasesComposerModelPick({ ...newTurn, dispatchMode: "queue" })).toBe(false); + expect(releasesComposerModelPick({ ...newTurn, dispatchMode: "steer" })).toBe(false); + expect(releasesComposerModelPick({ ...newTurn, nadirRouting: false })).toBe(false); + }); + it("only removes a missing-thread message after shell synchronization is live", () => { expect( resolveThreadOutboxDeliveryAction({ diff --git a/apps/mobile/src/state/threadOutboxDelivery.ts b/apps/mobile/src/state/threadOutboxDelivery.ts index b245658ddfeaf..5e8eeeda2888b 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.ts @@ -59,6 +59,7 @@ import { import { removeThreadOutboxMessage } from "./thread-outbox-removal"; import { isQueuedThreadCreationSendable, + releasesComposerModelPick, resolveThreadOutboxDeliveryAction, resolveThreadOutboxDispatchStep, resolveThreadOutboxFailureAction, @@ -949,9 +950,12 @@ export function createThreadOutboxDelivery(): { acknowledgedExistingThreadMessageIds.add(persistedMessage.messageId); // Nadir may run a new turn on another model; the picker then shows the one that ran. if ( - currentConfig.settings?.nadirRouting === true && - (queuedMessage.dispatchMode === undefined || queuedMessage.dispatchMode === "auto") && - sendSettings.modelSelection !== undefined + sendSettings.modelSelection !== undefined && + releasesComposerModelPick({ + nadirRouting: currentConfig.settings?.nadirRouting === true, + dispatchMode: queuedMessage.dispatchMode, + activeRunId: thread.runtime?.activeRunId ?? null, + }) ) { releaseComposerModelPick( scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId), diff --git a/apps/server/src/orchestration-v2/NadirRouting.ts b/apps/server/src/orchestration-v2/NadirRouting.ts index 329bd5d18a94c..3a1f66aedb7cc 100644 --- a/apps/server/src/orchestration-v2/NadirRouting.ts +++ b/apps/server/src/orchestration-v2/NadirRouting.ts @@ -25,7 +25,8 @@ import * as ServerSettings from "../serverSettings.ts"; /** * Nadir routing: before a user's turn starts, ask Nadir (getnadir.com) which model from the * selected provider instance's menu should run it, and run that one. The model the user picked - * stays the ceiling: Nadir never routes above it. Any failure keeps the user's pick. + * stays the ceiling: Nadir never routes above it unless the user's Nadir organization pins a + * model. Any failure keeps the user's pick. */ const NADIR_BUCKET_URL = "https://api.getnadir.com/v1/bucket"; @@ -39,6 +40,9 @@ const failureKind = (cause: unknown): string => ? cause._tag : "unknown"; +// A retry arrives within moments of its first attempt; older decisions are not needed. +const MAX_REMEMBERED_DECISIONS = 1_000; + const Ceiling = Schema.Struct({ baseline: ModelSelection, chosen: ModelSelection }); type Ceiling = typeof Ceiling.Type; @@ -54,6 +58,9 @@ export class NadirCeilings extends Context.Service< readonly save: (threadId: string, ceiling: Ceiling | undefined) => Effect.Effect; /** Holds one turn's read, Nadir call and write at a time for each thread. */ readonly lock: (threadId: string) => Semaphore.Semaphore; + /** The selection a command already got, so a retry of it gets the same one. */ + readonly decided: (commandId: string) => ModelSelection | undefined; + readonly decide: (commandId: string, selection: ModelSelection) => void; } >()("t3/orchestration-v2/NadirRouting/NadirCeilings") {} @@ -65,6 +72,7 @@ export const layerCeilingsFromStore = Layer.effect( const store = KeyValueStore.toSchemaStore(yield* KeyValueStore.KeyValueStore, Ceiling); const memory = new Map(); const locks = new Map(); + const decisions = new Map(); return { get: Effect.fn("NadirCeilings.get")(function* (threadId: string) { if (memory.has(threadId)) return Option.fromUndefinedOr(memory.get(threadId)); @@ -94,6 +102,14 @@ export const layerCeilingsFromStore = Layer.effect( } return lock; }, + decided: (commandId) => decisions.get(commandId), + decide: (commandId, selection) => { + decisions.set(commandId, selection); + if (decisions.size > MAX_REMEMBERED_DECISIONS) { + const oldest = decisions.keys().next().value; + if (oldest !== undefined) decisions.delete(oldest); + } + }, }; }), ); @@ -292,6 +308,8 @@ const askNadir = Effect.fn("NadirRouting.askNadir")(function* ( export const chooseModelSelection = Effect.fn("NadirRouting.chooseModelSelection")( function* (input: { readonly threadId: string; + /** A retried command reuses its first decision instead of asking Nadir again. */ + readonly commandId?: string | undefined; readonly text: string; readonly selection: ModelSelection; readonly cache?: NadirCacheState | undefined; @@ -311,6 +329,8 @@ export const chooseModelSelection = Effect.fn("NadirRouting.chooseModelSelection const store = ceilings.value; return yield* store.lock(input.threadId).withPermits(1)( Effect.gen(function* () { + const earlier = input.commandId === undefined ? undefined : store.decided(input.commandId); + if (earlier !== undefined) return earlier; const baseline = yield* baselineFor(store, input.threadId, input.selection); const text = input.text.trim(); const judgeable = @@ -345,6 +365,7 @@ export const chooseModelSelection = Effect.fn("NadirRouting.chooseModelSelection const chosen = model === baseline.model ? baseline : routedSelection(baseline, model, provider); yield* rememberChoice(store, input.threadId, baseline, chosen); + if (input.commandId !== undefined) store.decide(input.commandId, chosen); if (model !== baseline.model) { yield* Effect.logInfo("Nadir routed turn", { from: baseline.model, to: model }); } diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts index 51d0907d2b7ea..75652da087567 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts @@ -539,9 +539,11 @@ describe("Nadir routing at intake", () => { }), Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({ getByCommandId: (commandId) => - Effect.succeed( - commandId === "send-replayed" ? Option.some({} as never) : Option.none(), - ), + commandId === "send-lookup-fails" + ? Effect.fail({} as never) + : Effect.succeed( + commandId === "send-replayed" ? Option.some({} as never) : Option.none(), + ), }), Layer.succeed( HttpClient.HttpClient, @@ -612,6 +614,11 @@ describe("Nadir routing at intake", () => { null, ], ["a retried command", send({ commandId: CommandId.make("send-replayed") }), null], + [ + "a receipt lookup that fails", + send({ commandId: CommandId.make("send-lookup-fails") }), + null, + ], ["a send during a run", send(), RunId.make("run-active")], ]; for (const [label, command, activeRunId] of unrouted) { @@ -621,6 +628,19 @@ describe("Nadir routing at intake", () => { }).pipe(Effect.provide(layerIntakeTest)), ); + it.effect("asks Nadir once for a command retried while its first attempt routes", () => + Effect.gen(function* () { + const twice = Effect.all([dispatchCommand(send()), dispatchCommand(send())], { + concurrency: "unbounded", + mode: "result", + }); + expect(yield* run(twice, null)).toEqual({ + requests: 1, + dispatched: ["claude-haiku-5-5", "claude-haiku-5-5"], + }); + }).pipe(Effect.provide(layerIntakeTest)), + ); + it.effect("routes a new thread's first message but not imported or delegated launches", () => Effect.gen(function* () { expect(yield* run(launchThread(launch()), null)).toEqual({ diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts index 432cd6733cc01..9a00b4e6aa638 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts @@ -108,10 +108,12 @@ const withNadirModel = Effect.fn("ThreadMessageIntake.withNadirModel")(function* // A retried command replays its first receipt; routing again would record a model it never ran. const receipts = yield* Effect.serviceOption(CommandReceiptStore.CommandReceiptStoreV2); if (Option.isSome(receipts)) { - const prior = yield* receipts.value - .getByCommandId(command.commandId) - .pipe(Effect.orElseSucceed(() => Option.none())); - if (Option.isSome(prior)) return command; + // A lookup that fails cannot rule a replay out, so the turn keeps its model. + const replayed = yield* receipts.value.getByCommandId(command.commandId).pipe( + Effect.map(Option.isSome), + Effect.orElseSucceed(() => true), + ); + if (replayed) return command; } const threads = yield* ThreadManagement.ThreadManagementService; const shell = yield* threads @@ -130,6 +132,7 @@ const withNadirModel = Effect.fn("ThreadMessageIntake.withNadirModel")(function* const selection = command.modelSelection ?? shell.modelSelection; const chosen = yield* NadirRouting.chooseModelSelection({ threadId: command.threadId, + commandId: command.commandId, text: command.text, selection, cache: diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index ba1dadad1864c..1b501fbdf9714 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -369,6 +369,8 @@ export const layer = Layer.mergeAll( ); export const layerProduction = Layer.mergeAll( + // Intake reads receipts so a retried command is not routed again. + layerCommandReceiptStoreProvided, WorktreeBranchSync.layer.pipe( Layer.provide(Layer.merge(layerThreadManagementProvided, ProjectionStore.layer)), ), From d3195ade8c42c797c769db33c49eb228b49da4fa Mon Sep 17 00:00:00 2001 From: doramirdor Date: Fri, 6 Nov 2026 13:14:49 -0500 Subject: [PATCH 09/16] fix: keep the web model pick for queued and steered sends With Nadir routing on, the web composer released its model pick after every successful send. A queued or steered send keeps its model, so the picker reverted to the thread's previous model and the next queued send could run on it. Release only after a send that starts a new turn. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.logic.test.ts | 13 +++++++++++++ apps/web/src/components/ChatView.logic.ts | 12 ++++++++++++ apps/web/src/components/chat/useComposerSend.ts | 3 ++- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 2e772cc0998c0..af680ccff6c90 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -3,6 +3,7 @@ import { buildExpiredTerminalContextToastCopy, mergeDirectPreviewAnnotation, planComposerTurn, + releasesModelPickAfterSend, restorePlanFollowUpComposer, snapshotComposerSend, withMessageContext, @@ -1064,6 +1065,18 @@ describe("snapshotComposerSend", () => { }); }); +describe("releasesModelPickAfterSend", () => { + it("hands the pick back only after a new turn Nadir may route", () => { + const newTurn = { nadirRouting: true, turnDispatchMode: "auto", phase: "ready" } as const; + expect(releasesModelPickAfterSend(newTurn)).toBe(true); + // A queued or steered send keeps the pick for the turn it waits for. + expect(releasesModelPickAfterSend({ ...newTurn, turnDispatchMode: "queue" })).toBe(false); + expect(releasesModelPickAfterSend({ ...newTurn, turnDispatchMode: "steer" })).toBe(false); + expect(releasesModelPickAfterSend({ ...newTurn, phase: "running" })).toBe(false); + expect(releasesModelPickAfterSend({ ...newTurn, nadirRouting: false })).toBe(false); + }); +}); + describe("planComposerTurn", () => { const base = { messageText: "hello", diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index d4b54a7e5a848..5e508f3b0105d 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1399,6 +1399,18 @@ export function snapshotComposerSend(contents: ComposerSendContents) { export type ComposerSendSnapshot = ReturnType; /** How a composer send dispatches: where it runs and whether it waits behind other work. */ +/** + * Whether a sent message hands the composer's model pick back to the thread: only a new turn, + * which Nadir may route. A queued or steered send keeps the pick for the turn it waits for. + */ +export function releasesModelPickAfterSend(input: { + readonly nadirRouting: boolean; + readonly turnDispatchMode: ComposerDispatchMode; + readonly phase: SessionPhase; +}): boolean { + return input.nadirRouting && input.turnDispatchMode === "auto" && input.phase !== "running"; +} + export function planComposerTurn(input: { readonly messageText: string; readonly sendQueuesBehindSetup: boolean; diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index e28704f92a339..439977b36777f 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -11,6 +11,7 @@ import { formatOutgoingPrompt, optimisticMessageAttachments, planComposerTurn, + releasesModelPickAfterSend, sendTitleFallbackLabels, snapshotComposerSend, withMessageContext, @@ -1421,7 +1422,7 @@ export function useComposerSend(input: ComposerSendInput) { } else { turnStartSucceeded = true; // Nadir may run the turn on another model; the picker then shows the one that ran. - if (nadirRouting) { + if (releasesModelPickAfterSend({ nadirRouting, turnDispatchMode, phase })) { releaseModelPick(scopeThreadRef(environmentId, threadIdForSend), modelPickAtSend); } setKeepFullHistory(routeThreadKey, false); From 78903e16c5a32c814e44a40ef9f7d5b0b39616ef Mon Sep 17 00:00:00 2001 From: doramirdor Date: Fri, 6 Nov 2026 13:25:50 -0500 Subject: [PATCH 10/16] test: replay through the real receipt store skips Nadir routing Inserts an accepted receipt into the SQLite-backed receipt store and dispatches the same command through intake: Nadir is not called and the model is unchanged. A compile-time check fails if layerProduction stops exposing the receipt store that intake reads. Co-Authored-By: Claude Opus 5.5 --- .../ThreadMessageIntake.test.ts | 58 ++++++++++++++++--- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts index 75652da087567..20599a9bbbecb 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts @@ -36,7 +36,9 @@ import { import * as ServerConfig from "../config.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; +import type * as RuntimeLayer from "./runtimeLayer.ts"; import { layerCeilingsMemory } from "./NadirRouting.ts"; import { OrchestratorCommandPreviouslyRejectedError, @@ -484,7 +486,19 @@ describe("Nadir routing at intake", () => { skills: [], }; - const run = (effect: Effect.Effect, activeRunId: RunId | null) => + const layerMockReceipts = Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({ + getByCommandId: (commandId) => + commandId === "send-lookup-fails" + ? Effect.fail({} as never) + : Effect.succeed(commandId === "send-replayed" ? Option.some({} as never) : Option.none()), + }); + + const run = ( + effect: Effect.Effect, + activeRunId: RunId | null, + // Layer.empty leaves the receipt store to the caller's context. + layerReceipts: typeof layerMockReceipts | typeof Layer.empty = layerMockReceipts, + ) => Effect.gen(function* () { let requests = 0; const dispatched: Array = []; @@ -537,14 +551,7 @@ describe("Nadir routing at intake", () => { Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([provider]), }), - Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({ - getByCommandId: (commandId) => - commandId === "send-lookup-fails" - ? Effect.fail({} as never) - : Effect.succeed( - commandId === "send-replayed" ? Option.some({} as never) : Option.none(), - ), - }), + layerReceipts, Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => { @@ -628,6 +635,39 @@ describe("Nadir routing at intake", () => { }).pipe(Effect.provide(layerIntakeTest)), ); + it.effect("does not route a command the server's receipt store already accepted", () => + Effect.gen(function* () { + // The server runs layerProduction; intake can only see receipts if it exposes the store. + const productionExposesReceipts: CommandReceiptStore.CommandReceiptStoreV2 extends Layer.Success< + typeof RuntimeLayer.layerProduction + > + ? true + : false = true; + expect(productionExposesReceipts).toBe(true); + const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2; + yield* receipts.insertIfAbsent({ + commandId: CommandId.make("send-routed"), + threadId: ThreadId.make("thread-routed"), + commandType: "message.dispatch", + acceptedAt: NOW, + resultSequence: 1, + status: "accepted", + error: null, + }); + expect(yield* run(dispatchCommand(send()), null, Layer.empty)).toEqual({ + requests: 0, + dispatched: [undefined], + }); + }).pipe( + Effect.provide( + Layer.merge( + CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), + layerIntakeTest, + ), + ), + ), + ); + it.effect("asks Nadir once for a command retried while its first attempt routes", () => Effect.gen(function* () { const twice = Effect.all([dispatchCommand(send()), dispatchCommand(send())], { From 1f9010ae3750cb6c71ed85c3f01fc3875af1314b Mon Sep 17 00:00:00 2001 From: doramirdor Date: Fri, 6 Nov 2026 13:33:04 -0500 Subject: [PATCH 11/16] test: a replayed command leaves the thread's stored ceiling alone The real-receipt-store replay test now seeds a ceiling that routing would overwrite and checks it is unchanged after the replay, alongside the zero Nadir request count. Co-Authored-By: Claude Opus 5.5 --- .../orchestration-v2/ThreadMessageIntake.test.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts index 20599a9bbbecb..70d710e68fb27 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts @@ -39,7 +39,7 @@ import * as ServerSettings from "../serverSettings.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import type * as RuntimeLayer from "./runtimeLayer.ts"; -import { layerCeilingsMemory } from "./NadirRouting.ts"; +import { layerCeilingsMemory, NadirCeilings, rememberChoice } from "./NadirRouting.ts"; import { OrchestratorCommandPreviouslyRejectedError, OrchestratorDispatchError, @@ -498,6 +498,7 @@ describe("Nadir routing at intake", () => { activeRunId: RunId | null, // Layer.empty leaves the receipt store to the caller's context. layerReceipts: typeof layerMockReceipts | typeof Layer.empty = layerMockReceipts, + layerCeilings: typeof layerCeilingsMemory | typeof Layer.empty = layerCeilingsMemory, ) => Effect.gen(function* () { let requests = 0; @@ -569,7 +570,7 @@ describe("Nadir routing at intake", () => { ); }), ), - layerCeilingsMemory, + layerCeilings, ServerSettings.layerTest({ nadirRouting: true }), ), ), @@ -654,14 +655,21 @@ describe("Nadir routing at intake", () => { status: "accepted", error: null, }); - expect(yield* run(dispatchCommand(send()), null, Layer.empty)).toEqual({ + // The thread last ran routed down from Opus to Sonnet. Routing this send would record + // Haiku instead, so an unchanged record shows the replay never reached Nadir. + const ceilings = yield* NadirCeilings; + const ceiling = { baseline: opus, chosen: { instanceId, model: "claude-sonnet-5-5" } }; + yield* rememberChoice(ceilings, "thread-routed", ceiling.baseline, ceiling.chosen); + expect(yield* run(dispatchCommand(send()), null, Layer.empty, Layer.empty)).toEqual({ requests: 0, dispatched: [undefined], }); + expect(yield* ceilings.get("thread-routed")).toEqual(Option.some(ceiling)); }).pipe( Effect.provide( - Layer.merge( + Layer.mergeAll( CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), + layerCeilingsMemory, layerIntakeTest, ), ), From 03fbe085e0b96071289c034e790253d0e754f3a4 Mon Sep 17 00:00:00 2001 From: doramirdor Date: Wed, 11 Nov 2026 12:38:39 -0500 Subject: [PATCH 12/16] Route launches once per command and check every loaded settings file - launchThread validates attachment limits before routing, passes its commandId to Nadir routing, and skips routing when the launch already has a receipt, so a retried launch cannot record a model it never ran. - The compaction guard reads the project and local Claude settings as well as the user settings, so a repo that already runs Nadir's hooks does not get them twice. - Prompt length, slash and word-count checks run before the receipt and thread reads, and the setting text says a send can wait up to 2.5 s. Co-Authored-By: Claude Opus 5.5 --- .../SettingsServerControlsRouteScreen.tsx | 2 +- .../Adapters/ClaudeAdapterV2.ts | 61 ++++++++------- .../orchestration-v2/NadirCompaction.test.ts | 16 ++-- .../src/orchestration-v2/NadirCompaction.ts | 11 ++- .../src/orchestration-v2/NadirRouting.ts | 19 +++-- .../ThreadMessageIntake.test.ts | 51 +++++++++++-- .../orchestration-v2/ThreadMessageIntake.ts | 76 ++++++++++++------- .../components/settings/SettingsPanels.tsx | 2 +- 8 files changed, 159 insertions(+), 79 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 98477e88d785f..9ba7c48490f2d 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -402,7 +402,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { "nadirRouting", projectSelected ? "Environment-wide setting. Select All projects to change it." - : "Nadir picks each turn's model from the selected provider's list, never above yours unless your Nadir organization pins one. Sends the message text to getnadir.com.", + : "Nadir picks each turn's model from the selected provider's list, never above yours unless your Nadir organization pins one. Sends the message text to getnadir.com and can delay a send by up to 2.5 seconds.", )} value={uniform("nadirRouting")} disabled={disabledFor("nadirRouting")} diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index f22e430927a55..867f60850412b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -5047,32 +5047,39 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (path.dirname(directory) === directory) return cwd; } }); - const resolveNadirCompactionHooks = Effect.gen(function* () { - if (Option.isNone(serverSettings)) return undefined; - const enabled = yield* serverSettings.value.getSettings.pipe( - Effect.map((settings) => settings.nadirCompaction), - Effect.orElseSucceed(() => false), - ); - if (!enabled) return undefined; - const script = yield* findNadirCompactionScript({ - fileSystem, - path, - claudeHome: homePath, - hostHome, - userSettingsLoaded: !customHome, - }); - if (script === undefined) { - yield* Effect.logDebug("Nadir compaction skipped: not installed, or Claude Code runs it"); - return undefined; - } - // Every hook would fail on a host without python3, so skip rather than add them. - const resolveExecutable = yield* SpawnExecutableResolution; - if (resolveExecutable("python3", hostPlatform, adapterOptions.environment) === undefined) { - yield* Effect.logDebug("Nadir compaction skipped: python3 is not on PATH"); - return undefined; - } - return nadirCompactionHooks(script, nadirStateDir); - }); + const resolveNadirCompactionHooks = Effect.fn("ClaudeAdapterV2.resolveNadirCompactionHooks")( + function* (cwd: string | null) { + if (Option.isNone(serverSettings)) return undefined; + const enabled = yield* serverSettings.value.getSettings.pipe( + Effect.map((settings) => settings.nadirCompaction), + Effect.orElseSucceed(() => false), + ); + if (!enabled) return undefined; + // A separate Claude home skips user settings; project and local settings always load. + const userSettings = path.join(homePath, "settings.json"); + const settingsFiles = (yield* settingsFilesByPrecedence(cwd)).filter( + (file) => !customHome || file !== userSettings, + ); + const script = yield* findNadirCompactionScript({ + fileSystem, + path, + claudeHome: homePath, + hostHome, + settingsFiles, + }); + if (script === undefined) { + yield* Effect.logDebug("Nadir compaction skipped: not installed, or Claude Code runs it"); + return undefined; + } + // Every hook would fail on a host without python3, so skip rather than add them. + const resolveExecutable = yield* SpawnExecutableResolution; + if (resolveExecutable("python3", hostPlatform, adapterOptions.environment) === undefined) { + yield* Effect.logDebug("Nadir compaction skipped: python3 is not on PATH"); + return undefined; + } + return nadirCompactionHooks(script, nadirStateDir); + }, + ); const settingsFilesByPrecedence = Effect.fn("ClaudeAdapterV2.settingsFilesByPrecedence")( function* (cwd: string | null) { @@ -11072,7 +11079,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( turnInput.runtimePolicy.cwd, launchArgSettings, ); - const extraHooks = yield* resolveNadirCompactionHooks; + const extraHooks = yield* resolveNadirCompactionHooks(turnInput.runtimePolicy.cwd); const queryOptions = makeClaudeQueryOptions({ modelSelection: turnInput.modelSelection, modelCatalog, diff --git a/apps/server/src/orchestration-v2/NadirCompaction.test.ts b/apps/server/src/orchestration-v2/NadirCompaction.test.ts index 8d1a2bb38f730..6acfda18c7a2c 100644 --- a/apps/server/src/orchestration-v2/NadirCompaction.test.ts +++ b/apps/server/src/orchestration-v2/NadirCompaction.test.ts @@ -18,8 +18,9 @@ it.layer(NodeServices.layer)("NadirCompaction", (it) => { const path = yield* Path.Path; const claudeHome = yield* fileSystem.makeTempDirectoryScoped(); const hostHome = yield* fileSystem.makeTempDirectoryScoped(); - const find = (userSettingsLoaded = true) => - findNadirCompactionScript({ fileSystem, path, claudeHome, hostHome, userSettingsLoaded }); + const userSettings = path.join(claudeHome, "settings.json"); + const find = (settingsFiles: ReadonlyArray = [userSettings]) => + findNadirCompactionScript({ fileSystem, path, claudeHome, hostHome, settingsFiles }); const touch = (file: string) => fileSystem .makeDirectory(path.dirname(file), { recursive: true }) @@ -66,7 +67,12 @@ it.layer(NodeServices.layer)("NadirCompaction", (it) => { }); expect(yield* find()).toBeUndefined(); // A separate T3 Claude home skips user settings, so T3 must add the hooks itself. - expect(yield* find(false)).toBe(labsScript); + expect(yield* find([])).toBe(labsScript); + // Project and local settings load either way, and a repo can enable Nadir there. + const projectSettings = path.join(hostHome, "repo", ".claude", "settings.local.json"); + yield* touch(projectSettings); + yield* writeJson(projectSettings, { enabledPlugins: { "nadir-route@nadir": true } }); + expect(yield* find([projectSettings])).toBeUndefined(); // A plugin installed only under the host's own Claude home is found from a separate one. const separateHome = yield* fileSystem.makeTempDirectoryScoped(); @@ -88,11 +94,11 @@ it.layer(NodeServices.layer)("NadirCompaction", (it) => { path, claudeHome: separateHome, hostHome, - userSettingsLoaded: false, + settingsFiles: [], }), ).toBe(hostPluginScript); - yield* writeJson(path.join(claudeHome, "settings.json"), { + yield* writeJson(userSettings, { enabledPlugins: { "nadir-route@nadir": true }, }); expect(yield* find()).toBeUndefined(); diff --git a/apps/server/src/orchestration-v2/NadirCompaction.ts b/apps/server/src/orchestration-v2/NadirCompaction.ts index 63f7ed6e2367d..5c890089d8861 100644 --- a/apps/server/src/orchestration-v2/NadirCompaction.ts +++ b/apps/server/src/orchestration-v2/NadirCompaction.ts @@ -41,7 +41,7 @@ function runsNadirCompaction(settings: unknown): boolean { /** * The compaction script to run, or undefined when Nadir is not installed or Claude Code already - * runs it from the user settings it loads, so the hooks never run twice. Looks in the + * runs it from a settings file it loads, so the hooks never run twice. Looks in the * `npx nadir-labs` install first, then the `nadir-route` plugin. */ export const findNadirCompactionScript = Effect.fn("NadirCompaction.findScript")(function* (input: { @@ -49,13 +49,12 @@ export const findNadirCompactionScript = Effect.fn("NadirCompaction.findScript") readonly path: Path.Path; readonly claudeHome: string; readonly hostHome: string; - /** False when T3 runs Claude with its own home, which skips the user's settings. */ - readonly userSettingsLoaded: boolean; + /** The user, project and local settings files Claude Code loads for this session. */ + readonly settingsFiles: ReadonlyArray; }) { const { fileSystem, path, claudeHome } = input; - if (input.userSettingsLoaded) { - const userSettings = yield* readJson(fileSystem, path.join(claudeHome, "settings.json")); - if (runsNadirCompaction(userSettings)) return undefined; + for (const file of input.settingsFiles) { + if (runsNadirCompaction(yield* readJson(fileSystem, file))) return undefined; } const homes = [claudeHome, path.join(input.hostHome, ".claude")]; const pluginScripts: string[] = []; diff --git a/apps/server/src/orchestration-v2/NadirRouting.ts b/apps/server/src/orchestration-v2/NadirRouting.ts index 3a1f66aedb7cc..70e67768b2d2e 100644 --- a/apps/server/src/orchestration-v2/NadirRouting.ts +++ b/apps/server/src/orchestration-v2/NadirRouting.ts @@ -276,6 +276,16 @@ export function pickModel( return selected; } +/** Whether a prompt is worth asking Nadir about. Short follow-ups and slash commands are not. */ +export function isJudgeable(text: string): boolean { + const trimmed = text.trim(); + return ( + trimmed.length <= MAX_PROMPT_CHARS && + !trimmed.startsWith("/") && + trimmed.split(/\s+/).length >= MIN_PROMPT_WORDS + ); +} + /** True when Nadir routing is on in Settings. */ export const isEnabled = ServerSettings.ServerSettingsService.pipe( Effect.flatMap((service) => service.getSettings), @@ -314,6 +324,8 @@ export const chooseModelSelection = Effect.fn("NadirRouting.chooseModelSelection readonly selection: ModelSelection; readonly cache?: NadirCacheState | undefined; }) { + // A short follow-up or a slash command continues on the thread's model; the ceiling stays. + if (!isJudgeable(input.text)) return input.selection; // Optional so callers outside the client intake (MCP tools, tests) need not provide them. const client = yield* Effect.serviceOption(HttpClient.HttpClient); const registry = yield* Effect.serviceOption(ProviderRegistry.ProviderRegistry); @@ -333,17 +345,12 @@ export const chooseModelSelection = Effect.fn("NadirRouting.chooseModelSelection if (earlier !== undefined) return earlier; const baseline = yield* baselineFor(store, input.threadId, input.selection); const text = input.text.trim(); - const judgeable = - text.length <= MAX_PROMPT_CHARS && - !text.startsWith("/") && - text.split(/\s+/).length >= MIN_PROMPT_WORDS; const providers = yield* registry.value.getProviders; const provider = providers.find( (candidate) => candidate.instanceId === baseline.instanceId, ); const menu = modelMenu(provider); - // A short follow-up or a slash command continues on the thread's model; the ceiling stays. - if (!judgeable || !menu.includes(baseline.model)) return input.selection; + if (!menu.includes(baseline.model)) return input.selection; const current = input.cache?.model ?? input.selection.model; const answer = yield* askNadir( client.value, diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts index c099d45ebdb70..59b56ca996787 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts @@ -10,6 +10,7 @@ import { EventId, GuestMessageId, MessageId, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProviderDriverKind, ProviderInstanceId, RunId, @@ -462,6 +463,7 @@ describe("Nadir routing at intake", () => { // Only the fields the launch path reads; the launch service is a stub. const launch = (overrides: Record = {}) => ({ + commandId: CommandId.make("launch-routed"), threadId: ThreadId.make("thread-launched"), modelSelection: opus, initialMessage: { text: prompt, attachments: [] }, @@ -486,6 +488,9 @@ describe("Nadir routing at intake", () => { skills: [], }; + // Counts the thread reads routing does, so a test can show the cheap checks skip them. + let shellReads = 0; + const layerMockReceipts = Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({ getByCommandId: (commandId) => commandId === "send-lookup-fails" @@ -523,13 +528,19 @@ describe("Nadir routing at intake", () => { ), // Only these shell and snapshot fields are read. getThreadShell: () => - Effect.succeed({ - activeRunId, - modelSelection: opus, - activeProviderThreadId: null, - } as unknown as Effect.Success< - ReturnType - >), + Effect.sync(() => { + shellReads += 1; + }).pipe( + Effect.as({ + activeRunId, + modelSelection: opus, + activeProviderThreadId: null, + } as unknown as Effect.Success< + ReturnType< + ThreadManagementService.ThreadManagementServiceShape["getThreadShell"] + > + >), + ), getThreadSnapshotWindow: () => Effect.succeed({ projection: { @@ -698,10 +709,36 @@ describe("Nadir routing at intake", () => { for (const [label, input] of [ ["an imported thread", launch({ importedNativeThread: {} })], ["a delegated thread", launch({ delegatedFrom: {} })], + // The launch service replays a retried launch's first receipt. + ["a retried launch", launch({ commandId: CommandId.make("send-replayed") })], + ["a receipt lookup that fails", launch({ commandId: CommandId.make("send-lookup-fails") })], ] as const) { const result = yield* run(launchThread(input), null); expect(result, label).toEqual({ requests: 0, dispatched: ["claude-opus-5-5"] }); } + // A launch over the attachment limit is rejected before its text reaches Nadir. + const overLimit = launch({ + initialMessage: { + text: prompt, + attachments: Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS + 1 }, () => ({ + type: "file", + })), + }, + }); + expect(yield* run(launchThread(overLimit), null)).toEqual({ requests: 0, dispatched: [] }); + }).pipe(Effect.provide(layerIntakeTest)), + ); + + it.effect("skips the thread reads for a follow-up too short to judge", () => + Effect.gen(function* () { + shellReads = 0; + expect(yield* run(dispatchCommand(send({ text: "thanks, ship it" })), null)).toEqual({ + requests: 0, + dispatched: [undefined], + }); + expect(shellReads).toBe(0); + yield* run(dispatchCommand(send()), null); + expect(shellReads).toBe(1); }).pipe(Effect.provide(layerIntakeTest)), ); }); diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts index 99d4c2e725241..a39b2efda4c42 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts @@ -86,6 +86,19 @@ const withGuestAttachments = Effect.fn("ThreadMessageIntake.withGuestAttachments return { ...command, attachments: message?.attachments ?? [] }; }); +/** A retried command replays its first receipt; routing again would record a model it never ran. */ +const hasReceipt = Effect.fn("ThreadMessageIntake.hasReceipt")(function* ( + commandId: ThreadLaunch.ThreadLaunchInput["commandId"], +) { + const receipts = yield* Effect.serviceOption(CommandReceiptStore.CommandReceiptStoreV2); + if (Option.isNone(receipts)) return false; + // A lookup that fails cannot rule a replay out, so the turn keeps its model. + return yield* receipts.value.getByCommandId(commandId).pipe( + Effect.map(Option.isSome), + Effect.orElseSucceed(() => true), + ); +}); + /** * A user's own new turn gets Nadir's model pick. Steering into a running turn, continuations, * compactions, and scheduled, delegated or guest sends keep the model they came with. @@ -105,16 +118,14 @@ const withNadirModel = Effect.fn("ThreadMessageIntake.withNadirModel")(function* command.deliveryIntent !== "steer" && command.deliveryIntent !== "restart" && command.dispatchMode.type === "start_immediately"; - if (!plainUserTurn || !(yield* NadirRouting.isEnabled)) return command; - // A retried command replays its first receipt; routing again would record a model it never ran. - const receipts = yield* Effect.serviceOption(CommandReceiptStore.CommandReceiptStoreV2); - if (Option.isSome(receipts)) { - // A lookup that fails cannot rule a replay out, so the turn keeps its model. - const replayed = yield* receipts.value.getByCommandId(command.commandId).pipe( - Effect.map(Option.isSome), - Effect.orElseSucceed(() => true), - ); - if (replayed) return command; + // The cheap checks go first, so most short follow-ups skip the reads below. + if ( + !plainUserTurn || + !NadirRouting.isJudgeable(command.text) || + !(yield* NadirRouting.isEnabled) || + (yield* hasReceipt(command.commandId)) + ) { + return command; } const threads = yield* ThreadManagement.ThreadManagementService; const shell = yield* threads @@ -271,26 +282,39 @@ export const sendToThread = Effect.fn("ThreadMessageIntake.sendToThread")(functi ); }); +/** A new thread has no warm cache, so its first turn is the cheapest place to pick a model. */ +const withNadirLaunchModel = Effect.fn("ThreadMessageIntake.withNadirLaunchModel")(function* ( + input: ThreadLaunch.ThreadLaunchInput, +) { + const message = input.initialMessage; + const threadId = input.threadId; + if ( + message === undefined || + threadId === undefined || + input.importedNativeThread !== undefined || + input.delegatedFrom !== undefined || + !NadirRouting.isJudgeable(message.text) || + !(yield* NadirRouting.isEnabled) || + (yield* hasReceipt(input.commandId)) + ) { + return input; + } + const modelSelection = yield* NadirRouting.chooseModelSelection({ + threadId, + commandId: input.commandId, + text: message.text, + selection: input.modelSelection, + }); + return modelSelection === input.modelSelection ? input : { ...input, modelSelection }; +}); + export const launchThread = Effect.fn("ThreadMessageIntake.launchThread")(function* ( launchInput: ThreadLaunch.ThreadLaunchInput, ) { - // A new thread has no warm cache, so its first turn is the cheapest place to pick a model. - const input = - launchInput.initialMessage === undefined || - launchInput.threadId === undefined || - launchInput.importedNativeThread !== undefined || - launchInput.delegatedFrom !== undefined - ? launchInput - : { - ...launchInput, - modelSelection: yield* NadirRouting.chooseModelSelection({ - threadId: launchInput.threadId, - text: launchInput.initialMessage.text, - selection: launchInput.modelSelection, - }), - }; + // A launch these limits reject never sends its text to Nadir. + yield* AttachmentClaims.validateAttachmentLimits(launchInput.initialMessage?.attachments ?? []); + const input = yield* withNadirLaunchModel(launchInput); const launches = yield* ThreadLaunch.ThreadLaunchService; - yield* AttachmentClaims.validateAttachmentLimits(input.initialMessage?.attachments ?? []); if (!input.initialMessage?.attachments.some(AttachmentClaims.attachmentIsPendingUpload)) { return yield* launches.launch(input); } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 847a765e3b81e..3a0224fc964a6 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1529,7 +1529,7 @@ const NADIR_SETTING_ROWS = { label: "Nadir model routing", searchId: "nadir-routing", description: - "Before each message you send, Nadir picks the model from the selected provider's list, never above the one you chose unless your Nadir organization pins a model, and the model picker shows what ran. Sends the message text to getnadir.com. Set NADIR_API_KEY on the host to use your Nadir account.", + "Before each message you send, Nadir picks the model from the selected provider's list, never above the one you chose unless your Nadir organization pins a model, and the model picker shows what ran. Sends the message text to getnadir.com, and a message can wait up to 2.5 seconds for its answer. Set NADIR_API_KEY on the host to use your Nadir account.", }, nadirCompaction: { label: "Nadir compaction", From c8e9d524ae7dc3c6556e7b080af0b8e5e8b3d992 Mon Sep 17 00:00:00 2001 From: doramirdor Date: Wed, 11 Nov 2026 16:00:55 -0500 Subject: [PATCH 13/16] Count a custom Claude home's settings and plugin precedence in the compaction guard The query loads every settings source, so a custom home's settings.json is its user settings; the guard now reads it instead of filtering it out. enabledPlugins now follows Claude Code's precedence: the highest file that names nadir-route@nadir decides it, while hooks from every file still count. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 50 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 8 +-- .../orchestration-v2/NadirCompaction.test.ts | 17 +++++-- .../src/orchestration-v2/NadirCompaction.ts | 21 +++++--- 4 files changed, 78 insertions(+), 18 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 21113c07b510b..72f85f0cdb3b8 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -95,6 +95,7 @@ import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts"; import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutable.ts"; +import * as ServerSettings from "../../serverSettings.ts"; import { BUNDLED_CLAUDE_MODEL_CATALOG, type ClaudeModelCatalog, @@ -4407,6 +4408,55 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("skips Nadir compaction when a custom home's settings already run it", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-nadir-" }); + const hostHome = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-host-home-" }); + const script = path.join(home, "nadir", "context_session.py"); + yield* fileSystem.makeDirectory(path.dirname(script), { recursive: true }); + yield* fileSystem.writeFileString(script, ""); + const injectsNadir = Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarnessWithOptions({ + settings: { ...DEFAULT_CLAUDE_SETTINGS, homePath: home }, + }); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-nadir-custom-home"), + text: "Run git status.", + attachments: [], + }), + ); + return JSON.stringify(harness.getOpenedOptions()?.settings ?? {}).includes(script); + }), + ).pipe( + Effect.provide(ServerSettings.layerTest({ nadirCompaction: true })), + Effect.provideService(HostProcess.HomeDirectory, hostHome), + Effect.provideService(SpawnExecutableResolution, () => "/usr/bin/python3"), + ); + assert.isTrue(yield* injectsNadir); + yield* fileSystem.writeFileString( + path.join(home, "settings.json"), + JSON.stringify({ enabledPlugins: { "nadir-route@nadir": true } }), + ); + assert.isFalse(yield* injectsNadir); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + // regression: #21806 only a missing custom settings file is benign; bad policy fails it.effect( "distinguishes missing custom settings from unreadable, malformed, or unresolved policy", diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 867f60850412b..27068ffc07e44 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -5055,17 +5055,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( Effect.orElseSucceed(() => false), ); if (!enabled) return undefined; - // A separate Claude home skips user settings; project and local settings always load. - const userSettings = path.join(homePath, "settings.json"); - const settingsFiles = (yield* settingsFilesByPrecedence(cwd)).filter( - (file) => !customHome || file !== userSettings, - ); + // The query loads every source, and a custom home's settings.json is its user settings. const script = yield* findNadirCompactionScript({ fileSystem, path, claudeHome: homePath, hostHome, - settingsFiles, + settingsFiles: yield* settingsFilesByPrecedence(cwd), }); if (script === undefined) { yield* Effect.logDebug("Nadir compaction skipped: not installed, or Claude Code runs it"); diff --git a/apps/server/src/orchestration-v2/NadirCompaction.test.ts b/apps/server/src/orchestration-v2/NadirCompaction.test.ts index 6acfda18c7a2c..5661f6b8d992d 100644 --- a/apps/server/src/orchestration-v2/NadirCompaction.test.ts +++ b/apps/server/src/orchestration-v2/NadirCompaction.test.ts @@ -66,13 +66,20 @@ it.layer(NodeServices.layer)("NadirCompaction", (it) => { }, }); expect(yield* find()).toBeUndefined(); - // A separate T3 Claude home skips user settings, so T3 must add the hooks itself. + // With no loaded settings running it, T3 adds the hooks itself. expect(yield* find([])).toBe(labsScript); - // Project and local settings load either way, and a repo can enable Nadir there. - const projectSettings = path.join(hostHome, "repo", ".claude", "settings.local.json"); - yield* touch(projectSettings); + // A repo can enable Nadir too. The highest file naming the plugin decides it. + const localSettings = path.join(hostHome, "repo", ".claude", "settings.local.json"); + const projectSettings = path.join(hostHome, "repo", ".claude", "settings.json"); + yield* touch(localSettings); + yield* writeJson(localSettings, { enabledPlugins: { "nadir-route@nadir": true } }); + expect(yield* find([localSettings])).toBeUndefined(); + yield* writeJson(localSettings, { enabledPlugins: { "nadir-route@nadir": false } }); yield* writeJson(projectSettings, { enabledPlugins: { "nadir-route@nadir": true } }); - expect(yield* find([projectSettings])).toBeUndefined(); + expect(yield* find([localSettings, projectSettings])).toBe(labsScript); + expect(yield* find([projectSettings, localSettings])).toBeUndefined(); + // A disabled plugin does not cancel hooks a lower file wires. + expect(yield* find([localSettings, userSettings])).toBeUndefined(); // A plugin installed only under the host's own Claude home is found from a separate one. const separateHome = yield* fileSystem.makeTempDirectoryScoped(); diff --git a/apps/server/src/orchestration-v2/NadirCompaction.ts b/apps/server/src/orchestration-v2/NadirCompaction.ts index 5c890089d8861..e4bc7089e28e5 100644 --- a/apps/server/src/orchestration-v2/NadirCompaction.ts +++ b/apps/server/src/orchestration-v2/NadirCompaction.ts @@ -26,9 +26,8 @@ const field = (value: unknown, key: string): unknown => const SCRIPT = "context_session.py"; -/** Whether Claude Code settings already run Nadir's compaction, as `npx nadir-labs` installs it. */ -function runsNadirCompaction(settings: unknown): boolean { - if (field(field(settings, "enabledPlugins"), PLUGIN_ID) === true) return true; +/** Whether a settings file wires Nadir's compaction script, as `npx nadir-labs` installs it. */ +function wiresNadirScript(settings: unknown): boolean { const hooks = field(settings, "hooks"); return ( typeof hooks === "object" && @@ -39,6 +38,15 @@ function runsNadirCompaction(settings: unknown): boolean { ); } +/** Whether settings files, highest precedence first, already run Nadir's compaction. */ +function runsNadirCompaction(settingsByPrecedence: ReadonlyArray): boolean { + // The highest file that names the plugin decides it; hooks from every file run. + const plugin = settingsByPrecedence + .map((settings) => field(field(settings, "enabledPlugins"), PLUGIN_ID)) + .find((enabled) => enabled !== undefined); + return plugin === true || settingsByPrecedence.some(wiresNadirScript); +} + /** * The compaction script to run, or undefined when Nadir is not installed or Claude Code already * runs it from a settings file it loads, so the hooks never run twice. Looks in the @@ -49,13 +57,12 @@ export const findNadirCompactionScript = Effect.fn("NadirCompaction.findScript") readonly path: Path.Path; readonly claudeHome: string; readonly hostHome: string; - /** The user, project and local settings files Claude Code loads for this session. */ + /** The settings files Claude Code loads for this session, highest precedence first. */ readonly settingsFiles: ReadonlyArray; }) { const { fileSystem, path, claudeHome } = input; - for (const file of input.settingsFiles) { - if (runsNadirCompaction(yield* readJson(fileSystem, file))) return undefined; - } + const settings = yield* Effect.forEach(input.settingsFiles, (file) => readJson(fileSystem, file)); + if (runsNadirCompaction(settings)) return undefined; const homes = [claudeHome, path.join(input.hostHome, ".claude")]; const pluginScripts: string[] = []; for (const home of homes) { From f9de6cfa82ca2ffc4e6d171a8f65e4a96bf63a4c Mon Sep 17 00:00:00 2001 From: doramirdor Date: Wed, 11 Nov 2026 16:46:01 -0500 Subject: [PATCH 14/16] Compare the compaction hook's script argument directly in the custom-home test JSON.stringify escapes backslashes, so the serialized-settings check missed a Windows script path even when the hook was there. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 72f85f0cdb3b8..94ee5231336d4 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -4432,7 +4432,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachments: [], }), ); - return JSON.stringify(harness.getOpenedOptions()?.settings ?? {}).includes(script); + const settings = harness.getOpenedOptions()?.settings as ClaudeSdk.Settings | undefined; + return (settings?.hooks?.PostToolUse ?? []).some((matcher) => + matcher.hooks.some((hook) => "args" in hook && hook.args?.[0] === script), + ); }), ).pipe( Effect.provide(ServerSettings.layerTest({ nadirCompaction: true })), From 777496b1caaeae259b46ddd91b4cebdb97a6dc60 Mon Sep 17 00:00:00 2001 From: doramirdor Date: Wed, 11 Nov 2026 19:43:45 -0500 Subject: [PATCH 15/16] Restore the Nadir ceiling when a routed send is refused, and release only the exact composer pick - A routed send the server refuses (an attachment claim failure, or a confirmed rejection) now puts back the thread's ceiling record it replaced, unless a later turn saved one since. The command's decision is dropped too, so a retry asks Nadir again. - The compaction guard reads the `--settings` launch argument, which outranks every settings file. - Web and mobile release the composer pick only when it is the same pick the send used, so picking the same model again while a send is in flight keeps it. Mobile remembers the pick per queued command. A draft with no pick at enqueue releases nothing. Co-Authored-By: Claude Opus 5.5 --- .../threadOutboxDelivery.permissions.test.ts | 1 + apps/mobile/src/state/threadOutboxDelivery.ts | 10 +- .../src/state/use-composer-drafts.test.ts | 20 +++ apps/mobile/src/state/use-composer-drafts.ts | 24 ++-- .../src/state/use-thread-composer-state.ts | 2 + .../Adapters/ClaudeAdapterV2.ts | 8 +- .../orchestration-v2/NadirCompaction.test.ts | 21 ++- .../src/orchestration-v2/NadirCompaction.ts | 7 +- .../src/orchestration-v2/NadirRouting.test.ts | 24 ++++ .../src/orchestration-v2/NadirRouting.ts | 127 +++++++++++++----- .../ThreadMessageIntake.test.ts | 40 +++++- .../orchestration-v2/ThreadMessageIntake.ts | 7 +- apps/web/src/composerDraftStore.test.ts | 21 +-- apps/web/src/composerDraftStore.ts | 7 +- 14 files changed, 246 insertions(+), 73 deletions(-) diff --git a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts index 8014e579197f9..bde9c1587117f 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts @@ -153,6 +153,7 @@ vi.mock("./use-composer-drafts", async () => { }), replaceComposerDraftAttachments: vi.fn(), setFreshSessionPending: vi.fn(), + takeSentComposerPick: () => undefined, undoComposerDraftMerge: vi.fn(), updateComposerDraftSettings: vi.fn(), waitForComposerDraftsLoaded: vi.fn(), diff --git a/apps/mobile/src/state/threadOutboxDelivery.ts b/apps/mobile/src/state/threadOutboxDelivery.ts index 5d26bc7279fd8..edf8bd6cd0011 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.ts @@ -79,9 +79,10 @@ import { type ComposerDraft, getComposerDraftSnapshot, mergeComposerDraftContent, - releaseComposerModelPick, + releaseComposerDraftModelSelection, replaceComposerDraftAttachments, removeDeliveredCloudQueuedMessage, + takeSentComposerPick, undoComposerDraftMerge, updateComposerDraftSettings, waitForComposerDraftsLoaded, @@ -965,17 +966,18 @@ export function createThreadOutboxDelivery(): { } acknowledgedExistingThreadMessageIds.add(persistedMessage.messageId); // Nadir may run a new turn on another model; the picker then shows the one that ran. + const sentPick = takeSentComposerPick(queuedMessage.commandId); if ( - sendSettings.modelSelection !== undefined && + sentPick !== undefined && releasesComposerModelPick({ nadirRouting: currentConfig.settings?.nadirRouting === true, dispatchMode: queuedMessage.dispatchMode, activeRunId: thread.runtime?.activeRunId ?? null, }) ) { - releaseComposerModelPick( + releaseComposerDraftModelSelection( scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId), - sendSettings.modelSelection, + sentPick, ); } const delivered = diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index f8a14977acca9..c02cb7e6a307b 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -209,10 +209,13 @@ import { insertComposerDraftContext, insertComposerDraftText, rememberComposerDraftSelection, + rememberSentComposerPick, + releaseComposerDraftModelSelection, setComposerDraftAttachmentUpload, waitForComposerDraftsLoaded, setStickyComposerModelSelection, stickyComposerModelSelectionAtom, + takeSentComposerPick, undoComposerDraftMergeState, updateComposerDraftSettings, } from "./use-composer-drafts"; @@ -586,6 +589,23 @@ describe("mobile composer drafts", () => { expect(appAtomRegistry.get(composerDraftsAtom)[key]).toBeUndefined(); }); + it("releases only the pick a queued send used", () => { + const key = "environment-1:thread-picks"; + const sol = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6-sol" }; + // A draft with no pick sends the thread's model, so a pick made while it waits stays. + rememberSentComposerPick("send-1", getComposerDraftSnapshot(key).modelSelection); + updateComposerDraftSettings(key, { modelSelection: sol }); + expect(takeSentComposerPick("send-1")).toBeUndefined(); + // Picking the sent model again is a new pick, so releasing the sent one leaves it. + rememberSentComposerPick("send-2", getComposerDraftSnapshot(key).modelSelection); + updateComposerDraftSettings(key, { modelSelection: { ...sol } }); + const sent = takeSentComposerPick("send-2"); + expect(sent).toBe(sol); + releaseComposerDraftModelSelection(key, sent!); + expect(getComposerDraftSnapshot(key).modelSelection).toEqual(sol); + expect(takeSentComposerPick("send-2")).toBeUndefined(); + }); + it("drops chips and records for attachments a replace no longer keeps", () => { const key = "new-task:draft-1"; const kept = { diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index a5c8b9af9dff3..50aa28b4ffb82 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1328,16 +1328,20 @@ function updateComposerDrafts( schedulePersistComposerState(); } -/** Drops a thread draft's model pick that a send used, so the picker follows the thread's model. */ -export function releaseComposerModelPick(draftKey: string, sentPick: ModelSelection): void { - updateComposerDrafts((current) => { - const draft = current[draftKey]; - if (!draft?.modelSelection || !draftModelPickMatchesThread(draft.modelSelection, sentPick)) { - return current; - } - const { modelSelection: _modelSelection, ...rest } = draft; - return withComposerDraft(current, draftKey, rest); - }); +// The draft pick each queued send used, by command id. A send from a draft with no pick has no +// entry, so a pick made while it was queued stays. After a restart the entry is gone and the +// picker keeps the pick. ponytail: a send that never delivers leaves its entry until then. +const sentComposerPicks = new Map(); + +export function rememberSentComposerPick(commandId: string, pick: ModelSelection | undefined) { + if (pick !== undefined) sentComposerPicks.set(commandId, pick); +} + +/** The draft pick a queued send used, if it had one; forgotten once read. */ +export function takeSentComposerPick(commandId: string): ModelSelection | undefined { + const pick = sentComposerPicks.get(commandId); + sentComposerPicks.delete(commandId); + return pick; } export function setStickyComposerModelSelection(modelSelection: ModelSelection): void { diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 702aa53f90f0f..2d63d571a55a4 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -76,6 +76,7 @@ import { getComposerDraftSnapshot, isFreshSessionPending, mergeComposerDraftContent, + rememberSentComposerPick, removeComposerDraftAttachment, scheduleUnusedComposerAttachmentCleanup, setComposerDraftText, @@ -755,6 +756,7 @@ export function useThreadComposerState(input: { const messageId = MessageId.make(metadata.messageId); onWillEnqueue?.(); clearThreadComposerError(threadKey); + rememberSentComposerPick(metadata.commandId, draft.modelSelection); const enqueuePromise = enqueueThreadOutboxMessage({ environmentId: selectedThreadShell.environmentId, threadId: selectedThreadShell.id, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 27068ffc07e44..54ee5d32dba34 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -5048,7 +5048,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } }); const resolveNadirCompactionHooks = Effect.fn("ClaudeAdapterV2.resolveNadirCompactionHooks")( - function* (cwd: string | null) { + function* (cwd: string | null, launchSettings: ClaudeSdkSettings | undefined) { if (Option.isNone(serverSettings)) return undefined; const enabled = yield* serverSettings.value.getSettings.pipe( Effect.map((settings) => settings.nadirCompaction), @@ -5062,6 +5062,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( claudeHome: homePath, hostHome, settingsFiles: yield* settingsFilesByPrecedence(cwd), + launchSettings, }); if (script === undefined) { yield* Effect.logDebug("Nadir compaction skipped: not installed, or Claude Code runs it"); @@ -11075,7 +11076,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( turnInput.runtimePolicy.cwd, launchArgSettings, ); - const extraHooks = yield* resolveNadirCompactionHooks(turnInput.runtimePolicy.cwd); + const extraHooks = yield* resolveNadirCompactionHooks( + turnInput.runtimePolicy.cwd, + launchArgSettings, + ); const queryOptions = makeClaudeQueryOptions({ modelSelection: turnInput.modelSelection, modelCatalog, diff --git a/apps/server/src/orchestration-v2/NadirCompaction.test.ts b/apps/server/src/orchestration-v2/NadirCompaction.test.ts index 5661f6b8d992d..2b6ed5a300235 100644 --- a/apps/server/src/orchestration-v2/NadirCompaction.test.ts +++ b/apps/server/src/orchestration-v2/NadirCompaction.test.ts @@ -19,8 +19,18 @@ it.layer(NodeServices.layer)("NadirCompaction", (it) => { const claudeHome = yield* fileSystem.makeTempDirectoryScoped(); const hostHome = yield* fileSystem.makeTempDirectoryScoped(); const userSettings = path.join(claudeHome, "settings.json"); - const find = (settingsFiles: ReadonlyArray = [userSettings]) => - findNadirCompactionScript({ fileSystem, path, claudeHome, hostHome, settingsFiles }); + const find = ( + settingsFiles: ReadonlyArray = [userSettings], + launchSettings?: unknown, + ) => + findNadirCompactionScript({ + fileSystem, + path, + claudeHome, + hostHome, + settingsFiles, + launchSettings, + }); const touch = (file: string) => fileSystem .makeDirectory(path.dirname(file), { recursive: true }) @@ -80,6 +90,13 @@ it.layer(NodeServices.layer)("NadirCompaction", (it) => { expect(yield* find([projectSettings, localSettings])).toBeUndefined(); // A disabled plugin does not cancel hooks a lower file wires. expect(yield* find([localSettings, userSettings])).toBeUndefined(); + // The --settings launch argument outranks every file. + const launchHooks = { + hooks: { PostToolUse: [{ hooks: [{ command: `python3 ${labsScript}` }] }] }, + }; + expect(yield* find([], launchHooks)).toBeUndefined(); + const launchOff = { enabledPlugins: { "nadir-route@nadir": false } }; + expect(yield* find([projectSettings], launchOff)).toBe(labsScript); // A plugin installed only under the host's own Claude home is found from a separate one. const separateHome = yield* fileSystem.makeTempDirectoryScoped(); diff --git a/apps/server/src/orchestration-v2/NadirCompaction.ts b/apps/server/src/orchestration-v2/NadirCompaction.ts index e4bc7089e28e5..9b10b162057b7 100644 --- a/apps/server/src/orchestration-v2/NadirCompaction.ts +++ b/apps/server/src/orchestration-v2/NadirCompaction.ts @@ -59,9 +59,14 @@ export const findNadirCompactionScript = Effect.fn("NadirCompaction.findScript") readonly hostHome: string; /** The settings files Claude Code loads for this session, highest precedence first. */ readonly settingsFiles: ReadonlyArray; + /** The `--settings` launch argument, which outranks every file. */ + readonly launchSettings?: unknown; }) { const { fileSystem, path, claudeHome } = input; - const settings = yield* Effect.forEach(input.settingsFiles, (file) => readJson(fileSystem, file)); + const settings = [ + input.launchSettings, + ...(yield* Effect.forEach(input.settingsFiles, (file) => readJson(fileSystem, file))), + ]; if (runsNadirCompaction(settings)) return undefined; const homes = [claudeHome, path.join(input.hostHome, ".claude")]; const pluginScripts: string[] = []; diff --git a/apps/server/src/orchestration-v2/NadirRouting.test.ts b/apps/server/src/orchestration-v2/NadirRouting.test.ts index e6213069be2af..42427b0460617 100644 --- a/apps/server/src/orchestration-v2/NadirRouting.test.ts +++ b/apps/server/src/orchestration-v2/NadirRouting.test.ts @@ -97,6 +97,30 @@ describe("baselineFor", () => { }).pipe(Effect.provide(layerCeilingsMemory)), ); + it.effect("puts back the record a refused send replaced, unless a later turn saved one", () => + Effect.gen(function* () { + const ceilings = yield* NadirCeilings; + const sonnet: ModelSelection = { instanceId, model: "claude-sonnet-4-6" }; + yield* rememberChoice(ceilings, "thread-1", opus, haiku); + // Routing back to the ceiling clears the record, then the server refuses the send. + const backToOpus = { threadId: "thread-1", baseline: opus, chosen: opus }; + yield* ceilings.decide({ ...backToOpus, commandId: "send-1" }); + expect(Option.isNone(yield* ceilings.get("thread-1"))).toBe(true); + yield* ceilings.undo("send-1"); + expect(yield* ceilings.get("thread-1")).toEqual( + Option.some({ baseline: opus, chosen: haiku }), + ); + // A retry of the refused command asks Nadir again. + expect(ceilings.decided("send-1")).toBeUndefined(); + yield* ceilings.decide({ ...backToOpus, commandId: "send-2" }); + yield* rememberChoice(ceilings, "thread-1", opus, sonnet); + yield* ceilings.undo("send-2"); + expect(yield* ceilings.get("thread-1")).toEqual( + Option.some({ baseline: opus, chosen: sonnet }), + ); + }).pipe(Effect.provide(layerCeilingsMemory)), + ); + it.effect("keeps the ceiling across a server restart", () => Effect.gen(function* () { const directory = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped(); diff --git a/apps/server/src/orchestration-v2/NadirRouting.ts b/apps/server/src/orchestration-v2/NadirRouting.ts index 70e67768b2d2e..321e479604877 100644 --- a/apps/server/src/orchestration-v2/NadirRouting.ts +++ b/apps/server/src/orchestration-v2/NadirRouting.ts @@ -60,7 +60,15 @@ export class NadirCeilings extends Context.Service< readonly lock: (threadId: string) => Semaphore.Semaphore; /** The selection a command already got, so a retry of it gets the same one. */ readonly decided: (commandId: string) => ModelSelection | undefined; - readonly decide: (commandId: string, selection: ModelSelection) => void; + /** Saves a turn's ceiling, remembering the record it replaced until the send is settled. */ + readonly decide: (input: { + readonly commandId: string | undefined; + readonly threadId: string; + readonly baseline: ModelSelection; + readonly chosen: ModelSelection; + }) => Effect.Effect; + /** Puts back the record a refused command replaced, unless a later turn saved one since. */ + readonly undo: (commandId: string) => Effect.Effect; } >()("t3/orchestration-v2/NadirRouting/NadirCeilings") {} @@ -71,45 +79,80 @@ export const layerCeilingsFromStore = Layer.effect( Effect.gen(function* () { const store = KeyValueStore.toSchemaStore(yield* KeyValueStore.KeyValueStore, Ceiling); const memory = new Map(); + // Counts each thread's saves, so an undo can tell whether a later turn saved since. + const saves = new Map(); const locks = new Map(); - const decisions = new Map(); + const decisions = new Map< + string, + { + readonly threadId: string; + readonly chosen: ModelSelection; + readonly replaced: Ceiling | undefined; + readonly save: number; + } + >(); + const get = Effect.fn("NadirCeilings.get")(function* (threadId: string) { + if (memory.has(threadId)) return Option.fromUndefinedOr(memory.get(threadId)); + return yield* store.get(threadId).pipe( + Effect.tap((found) => + Effect.sync(() => memory.set(threadId, Option.getOrUndefined(found))), + ), + Effect.orElseSucceed(() => Option.none()), + ); + }); + const save = Effect.fn("NadirCeilings.save")(function* ( + threadId: string, + ceiling: Ceiling | undefined, + ) { + memory.set(threadId, ceiling); + saves.set(threadId, (saves.get(threadId) ?? 0) + 1); + yield* (ceiling === undefined ? store.remove(threadId) : store.set(threadId, ceiling)).pipe( + Effect.catch((cause) => + Effect.logWarning("Nadir ceiling kept in memory only", { reason: failureKind(cause) }), + ), + ); + }); + const lock = (threadId: string) => { + let found = locks.get(threadId); + if (found === undefined) { + found = Semaphore.makeUnsafe(1); + locks.set(threadId, found); + } + return found; + }; return { - get: Effect.fn("NadirCeilings.get")(function* (threadId: string) { - if (memory.has(threadId)) return Option.fromUndefinedOr(memory.get(threadId)); - return yield* store.get(threadId).pipe( - Effect.tap((found) => - Effect.sync(() => memory.set(threadId, Option.getOrUndefined(found))), - ), - Effect.orElseSucceed(() => Option.none()), - ); - }), - save: Effect.fn("NadirCeilings.save")(function* ( - threadId: string, - ceiling: Ceiling | undefined, - ) { - memory.set(threadId, ceiling); - yield* (ceiling === undefined ? store.remove(threadId) : store.set(threadId, ceiling)).pipe( - Effect.catch((cause) => - Effect.logWarning("Nadir ceiling kept in memory only", { reason: failureKind(cause) }), - ), - ); - }), - lock: (threadId) => { - let lock = locks.get(threadId); - if (lock === undefined) { - lock = Semaphore.makeUnsafe(1); - locks.set(threadId, lock); - } - return lock; - }, - decided: (commandId) => decisions.get(commandId), - decide: (commandId, selection) => { - decisions.set(commandId, selection); + get, + save, + lock, + decided: (commandId) => decisions.get(commandId)?.chosen, + decide: Effect.fn("NadirCeilings.decide")(function* ({ + commandId, + threadId, + baseline, + chosen, + }) { + const replaced = Option.getOrUndefined(yield* get(threadId)); + yield* save(threadId, baseline === chosen ? undefined : { baseline, chosen }); + if (commandId === undefined) return; + decisions.set(commandId, { threadId, chosen, replaced, save: saves.get(threadId) ?? 0 }); if (decisions.size > MAX_REMEMBERED_DECISIONS) { const oldest = decisions.keys().next().value; if (oldest !== undefined) decisions.delete(oldest); } - }, + }), + undo: Effect.fn("NadirCeilings.undo")(function* (commandId: string) { + const decision = decisions.get(commandId); + if (decision === undefined) return; + yield* lock(decision.threadId).withPermits(1)( + Effect.gen(function* () { + // A retry of the refused command asks Nadir again. + decisions.delete(commandId); + if (saves.get(decision.threadId) === decision.save) { + yield* save(decision.threadId, decision.replaced); + } + }), + ); + }), }; }), ); @@ -159,7 +202,13 @@ export const rememberChoice = ( threadId: string, baseline: ModelSelection, chosen: ModelSelection, -) => ceilings.save(threadId, baseline === chosen ? undefined : { baseline, chosen }); +) => ceilings.decide({ commandId: undefined, threadId, baseline, chosen }); + +/** A send the server refused leaves the thread's ceiling as it was before routing. */ +export const undoChoice = Effect.fn("NadirRouting.undoChoice")(function* (commandId: string) { + const ceilings = yield* Effect.serviceOption(NadirCeilings); + if (Option.isSome(ceilings)) yield* ceilings.value.undo(commandId); +}); /** Models the user can pick for this instance, the menu Nadir chooses from. */ function modelMenu(provider: ServerProvider | undefined): ReadonlyArray { @@ -371,8 +420,12 @@ export const chooseModelSelection = Effect.fn("NadirRouting.chooseModelSelection const model = pickModel(answer, { baseline: baseline.model, current, menu }); const chosen = model === baseline.model ? baseline : routedSelection(baseline, model, provider); - yield* rememberChoice(store, input.threadId, baseline, chosen); - if (input.commandId !== undefined) store.decide(input.commandId, chosen); + yield* store.decide({ + commandId: input.commandId, + threadId: input.threadId, + baseline, + chosen, + }); if (model !== baseline.model) { yield* Effect.logInfo("Nadir routed turn", { from: baseline.model, to: model }); } diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts index 59b56ca996787..cc12888cf26da 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts @@ -523,9 +523,19 @@ describe("Nadir routing at intake", () => { Layer.mergeAll( Layer.mock(ThreadManagementService.ThreadManagementService)({ dispatch: (command) => - record( - command.type === "message.dispatch" ? command.modelSelection?.model : undefined, - ), + command.commandId === "send-refused" + ? Effect.fail( + new OrchestratorCommandPreviouslyRejectedError({ + commandId: command.commandId, + commandType: command.type, + detail: "refused", + }), + ) + : record( + command.type === "message.dispatch" + ? command.modelSelection?.model + : undefined, + ), // Only these shell and snapshot fields are read. getThreadShell: () => Effect.sync(() => { @@ -687,6 +697,30 @@ describe("Nadir routing at intake", () => { ), ); + it.effect("puts the thread's ceiling back when the server refuses a routed send", () => + Effect.gen(function* () { + const ceilings = yield* NadirCeilings; + const sonnet = { instanceId, model: "claude-sonnet-5-5" }; + const models = ( + found: Option.Option<{ baseline: { model: string }; chosen: { model: string } }>, + ) => Option.map(found, ({ baseline, chosen }) => [baseline.model, chosen.model]); + yield* rememberChoice(ceilings, "thread-routed", opus, sonnet); + const refused = send({ commandId: CommandId.make("send-refused") }); + expect(yield* run(dispatchCommand(refused), null, layerMockReceipts, Layer.empty)).toEqual({ + requests: 1, + dispatched: [], + }); + expect(models(yield* ceilings.get("thread-routed"))).toEqual( + Option.some(["claude-opus-5-5", "claude-sonnet-5-5"]), + ); + // A send that may have been accepted keeps its decision. + yield* run(dispatchCommand(send()), null, layerMockReceipts, Layer.empty); + expect(models(yield* ceilings.get("thread-routed"))).toEqual( + Option.some(["claude-opus-5-5", "claude-haiku-5-5"]), + ); + }).pipe(Effect.provide(Layer.mergeAll(layerCeilingsMemory, layerIntakeTest))), + ); + it.effect("asks Nadir once for a command retried while its first attempt routes", () => Effect.gen(function* () { const twice = Effect.all([dispatchCommand(send()), dispatchCommand(send())], { diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts index a39b2efda4c42..51fba739d2199 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts @@ -232,10 +232,11 @@ export const dispatchCommand = Effect.fn("ThreadMessageIntake.dispatchCommand")( (command.type !== "queued-run.edit" || command.attachments === undefined) ) return yield* threads.dispatch(command); + // A send the server refused leaves the thread's Nadir ceiling as it was. const claimed = yield* AttachmentClaims.claimPendingAttachments({ threadId: command.threadId, attachments: command.attachments ?? [], - }); + }).pipe(Effect.tapError(() => NadirRouting.undoChoice(command.commandId))); return yield* threads .dispatch({ ...command, @@ -261,7 +262,9 @@ export const dispatchCommand = Effect.fn("ThreadMessageIntake.dispatchCommand")( ), Effect.tapError((error) => dispatchWasNotAccepted(error) - ? AttachmentClaims.releaseClaimedAttachments(claimed.claimedPaths) + ? AttachmentClaims.releaseClaimedAttachments(claimed.claimedPaths).pipe( + Effect.andThen(NadirRouting.undoChoice(command.commandId)), + ) : Effect.void, ), ); diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index b580a475e78c2..6ec8c18e58abf 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -90,6 +90,7 @@ import { type ComposerFileAttachment, type ComposerImageAttachment, composerFileNeedsReattach, + draftModelPick, partializeComposerDraftStoreState, useComposerDraftStore, DraftId, @@ -1583,17 +1584,19 @@ describe("composerDraftStore modelSelection", () => { expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined(); }); - it("releases the pick a send used but keeps one changed while it was in flight", () => { + it("releases the pick a send used but keeps one made while it was in flight", () => { const store = useComposerDraftStore.getState(); - const sent = modelSelection(CODEX_DRIVER, "gpt-5.5"); - const next = modelSelection(CODEX_DRIVER, "gpt-5.4"); - store.setModelSelection(threadRef, sent, { explicit: true }); - store.setModelSelection(threadRef, next, { explicit: true }); + const pickAtSend = () => draftModelPick(draftFor(threadId, TEST_ENVIRONMENT_ID)); + store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.5"), { explicit: true }); + const sent = pickAtSend(); + store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4"), { explicit: true }); store.releaseModelPick(threadRef, sent); - expect( - draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[next.instanceId], - ).toEqual(next); - store.releaseModelPick(threadRef, next); + expect(pickAtSend()?.model).toBe("gpt-5.4"); + // Picking the sent model again is a new pick too. + store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.5"), { explicit: true }); + store.releaseModelPick(threadRef, sent); + expect(pickAtSend()?.model).toBe("gpt-5.5"); + store.releaseModelPick(threadRef, pickAtSend()); expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined(); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 8add638eb9bcf..639489546a06f 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -4140,9 +4140,10 @@ const composerDraftStore = create()( const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; set((state) => { const current = state.draftsByThreadKey[threadKey]; - const pick = draftModelPick(current); - if (!current || pick === undefined || sentPick === undefined) return state; - if (!draftModelPickMatchesThread(pick, sentPick)) return state; + // A newer pick, even of the same model, is still waiting for its own reply. + if (!current || sentPick === undefined || draftModelPick(current) !== sentPick) { + return state; + } const { modelSelectionExplicit: _explicit, ...retained } = current; const draft = { ...retained, activeProvider: null, modelSelectionByProvider: {} }; const drafts = { ...state.draftsByThreadKey }; From e472260ef5f25f0d2d63655803e738d51007a973 Mon Sep 17 00:00:00 2001 From: doramirdor Date: Thu, 12 Nov 2026 07:50:46 -0500 Subject: [PATCH 16/16] Add rememberSentComposerPick to the mobile task permissions test mock Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/state/thread-task-permissions.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts index 5a31c1955b928..1db6e2373e70e 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -167,6 +167,7 @@ vi.mock("./use-composer-drafts", async () => { appendComposerDraftText: vi.fn(), mergeComposerDraftContent: vi.fn(), removeComposerDraftAttachment: vi.fn(), + rememberSentComposerPick: () => {}, useComposerDraft: () => state.draft, useComposerDraftSettings: () => state.draft, questionAttachmentDraftsAtom: () => Atom.make({}),