diff --git a/AGENTS.md b/AGENTS.md index c4f7d53a..cbd09148 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,8 @@ ## Windows-Specific Notes +- A per-thread Claude subagent override must remove case-insensitive aliases of `CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS` from the copied launch environment before setting the canonical key. Keep the inherited environment unchanged so sibling sessions retain their own policy. + - The separate scheduled/manual reliability workflow runs the existing Windows native installer/runtime/uninstaller smoke only on disposable GitHub-hosted Windows runners. It does not run on user machines or move process-backed/provider tests into the default suite. macOS uses its isolated DMG/ZIP smoke, and Linux keeps its existing artifact build and pipeline coverage. - Generic attachment originals and metadata are private `0600` files where POSIX permissions apply; provider-readable derivatives are `0400`. On Windows leave derivatives user-writable under Cafe's user-owned data-directory ACL instead of setting the read-only attribute, which would prevent safe cleanup. File names are inert display metadata and storage uses server-minted identifiers; Windows path separators in dropped names must never become server paths. Document extraction children use the current executable with `ELECTRON_RUN_AS_NODE=1` for packaged Electron backends and preserve only required Windows system-directory environment entries, not provider credentials or user-selected Node hooks. macOS/Linux retain the same isolated child behavior and their native permission checks. @@ -99,6 +101,8 @@ If a tradeoff is required, choose correctness, durability, and debuggability ove ## Provider Status And Maintenance +- Codex and Claude composer controls can persist an optional per-thread `threadSubagentLimit` model option from 1–64; `inherit` resets the thread to the selected provider instance policy. Keep this option when changing model traits, but exclude it from sticky defaults for unrelated chats. Save server threads through the exact environment's `thread.meta.update` command and drafts through their exact draft identity. The session snapshot records the materialized override (null for inheritance, omitted for older daemons); changes reconcile through the existing idle start/resume path without interrupting active work. Codex must still use its existing V1/V2 launch translation, and Claude must use a copied query environment with the official Agent-tool admission key and its documented exemptions. This is a concurrency ceiling, not a request to spawn an exact number of agents. + - Codex thread resume must keep Cafe's finite 64 MiB newline-delimited protocol boundary. On Codex 0.151+, request `excludeTurns` plus a one-turn, `notLoaded`, descending `initialTurnsPage`, then normalize that bounded page into the existing snapshot lifecycle so active-turn recovery still works without hydrating a multi-hour transcript into one JSON-RPC line. If an older Codex build ignores pagination and returns an oversized persisted resume response, classify only the nested `CodexAppServerIncomingMessageTooLargeError` tag and retry exactly once without the persisted cursor; never broaden this recovery to arbitrary provider text or raise the line limit. A failed start can leave `starting`/`ready`/`idle`, `session.started`, or `thread.started` events in the daemon journal, so ProviderRuntimeIngestion must not let those non-conclusive notifications clear a projected start error unless a new turn-start intent is pending or ProviderService confirms a matching registered ready/running session. - `ProviderRegistry` is the sole production owner of initial provider-status refresh admission. Keep initial CLI probes bounded to two concurrent instances, and do not add independent Codex or Claude startup probe fibers that bypass that aggregate limit. An externally admitted managed provider must not start its periodic clock until that initial refresh settles. Periodic probes then use a deterministic phase derived from the stable provider instance id: the first periodic check runs after one full interval plus that phase, then repeats at the normal interval. Renderer config subscriptions are read-only and must never trigger provider probes during reconnect. - Codex CLI status probes must be disposable subprocesses with tree-aware cleanup: use an isolated process group on POSIX and the platform child-tree path on Windows. Before releasing the child scope, a timeout sends `SIGTERM`, waits one bounded grace interval for actual process exit, and then sends `SIGKILL` with a second bounded wait; the scope itself keeps `SIGKILL` as the final backstop. This explicit wait is required because Effect's `forceKillAfter` currently bounds signal dispatch rather than the later exit wait. Provider snapshots may report only fixed phase names, bounded outcomes, and durations for runtime-home preparation, version, login status, and account usage; never include command text, stdout/stderr, auth state payloads, or unrestricted paths in these diagnostics. diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 4837d1aa..b6ef729a 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -15,7 +15,7 @@ import { ProviderInstanceId, PROVIDER_SESSION_TITLE_MAX_CHARS, } from "@cafecode/contracts"; -import { createModelSelection } from "@cafecode/shared/model"; +import { createModelSelection, resolveThreadSubagentLimit } from "@cafecode/shared/model"; import { ApprovalRequestId, CommandId, @@ -269,6 +269,10 @@ describe("ProviderCommandReactor", () => { ? { model: inputModelSelection?.model ?? modelSelection.model } : {}), ...(inputModelSelection ? { modelSelection: inputModelSelection } : {}), + threadSubagentLimit: resolveThreadSubagentLimit( + inputModelSelection, + providerInstanceId ?? ProviderInstanceId.make(provider), + ), threadId, resumeCursor: resumeCursor ?? { opaque: `resume-${sessionIndex}` }, createdAt: now, @@ -1774,6 +1778,97 @@ describe("ProviderCommandReactor", () => { expect(harness.generateThreadTitle).not.toHaveBeenCalled(); }); + it.each(["codex", "claudeAgent"])( + "defers %s thread limits during active work and resumes once idle", + async (driver) => { + const instanceId = ProviderInstanceId.make(driver); + const initial = createModelSelection( + instanceId, + driver === "codex" ? "gpt-5-codex" : "claude-opus-4-6", + ); + const harness = await createHarness({ + threadModelSelection: initial, + liveSteer: "supported", + }); + const threadId = ThreadId.make("thread-1"); + const createdAt = "2026-01-01T00:00:00.000Z"; + const send = async (index: number) => { + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`limit-turn-${index}`), + threadId, + message: { + messageId: asMessageId(`limit-message-${index}`), + role: "user", + text: "continue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt, + }), + ); + await waitFor(() => harness.sendTurn.mock.calls.length === index); + }; + await send(1); + const active = harness.runtimeSessions[0]!; + harness.runtimeSessions[0] = { + ...active, + status: "running", + activeTurnId: asTurnId("limit-active"), + }; + const changed = createModelSelection(instanceId, initial.model, [ + { id: "threadSubagentLimit", value: "4" }, + ]); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("limit-change"), + threadId, + modelSelection: changed, + }), + ); + await harness.drain(); + expect(harness.startSession).toHaveBeenCalledTimes(1); + expect(harness.interruptTurn).not.toHaveBeenCalled(); + expect(harness.runtimeSessions[0]?.threadSubagentLimit).toBeNull(); + expect( + (await harness.readModel()).threads.find((thread) => thread.id === threadId) + ?.modelSelection, + ).toEqual(changed); + + harness.runtimeSessions[0] = { ...active, status: "ready" }; + await harness.markThreadReady(); + await send(2); + expect(harness.startSession).toHaveBeenCalledTimes(2); + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + modelSelection: changed, + resumeCursor: active.resumeCursor, + }); + expect(harness.runtimeSessions[0]?.threadSubagentLimit).toBe(4); + await harness.markThreadReady(); + await send(3); + expect(harness.startSession).toHaveBeenCalledTimes(2); + + const inherited = createModelSelection(instanceId, initial.model, [ + { id: "threadSubagentLimit", value: "inherit" }, + ]); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("limit-reset"), + threadId, + modelSelection: inherited, + }), + ); + await harness.markThreadReady(); + await send(4); + expect(harness.startSession).toHaveBeenCalledTimes(3); + expect(harness.runtimeSessions[0]?.threadSubagentLimit).toBeNull(); + }, + ); + it("forwards provider model options through session start and turn send", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index c35eab45..06e6ef90 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -20,6 +20,7 @@ import { type RuntimeMode, TurnId, } from "@cafecode/contracts"; +import { omitThreadSubagentLimitOption, resolveThreadSubagentLimit } from "@cafecode/shared/model"; import { isTemporaryWorktreeBranch, LEGACY_WORKTREE_BRANCH_PREFIX, @@ -82,6 +83,10 @@ import { decideCodexSteerRecovery, } from "../codexSteerRecovery.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); + +function withoutThreadLimit(selection: ModelSelection | undefined) { + return selection && { ...selection, options: omitThreadSubagentLimitOption(selection.options) }; +} const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError); const isProviderDriverKind = Schema.is(ProviderDriverKind); const RAW_PROVIDER_PROCESS_FAILURE_PATTERN = @@ -1445,6 +1450,18 @@ const make = Effect.gen(function* () { }); } const preferredProvider: ProviderDriverKind = desiredDriverKind; + const desiredThreadSubagentLimit = + preferredProvider === "codex" || preferredProvider === "claudeAgent" + ? yield* Effect.try({ + try: () => resolveThreadSubagentLimit(desiredModelSelection, desiredInstanceId), + catch: () => + new ProviderAdapterRequestError({ + provider: preferredProvider, + method: "thread.turn.start", + detail: "Thread subagent limit must be an integer from 1 to 64.", + }), + }) + : null; const requestedInstanceChange = desiredInstanceId !== currentInstanceId; const currentInfo = requestedInstanceChange ? activeSession === undefined @@ -1588,12 +1605,25 @@ const make = Effect.gen(function* () { const restartResumeModelSelectionChanged = sessionModelSwitch === "restart-resume" && requestedModelSelection !== undefined && - !Equal.equals(activeSession.modelSelection, requestedModelSelection); + !Equal.equals( + withoutThreadLimit(activeSession.modelSelection), + withoutThreadLimit(requestedModelSelection), + ); const shouldRestartForModelSelectionChange = restartResumeModelSelectionChanged || (preferredProvider === "claudeAgent" && requestedModelSelection !== undefined && - !Equal.equals(previousModelSelection, requestedModelSelection)); + !Equal.equals( + withoutThreadLimit(previousModelSelection), + withoutThreadLimit(requestedModelSelection), + )); + const threadSubagentLimitChanged = + (preferredProvider === "codex" || preferredProvider === "claudeAgent") && + (activeSession.threadSubagentLimit ?? null) !== desiredThreadSubagentLimit; + // This process setting is deferred while the provider owns active work. + // The next idle start/resume reconciles it from the durable thread options. + const deferThreadSubagentLimitChange = + activeSession.status === "running" || activeSession.activeTurnId !== undefined; if ( !runtimeModeChanged && @@ -1603,7 +1633,8 @@ const make = Effect.gen(function* () { !instanceChanged && !providerResumeIdentityChanged && !shouldRestartForModelChange && - !shouldRestartForModelSelectionChange + !shouldRestartForModelSelectionChange && + (!threadSubagentLimitChanged || deferThreadSubagentLimitChange) ) { return activeSession; } diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 1eeea477..15279a50 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -567,6 +567,67 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect( + "isolates thread subagent limits from sibling Claude sessions and rejects invalid replacement", + () => { + const inheritedEnvironment = { + CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS: "8", + claude_code_max_concurrent_subagents: "63", + }; + const harness = makeHarness({ + claudeConfig: { maxConcurrentSubagents: 12 }, + environment: inheritedEnvironment, + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + for (const [id, value, expected] of [ + ["override", "4", "4"], + ["sibling", undefined, "12"], + ["reset", "inherit", "12"], + ] as const) { + const session = yield* adapter.startSession({ + threadId: ThreadId.make(id), + runtimeMode: "full-access", + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + value === undefined ? undefined : [{ id: "threadSubagentLimit", value }], + ), + }); + assert.equal( + harness.getLastCreateQueryInput()?.options.env?.CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS, + expected, + ); + assert.equal(session.threadSubagentLimit, value === "4" ? 4 : null); + if (process.platform === "win32" && value === "4") { + assert.isUndefined( + harness.getLastCreateQueryInput()?.options.env?.claude_code_max_concurrent_subagents, + ); + } + } + assert.equal(inheritedEnvironment.CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS, "8"); + assert.equal(inheritedEnvironment.claude_code_max_concurrent_subagents, "63"); + const closeCalls = harness.query.closeCalls; + const result = yield* Effect.result( + adapter.startSession({ + threadId: ThreadId.make("override"), + runtimeMode: "full-access", + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + [{ id: "threadSubagentLimit", value: "65" }], + ), + }), + ); + assert.equal(result._tag, "Failure"); + assert.equal(harness.query.closeCalls, closeCalls); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + it.effect("runs Claude SDK sessions with the configured Claude HOME", () => { const harness = makeHarness({ claudeConfig: { homePath: "~/.claude-work" } }); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index f6003aff..dec20ce0 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -84,6 +84,7 @@ import { getProviderOptionCurrentValue, getProviderOptionDescriptors, resolvePromptInjectedEffort, + resolveThreadSubagentLimit, } from "@cafecode/shared/model"; import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; @@ -6822,6 +6823,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } + // Reject malformed resource policy before replacing any live session. + const threadSubagentLimit = yield* Effect.try({ + try: () => resolveThreadSubagentLimit(input.modelSelection, boundInstanceId), + catch: () => + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "Invalid thread subagent limit.", + }), + }); const existingContext = sessions.get(input.threadId); if (existingContext) { yield* Effect.logWarning("claude.session.replacing", { @@ -7330,6 +7341,19 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const existingResumeSessionId = durableResumeState?.resume; const resumeBaseTurnCount = durableResumeState?.turnCount ?? 0; + const threadEnvironment = { ...claudeEnvironment }; + if (threadSubagentLimit !== null) { + // Node selects only one casing of duplicate Windows environment keys. + // Remove inherited aliases so this exact thread's override wins. + if (process.platform === "win32") { + for (const key of Object.keys(threadEnvironment)) { + if (key.toUpperCase() === "CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS") { + delete threadEnvironment[key]; + } + } + } + threadEnvironment.CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS = String(threadSubagentLimit); + } const queryOptions: ClaudeQueryOptions = { ...(input.cwd ? { cwd: input.cwd } : {}), ...(apiModelId ? { model: apiModelId } : {}), @@ -7416,7 +7440,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ), ); }, - env: claudeEnvironment, + env: threadEnvironment, ...(claudeAdditionalDirectories.length > 0 ? { additionalDirectories: [...claudeAdditionalDirectories] } : {}), @@ -7477,6 +7501,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } : undefined; const session: ProviderSession = { + threadSubagentLimit, threadId, provider: PROVIDER, providerInstanceId: boundInstanceId, diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index c65f2c14..ac815043 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -610,6 +610,7 @@ validationLayer("CodexAdapterLive validation", (it) => { }); assert.deepStrictEqual(validationRuntimeFactory.factory.mock.calls[0]?.[0], { + threadSubagentLimit: null, appServerCwd: path.join(process.cwd(), "userdata"), binaryPath: "codex", cwd: process.cwd(), @@ -1114,6 +1115,53 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }).pipe(Effect.provide(customLayer)); }); + it.effect("isolates thread subagent limits and restores the configured instance default", () => { + const factory = makeRuntimeFactory(); + const layer = Layer.effect( + CodexAdapter, + makeCodexAdapter(decodeCodexSettings({ maxConcurrentSubagents: 12 }), { + makeRuntime: factory.factory, + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + return Effect.gen(function* () { + const adapter = yield* CodexAdapter; + for (const [id, value, expected] of [ + ["override", "4", 4], + ["sibling", undefined, 12], + ["reset", "inherit", 12], + ] as const) { + yield* adapter.startSession({ + threadId: asThreadId(id), + runtimeMode: "full-access", + modelSelection: createModelSelection( + ProviderInstanceId.make("codex"), + "gpt-5.3-codex", + value === undefined ? undefined : [{ id: "threadSubagentLimit", value }], + ), + }); + assert.equal(factory.lastRuntime?.options.maxConcurrentSubagents, expected); + assert.equal(factory.lastRuntime?.options.threadSubagentLimit, value === "4" ? 4 : null); + } + const runtimeBeforeInvalid = factory.lastRuntime; + const result = yield* Effect.result( + adapter.startSession({ + threadId: asThreadId("override"), + runtimeMode: "full-access", + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ + { id: "threadSubagentLimit", value: "65" }, + ]), + }), + ); + assert.equal(result._tag, "Failure"); + assert.equal(factory.lastRuntime, runtimeBeforeInvalid); + }).pipe(Effect.provide(layer)); + }); + it.effect( "propagates configured Codex runtime limits into runtime options and reported usage", () => { diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index b1c2a517..c8f34ae0 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -58,6 +58,7 @@ import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { getModelSelectionBooleanOptionValue, getModelSelectionStringOptionValue, + resolveThreadSubagentLimit, } from "@cafecode/shared/model"; import { summarizeToolArguments } from "@cafecode/shared/toolActivity"; @@ -4310,6 +4311,15 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( }); } + const threadSubagentLimit = yield* Effect.try({ + try: () => resolveThreadSubagentLimit(input.modelSelection, boundInstanceId), + catch: () => + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "Invalid thread subagent limit.", + }), + }); const existing = sessions.get(input.threadId); if (existing && !existing.stopped) { yield* Effect.suspend(() => stopSessionInternal(existing)); @@ -4331,8 +4341,9 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ? { additionalDirectories: input.additionalDirectories } : {}), binaryPath: codexConfig.binaryPath, - ...(codexConfig.maxConcurrentSubagents !== undefined - ? { maxConcurrentSubagents: codexConfig.maxConcurrentSubagents } + threadSubagentLimit, + ...((threadSubagentLimit ?? codexConfig.maxConcurrentSubagents) !== undefined + ? { maxConcurrentSubagents: threadSubagentLimit ?? codexConfig.maxConcurrentSubagents } : {}), ...(options?.environment ? { environment: options.environment } : {}), ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 0d9543a9..2e23b667 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -365,6 +365,7 @@ export function buildCodexAppServerArgs( } export interface CodexSessionRuntimeOptions { + readonly threadSubagentLimit?: number | null; readonly threadId: ThreadId; readonly providerInstanceId?: ProviderInstanceId; readonly binaryPath: string; @@ -4000,6 +4001,7 @@ export const makeCodexSessionRuntime = ( const sessionCreatedAt = yield* nowIso; const initialSession = { + threadSubagentLimit: options.threadSubagentLimit ?? null, provider: PROVIDER, ...(options.providerInstanceId ? { providerInstanceId: options.providerInstanceId } : {}), status: "connecting", diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 86d2113e..b740a1e3 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -80,6 +80,7 @@ import { searchSlashCommandItems } from "./composerSlashCommandSearch"; import { getComposerProviderState, renderProviderTraitsMenuContent } from "./composerProviderState"; import { ContextWindowMeter } from "./ContextWindowMeter"; import { ThreadGoalFooterButton } from "./ThreadGoalControl"; +import { ThreadAgentControl } from "./ThreadAgentControl"; import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; import { basenameOfPath } from "../../vscode-icons"; import { cn, randomUUID } from "~/lib/utils"; @@ -3424,6 +3425,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onInstanceModelChange={onProviderModelSelect} /> + {(selectedProvider === "codex" || selectedProvider === "claudeAgent") && ( + + )} {isComposerFooterCompact ? ( ({ readEnvironmentApi: vi.fn() })); +const environmentId = EnvironmentId.make("remote-environment"); +const ref = scopeThreadRef(environmentId, ThreadId.make("thread-limit")); +const instanceId = ProviderInstanceId.make("claude-work"); +const props = { + environmentId, + draftTarget: ref, + serverThreadRef: ref, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection(instanceId, "claude-opus-4-6"), +}; +afterEach(() => { + vi.resetAllMocks(); + useComposerDraftStore.setState({ draftsByThreadKey: {}, stickyModelSelectionByProvider: {} }); +}); + +describe("ThreadAgentControl", () => { + it("validates input, saves the exact server thread, and preserves provider inheritance", async () => { + const dispatchCommand = vi.fn().mockResolvedValue(undefined); + vi.mocked(readEnvironmentApi).mockReturnValue({ + orchestration: { dispatchCommand }, + } as unknown as NonNullable>); + const screen = await render(); + try { + await page.getByRole("button", { name: "Subagent limit: provider default" }).click(); + const input = page.getByRole("textbox", { name: "Maximum subagents (1–64)" }); + await input.fill("65"); + await expect.element(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled(); + await input.fill("4"); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect.poll(() => dispatchCommand.mock.calls.length).toBe(1); + expect(readEnvironmentApi).toHaveBeenCalledWith(environmentId); + expect(dispatchCommand.mock.calls[0]?.[0]).toMatchObject({ + type: "thread.meta.update", + threadId: ref.threadId, + modelSelection: { instanceId, options: [{ id: "threadSubagentLimit", value: "4" }] }, + }); + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider).toEqual({}); + await page.getByRole("button", { name: "Subagent limit: provider default" }).click(); + await input.fill(""); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect.poll(() => dispatchCommand.mock.calls.length).toBe(2); + expect(dispatchCommand.mock.calls[1]?.[0].modelSelection.options).toEqual([ + { id: "threadSubagentLimit", value: "inherit" }, + ]); + } finally { + await screen.unmount(); + } + }); + + it("does not persist a draft override when the server rejects the write", async () => { + const dispatchCommand = vi.fn().mockRejectedValue(new Error("private transport detail")); + vi.mocked(readEnvironmentApi).mockReturnValue({ + orchestration: { dispatchCommand }, + } as unknown as NonNullable>); + const screen = await render(); + try { + await page.getByRole("button", { name: "Subagent limit: provider default" }).click(); + await page.getByRole("textbox", { name: "Maximum subagents (1–64)" }).fill("2"); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect + .element(page.getByRole("alert")) + .toHaveTextContent("Could not save the subagent limit."); + expect(useComposerDraftStore.getState().getComposerDraft(ref)).toBeNull(); + await expect.element(page.getByText("private transport detail")).not.toBeInTheDocument(); + } finally { + await screen.unmount(); + } + }); +}); diff --git a/apps/web/src/components/chat/ThreadAgentControl.tsx b/apps/web/src/components/chat/ThreadAgentControl.tsx new file mode 100644 index 00000000..17bd7620 --- /dev/null +++ b/apps/web/src/components/chat/ThreadAgentControl.tsx @@ -0,0 +1,168 @@ +import { + THREAD_SUBAGENT_LIMIT_OPTION_ID, + type EnvironmentId, + type ModelSelection, + type ProviderDriverKind, + type ScopedThreadRef, +} from "@cafecode/contracts"; +import { + createModelSelection, + omitThreadSubagentLimitOption, + readThreadSubagentLimitOption, +} from "@cafecode/shared/model"; +import { useId, useState } from "react"; +import { GitForkIcon } from "lucide-react"; +import { newCommandId } from "~/lib/utils"; +import { readEnvironmentApi } from "../../environmentApi"; +import { type DraftId, useComposerDraftStore } from "../../composerDraftStore"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; + +export function ThreadAgentControl(props: { + readonly environmentId: EnvironmentId; + readonly draftTarget: ScopedThreadRef | DraftId; + readonly serverThreadRef: ScopedThreadRef | null; + readonly provider: ProviderDriverKind; + readonly modelSelection: ModelSelection; +}) { + const limit = readThreadSubagentLimitOption(props.modelSelection.options); + const [open, setOpen] = useState(false); + const [value, setValue] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const inputId = useId(); + const descriptionId = useId(); + const valid = value === "" || (/^[1-9]\d?$/.test(value) && Number(value) <= 64); + + const save = async () => { + if (!valid || saving) return; + setSaving(true); + setError(null); + const options = [ + ...(omitThreadSubagentLimitOption(props.modelSelection.options) ?? []), + { id: THREAD_SUBAGENT_LIMIT_OPTION_ID, value: value || "inherit" }, + ]; + try { + // Persist against the captured environment/thread, never the active route. + // A failed server write must not leave a renderer-only success state. + if (props.serverThreadRef !== null) { + const api = readEnvironmentApi(props.serverThreadRef.environmentId); + if (!api) throw new Error("Environment unavailable"); + await api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: props.serverThreadRef.threadId, + modelSelection: createModelSelection( + props.modelSelection.instanceId, + props.modelSelection.model, + options, + ), + }); + } + useComposerDraftStore + .getState() + .setProviderModelOptions(props.draftTarget, props.provider, options, { + instanceId: props.modelSelection.instanceId, + model: props.modelSelection.model, + persistSticky: false, + }); + setOpen(false); + } catch { + setError("Could not save the subagent limit. Connect to this environment and try again."); + } finally { + setSaving(false); + } + }; + + return ( + <> + + { + if (!saving) setOpen(next); + }} + > + + + Subagent limit for this thread + + Set the maximum concurrent subagents. The agent decides how many to use. + + + + + setValue(event.target.value)} + placeholder="Provider default" + /> +

+ Leave this field empty to use the provider instance setting. Changes take effect on + the next idle session start or resume. Active work continues. +

+ {props.provider === "claudeAgent" && ( +

+ Claude applies this limit to Agent-tool launches. Resumed agents and team workflows + can exceed it. +

+ )} + {!valid && ( +

+ Enter a whole number from 1 to 64, or leave the field empty. +

+ )} + {error && ( +

+ {error} +

+ )} +
+ + + + +
+
+ + ); +} diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 41936d49..87abb3e0 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -13,6 +13,7 @@ import { getProviderOptionCurrentValue, getProviderOptionDescriptors, isClaudeUltrathinkPrompt, + preserveThreadSubagentLimitOption, } from "@cafecode/shared/model"; import { memo, useCallback, useState } from "react"; import type { VariantProps } from "class-variance-authority"; @@ -285,7 +286,12 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ allowPromptInjectedEffort, }); const updateDescriptors = (nextDescriptors: ReadonlyArray) => { - updateModelOptions(buildProviderOptionSelectionsFromDescriptors(nextDescriptors)); + updateModelOptions( + preserveThreadSubagentLimitOption( + buildProviderOptionSelectionsFromDescriptors(nextDescriptors), + modelOptions, + ), + ); }; const handleSelectChange = ( diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index dc2d7ddf..9a428414 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -249,3 +249,21 @@ describe("provider traits render guards", () => { expect(renderProviderTraitsMenuContent(args)).toBeNull(); }); }); + +describe("composer thread subagent policy", () => { + it.each(["codex", "claudeAgent"])( + "retains the %s override in turn dispatch even without a trait descriptor", + (provider) => { + for (const value of ["4", "inherit"]) { + const state = getComposerProviderState({ + provider: ProviderDriverKind.make(provider), + model: "model", + models: [], + prompt: "", + modelOptions: [{ id: "threadSubagentLimit", value }], + }); + expect(state.modelOptionsForDispatch).toContainEqual({ id: "threadSubagentLimit", value }); + } + }, + ); +}); diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index 4024af13..4ce8b1c2 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -10,6 +10,7 @@ import { getProviderOptionCurrentValue, getProviderOptionDescriptors, isClaudeUltrathinkPrompt, + preserveThreadSubagentLimitOption, } from "@cafecode/shared/model"; import type { ReactNode } from "react"; @@ -75,7 +76,10 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com provider, promptEffort, traitsTriggerLabel: traitsTriggerLabel || null, - modelOptionsForDispatch: buildProviderOptionSelectionsFromDescriptors(descriptors), + modelOptionsForDispatch: preserveThreadSubagentLimitOption( + buildProviderOptionSelectionsFromDescriptors(descriptors), + provider === "codex" || provider === "claudeAgent" ? modelOptions : undefined, + ), ...(ultrathinkActive ? { composerFrameClassName: "ultrathink-frame", diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 51cceaa8..d3b5409e 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -131,6 +131,57 @@ const TEST_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const OTHER_TEST_ENVIRONMENT_ID = EnvironmentId.make("environment-remote"); const LEGACY_TEST_ENVIRONMENT_ID = EnvironmentId.make("__legacy__"); +describe("thread subagent policy persistence", () => { + beforeEach(resetComposerDraftStore); + it("round-trips the exact environment and instance without leaking into sticky defaults", () => { + const ref = scopeThreadRef(TEST_ENVIRONMENT_ID, ThreadId.make("same-thread")); + const sibling = scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, ref.threadId); + const options = [ + { id: "reasoningEffort", value: "high" }, + { id: "threadSubagentLimit", value: "4" }, + ]; + const store = useComposerDraftStore.getState(); + store.setProviderModelOptions(ref, CODEX_DRIVER, options, { + instanceId: CODEX_ZKM_INSTANCE, + model: "gpt-test", + persistSticky: true, + }); + expect( + store.getComposerDraft(ref)?.modelSelectionByProvider[CODEX_ZKM_INSTANCE]?.options, + ).toEqual(options); + expect(store.getComposerDraft(sibling)).toBeNull(); + expect( + useComposerDraftStore.getState().stickyModelSelectionByProvider[CODEX_ZKM_INSTANCE]?.options, + ).toEqual([options[0]]); + store.setStickyModelSelection(createModelSelection(CODEX_ZKM_INSTANCE, "gpt-test", options)); + expect( + useComposerDraftStore.getState().stickyModelSelectionByProvider[CODEX_ZKM_INSTANCE]?.options, + ).toEqual([options[0]]); + const persistence = useComposerDraftStore.persist.getOptions(); + useComposerDraftStore.setState( + persistence.merge!( + JSON.parse(JSON.stringify(persistence.partialize!(useComposerDraftStore.getState()))), + useComposerDraftStore.getState(), + ), + ); + expect( + store.getComposerDraft(ref)?.modelSelectionByProvider[CODEX_ZKM_INSTANCE]?.options, + ).toEqual(options); + store.setProviderModelOptions( + ref, + CODEX_DRIVER, + [{ id: "threadSubagentLimit", value: "inherit" }], + { instanceId: CODEX_ZKM_INSTANCE, model: "gpt-test", persistSticky: true }, + ); + expect( + store.getComposerDraft(ref)?.modelSelectionByProvider[CODEX_ZKM_INSTANCE]?.options, + ).toEqual([{ id: "threadSubagentLimit", value: "inherit" }]); + expect( + useComposerDraftStore.getState().stickyModelSelectionByProvider[CODEX_ZKM_INSTANCE]?.options, + ).toBeUndefined(); + }); +}); + function threadKeyFor( threadId: ThreadId, environmentId: EnvironmentId = LEGACY_TEST_ENVIRONMENT_ID, diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 33881728..4b453266 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -27,7 +27,11 @@ import { import * as Schema from "effect/Schema"; import * as Equal from "effect/Equal"; import { DeepMutable } from "effect/Types"; -import { createModelSelection, normalizeModelSlug } from "@cafecode/shared/model"; +import { + createModelSelection, + normalizeModelSlug, + omitThreadSubagentLimitOption, +} from "@cafecode/shared/model"; import { useMemo } from "react"; import { getLocalStorageItemWithLegacy } from "./hooks/useLocalStorage"; import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection"; @@ -2302,7 +2306,11 @@ const composerDraftStore = create()( } const nextMap: Partial> = { ...state.stickyModelSelectionByProvider, - [normalized.instanceId]: normalized, + [normalized.instanceId]: createModelSelection( + normalized.instanceId, + normalized.model, + omitThreadSubagentLimitOption(normalized.options), + ), }; if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) { return state.stickyActiveProvider === normalized.instanceId @@ -2532,16 +2540,17 @@ const composerDraftStore = create()( let nextStickyMap = state.stickyModelSelectionByProvider; let nextStickyActiveProvider = state.stickyActiveProvider; if (options?.persistSticky === true) { + const stickyProviderOpts = omitThreadSubagentLimitOption(providerOpts); nextStickyMap = { ...state.stickyModelSelectionByProvider }; const stickyBase = nextStickyMap[instanceKey] ?? base.modelSelectionByProvider[instanceKey] ?? createModelSelection(instanceKey, fallbackModel); - if (providerOpts) { + if (stickyProviderOpts) { nextStickyMap[instanceKey] = createModelSelection( instanceKey, stickyBase.model, - providerOpts, + stickyProviderOpts, ); } else if ((stickyBase.options?.length ?? 0) > 0) { const { options: _, ...rest } = stickyBase; diff --git a/docs/pr-assets/thread-subagent-limit/README.md b/docs/pr-assets/thread-subagent-limit/README.md new file mode 100644 index 00000000..d44a6134 --- /dev/null +++ b/docs/pr-assets/thread-subagent-limit/README.md @@ -0,0 +1,8 @@ +These images use Cafe's full-app browser test fixture with synthetic messages and provider responses. They contain no live account or project data. + +- `before.png`: the composer from upstream dev commit `99fbaec89da429924171c89d66a8f3455e42d9b0`. +- `after.png`: the composer with the new Agents control. +- `dialog.png`: the thread limit dialog with an explicit limit of four. +- `interaction.webm`: a Chromium recording of opening the control and entering the limit. The recording includes the test app startup. + +The capture used the existing composer fixture's dictation-control case. The before image loaded the original `ChatComposer.tsx` from the base commit. Temporary capture harnesses were removed after recording. The focused `ThreadAgentControl.browser.tsx` tests verify save, reset, invalid input, exact environment routing, and failed-write behavior. diff --git a/docs/pr-assets/thread-subagent-limit/after.png b/docs/pr-assets/thread-subagent-limit/after.png new file mode 100644 index 00000000..4d7ec5be Binary files /dev/null and b/docs/pr-assets/thread-subagent-limit/after.png differ diff --git a/docs/pr-assets/thread-subagent-limit/before.png b/docs/pr-assets/thread-subagent-limit/before.png new file mode 100644 index 00000000..b8b9e9fe Binary files /dev/null and b/docs/pr-assets/thread-subagent-limit/before.png differ diff --git a/docs/pr-assets/thread-subagent-limit/dialog.png b/docs/pr-assets/thread-subagent-limit/dialog.png new file mode 100644 index 00000000..d1b005e5 Binary files /dev/null and b/docs/pr-assets/thread-subagent-limit/dialog.png differ diff --git a/docs/pr-assets/thread-subagent-limit/interaction.webm b/docs/pr-assets/thread-subagent-limit/interaction.webm new file mode 100644 index 00000000..7771d59d Binary files /dev/null and b/docs/pr-assets/thread-subagent-limit/interaction.webm differ diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index 221a9154..c7ab29b3 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -14,6 +14,24 @@ const decodeProviderSendTurnInput = Schema.decodeUnknownSync(ProviderSendTurnInp const decodeProviderSession = Schema.decodeUnknownSync(ProviderSession); const decodeProviderEvent = Schema.decodeUnknownSync(ProviderEvent); +it("keeps legacy session snapshots readable and bounds materialized thread limits", () => { + const base = { + provider: "codex", + status: "ready", + runtimeMode: "full-access", + threadId: "thread-limit", + createdAt: "2026-09-11T00:00:00.000Z", + updatedAt: "2026-09-11T00:00:00.000Z", + }; + expect(decodeProviderSession(base).threadSubagentLimit).toBeUndefined(); + for (const value of [null, 1, 64]) + expect(decodeProviderSession({ ...base, threadSubagentLimit: value }).threadSubagentLimit).toBe( + value, + ); + for (const value of [0, 65, 1.5, "4"]) + expect(() => decodeProviderSession({ ...base, threadSubagentLimit: value })).toThrow(); +}); + function getOptionValue( options: ReadonlyArray<{ id: string; value: unknown }> | undefined, id: string, diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 3f61e4e4..7f77dfb4 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -24,6 +24,12 @@ import { } from "./orchestration.ts"; import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts"; +export const THREAD_SUBAGENT_LIMIT_OPTION_ID = "threadSubagentLimit"; +export const MAX_THREAD_SUBAGENT_LIMIT = 64; +export const ThreadSubagentLimit = Schema.Int.check( + Schema.isBetween({ minimum: 1, maximum: MAX_THREAD_SUBAGENT_LIMIT }), +); + const ProviderSessionStatus = Schema.Literals([ "connecting", "ready", @@ -51,6 +57,9 @@ export const ProviderSession = Schema.Struct({ // with the process that is actually materialized instead of relying on a // renderer draft or an in-memory command cache after backend recovery. modelSelection: Schema.optional(ModelSelection), + // Null means this session inherited its provider-instance policy. Omission + // keeps older daemon snapshots readable until their next safe reconnect. + threadSubagentLimit: Schema.optional(Schema.NullOr(ThreadSubagentLimit)), threadId: ThreadId, resumeCursor: Schema.optional(Schema.Unknown), activeTurnId: Schema.optional(TurnId), diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 36afe692..49a3cd6a 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -22,8 +22,45 @@ import { resolveModelSlugForProvider, resolveSelectableModel, trimOrNull, + resolveThreadSubagentLimit, + preserveThreadSubagentLimitOption, + omitThreadSubagentLimitOption, } from "./model.ts"; +describe("thread subagent resource policy", () => { + const instance = ProviderInstanceId.make("codex-work"); + const selection = (value: string | boolean) => + createModelSelection(instance, "model", [{ id: "threadSubagentLimit", value }]); + it("inherits unless the exact instance has an explicit bounded override", () => { + expect(resolveThreadSubagentLimit(undefined, instance)).toBeNull(); + expect(resolveThreadSubagentLimit(selection("inherit"), instance)).toBeNull(); + expect(resolveThreadSubagentLimit(selection("64"), instance)).toBe(64); + expect(resolveThreadSubagentLimit(selection("1"), instance)).toBe(1); + expect( + resolveThreadSubagentLimit(selection("64"), ProviderInstanceId.make("other")), + ).toBeNull(); + }); + it.each(["0", "65", "128", "1.5", "01", "-1", " 2", "1e1", "", true])( + "rejects malformed launch policy %s", + (value) => { + expect(() => resolveThreadSubagentLimit(selection(value), instance)).toThrow(RangeError); + }, + ); + it("rejects duplicate policy entries and separates thread options from sticky traits", () => { + const policy = { id: "threadSubagentLimit", value: "4" }; + const effort = { id: "reasoningEffort", value: "high" }; + expect(() => + resolveThreadSubagentLimit( + createModelSelection(instance, "model", [policy, policy]), + instance, + ), + ).toThrow(RangeError); + expect(preserveThreadSubagentLimitOption([effort], [policy])).toEqual([effort, policy]); + expect(omitThreadSubagentLimitOption([effort, policy])).toEqual([effort]); + expect(omitThreadSubagentLimitOption([policy])).toBeUndefined(); + }); +}); + const codexCaps: ModelCapabilities = createModelCapabilities({ optionDescriptors: [ { diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 12321280..47b61b74 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,5 +1,7 @@ import { DEFAULT_MODEL, + MAX_THREAD_SUBAGENT_LIMIT, + THREAD_SUBAGENT_LIMIT_OPTION_ID, DEFAULT_MODEL_BY_PROVIDER, MODEL_SLUG_ALIASES_BY_PROVIDER, type ModelCapabilities, @@ -12,6 +14,54 @@ import { const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex"); +export function readThreadSubagentLimitOption( + options: ReadonlyArray | null | undefined, +): number | null { + const entries = options?.filter((entry) => entry.id === THREAD_SUBAGENT_LIMIT_OPTION_ID) ?? []; + if (entries.length !== 1) return null; + const value = entries[0]!.value; + if (typeof value !== "string" || !/^[1-9]\d?$/.test(value)) return null; + const limit = Number(value); + return limit <= MAX_THREAD_SUBAGENT_LIMIT ? limit : null; +} + +/** Validate only the selected instance's override before creating its process. */ +export function resolveThreadSubagentLimit( + selection: ModelSelection | null | undefined, + instanceId: ProviderInstanceId, +): number | null { + if (selection?.instanceId !== instanceId) return null; + const entries = + selection.options?.filter((entry) => entry.id === THREAD_SUBAGENT_LIMIT_OPTION_ID) ?? []; + if (entries.length === 0 || (entries.length === 1 && entries[0]!.value === "inherit")) + return null; + const limit = readThreadSubagentLimitOption(selection.options); + if (limit === null) + throw new RangeError("Thread subagent limit must be an integer from 1 to 64."); + return limit; +} + +export function preserveThreadSubagentLimitOption( + next: ReadonlyArray | undefined, + previous: ReadonlyArray | null | undefined, +): ReadonlyArray | undefined { + const retained = previous?.filter((entry) => entry.id === THREAD_SUBAGENT_LIMIT_OPTION_ID) ?? []; + return retained.length === 0 + ? next + : [ + ...(next ?? []).filter((entry) => entry.id !== THREAD_SUBAGENT_LIMIT_OPTION_ID), + ...retained, + ]; +} + +/** Thread resource policy must never become a default for unrelated chats. */ +export function omitThreadSubagentLimitOption( + options: ReadonlyArray | null | undefined, +): ReadonlyArray | undefined { + const remaining = options?.filter((entry) => entry.id !== THREAD_SUBAGENT_LIMIT_OPTION_ID); + return remaining?.length ? remaining : undefined; +} + export interface SelectableModelOption { slug: string; name: string;