From 409ef90e1c018da2b03d11d58cab81f39eebae38 Mon Sep 17 00:00:00 2001 From: Yusuf Arfan Ismail Date: Sun, 11 Oct 2026 14:08:48 +0100 Subject: [PATCH 1/6] feat(agents): add project custom agents Add Markdown-defined agents, project defaults, composer selection, and named T3 delegation with model preferences and instruction delivery. --- apps/server/src/agent/AgentService.test.ts | 310 +++++++++++++++++ apps/server/src/agent/AgentService.ts | 210 ++++++++++++ apps/server/src/auth/RpcAuthorization.ts | 1 + .../OrchestratorMcpService.activity.test.ts | 5 + .../src/mcp/OrchestratorMcpService.test.ts | 296 ++++++++++++++++- apps/server/src/mcp/OrchestratorMcpService.ts | 134 +++++++- apps/server/src/mcp/toolkits/core.test.ts | 4 + .../src/mcp/toolkits/orchestrator/tools.ts | 2 +- .../src/mcp/toolkits/project/handlers.ts | 1 + .../src/observability/RpcInstrumentation.ts | 1 + .../src/orchestration-v2/Orchestrator.ts | 2 + .../src/orchestration-v2/ProjectionStore.ts | 2 + .../ProviderTurnStartService.test.ts | 147 +++++++- .../ProviderTurnStartService.ts | 35 +- .../SubagentProjection.test.ts | 39 +++ .../ThreadLaunchService.test.ts | 314 +++++++++++++++++- .../orchestration-v2/ThreadLaunchService.ts | 106 +++++- .../ThreadMessageIntake.test.ts | 107 +++++- .../orchestration-v2/ThreadMessageIntake.ts | 58 ++++ .../server/src/project/ProjectService.test.ts | 2 +- apps/server/src/project/ProjectService.ts | 40 +++ .../src/relay/AgentAwarenessRelay.test.ts | 1 + .../scheduledTasks/ScheduledTaskService.ts | 1 + apps/server/src/serverRuntimeStartup.ts | 1 + apps/server/src/ws.test.ts | 17 + apps/server/src/ws.ts | 33 +- apps/web/src/components/ChatView.tsx | 97 ++++-- .../src/components/chat/AgentPicker.test.tsx | 142 ++++++++ apps/web/src/components/chat/AgentPicker.tsx | 203 +++++++++++ apps/web/src/components/chat/ChatComposer.tsx | 41 +++ .../settings/ProjectDefaultsSettings.tsx | 61 ++++ apps/web/src/state/agents.ts | 11 + docs/user/composer.md | 41 +++ docs/user/project-settings.md | 4 + .../src/operations/commands.test.ts | 108 ++++++ .../client-runtime/src/operations/commands.ts | 13 + packages/client-runtime/src/state/models.ts | 3 + .../contracts/src/agentDefinition.test.ts | 54 +++ packages/contracts/src/agentDefinition.ts | 62 ++++ packages/contracts/src/index.ts | 1 + .../contracts/src/orchestrationV2.test.ts | 21 ++ packages/contracts/src/orchestrationV2.ts | 11 + .../contracts/src/orchestratorMcp.test.ts | 49 +++ packages/contracts/src/orchestratorMcp.ts | 9 + packages/contracts/src/project.test.ts | 1 - packages/contracts/src/rpc.ts | 9 + packages/contracts/src/settings.test.ts | 14 + packages/contracts/src/settings.ts | 7 + .../src/server/subagentProjection.ts | 7 +- 49 files changed, 2749 insertions(+), 89 deletions(-) create mode 100644 apps/server/src/agent/AgentService.test.ts create mode 100644 apps/server/src/agent/AgentService.ts create mode 100644 apps/web/src/components/chat/AgentPicker.test.tsx create mode 100644 apps/web/src/components/chat/AgentPicker.tsx create mode 100644 apps/web/src/state/agents.ts create mode 100644 packages/contracts/src/agentDefinition.test.ts create mode 100644 packages/contracts/src/agentDefinition.ts diff --git a/apps/server/src/agent/AgentService.test.ts b/apps/server/src/agent/AgentService.test.ts new file mode 100644 index 000000000000..91186b98da68 --- /dev/null +++ b/apps/server/src/agent/AgentService.test.ts @@ -0,0 +1,310 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { + AgentDefinition, + AgentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { getAgentDefinition, listAgentSummaries, resolveModelForAgent } from "./AgentService.ts"; + +/** An agent file: frontmatter lines, then the body that becomes its instructions. */ +const agentFile = (frontmatter: ReadonlyArray, body: string) => + ["---", ...frontmatter, "---", "", body].join("\n"); + +const writeFiles = Effect.fn("writeFiles")(function* (root: string, files: Record) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const [relativePath, contents] of Object.entries(files)) { + const filePath = path.join(root, relativePath); + yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, contents); + } +}); + +const decodeAgentDefinition = Schema.decodeUnknownSync(AgentDefinition); + +const definition = (fields: Record) => + decodeAgentDefinition({ + id: "reviewer", + name: "Reviewer", + description: "Reviews diffs.", + instructions: "Review the diff.", + ...fields, + }); + +const provider = ( + instanceId: string, + driver: string, + slugs: ReadonlyArray, +): ServerProvider => ({ + instanceId: ProviderInstanceId.make(instanceId), + driver: ProviderDriverKind.make(driver), + enabled: true, + installed: true, + version: "test", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-10-10T00:00:00.000Z", + models: slugs.map((slug) => ({ slug, name: slug, isCustom: false, capabilities: null })), + slashCommands: [], + skills: [], +}); + +it.layer(NodeServices.layer)("agent discovery", (it) => { + it.effect("discovers agents from every agent location and AGENTS.md", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agents-locations-" }); + yield* writeFiles(root, { + ".agents/planner.agent.md": agentFile( + ["id: planner", "name: Planner", "description: Plans work."], + "Plan it.", + ), + ".agents/reviewer.md": agentFile( + ["id: reviewer", "name: Reviewer", "description: Reviews work."], + "Review it.", + ), + ".github/agents/tester.agent.md": agentFile( + ["id: tester", "name: Tester", "description: Tests work."], + "Test it.", + ), + ".github/agents/docs.md": agentFile( + ["id: docs", "name: Docs", "description: Writes docs."], + "Document it.", + ), + "AGENTS.md": agentFile( + ["id: coordinator", "name: Coordinator", "description: Coordinates work."], + "Coordinate it.", + ), + }); + + const agents = yield* listAgentSummaries(root); + + assert.deepEqual(agents.map((agent) => agent.id).toSorted(), [ + "coordinator", + "docs", + "planner", + "reviewer", + "tester", + ]); + }), + ); + + it.effect("parses every frontmatter field and keeps the body as instructions", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agents-fields-" }); + yield* writeFiles(root, { + ".agents/full.agent.md": agentFile( + [ + "id: full", + "name: Full agent", + "description: Uses every field.", + "model:", + " provider: claudeAgent", + " model: claude-haiku-4-5", + "tier: economy", + "tools:", + " allow: [read, search]", + " deny: [shell]", + "canDelegateTo: [planner]", + ], + "Be exhaustive.\n\nKeep it short.", + ), + }); + + const agent = Option.getOrUndefined(yield* getAgentDefinition(AgentId.make("full"), root)); + + assert.deepEqual(agent, { + id: AgentId.make("full"), + name: "Full agent", + description: "Uses every field.", + model: { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-haiku-4-5" }, + tier: "economy", + canDelegateTo: [AgentId.make("planner")], + instructions: "Be exhaustive.\n\nKeep it short.", + }); + assert.deepEqual(yield* listAgentSummaries(root), [ + { + id: AgentId.make("full"), + name: "Full agent", + description: "Uses every field.", + tier: "economy", + model: { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-haiku-4-5" }, + }, + ]); + }), + ); + + it.effect("leaves the tier unset and defaults delegation to empty", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agents-defaults-" }); + yield* writeFiles(root, { + ".github/agents/minimal.agent.md": agentFile( + ["id: minimal", "name: Minimal", "description: Bare minimum."], + "Do the task.", + ), + }); + + const agent = Option.getOrUndefined(yield* getAgentDefinition(AgentId.make("minimal"), root)); + + assert.deepEqual(agent, { + id: AgentId.make("minimal"), + name: "Minimal", + description: "Bare minimum.", + canDelegateTo: [], + instructions: "Do the task.", + }); + }), + ); + + it.effect("skips guidance and invalid definitions without hiding valid agents", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agents-invalid-" }); + yield* writeFiles(root, { + "AGENTS.md": "# Project guidance\n\nNo frontmatter here.", + ".agents/notes.md": "Just notes.", + ".agents/bad-id.agent.md": agentFile( + ["id: Bad Id", "name: Bad id", "description: Not kebab case."], + "Body.", + ), + ".agents/bad-tier.agent.md": agentFile( + ["id: bad-tier", "name: Bad tier", "description: Unknown tier.", "tier: fast"], + "Body.", + ), + ".agents/empty-body.agent.md": agentFile( + ["id: empty-body", "name: Empty body", "description: No instructions."], + "", + ), + ".agents/broken.agent.md": "---\nid: [unclosed\n---\n\nBody.", + ".agents/valid.agent.md": agentFile( + ["id: valid", "name: Valid", "description: Parses fine."], + "Body.", + ), + }); + + const agents = yield* listAgentSummaries(root); + + assert.deepEqual( + agents.map((agent) => agent.id), + ["valid"], + ); + }), + ); + + it.effect("keeps the first definition when two locations define the same id", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agents-duplicate-" }); + yield* writeFiles(root, { + ".agents/reviewer.agent.md": agentFile( + ["id: reviewer", "name: Local reviewer", "description: Local copy."], + "Local.", + ), + "AGENTS.md": agentFile( + ["id: reviewer", "name: Root reviewer", "description: Root copy."], + "Root.", + ), + }); + + const agent = Option.getOrUndefined( + yield* getAgentDefinition(AgentId.make("reviewer"), root), + ); + + assert.equal(agent?.name, "Local reviewer"); + assert.lengthOf(yield* listAgentSummaries(root), 1); + }), + ); + + it.effect("finds nothing without a workspace root, in an empty one, or for an unknown id", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agents-none-" }); + const emptyRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agents-empty-" }); + yield* writeFiles(root, { + ".agents/reviewer.agent.md": agentFile( + ["id: reviewer", "name: Reviewer", "description: Reviews."], + "Review.", + ), + }); + + assert.deepEqual(yield* listAgentSummaries(null), []); + assert.deepEqual(yield* listAgentSummaries(emptyRoot), []); + assert.isTrue(Option.isNone(yield* getAgentDefinition(AgentId.make("reviewer"), null))); + assert.isTrue(Option.isNone(yield* getAgentDefinition(AgentId.make("missing"), root))); + }), + ); +}); + +describe("resolveModelForAgent", () => { + const claude = provider("claudeAgent", "claudeAgent", ["claude-fable-5-1", "claude-haiku-4-5"]); + const codex = provider("codex", "codex", ["gpt-5.4"]); + + it("prefers an explicit model in the agent file over its tier", () => { + const agent = definition({ tier: "economy", model: { provider: "codex", model: "gpt-5.4" } }); + + assert.deepEqual(resolveModelForAgent(agent, [claude, codex]), { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }); + }); + + it("maps an explicit tier to the driver's available default model", () => { + assert.deepEqual(resolveModelForAgent(definition({ tier: "economy" }), [claude]), { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-haiku-4-5", + }); + assert.deepEqual(resolveModelForAgent(definition({ tier: "reasoning" }), [claude]), { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-fable-5-1", + }); + assert.isUndefined(resolveModelForAgent(definition({}), [claude])); + }); + + it("picks from the first provider that advertises the tier model", () => { + assert.deepEqual(resolveModelForAgent(definition({ tier: "economy" }), [codex, claude]), { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-haiku-4-5", + }); + }); + + it("returns nothing when no provider advertises the tier model", () => { + assert.isUndefined(resolveModelForAgent(definition({ tier: "economy" }), [codex])); + }); + + it("prefers the current provider instance when it offers the tier model", () => { + const firstClaude = provider("claude-first", "claudeAgent", ["claude-haiku-4-5"]); + const currentClaude = provider("claude-current", "claudeAgent", ["claude-haiku-4-5"]); + + assert.deepEqual( + resolveModelForAgent( + definition({ tier: "economy" }), + [firstClaude, currentClaude], + currentClaude.instanceId, + ), + { instanceId: currentClaude.instanceId, model: "claude-haiku-4-5" }, + ); + }); + + it("skips providers that are disabled, unavailable, or missing the fixed model", () => { + const disabled = { ...codex, enabled: false }; + const unavailable = { ...claude, availability: "unavailable" as const }; + assert.isUndefined( + resolveModelForAgent( + definition({ model: { instanceId: codex.instanceId, model: "gpt-5.4" } }), + [disabled], + ), + ); + assert.isUndefined(resolveModelForAgent(definition({ tier: "economy" }), [unavailable])); + }); +}); diff --git a/apps/server/src/agent/AgentService.ts b/apps/server/src/agent/AgentService.ts new file mode 100644 index 000000000000..afff018ca6ed --- /dev/null +++ b/apps/server/src/agent/AgentService.ts @@ -0,0 +1,210 @@ +/** + * AgentService — filesystem discovery of custom agents for thread startup and + * delegated tasks. An agent is a markdown file: its YAML + * frontmatter holds the `AgentDefinition` fields and its body is the + * instructions. Agents come from `.agents/*.md`, then `.github/agents/*.md`, + * then the project's `AGENTS.md`, and the first definition of an id wins. + * + * A file without frontmatter is project guidance rather than an agent, so it + * is skipped quietly. Frontmatter that is not a YAML mapping or fails to decode + * is skipped with a warning, so one broken file never hides the others. + * + * @module agent/AgentService + */ +import { + AgentDefinition, + type AgentId, + type AgentSummary, + DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, + isProviderAvailable, + type ModelSelection, + type ServerProvider, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { parse as parseYamlDocument } from "yaml"; + +const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; + +const decodeAgentDefinition = Schema.decodeUnknownEffect(AgentDefinition); + +/** The frontmatter mapping, or undefined when the YAML does not parse to one. */ +function parseFrontmatter(source: string): Record | undefined { + let parsed: unknown; + try { + parsed = parseYamlDocument(source); + } catch { + return undefined; + } + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : undefined; +} + +/** Markdown files that may define agents, in precedence order. */ +const listAgentFiles = Effect.fn("listAgentFiles")(function* (workspaceRoot: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const files: string[] = []; + for (const directory of [ + path.join(workspaceRoot, ".agents"), + path.join(workspaceRoot, ".github", "agents"), + ]) { + const entries = yield* fileSystem + .readDirectory(directory) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + for (const entry of [...entries].sort()) { + if (entry.endsWith(".md")) { + files.push(path.join(directory, entry)); + } + } + } + return [...files, path.join(workspaceRoot, "AGENTS.md")]; +}); + +/** The agent a file defines, or undefined when it defines none. */ +const readAgentFile = Effect.fn("readAgentFile")(function* (filePath: string) { + const fileSystem = yield* FileSystem.FileSystem; + const contents = yield* fileSystem + .readFileString(filePath) + .pipe(Effect.orElseSucceed(() => undefined)); + const match = contents === undefined ? null : FRONTMATTER_PATTERN.exec(contents); + if (contents === undefined || match === null) { + return undefined; + } + const frontmatter = parseFrontmatter(match[1] ?? ""); + if (frontmatter === undefined) { + yield* Effect.logWarning("agent frontmatter did not parse as a YAML mapping; skipping", { + path: filePath, + }); + return undefined; + } + if (Object.hasOwn(frontmatter, "tools")) { + yield* Effect.logWarning("agent tools frontmatter is not supported and was ignored", { + path: filePath, + }); + } + const instructions = contents.slice(match[0].length).trim(); + return yield* decodeAgentDefinition({ ...frontmatter, instructions }).pipe( + Effect.tapError((cause) => + Effect.logWarning("agent definition is invalid; skipping", { path: filePath, cause }), + ), + Effect.orElseSucceed(() => undefined), + ); +}); + +/** Every valid agent in a worktree, keeping the first definition of each id. */ +const discoverAgentDefinitions = Effect.fn("discoverAgentDefinitions")(function* ( + workspaceRoot: string | null, +) { + if (!workspaceRoot) { + return []; + } + const agents = new Map(); + const filePaths = yield* listAgentFiles(workspaceRoot); + for (const filePath of filePaths) { + const agent = yield* readAgentFile(filePath); + if (agent === undefined) { + continue; + } + if (agents.has(agent.id)) { + yield* Effect.logWarning("agent id is defined more than once; keeping the first", { + id: agent.id, + path: filePath, + }); + continue; + } + agents.set(agent.id, agent); + } + return [...agents.values()]; +}); + +export const listAgentDefinitions = discoverAgentDefinitions; + +export function summarizeAgentDefinition(agent: AgentDefinition): AgentSummary { + return { + id: agent.id, + name: agent.name, + description: agent.description, + ...(agent.tier === undefined ? {} : { tier: agent.tier }), + ...(agent.model === undefined ? {} : { model: agent.model }), + }; +} + +/** The roster the composer and `orchestrator_capabilities` advertise. */ +export const listAgentSummaries = Effect.fn("listAgentSummaries")(function* ( + workspaceRoot: string | null, +) { + const agents = yield* listAgentDefinitions(workspaceRoot); + return agents.map(summarizeAgentDefinition); +}); + +/** The definition an id names, read from the same locations as `listAgentSummaries`. */ +export const getAgentDefinition = Effect.fn("getAgentDefinition")(function* ( + agentId: AgentId, + workspaceRoot: string | null, +) { + const agents = yield* listAgentDefinitions(workspaceRoot); + return Option.fromNullishOr(agents.find((agent) => agent.id === agentId)); +}); + +/** + * The model an agent uses at a thread or delegation boundary. An explicit + * `model` in the agent file wins when that exact instance/model is available. + * Otherwise an explicit tier picks the first available provider that advertises + * its default model, preferring the current instance. An omitted model and tier + * leave the child on its selected or inherited model. + */ +export function resolveModelForAgent( + agent: AgentDefinition, + availableProviders: ReadonlyArray, + preferredInstanceId?: ModelSelection["instanceId"], +): ModelSelection | undefined { + if (agent.model !== undefined) { + const provider = availableProviders.find( + (candidate) => + candidate.instanceId === agent.model?.instanceId && + canRunAgentModel(candidate) && + candidate.models.some((model) => model.slug === agent.model?.model), + ); + return provider === undefined ? undefined : agent.model; + } + if (agent.tier === undefined) return undefined; + const tierDefaults = + agent.tier === "economy" + ? DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER + : DEFAULT_MODEL_BY_PROVIDER; + const orderedProviders = availableProviders.toSorted((left, right) => { + if (left.instanceId === preferredInstanceId) { + return right.instanceId === preferredInstanceId ? 0 : -1; + } + if (right.instanceId === preferredInstanceId) return 1; + return 0; + }); + for (const provider of orderedProviders) { + const model = tierDefaults[provider.driver]; + if ( + model !== undefined && + canRunAgentModel(provider) && + provider.models.some((candidate) => candidate.slug === model) + ) { + return { instanceId: provider.instanceId, model }; + } + } + return undefined; +} + +function canRunAgentModel(provider: ServerProvider): boolean { + return ( + provider.enabled && + provider.installed && + isProviderAvailable(provider) && + provider.status !== "error" && + provider.status !== "disabled" && + provider.auth.status !== "unauthenticated" + ); +} diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 5b8d483dc102..32d521c8cb94 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -60,6 +60,7 @@ export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.subscribeThread]: AuthOrchestrationReadScope, [WS_METHODS.projectsMutate]: AuthOrchestrationOperateScope, + [WS_METHODS.agentsList]: AuthOrchestrationReadScope, [WS_METHODS.serverProbe]: AuthOrchestrationReadScope, [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope, [WS_METHODS.serverRefreshProviders]: AuthOrchestrationReadScope, diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index ce6921d02da4..5faad81cb8e2 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -14,6 +14,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "vite-plus/test"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; @@ -130,6 +131,7 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a const layer = OrchestratorMcpService.layer.pipe( Layer.provide( Layer.mergeAll( + NodeServices.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getTimelinePage: () => Effect.succeed({ items: [], totalItems: 0, hasMore: false }), getThreadRecords: (threadId) => @@ -196,6 +198,7 @@ it("readThread prefers waiting activity status over a newer cancelled queued run const layer = OrchestratorMcpService.layer.pipe( Layer.provide( Layer.mergeAll( + NodeServices.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getTimelinePage: () => Effect.succeed({ items: [], totalItems: 0, hasMore: false }), getThreadRecords: (threadId) => @@ -319,6 +322,7 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy const layer = OrchestratorMcpService.layer.pipe( Layer.provide( Layer.mergeAll( + NodeServices.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getTimelinePage: () => Effect.succeed({ items: [], totalItems: 0, hasMore: false }), getThreadRecords: (threadId) => { @@ -418,6 +422,7 @@ it("readThread and sendToThread reach threads in other projects", async () => { const layer = OrchestratorMcpService.layer.pipe( Layer.provide( Layer.mergeAll( + NodeServices.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (threadId) => { if (threadId === parentThreadId) return Effect.succeed(parentProjection); diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index d0a85e352a29..3b5aa87b048b 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { + AgentId, CommandId, EnvironmentId, NodeId, @@ -17,7 +18,10 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import { @@ -843,6 +847,8 @@ describe("OrchestratorMcpService provider resolution", () => { readonly model: string; readonly options?: ReadonlyArray<{ readonly id: string; readonly value: unknown }>; } = { instanceId: codexInstanceId, model: "gpt-5.4" }, + worktreePath?: string | null, + agentId?: AgentId, ): OrchestrationV2ThreadProjection => ({ thread: { @@ -852,8 +858,10 @@ describe("OrchestratorMcpService provider resolution", () => { createdBy: "user", creationSource: "web", modelSelection, + ...(agentId === undefined ? {} : { agentId }), runtimeMode: "full-access", interactionMode: "default", + worktreePath, }, runs: [ { @@ -869,6 +877,11 @@ describe("OrchestratorMcpService provider resolution", () => { subagents, }) as unknown as OrchestrationV2ThreadProjection; + const projectServiceLayer = (workspaceRoot = "/t3-mcp-empty-workspace") => + Layer.mock(ProjectService.ProjectService)({ + getById: () => Effect.succeed(Option.some({ workspaceRoot } as never)), + }); + const childProjection = { thread: { id: childThreadId }, runs: [], @@ -947,7 +960,7 @@ describe("OrchestratorMcpService provider resolution", () => { antigravityInstanceId, disabledAntigravityInstanceId, ]), - Layer.mock(ProjectService.ProjectService)({}), + projectServiceLayer(), Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); @@ -1054,7 +1067,7 @@ describe("OrchestratorMcpService provider resolution", () => { }, ]), adapterRegistryLayer([codexInstanceId]), - Layer.mock(ProjectService.ProjectService)({}), + projectServiceLayer(), Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); @@ -1140,7 +1153,7 @@ describe("OrchestratorMcpService provider resolution", () => { ]), }), adapterRegistryLayer([codexInstanceId, antigravityInstanceId]), - Layer.mock(ProjectService.ProjectService)({}), + projectServiceLayer(), Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); @@ -1238,7 +1251,7 @@ describe("OrchestratorMcpService provider resolution", () => { ]), }), adapterRegistryLayer([codexInstanceId, antigravityInstanceId]), - Layer.mock(ProjectService.ProjectService)({}), + projectServiceLayer(), Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); @@ -1287,7 +1300,7 @@ describe("OrchestratorMcpService provider resolution", () => { forkShadow, ]), adapterRegistryLayer([codexInstanceId]), - Layer.mock(ProjectService.ProjectService)({}), + projectServiceLayer(), Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); @@ -1396,7 +1409,7 @@ describe("OrchestratorMcpService provider resolution", () => { }), adapterRegistryLayer([codexInstanceId, claudeInstanceId]), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), - Layer.mock(ProjectService.ProjectService)({}), + projectServiceLayer(), Layer.mock(SecretRequests.SecretRequests)({}), ); @@ -1554,7 +1567,7 @@ describe("OrchestratorMcpService provider resolution", () => { }), ]), adapterRegistryLayer([codexInstanceId, codexAltInstanceId]), - Layer.mock(ProjectService.ProjectService)({}), + projectServiceLayer(), Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); @@ -1912,4 +1925,273 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); }); + const economyReviewerFile = [ + "---", + "id: economy-reviewer", + "name: Economy reviewer", + "description: Reviews small diffs cheaply.", + "tier: economy", + "---", + "", + "Only report concrete defects.", + ].join("\n"); + + it.effect("delegates to a named agent with its tier model and preserved task", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-mcp-agent-project-" }); + const preparedWorktree = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-mcp-agent-worktree-", + }); + yield* fs.makeDirectory(path.join(projectRoot, ".agents"), { recursive: true }); + yield* fs.writeFileString( + path.join(projectRoot, ".agents", "economy-reviewer.agent.md"), + economyReviewerFile, + ); + const task = { + id: taskId, + threadId: parentThreadId, + runId: parentRunId, + parentNodeId, + origin: "app_owned", + createdBy: "agent", + driver: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef: null, + prompt: "Summarize the diff.", + title: null, + model: "gpt-6-luna", + status: "running", + result: null, + startedAt: null, + completedAt: null, + }; + let delegated = false; + const dispatched = yield* Ref.make>([]); + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (threadId) => + Effect.succeed( + threadId === parentThreadId + ? parentProjection(delegated ? [task] : [], undefined, preparedWorktree) + : childProjection, + ), + dispatch: (command) => + Ref.update(dispatched, (commands) => [...commands, command]).pipe( + Effect.andThen( + Effect.sync(() => { + delegated = true; + }), + ), + Effect.as({ + sequence: 1, + storedEvents: [ + { + sequence: 1, + commandId: null, + event: { type: "subagent.updated", payload: task }, + }, + ], + } as never), + ), + }), + providerRegistryLayer([ + providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-6-luna", + }), + ]), + adapterRegistryLayer([codexInstanceId]), + projectServiceLayer(projectRoot), + ServerSettings.layerTest(), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + yield* service.delegateTask(scope, { + task: "Summarize the diff.", + targetAgentId: AgentId.make("economy-reviewer"), + mode: "async", + clientRequestId: "delegate-agent-1", + }); + const [command] = yield* Ref.get(dispatched); + const request = command as { + type: string; + task: string; + targetAgentId?: AgentId; + modelSelection: unknown; + }; + assert.equal(request.type, "delegated_task.request"); + assert.equal(request.task, "Summarize the diff."); + assert.equal(request.targetAgentId, AgentId.make("economy-reviewer")); + assert.deepEqual(request.modelSelection, { + instanceId: codexInstanceId, + model: "gpt-6-luna", + }); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("refuses a targetAgentId that no agent definition provides", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const worktree = yield* fs.makeTempDirectoryScoped({ prefix: "t3-mcp-no-agent-" }); + const dispatched = yield* Ref.make(0); + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed(parentProjection([], undefined, worktree)), + dispatch: () => Ref.update(dispatched, (count) => count + 1).pipe(Effect.as({} as never)), + }), + providerRegistryLayer([ + providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + }), + ]), + adapterRegistryLayer([codexInstanceId]), + projectServiceLayer(), + ServerSettings.layerTest(), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const error = yield* service + .delegateTask(scope, { + task: "Summarize the diff.", + targetAgentId: AgentId.make("missing-agent"), + mode: "async", + }) + .pipe(Effect.flip); + assert.equal(error.code, "invalid_request"); + assert.isTrue(error.message.includes("missing-agent")); + assert.equal(yield* Ref.get(dispatched), 0); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("requires a custom lead to name an allowed child agent", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-mcp-agent-policy-project-", + }); + const preparedWorktree = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-mcp-agent-policy-worktree-", + }); + const agentDirectory = path.join(projectRoot, ".agents"); + yield* fs.makeDirectory(agentDirectory, { recursive: true }); + yield* fs.writeFileString( + path.join(agentDirectory, "lead.md"), + "---\nid: lead\nname: Lead\ndescription: Delegates reviews.\ncanDelegateTo:\n - economy-reviewer\n---\n\nDelegate only reviews.", + ); + yield* fs.writeFileString( + path.join(agentDirectory, "economy-reviewer.md"), + economyReviewerFile, + ); + yield* fs.writeFileString( + path.join(agentDirectory, "outsider.md"), + "---\nid: outsider\nname: Outsider\ndescription: Not allowed.\n---\n\nDo work.", + ); + const leadAgentId = AgentId.make("lead"); + const dispatched = yield* Ref.make(0); + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => + Effect.succeed(parentProjection([], undefined, preparedWorktree, leadAgentId)), + dispatch: () => Ref.update(dispatched, (count) => count + 1).pipe(Effect.as({} as never)), + }), + providerRegistryLayer([ + providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + }), + ]), + adapterRegistryLayer([codexInstanceId]), + projectServiceLayer(projectRoot), + ServerSettings.layerTest(), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const unnamed = yield* service + .delegateTask(scope, { task: "Review", mode: "async" }) + .pipe(Effect.flip); + assert.equal(unnamed.code, "invalid_request"); + assert.isTrue(unnamed.message.includes("canDelegateTo")); + + const unauthorized = yield* service + .delegateTask(scope, { + task: "Review", + targetAgentId: AgentId.make("outsider"), + mode: "async", + }) + .pipe(Effect.flip); + assert.equal(unauthorized.code, "invalid_request"); + assert.isTrue(unauthorized.message.includes("canDelegateTo")); + assert.equal(yield* Ref.get(dispatched), 0); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("advertises a root thread's agents from the project's workspace", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-mcp-root-agents-" }); + yield* fs.makeDirectory(path.join(workspaceRoot, ".github", "agents"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspaceRoot, ".github", "agents", "economy-reviewer.agent.md"), + economyReviewerFile, + ); + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => Effect.succeed(parentProjection([], undefined, null)), + }), + providerRegistryLayer([ + providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + }), + ]), + adapterRegistryLayer([codexInstanceId]), + Layer.mock(ProjectService.ProjectService)({ + getById: () => Effect.succeed(Option.some({ workspaceRoot } as never)), + }), + ServerSettings.layerTest(), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const capabilities = yield* service.capabilities(scope); + assert.deepEqual(capabilities.agents, [ + { + id: AgentId.make("economy-reviewer"), + name: "Economy reviewer", + description: "Reviews small diffs cheaply.", + tier: "economy", + }, + ]); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 64c8cc020086..2fcce44990c7 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -65,12 +65,19 @@ import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import { + listAgentDefinitions, + resolveModelForAgent, + summarizeAgentDefinition, +} from "../agent/AgentService.ts"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import { subagentResultForRun, @@ -677,6 +684,39 @@ function taskPrompt(input: OrchestratorMcpDelegateTaskInput): string { : `Act as the ${input.role} sub-agent for this task.\n\n${input.task}`; } +/** Restrict explicit targets, otherwise keep all providers for tier fallback. */ +function providersForTarget( + providers: ReadonlyArray, + target: OrchestratorMcpTarget | undefined, +): ReadonlyArray { + return providers.filter( + (provider) => + (target?.providerInstanceId === undefined || + provider.instanceId === target.providerInstanceId) && + (target?.driverKind === undefined || provider.driver === target.driverKind), + ); +} + +/** + * The caller's target with an agent's model filled in. A provider or options + * the caller named still win over the agent's. + */ +function targetWithAgentModel( + target: OrchestratorMcpTarget | undefined, + selection: ModelSelection | undefined, +): OrchestratorMcpTarget | undefined { + if (selection === undefined) { + return target; + } + const options = target?.options ?? selection.options; + return { + ...target, + providerInstanceId: target?.providerInstanceId ?? selection.instanceId, + model: selection.model, + ...(options === undefined ? {} : { options }), + }; +} + function threadSettlement( thread: Pick, ): Pick { @@ -932,6 +972,8 @@ const make = Effect.gen(function* () { ) : Effect.succeed(project.defaultModelSelection); const secretRequests = yield* SecretRequests.SecretRequests; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const requireCapability = (scope: McpInvocationScope) => scope.capabilities.has("orchestration") @@ -1025,6 +1067,31 @@ const make = Effect.gen(function* () { return { scope: threadScope, parent } as const; }); + /** Agent discovery reads files with the platform services this layer was built with. */ + const provideAgentFiles = ( + effect: Effect.Effect, + ) => + effect.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + + /** Agent definitions are project configuration and survive thread worktree creation. */ + const agentProjectRoot = Effect.fn("agentProjectRoot")(function* ( + parent: Pick | undefined, + ) { + if (parent === undefined) { + return null; + } + const project = yield* requireProject(parent.thread.projectId); + return project.workspaceRoot; + }); + + /** The agent a delegation names. An unknown id fails before any child task is created. */ + const loadAgentDefinitions = ( + parent: Pick | undefined, + ) => provideAgentFiles(agentProjectRoot(parent).pipe(Effect.flatMap(listAgentDefinitions))); + /** A target project: the one passed, else the calling thread's. */ const resolveProjectTarget = ( parent: Pick | undefined, @@ -1858,6 +1925,19 @@ const make = Effect.gen(function* () { Effect.map((settings) => settings.providerModelPreferences ?? {}), Effect.mapError(() => failure("orchestration_error", "Unable to read server settings.")), ); + const definitions = yield* loadAgentDefinitions(parent); + const leadAgent = + parent?.thread.agentId === undefined + ? undefined + : definitions.find((agent) => agent.id === parent.thread.agentId); + const allowedAgentIds = new Set(leadAgent?.canDelegateTo ?? []); + const agents = definitions + .filter((agent) => + parent?.thread.agentId === undefined + ? true + : leadAgent !== undefined && allowedAgentIds.has(agent.id), + ) + .map(summarizeAgentDefinition); return { parentThreadId: parent?.thread.id ?? null, inheritedProviderInstanceId: parent?.thread.modelSelection.instanceId ?? null, @@ -1896,6 +1976,7 @@ const make = Effect.gen(function* () { constraints: [...constraints], }; }), + agents, features: { appOwnedSubagents: true, asyncPolling: true, @@ -1924,10 +2005,58 @@ const make = Effect.gen(function* () { "Delegated tasks require an active run owned by this MCP provider session.", ); } + const definitions = yield* loadAgentDefinitions(parent); + const leadAgent = parent.thread.agentId + ? definitions.find((candidate) => candidate.id === parent.thread.agentId) + : undefined; + if (parent.thread.agentId !== undefined && leadAgent === undefined) { + return yield* failure( + "invalid_request", + `Custom lead agent ${parent.thread.agentId} is no longer available; delegation is disabled.`, + ); + } + if ( + leadAgent !== undefined && + (input.targetAgentId === undefined || + !leadAgent.canDelegateTo.includes(input.targetAgentId)) + ) { + return yield* failure( + "invalid_request", + `Custom lead agent ${leadAgent.id} may delegate only to agents listed in its canDelegateTo policy.`, + ); + } + const agent = + input.targetAgentId === undefined + ? undefined + : definitions.find((candidate) => candidate.id === input.targetAgentId); + if (input.targetAgentId !== undefined && agent === undefined) { + return yield* failure( + "invalid_request", + `Agent ${input.targetAgentId} was not found in .agents, .github/agents, or AGENTS.md.`, + ); + } const providers = yield* loadProviders; + const agentSelection = + agent === undefined || input.target?.model !== undefined + ? undefined + : resolveModelForAgent( + agent, + providersForTarget(providers, input.target), + parent.thread.modelSelection.instanceId, + ); + if ( + agent?.model !== undefined && + input.target?.model === undefined && + agentSelection === undefined + ) { + return yield* failure( + "invalid_request", + `Custom agent ${agent.id} names a fixed model that is unavailable on the selected target.`, + ); + } const target = yield* resolveTargetRechecking({ parent, - target: input.target, + target: targetWithAgentModel(input.target, agentSelection), providers, }); const runtimeMode = yield* resolveRuntimeMode( @@ -1955,6 +2084,7 @@ const make = Effect.gen(function* () { parentRunId: parentRun.id, parentNodeId: parentRun.rootNodeId, task: taskPrompt(input), + ...(input.targetAgentId === undefined ? {} : { targetAgentId: input.targetAgentId }), ...(input.title === undefined ? {} : { title: input.title }), modelSelection: target.modelSelection, runtimeMode, @@ -2608,4 +2738,6 @@ export const layer: Layer.Layer< | ProjectService.ProjectService | SecretRequests.SecretRequests | ServerSettings.ServerSettingsService + | FileSystem.FileSystem + | Path.Path > = Layer.effect(OrchestratorMcpService, make); diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index a9e2997282ed..9dd1bf21679a 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -581,6 +581,7 @@ it.effect("refuses act-as-caller tools to a client caller", () => McpHttpServer.layerOrchestratorToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), + Layer.provide(NodeServices.layer), Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), Layer.provide(ServerSettings.layerTest()), Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), @@ -620,6 +621,7 @@ it.effect("a caller cannot rewrite a scheduled task that runs above its own mode McpHttpServer.layerOrchestratorToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), + Layer.provide(NodeServices.layer), Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), Layer.provide(ServerSettings.layerTest()), Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), @@ -695,6 +697,7 @@ it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])( McpHttpServer.layerOrchestratorToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), + Layer.provide(NodeServices.layer), Layer.provide( Layer.mock(ThreadManagement.ThreadManagementService)({ getThreadShell: () => @@ -799,6 +802,7 @@ it.effect("discovers workflow stop targets and requires orchestration capability McpHttpServer.layerOrchestratorToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), + Layer.provide(NodeServices.layer), Layer.provide( Layer.mock(ThreadManagement.ThreadManagementService)({ getThreadShell: (id) => Effect.succeed(McpToolAccessTestkit.liveThreadShell(id)), diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 3248618a2de0..6fd8c624a5bd 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -65,7 +65,7 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { export const DelegateTaskTool = Tool.make("delegate_task", { description: - "Needs an agent running inside a T3 thread. Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. For every T3 delegated review round, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. A requested runtimeMode must be one the target provider lists in orchestrator_capabilities runtimeModes; an inherited one steps down to the broadest mode it offers. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", + "Needs an agent running inside a T3 thread. Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. Set targetAgentId to run the child with a named custom persona; that agent's model or tier is selected when the child thread starts and does not switch models during a run. A custom lead's canDelegateTo allowlist is enforced for this T3 delegate_task tool only; provider-native delegation remains controlled by the provider. Agent frontmatter tools policies are unsupported. For every T3 delegated review round, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", parameters: OrchestratorMcpDelegateTaskInput, success: OrchestratorMcpDelegateTaskResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index edb732e400bc..aa09589359ad 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -141,6 +141,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { projectId, title: input.title, modelSelection, + modelSelectionExplicit: true, runtimeMode, interactionMode, workspaceStrategy: input.workspaceStrategy ?? { type: "root" }, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 167ce8d11fff..f0e3067ebbd6 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -136,6 +136,7 @@ const RPC_AGGREGATES = { [WS_METHODS.projectCloneRetry]: "source-control", [WS_METHODS.subscribeProjectClones]: "source-control", [WS_METHODS.projectsListEntries]: "workspace", + [WS_METHODS.agentsList]: "workspace", [WS_METHODS.projectsReadFile]: "workspace", [WS_METHODS.projectsSearchContents]: "workspace", [WS_METHODS.projectsSearchEntries]: "workspace", diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index de38ae792388..7d1dfe627630 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2217,6 +2217,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio title: command.title, providerInstanceId: command.modelSelection.instanceId, modelSelection: command.modelSelection, + ...(command.agentId === undefined ? {} : { agentId: command.agentId }), runtimeMode: command.runtimeMode, interactionMode: command.interactionMode, branch: command.branch, @@ -6619,6 +6620,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio now, createdBy: command.createdBy, creationSource: command.creationSource, + ...(command.targetAgentId === undefined ? {} : { targetAgentId: command.targetAgentId }), }), runtimeMode: command.runtimeMode, interactionMode: command.interactionMode, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 4f10a0512fd5..d1f574bf255e 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1466,6 +1466,7 @@ export function threadShellFromProjection( title: projection.thread.title, providerInstanceId: projection.thread.providerInstanceId, modelSelection: projection.thread.modelSelection, + ...(projection.thread.agentId === undefined ? {} : { agentId: projection.thread.agentId }), runtimeMode: projection.thread.runtimeMode, interactionMode: projection.thread.interactionMode, branch: projection.thread.branch, @@ -1734,6 +1735,7 @@ function shellFromState(input: { title: input.state.thread.title, providerInstanceId: input.state.thread.providerInstanceId, modelSelection: input.state.thread.modelSelection, + ...(input.state.thread.agentId === undefined ? {} : { agentId: input.state.thread.agentId }), runtimeMode: input.state.thread.runtimeMode, interactionMode: input.state.thread.interactionMode, branch: input.state.thread.branch, diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index d6c12fab7ee1..9dbbe3804a2b 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -1,6 +1,9 @@ import { expect, it, vi } from "vite-plus/test"; import { it as effectIt } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodePath from "@effect/platform-node/NodePath"; import { + AgentId, CheckpointScopeId, MessageId, NodeId, @@ -12,6 +15,7 @@ import { RunAttemptId, RunId, ThreadId, + ProviderTurnId, ProjectId, type OrchestrationV2ThreadProjection, OrchestrationV2DomainEvent, @@ -22,6 +26,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as GitWorkflow from "../git/GitWorkflowService.ts"; @@ -92,6 +97,7 @@ it("does not commit running state when inherited background routing cannot be re const layer = ProviderTurnStart.layer.pipe( Layer.provide( Layer.mergeAll( + NodePath.layer, Layer.mock(ContextHandoffService.ContextHandoffServiceV2)({}), Layer.mock(EventSink.EventSinkV2)({ writeIfRunCurrent }), IdAllocator.layer, @@ -155,6 +161,13 @@ it("does not commit running state when inherited background routing cannot be re function makeLocalCommandHarness(input: { readonly text: string; + readonly agentId?: AgentId; + readonly projectWorkspaceRoot?: string; + readonly allowStart?: boolean; + readonly firstRunOrdinal?: number | null; + readonly runOrdinal?: number; + readonly threadWorktreePath?: string | null; + readonly existingProviderTurn?: boolean; readonly previousNativeSession?: boolean; readonly previousMessages?: ReadonlyArray; readonly logoutFailure?: string; @@ -187,7 +200,7 @@ function makeLocalCommandHarness(input: { const run: OrchestrationV2ThreadProjection["runs"][number] = { id: runId, threadId, - ordinal: 2, + ordinal: input.runOrdinal ?? 2, providerInstanceId: newInstanceId, modelSelection: { instanceId: newInstanceId, model: "gpt-5.4" }, providerThreadId, @@ -211,8 +224,8 @@ function makeLocalCommandHarness(input: { nativeThreadRef: null, nativeConversationHeadRef: null, status: "not_loaded", - firstRunOrdinal: 2, - lastRunOrdinal: 2, + firstRunOrdinal: "firstRunOrdinal" in input ? input.firstRunOrdinal : 2, + lastRunOrdinal: input.runOrdinal ?? 2, handoffIds: [], forkedFrom: null, createdAt: now, @@ -237,7 +250,8 @@ function makeLocalCommandHarness(input: { id: threadId, activeProviderThreadId: providerThreadId, branch: null, - worktreePath: null, + worktreePath: input.threadWorktreePath ?? null, + ...(input.agentId === undefined ? {} : { agentId: input.agentId }), } as OrchestrationV2ThreadProjection["thread"], runs: [ ...(input.previousNativeSession @@ -331,7 +345,21 @@ function makeLocalCommandHarness(input: { }, ], providerSessions: [], - providerTurns: [], + providerTurns: input.existingProviderTurn + ? [ + { + id: ProviderTurnId.make("previous-provider-turn"), + providerThreadId, + nodeId: rootNodeId, + runAttemptId: null, + nativeTurnRef: null, + ordinal: 1, + status: "completed", + startedAt: now, + completedAt: now, + }, + ] + : [], contextHandoffs: [], contextTransfers: [], turnItems: [], @@ -414,7 +442,7 @@ function makeLocalCommandHarness(input: { ), ), ) - : input.failReadsAfterRunning === true + : input.failReadsAfterRunning === true || input.allowStart === true ? Effect.succeed({ driver: providerThread.driver, providerSession: { @@ -436,7 +464,7 @@ function makeLocalCommandHarness(input: { const startRootRun = vi.fn< (input: RunExecutionService.RunExecutionServiceV2StartRootRunInput) => Effect.Effect >(() => - input.failReadsAfterRunning === true + input.failReadsAfterRunning === true || input.allowStart === true ? Effect.void : Effect.die("A local command must not start a native turn."), ); @@ -483,6 +511,10 @@ function makeLocalCommandHarness(input: { return { committed, storedEvents: [] }; }), ); + const filesystemLayer = + input.agentId === undefined + ? Layer.merge(NodePath.layer, FileSystem.layerNoop({})) + : NodeServices.layer; const layer = ProviderTurnStart.layer.pipe( Layer.provide( Layer.mergeAll( @@ -491,9 +523,16 @@ function makeLocalCommandHarness(input: { }), Layer.mock(EventSink.EventSinkV2)({ writeIfRunCurrent }), IdAllocator.layer, - FileSystem.layerNoop({}), + filesystemLayer, Layer.mock(GitWorkflow.GitWorkflowService)({}), - Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(ProjectService.ProjectService)({ + getById: () => + Effect.succeed( + Option.some({ + workspaceRoot: input.projectWorkspaceRoot ?? "/tmp/provider-turn-start-project", + } as never), + ), + }), Layer.mock(ProjectionStore.ProjectionStoreV2)({ getTurnStartContext: () => Effect.succeed({ @@ -553,6 +592,96 @@ function makeLocalCommandHarness(input: { }; } +effectIt.effect("loads custom agent instructions from the project root after worktree setup", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agent-turn-" }); + const worktreePath = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agent-worktree-" }); + const agentDirectory = path.join(workspaceRoot, ".agents"); + yield* fs.makeDirectory(agentDirectory, { recursive: true }); + yield* fs.writeFileString( + path.join(agentDirectory, "reviewer.md"), + "---\nid: reviewer\nname: Reviewer\ndescription: Reviews changes.\n---\n\nCheck edge cases before concluding.", + ); + + const agentId = AgentId.make("reviewer"); + const harness = makeLocalCommandHarness({ + text: "Review this project.", + agentId, + projectWorkspaceRoot: workspaceRoot, + threadWorktreePath: worktreePath, + allowStart: true, + }); + + yield* harness.start; + + expect(harness.startRootRun).toHaveBeenCalledOnce(); + expect(harness.startRootRun.mock.calls[0]?.[0]?.message.text).toContain( + "Check edge cases before concluding.", + ); + expect(harness.startRootRun.mock.calls[0]?.[0]?.message.text).toContain("Review this project."); + }).pipe(Effect.provide(NodeServices.layer)), +); + +effectIt.effect("retries instructions after setup failed before any provider turn", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agent-turn-" }); + const agentDirectory = path.join(workspaceRoot, ".agents"); + yield* fs.makeDirectory(agentDirectory, { recursive: true }); + yield* fs.writeFileString( + path.join(agentDirectory, "reviewer.md"), + "---\nid: reviewer\nname: Reviewer\ndescription: Reviews changes.\n---\n\nCheck edge cases before concluding.", + ); + + const harness = makeLocalCommandHarness({ + text: "Continue reviewing.", + agentId: AgentId.make("reviewer"), + projectWorkspaceRoot: workspaceRoot, + firstRunOrdinal: 1, + runOrdinal: 2, + allowStart: true, + }); + + yield* harness.start; + + expect(harness.startRootRun.mock.calls[0]?.[0]?.message.text).toContain( + "Check edge cases before concluding.", + ); + expect(harness.startRootRun.mock.calls[0]?.[0]?.message.text).toContain("Continue reviewing."); + }).pipe(Effect.provide(NodeServices.layer)), +); + +effectIt.effect("does not repeat instructions on an existing provider thread", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agent-turn-" }); + const agentDirectory = path.join(workspaceRoot, ".agents"); + yield* fs.makeDirectory(agentDirectory, { recursive: true }); + yield* fs.writeFileString( + path.join(agentDirectory, "reviewer.md"), + "---\nid: reviewer\nname: Reviewer\ndescription: Reviews changes.\n---\n\nCheck edge cases before concluding.", + ); + + const harness = makeLocalCommandHarness({ + text: "Continue reviewing.", + agentId: AgentId.make("reviewer"), + projectWorkspaceRoot: workspaceRoot, + firstRunOrdinal: 1, + runOrdinal: 2, + existingProviderTurn: true, + allowStart: true, + }); + + yield* harness.start; + + expect(harness.startRootRun.mock.calls[0]?.[0]?.message.text).toBe("Continue reviewing."); + }).pipe(Effect.provide(NodeServices.layer)), +); + effectIt.effect("terminalizes a starting run when its provider session cannot open", () => Effect.gen(function* () { const harness = makeLocalCommandHarness({ diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 684bde2c0669..97ba4e72fd9d 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -19,6 +19,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; @@ -44,6 +45,7 @@ import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import * as RunExecutionService from "./RunExecutionService.ts"; import * as RuntimePolicy from "./RuntimePolicy.ts"; +import { getAgentDefinition } from "../agent/AgentService.ts"; import { isRestartNoteContinuation, pendingRestartCancelledBackgroundWork, @@ -90,6 +92,7 @@ export const layer: Layer.Layer< | ContextHandoffService.ContextHandoffServiceV2 | IdAllocator.IdAllocatorV2 | FileSystem.FileSystem + | Path.Path | GitWorkflowService.GitWorkflowService | ProjectService.ProjectService | ProviderAuthService.ProviderAuthService @@ -104,6 +107,7 @@ export const layer: Layer.Layer< const contextHandoffService = yield* ContextHandoffService.ContextHandoffServiceV2; const idAllocator = yield* IdAllocator.IdAllocatorV2; const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; const projects = yield* ProjectService.ProjectService; const providerAuth = yield* ProviderAuthService.ProviderAuthService; @@ -947,12 +951,37 @@ export const layer: Layer.Layer< const routableSubagents = projection.subagents.filter((subagent) => RunExecutionService.canRouteRelatedSubagent(subagent.status), ); + let agentInstructions: string | undefined; + const providerAlreadyReceivedTurn = projection.providerTurns.some( + (turn) => turn.providerThreadId === providerThread.id, + ); + if (projection.thread.agentId !== undefined && !providerAlreadyReceivedTurn) { + const project = yield* projects.getById(projection.thread.projectId); + const projectRoot = Option.isSome(project) ? project.value.workspaceRoot : null; + const definition = yield* getAgentDefinition(projection.thread.agentId, projectRoot).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + if (Option.isSome(definition)) { + agentInstructions = definition.value.instructions; + } else { + yield* Effect.logWarning("thread custom agent definition is no longer available", { + agentId: projection.thread.agentId, + threadId: projection.thread.id, + }); + } + } const userText = projectComposerContextForProvider({ - text: message.text, + text: + agentInstructions === undefined + ? message.text + : `Follow these custom agent instructions for this T3 thread:\n\n${agentInstructions}\n\n---\n\n${message.text}`, records: message.context?.records ?? [], }); - // Delivered once: this run's provider turn marks the work as told. A - // restart continuation is prompted by its own text or resumes natively. + // A provider turn is the durable proof that the instructions were sent. + // If setup failed before that event (or the first attempt was cancelled + // before delivery), the next attempt sends them again. Native resumes and + // compaction already have a provider turn and do not repeat the preamble. const noteContinuation = isRestartNoteContinuation( run, projection.runs, diff --git a/apps/server/src/orchestration-v2/SubagentProjection.test.ts b/apps/server/src/orchestration-v2/SubagentProjection.test.ts index 65b64000b470..ea432ff6bd6b 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.test.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.test.ts @@ -1,5 +1,6 @@ import { assert, it } from "@effect/vitest"; import { + AgentId, type ModelSelection, NodeId, RunId, @@ -117,6 +118,44 @@ it("keeps a subagent child awake when its parent thread is snoozed", () => { }); }); +it("does not inherit a custom agent for a provider-native child", () => { + const parentThread = { ...makeParentThread(), agentId: AgentId.make("lead") }; + const child = makeSubagentChildThread({ + parentThread, + childThreadId, + parentNodeId: NodeId.make("node-native-agent-child"), + activeProviderThreadId: null, + providerInstanceId: childProviderInstanceId, + modelSelection: childModelSelection, + title: "Native child", + now: childCreatedAt, + createdBy: "agent", + creationSource: "provider", + }); + + assert.isUndefined(child.agentId); +}); + +it("applies a separately selected named agent to an app-owned child", () => { + const parentThread = { ...makeParentThread(), agentId: AgentId.make("lead") }; + const targetAgentId = AgentId.make("reviewer"); + const child = makeSubagentChildThread({ + parentThread, + childThreadId, + parentNodeId: NodeId.make("node-named-agent-child"), + activeProviderThreadId: null, + providerInstanceId: childProviderInstanceId, + modelSelection: childModelSelection, + title: "Named child", + now: childCreatedAt, + createdBy: "agent", + creationSource: "provider", + targetAgentId, + }); + + assert.equal(child.agentId, targetAgentId); +}); + it("attributes native subagent prompts to their parent thread", () => { for (const role of ["user", "assistant"] as const) { const artifacts = makeSubagentConversationArtifacts({ diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index f7eede36b8e4..8f0aecd51881 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -15,6 +15,8 @@ import { ComposerContextId, type ChatAttachment, CommandId, + AgentId, + type ModelSelection, DEFAULT_SERVER_SETTINGS, GitCommandError, MessageId, @@ -34,6 +36,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as Schema from "effect/Schema"; @@ -108,9 +111,14 @@ interface HarnessOptions { readonly serverSettings?: Parameters[0]; readonly providers?: ReadonlyArray; readonly git?: Partial; + readonly workspaceRoot?: string; } function makeHarness(options: HarnessOptions = {}) { + const configuredProject = { + ...project, + workspaceRoot: options.workspaceRoot ?? project.workspaceRoot, + }; const layerDatabase = SqlitePersistence.layerMemory; const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([adapter]); const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( @@ -145,6 +153,7 @@ function makeHarness(options: HarnessOptions = {}) { options.generateTitle ?? (() => Effect.succeed({ title: "Generated title" })), ); const layerExternalServices = Layer.mergeAll( + NodeServices.layer, WorktreeSetupTracker.layer, Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({ get: () => Effect.succeed(null) }), Layer.mock(TerminalManager.TerminalManager)({ close: () => Effect.void }), @@ -156,15 +165,16 @@ function makeHarness(options: HarnessOptions = {}) { getById: (id) => Effect.succeed( id === projectId - ? Option.some(project) + ? Option.some(configuredProject) : id === otherProjectId ? Option.some(otherProject) : Option.none(), ), - getByWorkspaceRoot: () => Effect.succeed(Option.some(project)), + getByWorkspaceRoot: () => Effect.succeed(Option.some(configuredProject)), snapshot: Effect.die("unused"), getShell: () => Effect.die("unused"), listShells: () => Effect.die("unused"), + listAgents: () => Effect.die("unused"), }), Layer.mock(GitWorkflow.GitWorkflowService)({ createWorktree, @@ -210,16 +220,16 @@ function makeHarness(options: HarnessOptions = {}) { ? Option.some({ projectId, title: project.title, - workspaceRoot: project.workspaceRoot, - defaultModelSelection: project.defaultModelSelection, + workspaceRoot: configuredProject.workspaceRoot, + defaultModelSelection: configuredProject.defaultModelSelection, defaultThreadEnvMode: null, autoPull: false, faviconPath: null, projectIcon: null, scripts: project.scripts, - createdAt: project.createdAt, - updatedAt: project.updatedAt, - deletedAt: project.deletedAt, + createdAt: configuredProject.createdAt, + updatedAt: configuredProject.updatedAt, + deletedAt: configuredProject.deletedAt, }) : Option.none(), ), @@ -252,13 +262,22 @@ function launchInput(input: { readonly thread: string; readonly message?: string; readonly workspace?: ThreadLaunch.ThreadLaunchWorkspaceStrategy; + readonly modelSelection?: ModelSelection; + readonly modelSelectionExplicit?: boolean; + readonly agentId?: AgentId | null; + readonly creationSource?: ThreadLaunch.ThreadLaunchInput["creationSource"]; + readonly scheduledTaskId?: ScheduledTaskId; }) { return { commandId: CommandId.make(input.command), threadId: ThreadId.make(input.thread), projectId, title: "New thread", - modelSelection, + modelSelection: input.modelSelection ?? modelSelection, + ...(input.agentId === undefined ? {} : { agentId: input.agentId }), + ...(input.modelSelectionExplicit === undefined + ? {} + : { modelSelectionExplicit: input.modelSelectionExplicit }), runtimeMode: "full-access" as const, interactionMode: "default" as const, workspaceStrategy: input.workspace ?? { type: "root" as const }, @@ -267,12 +286,15 @@ function launchInput(input: { : { initialMessage: { messageId: MessageId.make(`${input.message}:id`), + ...(input.scheduledTaskId === undefined + ? {} + : { scheduledTaskId: input.scheduledTaskId }), text: input.message, attachments: [], }, }), createdBy: "user" as const, - creationSource: "web" as const, + creationSource: input.creationSource ?? ("web" as const), }; } @@ -291,6 +313,223 @@ function waitUntil(predicate: () => Effect.Effect): Effect. }); } +it.effect("applies the project default to new threads and preserves scheduled/mobile models", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agent-launch-" }); + const agentId = AgentId.make("lead"); + const agentDirectory = path.join(workspaceRoot, ".agents"); + yield* fs.makeDirectory(agentDirectory, { recursive: true }); + yield* fs.writeFileString( + path.join(agentDirectory, "lead.md"), + "---\nid: lead\nname: Lead\ndescription: Coordinates work.\ntier: economy\n---\n\nCoordinate the task.", + ); + const harness = makeHarness({ + workspaceRoot, + providers: [ + { + instanceId: modelSelection.instanceId, + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "test", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-10-10T00:00:00.000Z", + models: [{ slug: "gpt-6-luna", name: "GPT-6 Luna", isCustom: false, capabilities: null }], + slashCommands: [], + skills: [], + }, + ], + serverSettings: { + projectSettingsOverrides: { + [projectId]: { defaultAgentId: agentId }, + }, + }, + }); + return yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const result = yield* launches.launch( + launchInput({ command: "command:default-agent", thread: "thread:default-agent" }), + ); + assert.equal(result.projection.thread.agentId, agentId); + assert.equal(result.projection.thread.modelSelection.model, "gpt-6-luna"); + + const scheduledModel = { instanceId: modelSelection.instanceId, model: "gpt-5.1-codex" }; + const scheduled = yield* launches.launch( + launchInput({ + command: "command:default-agent:scheduled", + thread: "thread:default-agent:scheduled", + message: "Run the scheduled review.", + modelSelection: scheduledModel, + modelSelectionExplicit: true, + creationSource: "server", + scheduledTaskId: ScheduledTaskId.make("scheduled-task:review"), + }), + ); + assert.equal(scheduled.projection.thread.agentId, agentId); + assert.deepEqual(scheduled.projection.thread.modelSelection, scheduledModel); + + for (const creationSource of ["mobile"] as const) { + const explicitLaunch = yield* launches.launch({ + ...launchInput({ + command: `command:default-agent:${creationSource}`, + thread: `thread:default-agent:${creationSource}`, + modelSelection: scheduledModel, + modelSelectionExplicit: true, + }), + creationSource, + }); + assert.equal(explicitLaunch.projection.thread.agentId, agentId); + assert.deepEqual(explicitLaunch.projection.thread.modelSelection, scheduledModel); + } + }).pipe(Effect.provide(harness.layer)); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("lets a new draft explicitly opt out of the project default agent", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agent-opt-out-" }); + const agentDirectory = path.join(workspaceRoot, ".agents"); + yield* fs.makeDirectory(agentDirectory, { recursive: true }); + yield* fs.writeFileString( + path.join(agentDirectory, "lead.md"), + "---\nid: lead\nname: Lead\ndescription: Coordinates work.\n---\n\nCoordinate the task.", + ); + const harness = makeHarness({ + workspaceRoot, + serverSettings: { + projectSettingsOverrides: { + [projectId]: { defaultAgentId: AgentId.make("lead") }, + }, + }, + }); + + return yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const result = yield* launches.launch( + launchInput({ + command: "command:agent-opt-out", + thread: "thread:agent-opt-out", + agentId: null, + }), + ); + assert.isUndefined(result.projection.thread.agentId); + }).pipe(Effect.provide(harness.layer)); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("does not attach a project default agent to an imported provider session", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agent-import-" }); + const agentDirectory = path.join(workspaceRoot, ".agents"); + yield* fs.makeDirectory(agentDirectory, { recursive: true }); + yield* fs.writeFileString( + path.join(agentDirectory, "lead.md"), + "---\nid: lead\nname: Lead\ndescription: Coordinates work.\n---\n\nCoordinate the task.", + ); + const harness = makeHarness({ + workspaceRoot, + serverSettings: { + projectSettingsOverrides: { + [projectId]: { defaultAgentId: AgentId.make("lead") }, + }, + }, + }); + + return yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const result = yield* launches.launch({ + ...launchInput({ command: "command:agent-import", thread: "thread:agent-import" }), + importedNativeThread: { + ref: { + driver: ProviderDriverKind.make("codex"), + nativeId: "native-imported-session", + strength: "strong", + }, + }, + }); + assert.isUndefined(result.projection.thread.agentId); + }).pipe(Effect.provide(harness.layer)); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("uses the custom agent model unless the composer explicitly selected one", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agent-model-" }); + const agentDirectory = path.join(workspaceRoot, ".agents"); + yield* fs.makeDirectory(agentDirectory, { recursive: true }); + yield* fs.writeFileString( + path.join(agentDirectory, "reasoner.md"), + "---\nid: reasoner\nname: Reasoner\ndescription: Reasons carefully.\ntier: reasoning\n---\n\nThink carefully.", + ); + yield* fs.writeFileString( + path.join(agentDirectory, "inheritor.md"), + "---\nid: inheritor\nname: Inheritor\ndescription: Inherits the selected model.\n---\n\nUse the selected model.", + ); + const agentId = AgentId.make("reasoner"); + const provider: ServerProvider = { + instanceId: modelSelection.instanceId, + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "test", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-10-10T00:00:00.000Z", + models: [{ slug: "gpt-6-astra", name: "GPT 6 Astra", isCustom: false, capabilities: null }], + slashCommands: [], + skills: [], + }; + const harness = makeHarness({ workspaceRoot, providers: [provider] }); + + return yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const defaulted = yield* launches.launch( + launchInput({ + command: "command:agent-model-default", + thread: "thread:agent-model-default", + agentId, + }), + ); + assert.deepEqual(defaulted.projection.thread.modelSelection, { + instanceId: modelSelection.instanceId, + model: "gpt-6-astra", + }); + + const explicitModel = { instanceId: modelSelection.instanceId, model: "gpt-5.1-codex" }; + const explicitlySelected = yield* launches.launch( + launchInput({ + command: "command:agent-model-explicit", + thread: "thread:agent-model-explicit", + agentId, + modelSelection: explicitModel, + modelSelectionExplicit: true, + }), + ); + assert.deepEqual(explicitlySelected.projection.thread.modelSelection, explicitModel); + + const inheritedSelection = { instanceId: modelSelection.instanceId, model: "gpt-5.1-codex" }; + const tierless = yield* launches.launch( + launchInput({ + command: "command:agent-tierless-inherits-model", + thread: "thread:agent-tierless-inherits-model", + agentId: AgentId.make("inheritor"), + modelSelection: inheritedSelection, + }), + ); + assert.deepEqual(tierless.projection.thread.modelSelection, inheritedSelection); + }).pipe(Effect.provide(harness.layer)); + }).pipe(Effect.provide(NodeServices.layer)), +); + it.effect.each( (["new", "existing"] as const).flatMap((target) => (["user", "agent"] as const).map((createdBy) => ({ target, createdBy })), @@ -1765,6 +2004,63 @@ it.effect("replays a server-allocated launch", () => }), ); +it.effect("uses the persisted thread model when resuming after an accepted create", () => { + const harness = makeHarness({ + providers: [ + { + instanceId: modelSelection.instanceId, + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "test", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-10-10T00:00:00.000Z", + models: [ + { slug: "gpt-5.1-codex", name: "GPT-5.1 Codex", isCustom: false, capabilities: null }, + { slug: "gpt-6-luna", name: "GPT-6 Luna", isCustom: false, capabilities: null }, + ], + slashCommands: [], + skills: [], + }, + ], + }); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const input = launchInput({ + command: "command:launch:model-retry", + thread: "thread:launch:model-retry", + message: "Continue the accepted launch", + }); + const persistedModelSelection = { + instanceId: modelSelection.instanceId, + model: "gpt-6-luna", + } as const; + + // Simulate a crash after create committed but before the initial message did. + yield* threads.dispatch({ + type: "thread.create", + commandId: input.commandId, + threadId: input.threadId, + projectId: input.projectId, + title: input.title, + modelSelection: persistedModelSelection, + agentId: AgentId.make("lead"), + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + branch: null, + worktreePath: null, + createdBy: input.createdBy, + creationSource: input.creationSource, + }); + + const resumed = yield* launches.launch(input); + assert.deepEqual(resumed.projection.thread.modelSelection, persistedModelSelection); + assert.deepEqual(resumed.projection.runs[0]?.modelSelection, persistedModelSelection); + }).pipe(Effect.provide(harness.layer)); +}); + it.effect("rejects a server-allocated launch replay with a mismatching thread id", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index d1a432bc9b52..0c4c7dc37905 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -4,6 +4,7 @@ import * as TerminalManager from "../terminal/Manager.ts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { CommandId, + type AgentId, type ChatAttachment, type MessageId, type ModelSelection, @@ -17,14 +18,17 @@ import { type RunId, type RuntimeMode, type ScheduledTaskId, + type PlanId, ThreadId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -48,6 +52,7 @@ import type * as Orchestrator from "./Orchestrator.ts"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid"; import * as ThreadManagement from "./ThreadManagementService.ts"; +import { getAgentDefinition, resolveModelForAgent } from "../agent/AgentService.ts"; export type ThreadLaunchWorkspaceStrategy = | { readonly type: "root"; readonly branch?: string | undefined } @@ -70,6 +75,7 @@ export interface ThreadLaunchInitialMessage { readonly text: string; readonly attachments: ReadonlyArray; readonly context?: import("@t3tools/contracts").OrchestrationMessageContext | undefined; + readonly sourcePlanRef?: { readonly threadId: ThreadId; readonly planId: PlanId } | undefined; } export interface ThreadLaunchInput { @@ -80,6 +86,9 @@ export interface ThreadLaunchInput { readonly title: string; readonly generateTitle?: boolean; readonly modelSelection: ModelSelection; + /** Omitted inherits the project default; null explicitly opts out. */ + readonly agentId?: AgentId | null; + readonly modelSelectionExplicit?: boolean; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; readonly workspaceStrategy: ThreadLaunchWorkspaceStrategy; @@ -203,6 +212,8 @@ const make = Effect.gen(function* () { const setupScripts = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const serverSettings = yield* ServerSettings.ServerSettingsService; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const textGeneration = yield* TextGeneration.TextGeneration; const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2; const ids = yield* IdAllocator.IdAllocatorV2; @@ -762,20 +773,88 @@ const make = Effect.gen(function* () { "update-thread", )("Reusing an existing thread requires a thread id."); } - const launchReceipt = yield* readReceipt(input, input.commandId); + const acceptedCreateReceipt = + Option.isSome(launchReceipt) && + launchReceipt.value.status === "accepted" && + launchReceipt.value.commandType === "thread.create" + ? launchReceipt.value + : undefined; + + // Imported native sessions retain their original provider persona. A + // project default applies to new T3-created threads, not imported ones. + let agentId = + input.importedNativeThread === undefined ? (input.agentId ?? undefined) : undefined; + let modelSelection = input.modelSelection; + if (input.reuseExistingThread !== true && Option.isNone(launchReceipt)) { + if (input.agentId === undefined && input.importedNativeThread === undefined) { + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError(mapError(input, "resolve-project")), + ); + agentId = + resolveProjectSettings(settings, input.projectId).settings.defaultAgentId ?? undefined; + } + if (agentId !== undefined) { + const agentOption = yield* getAgentDefinition(agentId, project.workspaceRoot).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + if (Option.isNone(agentOption)) { + if (input.agentId !== undefined) { + return yield* mapError( + input, + "resolve-project", + )(`Custom agent '${agentId}' was not found in the project workspace.`); + } + yield* Effect.logWarning( + "default custom agent was not found in the project workspace", + { + agentId, + projectId: input.projectId, + }, + ); + agentId = undefined; + } else { + const providers = yield* providerRegistry.getProviders; + const agent = agentOption.value; + const agentModelSelection = resolveModelForAgent( + agent, + providers, + input.modelSelection.instanceId, + ); + if ( + !input.modelSelectionExplicit && + agent.model !== undefined && + (agentModelSelection === undefined || + !providers.some( + (provider) => + provider.instanceId === agent.model?.instanceId && + provider.models.some((candidate) => candidate.slug === agent.model?.model), + )) + ) { + return yield* mapError( + input, + "resolve-project", + )( + `Custom agent '${agent.id}' names a model that is unavailable on this environment.`, + ); + } + // Explicit caller selections win. Otherwise the agent controls the + // model only when its configured model or tier maps to an available provider. + if (!input.modelSelectionExplicit) { + modelSelection = agentModelSelection ?? modelSelection; + } + } + } + } + return yield* Effect.gen(function* () { // A retried launch has no client-supplied id to replay against, so // recover the thread id its accepted create was recorded under before // allocating another one; a fresh id would only collide with the // recorded receipt. const reusableLaunchReceipt = - input.threadId === undefined && - Option.isSome(launchReceipt) && - launchReceipt.value.status === "accepted" && - launchReceipt.value.commandType === "thread.create" - ? launchReceipt.value - : undefined; + input.threadId === undefined ? acceptedCreateReceipt : undefined; const candidateThreadId = input.threadId ?? reusableLaunchReceipt?.threadId ?? @@ -783,7 +862,7 @@ const make = Effect.gen(function* () { .thread({ projectId: input.projectId }) .pipe(Effect.mapError(mapError(input, "create-thread")))); - if (reusableLaunchReceipt !== undefined) { + if (acceptedCreateReceipt?.threadId === candidateThreadId) { const shell = yield* threads .getThreadShell(candidateThreadId) .pipe(Effect.mapError(mapError(input, "create-thread", candidateThreadId))); @@ -797,6 +876,9 @@ const make = Effect.gen(function* () { candidateThreadId, )("Project identity changed."); } + // Once the create receipt is durable, a retry must dispatch its first + // message with the model that was actually saved on the thread. + modelSelection = shell.modelSelection; } if (input.reuseExistingThread === true && Option.isNone(launchReceipt)) { @@ -838,7 +920,8 @@ const make = Effect.gen(function* () { threadId: candidateThreadId, projectId: input.projectId, title: input.title, - modelSelection: input.modelSelection, + modelSelection, + ...(agentId === undefined ? {} : { agentId }), runtimeMode: input.runtimeMode, interactionMode: input.interactionMode, branch: initialBranch, @@ -891,8 +974,11 @@ const make = Effect.gen(function* () { : { senderThreadId: input.initialMessage.senderThreadId }), attachments: input.initialMessage.attachments, ...(input.initialMessage.context ? { context: input.initialMessage.context } : {}), + ...(input.initialMessage.sourcePlanRef === undefined + ? {} + : { sourcePlanRef: input.initialMessage.sourcePlanRef }), ...(input.generateTitle === true ? { titleSeed: input.title } : {}), - modelSelection: input.modelSelection, + modelSelection, dispatchMode: { type: "defer_start", workspaceStrategy }, createdBy: input.createdBy, creationSource: input.creationSource, diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts index 81dd25847799..2b24cf054f72 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts @@ -4,9 +4,14 @@ import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { + AgentId, ChatAttachmentId, CommandId, EventId, + NodeId, + ProjectId, + ProviderInstanceId, + RunId, RuntimeRequestId, ThreadId, TurnItemId, @@ -17,9 +22,14 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import { createPendingAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts"; +import * as AgentService from "../agent/AgentService.ts"; import * as ServerConfig from "../config.ts"; +import * as ProjectService from "../project/ProjectService.ts"; import { OrchestratorCommandPreviouslyRejectedError, OrchestratorDispatchError, @@ -27,9 +37,14 @@ import { import * as ThreadManagementService from "./ThreadManagementService.ts"; import { dispatchCommand } from "./ThreadMessageIntake.ts"; -const layerIntakeTest = ServerConfig.layerTest(process.cwd(), { - prefix: "t3-question-intake-", -}).pipe(Layer.provideMerge(NodeServices.layer)); +const layerIntakeTest = Layer.mergeAll( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-question-intake-" }).pipe( + Layer.provideMerge(NodeServices.layer), + ), + Layer.mock(ProjectService.ProjectService)({ + getById: () => Effect.succeed(Option.none()), + }), +); const layerFailingDispatch = (captured: OrchestrationV2ServerCommand[]) => Layer.mock(ThreadManagementService.ThreadManagementService)({ @@ -764,3 +779,89 @@ it.effect("applies the image budget across all questions before dispatch", () => expect(captured).toEqual([]); }).pipe(Effect.provide(layerIntakeTest)), ); + +it.effect("enforces a custom lead allowlist for direct delegated-task commands", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-delegation-policy-", + }); + const filesystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const agentDirectory = path.join(workspaceRoot, ".agents"); + yield* filesystem.makeDirectory(agentDirectory, { recursive: true }); + yield* filesystem.writeFileString( + path.join(agentDirectory, "lead.md"), + "---\nid: lead\nname: Lead\ndescription: Coordinates work.\ncanDelegateTo: [reviewer]\n---\nCoordinate the task.", + ); + yield* filesystem.writeFileString( + path.join(agentDirectory, "reviewer.md"), + "---\nid: reviewer\nname: Reviewer\ndescription: Reviews work.\n---\nReview the task.", + ); + yield* filesystem.writeFileString( + path.join(agentDirectory, "researcher.md"), + "---\nid: researcher\nname: Researcher\ndescription: Researches work.\n---\nResearch the task.", + ); + const definitions = yield* AgentService.listAgentDefinitions(workspaceRoot); + expect(definitions.map((agent) => agent.id)).toEqual([ + AgentId.make("lead"), + AgentId.make("researcher"), + AgentId.make("reviewer"), + ]); + expect(definitions[0]?.canDelegateTo).toEqual([AgentId.make("reviewer")]); + + const parentThreadId = ThreadId.make("thread:delegation-policy"); + const projectId = ProjectId.make("project:delegation-policy"); + const leadAgentId = AgentId.make("lead"); + const dispatched: OrchestrationV2ServerCommand[] = []; + const layer = Layer.mergeAll( + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: () => + Effect.succeed({ + id: parentThreadId, + projectId, + agentId: leadAgentId, + } as never), + dispatch: (command) => { + dispatched.push(command); + return Effect.succeed({ sequence: 1, storedEvents: [] }); + }, + }), + Layer.mock(ProjectService.ProjectService)({ + getById: () => Effect.succeed(Option.some({ id: projectId, workspaceRoot } as never)), + }), + ); + const parent = yield* ThreadManagementService.ThreadManagementService.pipe( + Effect.flatMap((threads) => threads.getThreadShell(parentThreadId)), + Effect.provide(layer), + ); + expect(parent?.agentId).toBe(leadAgentId); + + for (const targetAgentId of [undefined, AgentId.make("researcher")] as const) { + const result = yield* Effect.exit( + dispatchCommand({ + type: "delegated_task.request", + commandId: CommandId.make( + targetAgentId === undefined ? "delegate-unnamed" : "delegate-unauthorized", + ), + parentThreadId, + parentRunId: RunId.make("run:delegation-policy"), + parentNodeId: NodeId.make("node:delegation-policy"), + task: "Review the change", + ...(targetAgentId === undefined ? {} : { targetAgentId }), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "test-model" }, + runtimeMode: "full-access", + interactionMode: "default", + createdBy: "agent", + creationSource: "mcp", + }).pipe(Effect.provide(layer)), + ); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(String(result.cause)).toContain("canDelegateTo"); + } + } + + expect(dispatched).toEqual([]); + }).pipe(Effect.provide(layerIntakeTest)), +); diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts index c8f65fd0340a..16281fce48a3 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts @@ -4,12 +4,15 @@ import type { ChatAttachment, OrchestrationV2Command } from "@t3tools/contracts" import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { resolveAttachmentPath } from "../attachmentStore.ts"; +import * as AgentService from "../agent/AgentService.ts"; import * as ServerConfig from "../config.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as AttachmentClaims from "./AttachmentClaims.ts"; +import * as ProjectService from "../project/ProjectService.ts"; import * as ThreadLaunch from "./ThreadLaunchService.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; +import * as Option from "effect/Option"; // These dispatcher failures occur in receipt validation or planning, before // commitCommand. Generic dispatch errors can follow a commit and remain uncertain. @@ -50,9 +53,64 @@ const releaseUnusedClaims = Effect.fn("ThreadMessageIntake.releaseUnusedClaims") ); }); +const validateDelegatedTaskAgent = Effect.fn("ThreadMessageIntake.validateDelegatedTaskAgent")( + function* ( + command: Extract, + ) { + const threads = yield* ThreadManagement.ThreadManagementService; + const parent = yield* threads.getThreadShell(command.parentThreadId); + if (parent === null) return; + + const projects = yield* ProjectService.ProjectService; + const project = yield* projects.getById(parent.projectId); + const definitions = yield* AgentService.listAgentDefinitions( + Option.getOrUndefined(project)?.workspaceRoot ?? null, + ); + const leadAgent = + parent.agentId === undefined + ? undefined + : definitions.find((candidate) => candidate.id === parent.agentId); + const targetAgent = + command.targetAgentId === undefined + ? undefined + : definitions.find((candidate) => candidate.id === command.targetAgentId); + const reject = (cause: string) => + Effect.fail( + new Orchestrator.OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause, + }), + ); + + if (parent.agentId !== undefined && leadAgent === undefined) { + return yield* reject( + `Custom lead agent ${parent.agentId} is no longer available; delegation is disabled.`, + ); + } + if ( + leadAgent !== undefined && + (command.targetAgentId === undefined || + !leadAgent.canDelegateTo.includes(command.targetAgentId)) + ) { + return yield* reject( + `Custom lead agent ${leadAgent.id} may delegate only to agents listed in its canDelegateTo policy.`, + ); + } + if (command.targetAgentId !== undefined && targetAgent === undefined) { + return yield* reject( + `Agent ${command.targetAgentId} was not found in .agents, .github/agents, or AGENTS.md.`, + ); + } + }, +); + export const dispatchCommand = Effect.fn("ThreadMessageIntake.dispatchCommand")(function* ( command: OrchestrationV2Command, ) { + if (command.type === "delegated_task.request") { + yield* validateDelegatedTaskAgent(command); + } const threads = yield* ThreadManagement.ThreadManagementService; if (command.type === "runtime-request.respond" && command.attachmentsByQuestionId) { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index 09d4f8229771..be73a8cf40b7 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -80,7 +80,7 @@ const layerProjectServiceDependencies = Layer.mergeAll( Layer.provideMerge(layerMetadata), Layer.provideMerge(SqlitePersistence.layerMemory), Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-race-" })), - Layer.provide(NodeServices.layer), + Layer.provideMerge(NodeServices.layer), ); const waitForProject = Effect.fn("ProjectServiceTest.waitForProject")(function* ( diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index f37b4e383061..66f9a1911357 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -1,5 +1,8 @@ import { CommandId, + AgentListError, + AgentListInput, + type AgentSummary, type OrchestrationProjectShell, ProjectId, type Project, @@ -12,8 +15,10 @@ import * as KeyedLock from "@t3tools/shared/KeyedLock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -32,6 +37,7 @@ import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecuto import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts"; import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import { listAgentSummaries } from "../agent/AgentService.ts"; export interface ProjectCreateInput extends ProjectCreatePayload { readonly commandId: CommandId; @@ -129,6 +135,9 @@ export class ProjectService extends Context.Service< workspaceRoot: string, options?: { readonly includeDeleted?: boolean }, ) => Effect.Effect, ProjectOperationError>; + readonly listAgents: ( + input: AgentListInput, + ) => Effect.Effect, AgentListError>; readonly snapshot: Effect.Effect; /** * An active project's shell with its immediately available repository @@ -148,6 +157,8 @@ export const make = Effect.gen(function* () { const projects = yield* ProjectStore.ProjectStoreV2; const projectEnrichment = yield* ProjectEnrichmentService.ProjectEnrichmentService; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const threadProjections = yield* ProjectionStore.ProjectionStoreV2; const eventSink = yield* EventSink.EventSinkV2; const idAllocator = yield* IdAllocator.IdAllocatorV2; @@ -334,6 +345,34 @@ export const make = Effect.gen(function* () { return Option.isNone(row) ? Option.none() : Option.some(yield* hydrate(row.value)); }); + const listAgents: ProjectService["Service"]["listAgents"] = Effect.fn( + "ProjectService.listAgents", + )(function* (input) { + const project = yield* getById(input.projectId).pipe( + Effect.mapError((cause) => new AgentListError({ projectId: input.projectId, cause })), + ); + if (Option.isNone(project)) { + return yield* new AgentListError({ projectId: input.projectId, cause: "Project not found." }); + } + if (input.threadId !== undefined) { + const shell = yield* threadProjections + .getThreadShell(input.threadId) + .pipe( + Effect.mapError((cause) => new AgentListError({ projectId: input.projectId, cause })), + ); + if (shell === null || shell.deletedAt !== null || shell.projectId !== input.projectId) { + return yield* new AgentListError({ + projectId: input.projectId, + cause: "The requested thread does not belong to this active project.", + }); + } + } + return yield* listAgentSummaries(project.value.workspaceRoot).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + }); + const create: ProjectService["Service"]["create"] = Effect.fn("ProjectService.create")( function* (input) { const workspaceRoot = yield* normalizeWorkspaceRoot({ @@ -561,6 +600,7 @@ export const make = Effect.gen(function* () { delete: deleteProject, getById, getByWorkspaceRoot, + listAgents, snapshot, getShell, listShells, diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index bc26ef7f021a..81e39b3bc35e 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -262,6 +262,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( update: unused, delete: unused, getByWorkspaceRoot: unused, + listAgents: unused, snapshot: Effect.succeed({ projects: [] } as never), getShell: unused, listShells: unused, diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 51403c124a98..65d6c5e74518 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -800,6 +800,7 @@ export const layer = Layer.effect( projectId: active.projectId, title: active.title, modelSelection: active.modelSelection, + modelSelectionExplicit: true, runtimeMode: active.runtimeMode, interactionMode: active.interactionMode, workspaceStrategy: active.workspaceStrategy, diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 88aa4298c596..82b1806ba9c3 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -292,6 +292,7 @@ const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { modelSelection: resolveProjectSettings(settings, project.id, project).settings.defaultModelSelection ?? threadModelSelection, + modelSelectionExplicit: true, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: resolveProjectSettings(settings, project.id, project).settings .defaultRuntimeMode, diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 0f735efd31c7..78e13c8c3723 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -1,8 +1,10 @@ import { assert, it } from "@effect/vitest"; import { ORCHESTRATION_PROTOCOL_VERSION, + PlanId, type ServerConfig, type ServerConfigStreamEvent, + ThreadId, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ConfigProvider from "effect/ConfigProvider"; @@ -23,6 +25,7 @@ import { hasCompatibleOrchestrationProtocol, resolveAvailableEditorsForConfig, shouldUseBoundedThreadSnapshot, + threadLaunchInitialMessageFromRpc, withLateEditorConfig, } from "./ws.ts"; @@ -46,6 +49,20 @@ it("keeps full thread snapshot fallback unless the client opts into bounded hist assert.isTrue(shouldUseBoundedThreadSnapshot({ acceptBoundedSnapshot: true })); }); +it("preserves a plan reference when the launch RPC reshapes its initial message", () => { + const sourcePlanRef = { + threadId: ThreadId.make("thread:plan-source"), + planId: PlanId.make("plan:implementation"), + }; + const initialMessage = threadLaunchInitialMessageFromRpc({ + text: "Implement the plan.", + attachments: [], + sourcePlanRef, + }); + + assert.deepEqual(initialMessage.sourcePlanRef, sourcePlanRef); +}); + it.effect("does not block server config when editor discovery never resolves", () => Effect.gen(function* () { const discoveryInterrupted = yield* Deferred.make(); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 80921ec4b5a2..4bcb8502b35b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -41,6 +41,7 @@ import { type FileManagerRevealKind, type OrchestrationClientOrigin, type OrchestrationV2Command, + type OrchestrationV2ThreadLaunchInput, type GitActionProgressEvent, type GitManagerServiceError, type MessageId, @@ -616,6 +617,18 @@ export function shouldUseBoundedThreadSnapshot(input: { return input.acceptBoundedSnapshot === true; } +export function threadLaunchInitialMessageFromRpc( + input: NonNullable, +): ThreadLaunchService.ThreadLaunchInitialMessage { + return { + ...(input.messageId === undefined ? {} : { messageId: input.messageId }), + text: input.text, + attachments: input.attachments, + ...(input.context === undefined ? {} : { context: input.context }), + ...(input.sourcePlanRef === undefined ? {} : { sourcePlanRef: input.sourcePlanRef }), + }; +} + // Optional client identity announced on the /ws upgrade URL next to wsTicket. // Lenient by design: absent or malformed values degrade to {} so a connection // never fails over attribution metadata. @@ -1197,7 +1210,9 @@ const layerWsRpc = ( const intakeContext = yield* Effect.context< | ThreadManagementService.ThreadManagementService | ThreadLaunchService.ThreadLaunchService + | ProjectService.ProjectService | FileSystem.FileSystem + | Path.Path | ServerConfig.ServerConfig >(); const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore; @@ -1487,6 +1502,8 @@ const layerWsRpc = ( projectId: input.projectId, title: input.title ?? "Imported ACP session", modelSelection: { instanceId: input.instanceId, model }, + agentId: null, + modelSelectionExplicit: true, runtimeMode: "approval-required", interactionMode: "default", workspaceStrategy: { type: "root" }, @@ -1969,22 +1986,17 @@ const layerWsRpc = ( ? {} : { generateTitle: input.generateTitle }), modelSelection: input.modelSelection, + ...(input.agentId === undefined ? {} : { agentId: input.agentId }), + ...(input.modelSelectionExplicit === undefined + ? {} + : { modelSelectionExplicit: input.modelSelectionExplicit }), runtimeMode: input.runtimeMode, interactionMode: input.interactionMode, workspaceStrategy: input.workspaceStrategy, ...(input.initialMessage === undefined ? {} : { - initialMessage: { - ...(input.initialMessage.messageId === undefined - ? {} - : { messageId: input.initialMessage.messageId }), - text: input.initialMessage.text, - attachments: input.initialMessage.attachments, - ...(input.initialMessage.context === undefined - ? {} - : { context: input.initialMessage.context }), - }, + initialMessage: threadLaunchInitialMessageFromRpc(input.initialMessage), }), createdBy: "user", creationSource: input.creationSource ?? "web", @@ -2653,6 +2665,7 @@ const layerWsRpc = ( }), ), ), + [WS_METHODS.agentsList]: (input) => projectService.listAgents(input), [WS_METHODS.projectsReadFile]: (input) => workspaceFileSystem.readFile(input).pipe( Effect.mapError( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 84f5b476a133..9948105e20e7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -58,6 +58,7 @@ import { type ChatFileAttachment, CommandId, DEFAULT_MODEL, + type AgentId, isProviderNativeSubagentThread, type ChatAttachment as ContractChatAttachment, EnvironmentAuthorizationError, @@ -435,6 +436,7 @@ import { waitForThreadShell, } from "../state/entities"; import { environmentShell } from "../state/shell"; +import { agentRosterQuery } from "../state/agents"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController"; import { isTimelineScrollTarget } from "./chat/timelineScrollTarget"; @@ -1588,6 +1590,10 @@ export default function ChatView(props: ChatViewProps) { ); const canReadTerminal = useEnvironmentScope(environmentId, AuthTerminalReadScope); const draftId = routeKind === "draft" ? props.draftId : null; + const [selectedDraftAgentId, setSelectedDraftAgentId] = useState( + undefined, + ); + useEffect(() => setSelectedDraftAgentId(undefined), [draftId]); const handleNewThread = useNewThreadHandler(); const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions(); const routeThreadRef = useMemo( @@ -2595,6 +2601,18 @@ export default function ChatView(props: ChatViewProps) { runProjectCloneAction, ]); const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection; + const activeProjectDefaultAgentId = activeProjectSettings.settings.defaultAgentId; + const agentRoster = useEnvironmentQuery( + activeProject + ? agentRosterQuery({ + environmentId: activeProject.environmentId, + input: { + projectId: activeProject.id, + ...(isServerThread && activeThread ? { threadId: activeThread.id } : {}), + }, + }) + : null, + ); const handleNewThreadInActiveProject = useCallback(() => { startNewThreadForProject(activeProjectRef, handleNewThread); }, [activeProjectRef, handleNewThread]); @@ -8906,6 +8924,7 @@ export default function ChatView(props: ChatViewProps) { selectedProviderModels: ctxSelectedProviderModels, selectedPromptEffort: ctxSelectedPromptEffort, selectedModelSelection: ctxSelectedModelSelection, + selectedModelSelectionExplicit: ctxSelectedModelSelectionExplicit, interactionMode: sendInteractionMode, interactionModeEnabled: sendInteractionModeEnabled, } = sendCtx; @@ -9601,6 +9620,10 @@ export default function ChatView(props: ChatViewProps) { projectId: activeProject.id, title, modelSelection: target.selection, + ...(selectedDraftAgentId === undefined + ? {} + : { agentId: selectedDraftAgentId }), + modelSelectionExplicit: true, runtimeMode, interactionMode: target.interactionMode, branch: activeThreadBranch, @@ -9941,6 +9964,10 @@ export default function ChatView(props: ChatViewProps) { projectId: activeProject.id, title, modelSelection: threadCreateModelSelection, + ...(selectedDraftAgentId === undefined + ? {} + : { agentId: selectedDraftAgentId }), + modelSelectionExplicit: ctxSelectedModelSelectionExplicit, runtimeMode, interactionMode: sendInteractionMode, branch: activeThreadBranch, @@ -10625,47 +10652,43 @@ export default function ChatView(props: ChatViewProps) { resetLocalDispatch(); }; - const createResult = await createThread({ + const startResult = await startThreadTurn({ environmentId, input: { threadId: nextThreadId, - projectId: activeProject.id, - title: nextThreadTitle, - modelSelection: nextThreadModelSelection, + message: { + messageId: newMessageId(), + role: "user", + text: outgoingImplementationPrompt, + attachments: [], + }, + modelSelection: ctxSelectedModelSelection, + titleSeed: nextThreadTitle, runtimeMode: defaultRuntimeMode, interactionMode: "default", - branch: activeThreadBranch, - worktreePath: activeThread.worktreePath, + sourceProposedPlan: { + threadId: activeThread.id, + planId: activeProposedPlan.id, + }, + bootstrap: { + createThread: { + projectId: activeProject.id, + title: nextThreadTitle, + modelSelection: nextThreadModelSelection, + agentId: activeThread.agentId ?? null, + modelSelectionExplicit: true, + runtimeMode: defaultRuntimeMode, + interactionMode: "default", + branch: activeThreadBranch, + worktreePath: activeThread.worktreePath, + createdAt, + }, + }, createdAt, }, }); let failure: AtomCommandResult | null = - createResult._tag === "Failure" ? createResult : null; - - if (failure === null) { - const startResult = await startThreadTurn({ - environmentId, - input: { - threadId: nextThreadId, - message: { - messageId: newMessageId(), - role: "user", - text: outgoingImplementationPrompt, - attachments: [], - }, - modelSelection: ctxSelectedModelSelection, - titleSeed: nextThreadTitle, - runtimeMode: defaultRuntimeMode, - interactionMode: "default", - sourceProposedPlan: { - threadId: activeThread.id, - planId: activeProposedPlan.id, - }, - createdAt, - }, - }); - failure = startResult._tag === "Failure" ? startResult : null; - } + startResult._tag === "Failure" ? startResult : null; if (failure === null) { const startedResult = await settlePromise(() => @@ -10722,7 +10745,6 @@ export default function ChatView(props: ChatViewProps) { activeThread, beginLocalDispatch, activeEnvironmentUnavailable, - createThread, deleteThread, isConnecting, isSendBusy, @@ -11641,6 +11663,15 @@ export default function ChatView(props: ChatViewProps) { {!composerMounted ? null : ( { + const React = await import("react"); + const MenuContext = React.createContext<{ + open: boolean; + onOpenChange: (open: boolean) => void; + } | null>(null); + const RadioContext = React.createContext<{ + value: string; + onValueChange: (value: string) => void; + } | null>(null); + return { + Menu: ({ + open, + onOpenChange, + children, + }: { + open: boolean; + onOpenChange: (open: boolean) => void; + children: ReactNode; + }) => {children}, + MenuTrigger: ({ render, children }: { render: ReactElement; children: ReactNode }) => { + const menu = React.useContext(MenuContext)!; + return React.cloneElement( + render as React.ReactElement>, + { onClick: () => menu.onOpenChange(!menu.open) }, + children, + ); + }, + MenuPopup: ({ children }: { children: ReactNode }) => { + const menu = React.useContext(MenuContext)!; + return menu.open ?
{children}
: null; + }, + MenuGroup: ({ children }: { children: ReactNode }) =>
{children}
, + MenuRadioGroup: ({ + value, + onValueChange, + children, + }: { + value: string; + onValueChange: (value: string) => void; + children: ReactNode; + }) => ( + {children} + ), + MenuRadioItem: ({ value, children }: { value: string; children: ReactNode }) => { + const menu = React.useContext(MenuContext)!; + const radio = React.useContext(RadioContext)!; + return ( + + ); + }, + MenuSeparator: () =>
, + }; +}); + +vi.mock("./ComposerControl", () => ({ + ComposerControl: ({ children, ...props }: ComponentProps<"button">) => ( + + ), + ComposerControlChevron: () => null, + ComposerControlIcon: () => null, +})); + +vi.mock("../ui/badge", () => ({ + Badge: ({ children }: { children: ReactNode }) => {children}, +})); + +import { AgentPicker } from "./AgentPicker"; + +const sampleAgents: ReadonlyArray = [ + { + id: AgentId.make("code-reviewer"), + name: "Code Reviewer", + description: "Reviews pull requests and provides detailed feedback", + tier: "reasoning", + }, + { + id: AgentId.make("doc-writer"), + name: "Documentation Writer", + description: "Generates clear and concise documentation", + tier: "economy", + }, +]; + +let renderer: ReactTestRenderer; +afterEach(() => { + act(() => renderer?.unmount()); + vi.unstubAllGlobals(); +}); + +it("distinguishes inheriting the project default from explicitly choosing no agent", () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + function PickerHarness() { + const [selectedAgentId, setSelectedAgentId] = useState(undefined); + return ( + + ); + } + + act(() => { + renderer = create(); + }); + const label = () => + renderer.root.findByProps({ "data-composer-control-label": true }).children.join(""); + const choose = (value: string) => { + const item = renderer.root.findByProps({ "data-value": value }); + act(() => item.props.onClick()); + }; + + expect(label()).toBe("Code Reviewer (Default)"); + act(() => renderer.root.findByProps({ "aria-label": "Select custom agent" }).props.onClick()); + choose("none"); + expect(label()).toBe("No custom agent"); + act(() => renderer.root.findByProps({ "aria-label": "Select custom agent" }).props.onClick()); + expect(renderer.root.findByProps({ "data-value": "none" }).props["aria-checked"]).toBe(true); + + choose("default"); + expect(label()).toBe("Code Reviewer (Default)"); + act(() => renderer.root.findByProps({ "aria-label": "Select custom agent" }).props.onClick()); + expect(renderer.root.findByProps({ "data-value": "default" }).props["aria-checked"]).toBe(true); +}); diff --git a/apps/web/src/components/chat/AgentPicker.tsx b/apps/web/src/components/chat/AgentPicker.tsx new file mode 100644 index 000000000000..f9185e3d3d13 --- /dev/null +++ b/apps/web/src/components/chat/AgentPicker.tsx @@ -0,0 +1,203 @@ +import { memo } from "react"; +import { BotIcon } from "lucide-react"; +import type { AgentId, AgentSummary } from "@t3tools/contracts"; + +import { cn } from "~/lib/utils"; +import { Badge } from "../ui/badge"; +import { + Menu, + MenuGroup, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator as MenuDivider, + MenuTrigger, +} from "../ui/menu"; +import { + ComposerControl, + ComposerControlChevron, + ComposerControlIcon, + type ComposerControlSize, +} from "./ComposerControl"; +import { useComposerMenuProps } from "./composerEventScope"; +import { useComposerMenuState } from "./useComposerMenuState"; + +const EMPTY_AGENTS: ReadonlyArray = []; + +export interface AgentPickerProps { + agents?: ReadonlyArray | undefined; + selectedAgentId?: AgentId | null | undefined; + defaultAgentId?: AgentId | null | undefined; + /** `undefined` inherits the project default; `null` explicitly selects none. */ + onSelectAgent: (agentId: AgentId | null | undefined) => void; + allowNone?: boolean | undefined; + size?: ComposerControlSize | undefined; + disabled?: boolean | undefined; + hidden?: boolean | undefined; + className?: string | undefined; + triggerClassName?: string | undefined; +} + +export const AgentPicker = memo(function AgentPicker({ + agents = EMPTY_AGENTS, + selectedAgentId, + defaultAgentId = null, + onSelectAgent, + allowNone = false, + size = "sm", + disabled = false, + hidden = false, + className, + triggerClassName, +}: AgentPickerProps) { + const [open, setOpen] = useComposerMenuState(hidden); + const menuProps = useComposerMenuProps(); + + const activeAgent = selectedAgentId + ? (agents.find((agent) => agent.id === selectedAgentId) ?? null) + : null; + + const defaultAgent = defaultAgentId + ? (agents.find((agent) => agent.id === defaultAgentId) ?? null) + : null; + + const triggerLabel = activeAgent + ? activeAgent.name + : allowNone && selectedAgentId === null + ? "No custom agent" + : defaultAgent + ? `${defaultAgent.name} (Default)` + : "Agent"; + + const handleValueChange = (value: string) => { + if (!value || value === "default") { + onSelectAgent(undefined); + } else if (value === "none") { + onSelectAgent(null); + } else { + onSelectAgent(value as AgentId); + } + }; + const selectedValue = allowNone + ? selectedAgentId === undefined + ? "default" + : selectedAgentId === null + ? "none" + : selectedAgentId + : (selectedAgentId ?? "default"); + + return ( + + + } + > + + + {triggerLabel} + + {activeAgent?.tier ? ( + + {activeAgent.tier === "economy" ? "Economy" : "Reasoning"} + + ) : null} + + + + + +
+ Custom Agents +
+ + +
+
+ Project default + {defaultAgent ? ( + + Inherits + + ) : null} +
+ + {defaultAgent + ? `Uses ${defaultAgent.name} for new threads.` + : "Uses the project setting; no custom agent is configured."} + +
+
+ + {allowNone ? ( + +
+ No custom agent + + Ignore the project default for this thread. + +
+
+ ) : null} + + {agents.length > 0 ? : null} + + {agents.map((agent) => { + const isDefault = agent.id === defaultAgentId; + return ( + +
+
+ {agent.name} +
+ {isDefault ? ( + + Default + + ) : null} + {agent.tier ? ( + + {agent.tier === "economy" ? "Economy" : "Reasoning"} + + ) : null} + {agent.model ? ( + + {agent.model.model} + + ) : null} +
+
+ {agent.description ? ( + + {agent.description} + + ) : null} +
+
+ ); + })} + + {agents.length === 0 ? ( +
+ No custom agents found in .agents/ or .github/agents/ +
+ ) : null} +
+ An explicit composer model choice wins. Without a configured model or tier, the agent + inherits the selected model. Instructions are sent on its first provider turn. +
+
+
+
+
+ ); +}); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6d7646e521d7..2905542a2756 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -38,6 +38,8 @@ import type { ServerProvider, ThreadId, SnapShotSource, + AgentId, + AgentSummary, } from "@t3tools/contracts"; import { AuthOrchestrationOperateScope, @@ -261,6 +263,7 @@ import { import { useEnvironmentQuery } from "~/state/query"; import { useDebouncedValue } from "~/state/queries"; import { ProviderModelPicker } from "./ProviderModelPicker"; +import { AgentPicker } from "./AgentPicker"; import { resolveModelPickerSelectedModel } from "./ModelPickerContent"; import { type ComposerCommandItem, @@ -1495,6 +1498,7 @@ export interface ChatComposerHandle { selectedPromptEffort: string | null; selectedModelOptionsForDispatch: unknown; selectedModelSelection: ModelSelection; + selectedModelSelectionExplicit: boolean; multipleModelSelections: ReadonlyArray | null; providerAvailable: boolean; selectedProvider: ProviderDriverKind; @@ -1657,6 +1661,13 @@ export interface ChatComposerProps { }> | null; onRemoveEditingQueuedAttachment: (attachmentId: string) => void; + // Custom agents + agents?: ReadonlyArray; + selectedAgentId?: AgentId | null | undefined; + defaultAgentId?: AgentId | null; + onSelectAgent?: (agentId: AgentId | null | undefined) => void; + agentPickerDisabled?: boolean; + // Callbacks onCompactContext: () => void; onSend: ( @@ -1807,6 +1818,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onFileOpen, editingQueuedAttachments, onRemoveEditingQueuedAttachment, + agents, + selectedAgentId, + defaultAgentId, + onSelectAgent, + agentPickerDisabled = false, } = props; const primaryEnvironmentId = usePrimaryEnvironmentId(); const composerDraftTargetKey = composerTargetKey(composerDraftTarget); @@ -5464,6 +5480,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) size: composerControlsCollapsed ? "xs" : "sm", hidden: composerControlsHidden || restingHiddenBlockCount > 1, }); + const restingAgentPicker = + onSelectAgent && ((agents && agents.length > 0) || defaultAgentId || selectedAgentId) ? ( + <> + +