diff --git a/.github/assets/swarm-enabled.png b/.github/assets/swarm-enabled.png new file mode 100644 index 00000000..29385231 Binary files /dev/null and b/.github/assets/swarm-enabled.png differ diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index b56961e2..77d94f92 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -624,6 +624,39 @@ describe("sendTurn", () => { }); }); + it("passes Codex swarm mode developer instructions with the configured loop cap", async () => { + const { manager, context, sendRequest } = createSendTurnHarness(); + + await manager.sendTurn({ + threadId: asThreadId("thread_1"), + input: "Implement this without guesswork", + interactionMode: "swarm", + swarmMaxLoops: 2, + }); + + expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { + threadId: "thread_1", + input: [ + { + type: "text", + text: "Implement this without guesswork", + text_elements: [], + }, + ], + model: "gpt-5.3-codex", + collaborationMode: { + mode: "default", + settings: { + model: "gpt-5.3-codex", + reasoning_effort: "medium", + developer_instructions: expect.stringMatching( + /Max loops: 2\..*must use real sub-agents.*Questionnaire.*Planner.*Coder.*Reviewer/s, + ), + }, + }, + }); + }); + it("keeps the session model when interaction mode is set without an explicit model", async () => { const { manager, context, sendRequest } = createSendTurnHarness(); context.session.model = "gpt-5.2-codex"; diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index e956e648..ce919a6d 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -32,6 +32,7 @@ import { type CodexAccountSnapshot, } from "./provider/codexAccount"; import { buildCodexInitializeParams, killCodexChildProcess } from "./provider/codexAppServer"; +import { buildCodexSwarmDeveloperInstructions } from "./provider/swarm"; export { buildCodexInitializeParams } from "./provider/codexAppServer"; export { readCodexAccountSnapshot, resolveCodexModelForAccount } from "./provider/codexAccount"; @@ -113,6 +114,7 @@ export interface CodexAppServerSendTurnInput { readonly serviceTier?: string | null; readonly effort?: string; readonly interactionMode?: ProviderInteractionMode; + readonly swarmMaxLoops?: 1 | 2; } export interface CodexAppServerStartSessionInput { @@ -365,9 +367,10 @@ export function normalizeCodexModelSlug( } function buildCodexCollaborationMode(input: { - readonly interactionMode?: "default" | "plan" | "ask" | "code" | "review"; + readonly interactionMode?: "default" | "plan" | "ask" | "code" | "review" | "swarm"; readonly model?: string; readonly effort?: string; + readonly swarmMaxLoops?: 1 | 2; }): | { mode: "default" | "plan"; @@ -391,10 +394,12 @@ function buildCodexCollaborationMode(input: { reasoning_effort: input.effort ?? "medium", developer_instructions: input.interactionMode === "plan" - ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS - : input.interactionMode === "review" - ? CODEX_REVIEW_MODE_DEVELOPER_INSTRUCTIONS - : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, + ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS + : input.interactionMode === "review" + ? CODEX_REVIEW_MODE_DEVELOPER_INSTRUCTIONS + : input.interactionMode === "swarm" + ? buildCodexSwarmDeveloperInstructions(input.swarmMaxLoops ?? 1) + : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, }, }; } @@ -756,6 +761,7 @@ export class CodexAppServerManager extends EventEmitter; }) { const query = new FakeClaudeQuery(); let createInput: @@ -169,7 +171,7 @@ function makeHarness(config?: { config?.baseDir ?? "/tmp", ), ), - Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(ServerSettingsService.layerTest(config?.settingsOverrides)), Layer.provideMerge(NodeServices.layer), ), query, @@ -2548,6 +2550,53 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("injects the swarm workflow prompt and uses the configured loop cap", () => { + const harness = makeHarness({ + settingsOverrides: { + swarmMaxLoops: 2, + }, + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "Implement the auth flow", + interactionMode: "swarm", + attachments: [], + }); + + const promptText = yield* Effect.promise(() => + readFirstPromptText(harness.getLastCreateQueryInput()), + ); + const configuredAgents = ( + harness.getLastCreateQueryInput()?.options as ClaudeQueryOptions & { + readonly agents?: Readonly>; + } + ).agents; + assert.ok(promptText?.includes("SWARM MODE (max 2 loops).")); + assert.ok(promptText?.includes("Use real subagents in this exact order:")); + assert.ok(promptText?.includes("swarm-questionnaire")); + assert.ok(promptText?.includes("swarm-reviewer")); + assert.ok(promptText?.includes("User request:\nImplement the auth flow")); + assert.deepEqual(Object.keys(configuredAgents ?? {}).sort(), [ + "swarm-coder", + "swarm-planner", + "swarm-questionnaire", + "swarm-reviewer", + ]); + assert.deepEqual(harness.query.setPermissionModeCalls, ["bypassPermissions"]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("does not call setPermissionMode when interactionMode is absent", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 97d56998..4d3a7543 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -67,6 +67,11 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { getClaudeModelCapabilities } from "./ClaudeProvider.ts"; +import { + buildClaudeSwarmAgents, + buildClaudeSwarmUserPromptPrefix, + normalizeSwarmMaxLoops, +} from "../swarm.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -510,8 +515,32 @@ const CLAUDE_SETTING_SOURCES = [ "project", "local", ] as const satisfies ReadonlyArray; +type ClaudeSwarmAgentsOption = Readonly< + Record< + string, + { + readonly description: string; + readonly prompt: string; + readonly tools?: ReadonlyArray; + readonly model?: "sonnet" | "opus" | "haiku" | "inherit"; + } + > +>; +const CLAUDE_SWARM_AGENTS = buildClaudeSwarmAgents(); -function buildPromptText(input: ProviderSendTurnInput): string { +function buildPromptText( + input: ProviderSendTurnInput, + options?: { + readonly swarmMaxLoops?: 1 | 2; + }, +): string { + const rawText = input.input?.trim() ?? ""; + const interactionText = + input.interactionMode === "swarm" + ? `${buildClaudeSwarmUserPromptPrefix(options?.swarmMaxLoops ?? 1)}\n${ + rawText.length > 0 ? rawText : "(no text provided; inspect the attachments and local context)" + }` + : rawText; const rawEffort = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null; const claudeModel = @@ -523,7 +552,7 @@ function buildPromptText(input: ProviderSendTurnInput): string { const trimmedEffort = trimOrNull(rawEffort); const promptEffort = trimmedEffort && caps.promptInjectedEffortLevels.includes(trimmedEffort) ? trimmedEffort : null; - return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort); + return applyClaudePromptEffortPrefix(interactionText, promptEffort); } function buildUserMessage(input: { @@ -559,9 +588,12 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( dependencies: { readonly fileSystem: FileSystem.FileSystem; readonly attachmentsDir: string; + readonly swarmMaxLoops?: 1 | 2; }, ) { - const text = buildPromptText(input); + const text = buildPromptText(input, { + swarmMaxLoops: dependencies.swarmMaxLoops, + }); const sdkContent: Array> = []; if (text.length > 0) { @@ -2710,7 +2742,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(fastMode ? { fastMode: true } : {}), }; - const queryOptions: ClaudeQueryOptions = { + const queryOptions: ClaudeQueryOptions & { + readonly agents?: ClaudeSwarmAgentsOption; + } = { ...(input.cwd ? { cwd: input.cwd } : {}), ...(apiModelId ? { model: apiModelId } : {}), pathToClaudeCodeExecutable: claudeBinaryPath, @@ -2727,6 +2761,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( canUseTool, env: process.env, ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), + agents: CLAUDE_SWARM_AGENTS, }; const queryRuntime = yield* Effect.try({ @@ -2864,6 +2899,21 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const resolvedModelSelection = modelSelection ? resolveModelSelectionDefault(modelSelection) : undefined; + const swarmMaxLoops = + input.interactionMode === "swarm" + ? yield* serverSettingsService.getSettings.pipe( + Effect.map((settings) => normalizeSwarmMaxLoops(settings.swarmMaxLoops)), + Effect.mapError( + (error) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: error.message, + cause: error, + }), + ), + ) + : undefined; if (context.turnState) { // Auto-close a stale synthetic turn (from background agent responses @@ -2899,7 +2949,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( try: () => context.query.setPermissionMode("plan"), catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause), }); - } else if (input.interactionMode === "default" || input.interactionMode === "code") { + } else if ( + input.interactionMode === "default" || + input.interactionMode === "code" || + input.interactionMode === "swarm" + ) { yield* Effect.tryPromise({ try: () => context.query.setPermissionMode(context.basePermissionMode ?? "bypassPermissions"), @@ -2942,6 +2996,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const message = yield* buildUserMessageEffect(input, { fileSystem, attachmentsDir: serverConfig.attachmentsDir, + ...(swarmMaxLoops !== undefined ? { swarmMaxLoops } : {}), }); yield* Queue.offer(context.promptQueue, { diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index db91e8da..dd2a77bc 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -284,6 +284,39 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { ); }); +const swarmManager = new FakeCodexManager(); +const swarmLayer = it.layer( + makeCodexAdapterLive({ manager: swarmManager }).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest({ swarmMaxLoops: 2 })), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ), +); + +swarmLayer("CodexAdapterLive swarm mode", (it) => { + it.effect("passes the configured swarm loop cap to the Codex manager", () => + Effect.gen(function* () { + swarmManager.sendTurnImpl.mockClear(); + const adapter = yield* CodexAdapter; + + yield* adapter.sendTurn({ + threadId: asThreadId("thread-swarm"), + input: "Ship the feature", + interactionMode: "swarm", + attachments: [], + }); + + assert.deepStrictEqual(swarmManager.sendTurnImpl.mock.calls[0]?.[0], { + threadId: asThreadId("thread-swarm"), + input: "Ship the feature", + interactionMode: "swarm", + swarmMaxLoops: 2, + }); + }), + ); +}); + const lifecycleManager = new FakeCodexManager(); const lifecycleLayer = it.layer( makeCodexAdapterLive({ manager: lifecycleManager }).pipe( diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index bb5579a9..80a663b2 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -42,6 +42,7 @@ import { import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { normalizeSwarmMaxLoops } from "../swarm.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const PROVIDER = "codex" as const; @@ -1483,6 +1484,21 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( (attachment) => resolveAttachment(input, attachment), { concurrency: 1 }, ); + const swarmMaxLoops = + input.interactionMode === "swarm" + ? yield* serverSettingsService.getSettings.pipe( + Effect.map((settings) => normalizeSwarmMaxLoops(settings.swarmMaxLoops)), + Effect.mapError( + (error) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: error.message, + cause: error, + }), + ), + ) + : undefined; return yield* Effect.tryPromise({ try: () => { @@ -1498,6 +1514,7 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), + ...(swarmMaxLoops !== undefined ? { swarmMaxLoops } : {}), ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}), }; return manager.sendTurn(managerInput); diff --git a/apps/server/src/provider/swarm.ts b/apps/server/src/provider/swarm.ts new file mode 100644 index 00000000..e3956bba --- /dev/null +++ b/apps/server/src/provider/swarm.ts @@ -0,0 +1,160 @@ +import { DEFAULT_SWARM_MAX_LOOPS, type SwarmMaxLoops } from "@t3tools/contracts"; + +interface ClaudeSwarmAgentDefinition { + readonly description: string; + readonly prompt: string; + readonly tools?: ReadonlyArray; + readonly model?: "sonnet" | "opus" | "haiku" | "inherit"; +} + +const SWARM_ROLE_SEQUENCE = "Questionnaire -> Planner -> Coder -> Reviewer"; +const CLAUDE_SWARM_AGENT_NAMES = { + questionnaire: "swarm-questionnaire", + planner: "swarm-planner", + coder: "swarm-coder", + reviewer: "swarm-reviewer", +} as const; + +function buildSecondLoopPolicy(maxLoops: SwarmMaxLoops): string { + return maxLoops === 2 + ? "If the first review finds material issues, unresolved blockers, or plan drift, run exactly one more refinement loop: Questionnaire critiques the plan and findings -> Planner revises -> Coder fixes -> Reviewer re-reviews." + : "Do not start a second loop. Finish after the first reviewer pass unless a hard blocker prevents completion."; +} + +export function normalizeSwarmMaxLoops(value: number | null | undefined): SwarmMaxLoops { + return value === 2 ? 2 : DEFAULT_SWARM_MAX_LOOPS; +} + +export function buildCodexSwarmDeveloperInstructions(maxLoops: SwarmMaxLoops): string { + return `# Collaboration Mode: Swarm + +You are in Swarm mode. The purpose is to remove guesswork by forcing a real multi-role workflow before you present results. + +## Swarm rules + +* Max loops: ${maxLoops}. Never exceed this cap. +* When collaboration tools are available, you must use real sub-agents. Do not collapse the workflow into a single thread. +* If collaboration tools are unavailable, emulate the same roles sequentially yourself and keep the role boundaries explicit. +* Prefer discovering facts in the repo over asking the user. Ask only when a material product decision or missing constraint cannot be derived locally. +* The coder is expected to execute end-to-end without stopping for non-critical questions. +* The reviewer must inspect the actual changes and call out concrete bugs, regressions, missing tests, and plan drift before the final answer. +* Keep the orchestration sequential unless parallel work clearly reduces time without duplicating effort. + +## Roles + +1. Questionnaire + * Stress-test the request. + * Identify feasibility, blockers, missing assumptions, acceptance criteria, and likely failure modes. + * Summarize the facts the planner must account for. + +2. Planner + * Produce a decision-complete implementation plan. + * Remove ambiguity around interfaces, data flow, edge cases, validation, and verification. + +3. Coder + * Implement the approved plan end-to-end. + * Do not stop mid-flight unless there is a hard blocker or conflicting repo state. + +4. Reviewer + * Review the actual diff, not the intent. + * If you find actionable issues, hand them back to the coder before finalizing. + +## Required execution pattern + +* Use separate sub-agent turns for the Questionnaire, Planner, Coder, and Reviewer roles. +* Wait for each role to finish before launching the next blocking role. +* Pass each downstream role the upstream findings it needs rather than redoing the same exploration. +* If custom sub-agent roles are configured, prefer them. Otherwise spawn default sub-agents with explicit role prompts. + +## Loop policy + +* Loop 1 order: ${SWARM_ROLE_SEQUENCE}. +* ${buildSecondLoopPolicy(maxLoops)} +* For loop 2, do not re-implement from scratch. Focus on the reviewer findings and unresolved risks from loop 1. + +## Sub-agent prompts + +Use prompts equivalent to the following: + +* Questionnaire: "You are the Questionnaire agent. Stress-test the request, inspect the repo, identify feasibility, blockers, missing assumptions, acceptance criteria, and likely failure modes. Return only the facts and questions the planner must account for." +* Planner: "You are the Planner agent. Using the questionnaire output and the repo state, produce a decision-complete implementation plan that removes ambiguity around interfaces, data flow, validation, edge cases, and verification." +* Coder: "You are the Coder agent. Implement the approved plan end-to-end in the workspace. Do not stop for non-critical questions. If the reviewer reports actionable findings, fix them directly." +* Reviewer: "You are the Reviewer agent. Review the actual diff and behavior, not the intent. Identify concrete bugs, regressions, missing tests, and plan drift. Return actionable findings or an explicit sign-off." + +## Final output + +Report: +* how many loops were used, +* the main blockers or questions that were resolved, +* what changed, +* remaining risks or follow-up items. +`; +} + +export function buildClaudeSwarmAgents(): Readonly> { + return { + [CLAUDE_SWARM_AGENT_NAMES.questionnaire]: { + description: + "Swarm questionnaire specialist. MUST BE USED first to inspect feasibility, blockers, assumptions, and acceptance criteria before planning begins.", + prompt: [ + "You are the Questionnaire subagent in the swarm workflow.", + "Inspect the repo and the request to identify feasibility, blockers, missing assumptions, acceptance criteria, and likely failure modes.", + "Do not implement or plan in detail. Return only the facts, risks, and questions the planner must account for.", + ].join("\n"), + tools: ["Read", "Grep", "Glob"], + model: "haiku", + }, + [CLAUDE_SWARM_AGENT_NAMES.planner]: { + description: + "Swarm planner specialist. MUST BE USED after the questionnaire to produce a decision-complete implementation plan.", + prompt: [ + "You are the Planner subagent in the swarm workflow.", + "Use the questionnaire findings and repo state to produce a decision-complete implementation plan.", + "Remove ambiguity around interfaces, data flow, validation, edge cases, and verification.", + "Do not edit files.", + ].join("\n"), + tools: ["Read", "Grep", "Glob"], + model: "sonnet", + }, + [CLAUDE_SWARM_AGENT_NAMES.coder]: { + description: + "Swarm coder specialist. MUST BE USED to implement the approved plan end-to-end and to address reviewer findings.", + prompt: [ + "You are the Coder subagent in the swarm workflow.", + "Implement the approved plan end-to-end in the workspace.", + "Do not stop for non-critical questions.", + "If the reviewer reports actionable findings, fix them directly and preserve the intended plan.", + ].join("\n"), + tools: ["Read", "Edit", "Write", "Grep", "Glob", "Bash"], + model: "inherit", + }, + [CLAUDE_SWARM_AGENT_NAMES.reviewer]: { + description: + "Swarm reviewer specialist. MUST BE USED after coding to review the actual diff for bugs, regressions, plan drift, and missing tests.", + prompt: [ + "You are the Reviewer subagent in the swarm workflow.", + "Review the actual diff and behavior, not the intent.", + "Identify concrete bugs, regressions, missing tests, and plan drift.", + "Return actionable findings or an explicit sign-off.", + ].join("\n"), + tools: ["Read", "Grep", "Glob", "Bash"], + model: "sonnet", + }, + }; +} + +export function buildClaudeSwarmUserPromptPrefix(maxLoops: SwarmMaxLoops): string { + return [ + `SWARM MODE (max ${maxLoops} loop${maxLoops === 1 ? "" : "s"}).`, + `Use real subagents in this exact order: ${CLAUDE_SWARM_AGENT_NAMES.questionnaire} -> ${CLAUDE_SWARM_AGENT_NAMES.planner} -> ${CLAUDE_SWARM_AGENT_NAMES.coder} -> ${CLAUDE_SWARM_AGENT_NAMES.reviewer}.`, + `This corresponds to the workflow: ${SWARM_ROLE_SEQUENCE}.`, + maxLoops === 2 + ? "If the reviewer finds material issues, run exactly one additional refinement loop before finalizing: questionnaire critiques -> planner revises -> coder fixes -> reviewer re-reviews." + : "Do not run a second loop.", + "If subagent tooling is available, you must use it rather than simulating the roles in a single thread.", + "If they are unavailable, emulate the same roles sequentially yourself.", + "Prefer repo inspection over asking the user. Implement end-to-end without stopping unless there is a hard blocker.", + "", + "User request:", + ].join("\n"); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2c03cdd6..832dc97e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -947,6 +947,9 @@ export default function ChatView({ threadId }: ChatViewProps) { composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; + const lastNonSwarmInteractionModeRef = useRef( + interactionMode === "swarm" ? DEFAULT_INTERACTION_MODE : interactionMode, + ); const isServerThread = serverThread !== undefined; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; @@ -1805,6 +1808,13 @@ export default function ChatView({ threadId }: ChatViewProps) { label: "/review", description: "Switch this thread into review mode", }, + { + id: "slash:swarm", + type: "slash-command", + command: "swarm", + label: "/swarm", + description: "Toggle the questionnaire, planner, coder, and reviewer workflow", + }, { id: "slash:usage", type: "slash-command", @@ -2407,6 +2417,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const handleInteractionModeChange = useCallback( (mode: ProviderInteractionMode) => { if (mode === interactionMode) return; + if (interactionMode !== "swarm") { + lastNonSwarmInteractionModeRef.current = interactionMode; + } setComposerDraftInteractionMode(threadId, mode); if (isLocalDraftThread) { setDraftThreadContext(threadId, { interactionMode: mode }); @@ -2433,15 +2446,28 @@ export default function ChatView({ threadId }: ChatViewProps) { threadId, ], ); + useEffect(() => { + if (interactionMode !== "swarm") { + lastNonSwarmInteractionModeRef.current = interactionMode; + } + }, [interactionMode]); const handleStandaloneSlashCommand = useCallback( (command: ComposerStandaloneSlashCommand) => { if (command === "usage") { setProviderUsagePanelOpen((open) => !open); return; } + if (command === "swarm") { + void handleInteractionModeChange( + interactionMode === "swarm" + ? lastNonSwarmInteractionModeRef.current + : "swarm", + ); + return; + } void handleInteractionModeChange(command); }, - [handleInteractionModeChange], + [handleInteractionModeChange, interactionMode], ); const applyPresetSelectionToComposer = useCallback( (provider: ProviderKind, presetId: string | null) => { @@ -2607,7 +2633,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ]); const toggleInteractionMode = useCallback(() => { const next = - interactionMode === "ask" + interactionMode === "swarm" + ? lastNonSwarmInteractionModeRef.current + : interactionMode === "ask" ? "plan" : interactionMode === "plan" ? "code" @@ -3334,7 +3362,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ? "ask" : command === "composer.mode.plan" ? "plan" - : command === "composer.mode.code" + : command === "composer.mode.code" ? "code" : "review"; handleInteractionModeChange(nextMode); diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index 0d9c3e2c..2d618ade 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -11,6 +11,7 @@ import { MenuSeparator as MenuDivider, MenuTrigger, } from "../ui/menu"; +import { SWARM_ACTIVE_HELP } from "./swarmCopy"; export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { activePlan: boolean; @@ -24,6 +25,7 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls }) { const selectedInteractionMode = props.interactionMode === "default" ? "code" : props.interactionMode; + const swarmEnabled = selectedInteractionMode === "swarm"; return ( @@ -47,18 +49,22 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls ) : null}
Mode
- { - if (!value || value === selectedInteractionMode) return; - props.onSetInteractionMode(value as ProviderInteractionMode); - }} - > - Ask - Plan - Code - Review - + {swarmEnabled ? ( +
{SWARM_ACTIVE_HELP}
+ ) : ( + { + if (!value || value === selectedInteractionMode) return; + props.onSetInteractionMode(value as ProviderInteractionMode); + }} + > + Ask + Plan + Code + Review + + )}
Access
= plan: ListTodoIcon, code: TerminalSquareIcon, review: WrenchIcon, + swarm: OrbitIcon, usage: EyeIcon, }; diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx index 5d4ce258..353692c7 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx @@ -87,4 +87,29 @@ describe("ComposerPrimaryActions", () => { expect(markup).toContain(">Add Details<"); }); + + it("renders the standard icon button when swarm mode is selected", () => { + const markup = renderToStaticMarkup( + {}} + onInterrupt={() => {}} + onImplementPlanInNewThread={() => {}} + />, + ); + + expect(markup).toContain('viewBox="0 0 14 14"'); + expect(markup).not.toContain(">Swarm<"); + }); }); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 9406b224..63949569 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -209,7 +209,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ : "Reviewing" : promptHasText ? "Add details" - : "Review" + : "Review" : isConnecting ? "Connecting" : isPreparingWorktree diff --git a/apps/web/src/components/chat/InteractionModePill.tsx b/apps/web/src/components/chat/InteractionModePill.tsx index 0979f7ea..7a1c618c 100644 --- a/apps/web/src/components/chat/InteractionModePill.tsx +++ b/apps/web/src/components/chat/InteractionModePill.tsx @@ -2,6 +2,7 @@ import { type ProviderInteractionMode } from "@t3tools/contracts"; import { memo, useState } from "react"; import { INTERACTION_MODE_ACCENT_COLORS } from "../../modeColors"; +import { SWARM_ACTIVE_TITLE } from "./swarmCopy"; const MODES = [ { mode: "ask" as const, label: "Ask", color: INTERACTION_MODE_ACCENT_COLORS.ask }, @@ -20,6 +21,20 @@ export const InteractionModePill = memo(function InteractionModePill({ const [hoveredMode, setHoveredMode] = useState(null); const activeMode = interactionMode === "default" ? "code" : interactionMode; + if (activeMode === "swarm") { + return ( +
+
+ Swarm +
+
+ ); + } + return (
{MODES.map(({ mode, label, color }) => { diff --git a/apps/web/src/components/chat/swarmCopy.ts b/apps/web/src/components/chat/swarmCopy.ts new file mode 100644 index 00000000..1acf7224 --- /dev/null +++ b/apps/web/src/components/chat/swarmCopy.ts @@ -0,0 +1,4 @@ +export const SWARM_ACTIVE_TITLE = "Swarm active: Questionnaire -> Planner -> Coder -> Reviewer"; + +export const SWARM_ACTIVE_HELP = + "Swarm is coordinating the questionnaire, planner, coder, and reviewer. Use /swarm to return to standard modes."; diff --git a/apps/web/src/components/settings/SettingsGeneralPanel.tsx b/apps/web/src/components/settings/SettingsGeneralPanel.tsx index ae2ec445..2e72762f 100644 --- a/apps/web/src/components/settings/SettingsGeneralPanel.tsx +++ b/apps/web/src/components/settings/SettingsGeneralPanel.tsx @@ -1,5 +1,9 @@ import { useMemo } from "react"; -import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings"; +import { + DEFAULT_SWARM_MAX_LOOPS, + DEFAULT_UNIFIED_SETTINGS, + type UnifiedSettings, +} from "@t3tools/contracts/settings"; import { Equal } from "effect"; import { resolveUtilityModelSelectionDefault } from "@t3tools/shared/model"; @@ -71,6 +75,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.commitMessageStyle !== DEFAULT_UNIFIED_SETTINGS.commitMessageStyle ? ["Commit message style"] : []), + ...(settings.swarmMaxLoops !== DEFAULT_UNIFIED_SETTINGS.swarmMaxLoops + ? ["Swarm loops"] + : []), ...(settings.promptEnhancePreset !== DEFAULT_UNIFIED_SETTINGS.promptEnhancePreset ? ["Enhance style"] : []), @@ -97,6 +104,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.planModelSelection, settings.promptEnhancePreset, settings.reviewModelSelection, + settings.swarmMaxLoops, settings.timestampFormat, theme, ], @@ -200,6 +208,45 @@ function GeneralBehaviorSection({ } /> + + updateSettings({ + swarmMaxLoops: DEFAULT_SWARM_MAX_LOOPS, + }) + } + /> + ) : null + } + control={ + + } + /> + { expect(parseStandaloneComposerSlashCommand("/code")).toBe("code"); }); + it("parses standalone /swarm command", () => { + expect(parseStandaloneComposerSlashCommand(" /swarm ")).toBe("swarm"); + }); + it("parses standalone /usage command", () => { expect(parseStandaloneComposerSlashCommand("/usage")).toBe("usage"); }); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 71d7077c..55f0ddd9 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -9,6 +9,7 @@ export type ComposerSlashCommand = | "plan" | "code" | "review" + | "swarm" | "usage"; export type ComposerStandaloneSlashCommand = Exclude; @@ -26,6 +27,7 @@ const SLASH_COMMANDS: readonly ComposerSlashCommand[] = [ "plan", "code", "review", + "swarm", "usage", ]; const isInlineTokenSegment = ( @@ -274,7 +276,7 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos export function parseStandaloneComposerSlashCommand( text: string, ): ComposerStandaloneSlashCommand | null { - const match = /^\/(ask|plan|code|review|usage)\s*$/i.exec(text.trim()); + const match = /^\/(ask|plan|code|review|swarm|usage)\s*$/i.exec(text.trim()); if (!match) { return null; } @@ -283,6 +285,7 @@ export function parseStandaloneComposerSlashCommand( if (command === "plan") return "plan"; if (command === "code") return "code"; if (command === "review") return "review"; + if (command === "swarm") return "swarm"; return "usage"; } diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 37d0878d..ce24f198 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1074,10 +1074,10 @@ describe("composerDraftStore runtime and interaction settings", () => { it("stores interaction mode overrides in the composer draft", () => { const store = useComposerDraftStore.getState(); - store.setInteractionMode(threadId, "plan"); + store.setInteractionMode(threadId, "swarm"); expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.interactionMode).toBe( - "plan", + "swarm", ); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 0ff75a3c..095eec1e 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -813,6 +813,7 @@ function normalizePersistedDraftThreads( interactionMode: candidateDraftThread.interactionMode === "plan" || candidateDraftThread.interactionMode === "review" || + candidateDraftThread.interactionMode === "swarm" || candidateDraftThread.interactionMode === "default" ? candidateDraftThread.interactionMode : DEFAULT_INTERACTION_MODE, @@ -899,6 +900,7 @@ function normalizePersistedDraftsByThreadId( const interactionMode = draftCandidate.interactionMode === "plan" || draftCandidate.interactionMode === "review" || + draftCandidate.interactionMode === "swarm" || draftCandidate.interactionMode === "default" ? draftCandidate.interactionMode : null; @@ -1861,6 +1863,7 @@ export const useComposerDraftStore = create()( interactionMode === "plan" || interactionMode === "review" || interactionMode === "code" || + interactionMode === "swarm" || interactionMode === "default" ? interactionMode : null; diff --git a/apps/web/src/modeColors.ts b/apps/web/src/modeColors.ts index 8c158e36..7ec1ee37 100644 --- a/apps/web/src/modeColors.ts +++ b/apps/web/src/modeColors.ts @@ -5,6 +5,7 @@ export const INTERACTION_MODE_ACCENT_COLORS = { plan: "#c8954a", code: "#5236CC", review: "#4DB6AC", + swarm: "#E06C2F", default: "#5236CC", } as const satisfies Record; diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 4d84d220..992b460e 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -260,6 +260,8 @@ export function getModeModelSelectionKey( ? "planModelSelection" : mode === "review" ? "reviewModelSelection" + : mode === "swarm" + ? "codeModelSelection" : "codeModelSelection"; } diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 2aa4891f..d5a600d1 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -67,6 +67,7 @@ export const ProviderInteractionMode = Schema.Literals([ "ask", "code", "review", + "swarm", ]); export type ProviderInteractionMode = typeof ProviderInteractionMode.Type; export const DEFAULT_PROVIDER_INTERACTION_MODE: ProviderInteractionMode = "default"; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 90be6c79..3d861416 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -60,6 +60,9 @@ export const CommitMessageStyle = Schema.Literals([ ]); export type CommitMessageStyle = typeof CommitMessageStyle.Type; export const DEFAULT_COMMIT_MESSAGE_STYLE: CommitMessageStyle = "summary"; +export const SwarmMaxLoops = Schema.Literals([1, 2]); +export type SwarmMaxLoops = typeof SwarmMaxLoops.Type; +export const DEFAULT_SWARM_MAX_LOOPS: SwarmMaxLoops = 1; const makeBinaryPathSetting = (fallback: string) => TrimmedString.pipe( @@ -145,6 +148,7 @@ export const ServerSettings = Schema.Struct({ commitMessageStyle: CommitMessageStyle.pipe( Schema.withDecodingDefault(() => DEFAULT_COMMIT_MESSAGE_STYLE), ), + swarmMaxLoops: SwarmMaxLoops.pipe(Schema.withDecodingDefault(() => DEFAULT_SWARM_MAX_LOOPS)), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault(() => ({ provider: "codex" as const, @@ -313,6 +317,7 @@ export const ServerSettingsPatch = Schema.Struct({ enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), commitMessageStyle: Schema.optionalKey(CommitMessageStyle), + swarmMaxLoops: Schema.optionalKey(SwarmMaxLoops), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), promptEnhanceModelSelection: Schema.optionalKey(ModelSelectionPatch),