diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index d724d8056e95..b6453a565cf1 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -776,7 +776,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { : workingLabel) ?? (isUnread ? DONE_STATUS_LABEL : undefined); const recede = shouldRecedeThreadRow({ status, selected }); const isRegeneratingTitle = thread.titleRegeneration != null; - const rowAccessibilityLabel = `${thread.title}${providerNameLabel ? `, ${providerNameLabel}` : ""}${props.hasQueuedMessages ? ", messages queued to send" : ""}${isRegeneratingTitle ? ", regenerating title" : ""}`; + const specialistPrefix = + thread.source.specialist === undefined ? "" : `@${thread.source.specialist.key} `; + const rowAccessibilityLabel = `${specialistPrefix}${thread.title}${providerNameLabel ? `, ${providerNameLabel}` : ""}${props.hasQueuedMessages ? ", messages queued to send" : ""}${isRegeneratingTitle ? ", regenerating title" : ""}`; const timeLabel = props.timeLabel; const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); @@ -1191,6 +1193,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { : undefined } > + {specialistPrefix} {thread.title} @@ -1499,6 +1502,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { )} numberOfLines={1} > + {specialistPrefix} {thread.title} {providerNameLabel ? ( diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index ccd739e93a6f..b76fcf6b1e58 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -882,6 +882,7 @@ const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(funct const HANDOFF_TOOLS: ReadonlySet = new Set([ "delegate_task", "create_threads", + "find_or_open_specialist", "t3_thread_launch", "t3_thread_send", "t3_thread_send_attachments", diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 960253adc398..bbd62f409a60 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -20,7 +20,11 @@ import { type OrchestratorMcpCreateThreadsInput, type OrchestratorMcpCreateThreadsResult, type OrchestratorMcpCreatedThread, + type OrchestratorMcpFindOrOpenSpecialistInput, + type OrchestratorMcpFindOrOpenSpecialistResult, type OrchestratorMcpDelegateTaskInput, + resolveSpecialistBinding, + specialistBindingsEqual, type OrchestratorMcpDelegateTaskResult, type OrchestratorMcpDeleteScheduledTaskInput, type OrchestratorMcpRequestSecretInput, @@ -62,6 +66,7 @@ import { type ServerProvider, type ServerProviderModel, ThreadId, + isListedThreadLineage, isOrchestrationV2WorkActive, latestProviderTurnForAttempt, resolveProjectProviderInstanceEnabled, @@ -176,6 +181,10 @@ export interface OrchestratorMcpServiceShape { scope: McpInvocationScope, input: OrchestratorMcpCreateThreadsInput, ) => Effect.Effect; + readonly findOrOpenSpecialist: ( + scope: McpInvocationScope, + input: OrchestratorMcpFindOrOpenSpecialistInput, + ) => Effect.Effect; readonly scheduleTask: ( scope: McpInvocationScope, input: OrchestratorMcpScheduleTaskInput, @@ -838,6 +847,7 @@ function listItemFromShell( ...threadSnooze(shell, nowMs), parentThreadId: shell.lineage.parentThreadId, relationshipToParent: shell.lineage.relationshipToParent, + ...(shell.specialist === undefined ? {} : { specialist: shell.specialist }), itemCount: shell.visibleItemCount, createdAt: DateTime.formatIso(shell.createdAt), updatedAt: DateTime.formatIso(shell.updatedAt), @@ -889,6 +899,9 @@ function threadDetail( archived: projection.thread.archivedAt !== null, ...threadSettlement(projection.thread), ...threadSnooze(shell, nowMs), + ...(projection.thread.specialist === undefined + ? {} + : { specialist: projection.thread.specialist }), createdAt: DateTime.formatIso(projection.thread.createdAt), updatedAt: DateTime.formatIso(projection.thread.updatedAt), }; @@ -2951,6 +2964,334 @@ const make = Effect.gen(function* () { return { threads: created }; }, ), + findOrOpenSpecialist: Effect.fn("OrchestratorMcpService.findOrOpenSpecialist")( + function* (callerScope, input) { + const { parent: listParent, limits } = yield* loadCaller(callerScope, ["runs"]); + const projectId = yield* resolveProjectTarget( + listParent, + input.projectId, + "Pass projectId: this MCP client is not running inside a T3 thread.", + ); + const key = yield* requestKey(input.clientRequestId); + // Same identity as the create path so a retry cannot fall into resume and re-send. + const createCommandId = stableCommandId({ + scope: callerScope, + requestKey: key, + operation: "create-specialist", + }); + const createThreadId = stableThreadId({ scope: callerScope, requestKey: key, index: 0 }); + const isCreateRetry = yield* threadManagement.hasReceipt(createCommandId); + + const snapshot = yield* threadManagement + .getShellSnapshot({ projectId }) + .pipe( + Effect.mapError((error) => + failure( + "orchestration_error", + `Unable to look up specialists: ${errorMessage(error)}`, + ), + ), + ); + const existing = [...snapshot.threads, ...snapshot.archivedThreads] + .filter( + (thread) => + thread.deletedAt === null && + thread.specialist?.key === input.key && + isListedThreadLineage(thread.lineage), + ) + .toSorted( + (left, right) => + (left.archivedAt === null ? 0 : 1) - (right.archivedAt === null ? 0 : 1) || + DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt), + )[0]; + + if (existing !== undefined && !(isCreateRetry && existing.id === createThreadId)) { + const prior = existing.specialist; + const specialist = resolveSpecialistBinding({ + key: input.key, + ...(input.label === undefined ? {} : { label: input.label }), + ...(input.instructions === undefined ? {} : { instructions: input.instructions }), + ...(input.skills === undefined ? {} : { skills: input.skills }), + ...(input.mcpServers === undefined ? {} : { mcpServers: input.mcpServers }), + ...(prior === undefined ? {} : { prior }), + }); + const needsRebind = !specialistBindingsEqual(prior, specialist); + const prompt = input.prompt; + if (needsRebind || prompt !== undefined || existing.archivedAt !== null) { + const target = yield* loadTargetThread(existing.id); + yield* assertLiveCallerForOtherThread(callerScope, listParent, target); + yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode); + yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode); + } + if (existing.archivedAt !== null) { + yield* threadManagement + .dispatch({ + type: "thread.unarchive", + commandId: stableCommandId({ + scope: callerScope, + requestKey: key, + operation: "unarchive-specialist", + }), + threadId: existing.id, + }) + .pipe( + Effect.mapError((error) => + failure( + "orchestration_error", + `Unable to reopen archived specialist: ${errorMessage(error)}`, + ), + ), + ); + } + if (needsRebind) { + yield* threadManagement + .dispatch({ + type: "thread.specialist.set", + commandId: stableCommandId({ + scope: callerScope, + requestKey: key, + operation: "rebind-specialist", + }), + threadId: existing.id, + specialist, + }) + .pipe( + Effect.mapError((error) => + failure( + "orchestration_error", + `Unable to update specialist binding: ${errorMessage(error)}`, + ), + ), + ); + } + let runId: RunId | null = existing.latestRunId; + let status: OrchestratorMcpFindOrOpenSpecialistResult["status"] = + existing.activityRunStatus ?? existing.status; + if (prompt !== undefined) { + const sent = yield* threadManagement + .sendToThread({ + projectId, + commandId: stableCommandId({ + scope: callerScope, + requestKey: key, + operation: "specialist-send", + }), + threadId: existing.id, + ...(listParent === undefined ? {} : { senderThreadId: listParent.thread.id }), + messageId: stableOperationMessageId({ + scope: callerScope, + requestKey: key, + operation: "specialist-send", + }), + text: prompt, + attachments: [], + mode: "auto", + createdBy: "agent", + creationSource: "mcp", + }) + .pipe( + Effect.mapError((error) => + isThreadManagementError(error) + ? threadManagementFailure(error) + : failure( + "orchestration_error", + `Unable to message specialist: ${userFacingDispatchErrorMessage(error) ?? errorMessage(error)}`, + ), + ), + ); + runId = sent.run.id; + status = sent.run.status; + } + return { + threadId: existing.id, + link: threadLinkFor(callerScope, existing.id, existing.title), + created: false, + specialist, + runId, + status, + title: existing.title, + } satisfies OrchestratorMcpFindOrOpenSpecialistResult; + } + + const specialist = resolveSpecialistBinding({ + key: input.key, + ...(input.label === undefined ? {} : { label: input.label }), + ...(input.instructions === undefined ? {} : { instructions: input.instructions }), + ...(input.skills === undefined ? {} : { skills: input.skills }), + ...(input.mcpServers === undefined ? {} : { mcpServers: input.mcpServers }), + }); + const { scope, parent } = yield* loadThreadCaller(callerScope, "find_or_open_specialist"); + yield* assertLiveCallerForOtherProject(scope, parent, projectId); + const parentRun = ThreadManagementService.latestActiveRun(parent); + if ( + parentRun === undefined || + parentRun.rootNodeId === null || + parentRun.providerInstanceId !== scope.thread.providerInstanceId + ) { + return yield* failure( + "parent_not_active", + "Creating a specialist requires an active run owned by this MCP provider session.", + ); + } + if (parent.thread.projectId !== projectId) { + return yield* failure( + "target_required", + "Creating a specialist in another project is not supported yet; open or create it from a thread in that project.", + ); + } + const providers = yield* loadProviders; + const committedThread = isCreateRetry + ? yield* threadManagement + .getThreadShell(createThreadId) + .pipe(Effect.mapError(threadManagementFailure)) + : null; + const target = yield* resolveTargetRechecking({ + parent, + target: + committedThread === null + ? input.target + : { + ...input.target, + providerInstanceId: committedThread.modelSelection.instanceId, + }, + providers, + skipProjectEnablementFilter: isCreateRetry, + }); + const runtimeMode = yield* resolveRuntimeMode( + parent.thread.runtimeMode, + input.runtimeMode, + target, + ); + const interactionMode = yield* resolveInteractionMode( + parent.thread.interactionMode, + input.interactionMode, + ); + yield* assertTargetEnabledForProject(parent, createCommandId, target); + const title = threadTitle({ + parentTitle: parent.thread.title, + prompt: input.prompt, + title: input.title ?? specialist.label, + index: 0, + }); + if (committedThread === null) { + yield* threadManagement + .dispatch({ + type: "thread.create", + createdBy: "agent", + creationSource: "mcp", + commandId: createCommandId, + threadId: createThreadId, + projectId, + title, + modelSelection: target.modelSelection, + runtimeMode, + interactionMode, + branch: parent.thread.branch, + worktreePath: parent.thread.worktreePath, + ...(parent.thread.linkOrigin === undefined + ? {} + : { linkOrigin: parent.thread.linkOrigin }), + }) + .pipe( + Effect.mapError((error) => + failure( + "orchestration_error", + `Unable to create specialist thread: ${errorMessage(error)}`, + ), + ), + ); + } + yield* threadManagement + .dispatch({ + type: "thread.specialist.set", + commandId: stableCommandId({ + scope: callerScope, + requestKey: key, + operation: "bind-specialist", + }), + threadId: createThreadId, + specialist, + }) + .pipe( + Effect.mapError((error) => + failure("orchestration_error", `Unable to bind specialist: ${errorMessage(error)}`), + ), + ); + const resolvedModelSelection = (yield* threadManagement + .getThreadRecords(createThreadId, []) + .pipe(Effect.mapError(threadManagementFailure))).thread.modelSelection; + if (input.prompt !== undefined) { + yield* threadManagement + .dispatch({ + type: "message.dispatch", + createdBy: "agent", + creationSource: "mcp", + commandId: stableCommandId({ + scope: callerScope, + requestKey: key, + operation: "dispatch-specialist", + }), + threadId: createThreadId, + senderThreadId: scope.thread.threadId, + messageId: stableMessageId({ + scope: callerScope, + requestKey: key, + index: 0, + }), + text: input.prompt, + attachments: [], + modelSelection: resolvedModelSelection, + dispatchMode: { type: "start_immediately" }, + }) + .pipe( + Effect.mapError((error) => + error._tag === "ThreadManagementProviderInstanceDisabledError" + ? threadManagementFailure(error) + : failure( + "orchestration_error", + `Unable to start specialist: ${errorMessage(error)}`, + ), + ), + ); + } + const projection = yield* loadThreadRecords(createThreadId, ["runs"]); + const run = projection.runs.at(-1); + yield* threadManagement + .dispatch({ + type: "thread.created.record", + commandId: stableCommandId({ + scope: callerScope, + requestKey: key, + operation: "record-specialist", + }), + parentThreadId: scope.thread.threadId, + parentRunId: parentRun.id, + parentNodeId: parentRun.rootNodeId, + targetThreadId: createThreadId, + targetRunId: run?.id ?? null, + }) + .pipe( + Effect.mapError((error) => + failure( + "orchestration_error", + `Unable to record specialist in the parent timeline: ${errorMessage(error)}`, + ), + ), + ); + return { + threadId: createThreadId, + link: formatThreadLink( + { environmentId: scope.environmentId, threadId: createThreadId }, + projection.thread.title, + ), + created: true, + specialist, + runId: run?.id ?? null, + status: run?.status ?? "idle", + title: projection.thread.title, + } satisfies OrchestratorMcpFindOrOpenSpecialistResult; + }, + ), listThreads: Effect.fn("OrchestratorMcpService.listThreads")(function* (scope, input) { const { parent } = yield* loadCaller(scope, []); const projectId = input.allProjects @@ -2991,6 +3332,10 @@ const make = Effect.gen(function* () { .filter( (thread) => titleContains === undefined || thread.title.toLocaleLowerCase().includes(titleContains), + ) + .filter( + (thread) => + input.specialistKey === undefined || thread.specialist?.key === input.specialistKey, ); const cursor = input.cursor ?? 0; const limit = input.limit ?? DEFAULT_THREAD_LIST_LIMIT; diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index 1f4ee4a57a6f..a3ff71722726 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -146,6 +146,13 @@ const handlers = { return yield* service.createThreads(scope, input); }), ), + find_or_open_specialist: McpToolAccess.actsAsCaller((input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service.findOrOpenSpecialist(scope, input); + }), + ), t3_thread_list: McpToolAccess.reads((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 38d8fb4e0ada..7c32f05f7047 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -9,6 +9,8 @@ import { OrchestratorMcpEnvironmentUnlinkResult, OrchestratorMcpCreateThreadsInput, OrchestratorMcpCreateThreadsResult, + OrchestratorMcpFindOrOpenSpecialistInput, + OrchestratorMcpFindOrOpenSpecialistResult, OrchestratorMcpDelegateTaskInput, OrchestratorMcpDelegateTaskResult, OrchestratorMcpDeleteScheduledTaskInput, @@ -229,6 +231,20 @@ const CreateThreadsTool = Tool.make("create_threads", { .annotate(Tool.Destructive, true) .annotate(Tool.OpenWorld, true); +const FindOrOpenSpecialistTool = Tool.make("find_or_open_specialist", { + description: + "Resume or create a durable specialist thread by stable `key` (e.g. `dev-ui`) in a project. Optional `skills` and `mcpServers` attach to the role and are injected automatically on turns. Keeps context across days; not a turn-scoped `delegate_task`. Creating needs an active run. In your reply, name it as its `link` verbatim.", + parameters: OrchestratorMcpFindOrOpenSpecialistInput, + success: OrchestratorMcpFindOrOpenSpecialistResult, + failure: OrchestratorMcpFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "Find or open a durable specialist") + .annotate(Tool.Destructive, true) + .annotate(Tool.OpenWorld, true) + .annotate(Tool.Idempotent, true); + const ThreadListTool = Tool.make("t3_thread_list", { description: "List threads newest first, in one project or with `allProjects`. Page with `cursor`.", @@ -368,6 +384,7 @@ export const OrchestratorToolkit = Toolkit.make( DeleteScheduledTaskTool, RequestSecretTool, CreateThreadsTool, + FindOrOpenSpecialistTool, ThreadListTool, ThreadReadTool, ThreadUpdateTool, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index d2330829b096..a4adf894ecde 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -619,6 +619,42 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { readOnlyToolNames, ); }); + + it("merges specialist role MCP servers with t3-code", () => { + const threadId = ThreadId.make("thread-claude-role-mcp"); + const mcpSession = mcpSessionFor(threadId); + const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({ + mcpSession, + readOnlySandbox: false, + specialist: { + key: "vault", + label: "Vault", + skills: ["vault-user"], + mcpServers: { + vault: { type: "stdio", command: "vault-mcp", args: ["serve"] }, + docs: { + type: "http", + url: "https://docs.example/mcp", + headers: { Authorization: "Bearer docs" }, + }, + }, + }, + }); + assert.deepEqual(overrides.mcpServers, { + vault: { type: "stdio", command: "vault-mcp", args: ["serve"] }, + docs: { + type: "http", + url: "https://docs.example/mcp", + headers: { Authorization: "Bearer docs" }, + }, + "t3-code": { + type: "http", + url: mcpSession.endpoint, + headers: { Authorization: mcpSession.authorizationHeader }, + timeout: ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_TIMEOUT_MS, + }, + }); + }); }); describe("ClaudeAdapterV2 native protocol logging", () => { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 7bdd80435a28..dfb6274c8f29 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -131,6 +131,9 @@ import { type ThreadId, resolveIdleCompaction, isDelegatedAppThread, + formatSpecialistRoleLoadout, + specialistMcpServerEntries, + type ThreadSpecialist, } from "@t3tools/contracts"; import { FILE_HEADERS_ONLY, formatPatch, structuredPatch } from "diff"; @@ -1915,6 +1918,8 @@ export function makeClaudeQueryOptions(input: { readonly sdkSettings?: string | ClaudeSdkSettings; readonly environment?: NodeJS.ProcessEnv; readonly mcpServers?: ClaudeQueryOptions["mcpServers"]; + /** Role brief + skills for a durable specialist thread. */ + readonly specialistRoleLoadout?: string; /** The thread's T3 MCP session holds the `device` capability. */ readonly deviceToolsAvailable?: boolean; readonly tools?: ClaudeAgentSdkQueryTools; @@ -2046,6 +2051,7 @@ export function makeClaudeQueryOptions(input: { (deviceInstructions ? `\n${T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH}\n` : "")), + ...(input.specialistRoleLoadout === undefined ? [] : [input.specialistRoleLoadout]), ...launchAppendSystemPrompt, ].join("\n\n"), }, @@ -2171,17 +2177,48 @@ export const CLAUDE_T3_MCP_TOOL_TIMEOUT_MS = 65 * 60 * 1_000; // allowedTools pre-approves calls; tools controls availability. Pre-approve T3 tools for headless modes, // limited to annotated read-only tools in read-only sandboxes. +function claudeSpecialistMcpServers( + specialist: ThreadSpecialist | undefined, +): NonNullable { + const servers: NonNullable = {}; + for (const [name, server] of specialistMcpServerEntries(specialist)) { + servers[name] = + server.type === "http" + ? { + type: "http" as const, + url: server.url, + ...(server.headers === undefined ? {} : { headers: server.headers }), + } + : { + type: "stdio" as const, + command: server.command, + ...(server.args === undefined ? {} : { args: [...server.args] }), + ...(server.env === undefined ? {} : { env: server.env }), + }; + } + return servers; +} + export function claudeMcpQueryOverrides(input: { readonly mcpSession: McpProviderSession.McpProviderSessionConfig | undefined; readonly readOnlySandbox: boolean; readonly allowedTools?: ReadonlyArray; + readonly specialist?: ThreadSpecialist; }): { readonly allowedTools?: ReadonlyArray; readonly mcpServers?: ClaudeQueryOptions["mcpServers"]; } { + const roleServers = claudeSpecialistMcpServers(input.specialist); const session = input.mcpSession; if (session === undefined) { - return input.allowedTools === undefined ? {} : { allowedTools: input.allowedTools }; + const mcpServers = + Object.keys(roleServers).length === 0 + ? undefined + : (roleServers as ClaudeQueryOptions["mcpServers"]); + return { + ...(input.allowedTools === undefined ? {} : { allowedTools: input.allowedTools }), + ...(mcpServers === undefined ? {} : { mcpServers }), + }; } const mcpAllowedTools = input.readOnlySandbox ? CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS @@ -2189,6 +2226,7 @@ export function claudeMcpQueryOverrides(input: { return { allowedTools: Array.from(new Set([...(input.allowedTools ?? []), ...mcpAllowedTools])), mcpServers: { + ...roleServers, "t3-code": { type: "http", url: session.endpoint, @@ -11359,6 +11397,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ) { const queryPolicy = yield* queryPolicyForTurn(turnInput); const mcpSession = yield* mcpSessions.read(turnInput.threadId); + const specialist = turnInput.appThread.specialist; + const specialistRoleLoadout = formatSpecialistRoleLoadout(specialist); const mcpOverrides = claudeMcpQueryOverrides({ mcpSession, readOnlySandbox: @@ -11366,6 +11406,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ...(queryPolicy.allowedTools === undefined ? {} : { allowedTools: queryPolicy.allowedTools }), + ...(specialist === undefined ? {} : { specialist }), }); const queryPolicyKey = claudeEffectiveQueryPolicyKey(queryPolicy, mcpOverrides); const compiledSelection = compileClaudeModelSelection( @@ -11497,6 +11538,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ...(mcpOverrides.mcpServers === undefined ? {} : { mcpServers: mcpOverrides.mcpServers }), + ...(specialistRoleLoadout === undefined ? {} : { specialistRoleLoadout }), deviceToolsAvailable: mcpSession?.capabilities?.has("device") === true, permissionMode: queryPolicy.permissionMode, ...(queryPolicy.allowDangerouslySkipPermissions === undefined diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 60d1449656a4..48046b5bac0a 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -39,6 +39,8 @@ import { ProviderSetupError, type ServerProviderCompatibilityAdvisory, isDelegatedAppThread, + specialistMcpServerEntries, + type ThreadSpecialist, } from "@t3tools/contracts"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; import { AgentScope } from "@t3tools/shared/AgentScope"; @@ -1425,11 +1427,32 @@ const CODEX_T3_MCP_TOOL_TIMEOUT_SEC = 65 * 60; const CODEX_T3_DIRECT_MCP_SERVER = "t3-code-direct"; const CODEX_DIRECT_ONLY_T3_TOOL = "t3_thread_wait"; +function codexSpecialistMcpConfig( + specialist: ThreadSpecialist | undefined, +): Readonly> { + const config: Record = {}; + for (const [name, server] of specialistMcpServerEntries(specialist)) { + config[`mcp_servers.${name}`] = + server.type === "http" + ? { + url: server.url, + ...(server.headers === undefined ? {} : { http_headers: server.headers }), + } + : { + command: server.command, + ...(server.args === undefined ? {} : { args: [...server.args] }), + ...(server.env === undefined ? {} : { env: server.env }), + }; + } + return config; +} + function codexThreadRuntimeParams(input: { readonly threadId: ThreadId | null; readonly mcpSession: McpProviderSession.McpProviderSessionConfig | undefined; readonly modelSelection?: { readonly model: string }; readonly runtimePolicy?: ProviderAdapter.ProviderAdapterV2RuntimePolicy; + readonly specialist?: ThreadSpecialist; }): { readonly cwd?: string; readonly model?: string; @@ -1453,6 +1476,7 @@ function codexThreadRuntimeParams(input: { ...(input.threadId === null ? {} : { [`shell_environment_policy.set.${PortScanner.AGENT_THREAD_ENV}`]: input.threadId }), + ...codexSpecialistMcpConfig(input.specialist), ...(t3Server === undefined ? {} : { @@ -7440,6 +7464,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( mcpSession, modelSelection: threadInput.modelSelection, runtimePolicy: threadInput.runtimePolicy, + ...(threadInput.specialist === undefined + ? {} + : { specialist: threadInput.specialist }), }), ), ), diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 295bdde820e6..3980fe60c023 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -614,6 +614,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "thread.pin.reorder": case "thread.active.reorder": case "thread.tag.set": + case "thread.specialist.set": case "thread.visit": case "thread.mark-unread": case "thread.metadata.update": @@ -3715,6 +3716,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | "thread.unpin" | "thread.pin.reorder" | "thread.tag.set" + | "thread.specialist.set" | "thread.active.reorder" | "thread.mark-unread" | "thread.metadata.update" @@ -3750,6 +3752,34 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.threadId} is deleted.`, }); } + if (command.type === "thread.specialist.set") { + if (thread.lineage.relationshipToParent === "subagent" || thread.lineage.sideChat === true) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} is a subagent or side chat and cannot own a specialist.`, + }); + } + const specialist = command.specialist; + if (specialist !== null) { + const snapshot = yield* projectionStore + .getShellSnapshot({ projectId: thread.projectId }) + .pipe(mapDispatchError(command)); + const conflict = [...snapshot.threads, ...snapshot.archivedThreads].find( + (candidate) => + candidate.id !== thread.id && + candidate.deletedAt === null && + candidate.specialist?.key === specialist.key, + ); + if (conflict !== undefined) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Specialist key "${specialist.key}" is already bound to thread ${conflict.id}.`, + }); + } + } + } if ( command.type === "thread.pull-request.watch" && command.watching && @@ -4295,6 +4325,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : current.filter((tag) => tag !== command.tag), }; } + case "thread.specialist.set": { + if (command.specialist === null) { + const { specialist: _cleared, ...rest } = thread; + return rest; + } + return { + ...thread, + specialist: command.specialist, + }; + } case "thread.mark-unread": { // Retire the wake before rewinding the visit, preserving its settlement timer. const snoozeExpired = @@ -4674,6 +4714,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return "thread.active-reordered" as const; case "thread.tag.set": return "thread.tags-updated" as const; + case "thread.specialist.set": + return "thread.specialist-updated" as const; case "thread.mark-unread": return "thread.marked-unread" as const; case "thread.metadata.update": @@ -14108,6 +14150,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.pin.reorder": case "thread.active.reorder": case "thread.tag.set": + case "thread.specialist.set": case "thread.mark-unread": case "thread.metadata.update": case "thread.pull-request.link": diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 4368e2fc7de9..32071ffc93f3 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -876,6 +876,7 @@ export function applyToProjection( // Reordering and PR refreshes preserve the orchestrator's activity timestamp. case "thread.active-reordered": case "thread.tags-updated": + case "thread.specialist-updated": case "thread.pull-request-synced": return { ...base, @@ -1718,6 +1719,9 @@ export function threadShellFromProjection( autoSettleDisabledAt: projection.thread.autoSettleDisabledAt ?? null, pinOrderKey: projection.thread.pinOrderKey ?? null, ...(projection.thread.tags?.length ? { tags: projection.thread.tags } : {}), + ...(projection.thread.specialist === undefined + ? {} + : { specialist: projection.thread.specialist }), lastVisitedAt: projection.thread.lastVisitedAt, opens: projection.thread.opens ?? null, titleRegeneration: projection.thread.titleRegeneration ?? null, @@ -2005,6 +2009,9 @@ function shellFromState(input: { autoSettleDisabledAt: input.state.thread.autoSettleDisabledAt ?? null, pinOrderKey: input.state.thread.pinOrderKey ?? null, ...(input.state.thread.tags?.length ? { tags: input.state.thread.tags } : {}), + ...(input.state.thread.specialist === undefined + ? {} + : { specialist: input.state.thread.specialist }), lastVisitedAt: input.state.thread.lastVisitedAt, opens: input.state.thread.opens ?? null, titleRegeneration: input.state.thread.titleRegeneration ?? null, @@ -2128,6 +2135,7 @@ export const layer: Layer.Layer = case "thread.pin-reordered": case "thread.active-reordered": case "thread.tags-updated": + case "thread.specialist-updated": case "thread.visited": case "thread.marked-unread": case "thread.metadata-updated": @@ -2945,6 +2953,7 @@ export const layer: Layer.Layer = event.type !== "thread.pin-reordered" && event.type !== "thread.active-reordered" && event.type !== "thread.tags-updated" && + event.type !== "thread.specialist-updated" && event.type !== "thread.visited" && event.type !== "thread.marked-unread" && event.type !== "thread.metadata-updated" && diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 42ec1118d4bb..067d3a3e624c 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -1236,6 +1236,9 @@ export const layer: Layer.Layer< : { ...providerThread, forkedFrom: null }, // The failed fork may have left the adapter's session half-switched. ...(nativeForkFailed ? { forceNewNativeThread: true } : {}), + ...(projection.thread.specialist === undefined + ? {} + : { specialist: projection.thread.specialist }), }), ); if (loaded === undefined) return undefined; @@ -1321,6 +1324,9 @@ export const layer: Layer.Layer< runtimePolicy: resolvedRuntimePolicy, providerSessionId, existingProviderThread: providerThread, + ...(projection.thread.specialist === undefined + ? {} + : { specialist: projection.thread.specialist }), }), ); } @@ -1433,6 +1439,9 @@ export const layer: Layer.Layer< // Clear the native ref to force a fresh session while preserving row identity. existingProviderThread: { ...providerThread, nativeThreadRef: null }, forceNewNativeThread: true, + ...(projection.thread.specialist === undefined + ? {} + : { specialist: projection.thread.specialist }), }), ); if (replacement === undefined) return undefined; diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index e9a7d674ff08..d9c91435d065 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -23,6 +23,7 @@ import { type RunAttemptId, type ThreadId, type TurnItemId, + formatSpecialistRoleLoadout, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Cause from "effect/Cause"; @@ -1802,7 +1803,7 @@ export const layer: Layer.Layer< }), ); yield* touchMcpCredential; - const appContext = (yield* mcpAppModelContext + const mcpAppContext = (yield* mcpAppModelContext .forThread(input.run.threadId) .pipe( Effect.catch((cause) => @@ -1817,6 +1818,14 @@ export const layer: Layer.Layer< key: `mcp_app_${entry.itemId.replace(/[^\w.-]/g, "_")}`, text: entry.text, })); + const specialist = input.appThread.specialist; + const specialistRoleLoadout = formatSpecialistRoleLoadout(specialist); + const appContext = [ + ...mcpAppContext, + ...(specialist === undefined || specialistRoleLoadout === undefined + ? [] + : [{ key: `specialist_role_${specialist.key}`, text: specialistRoleLoadout }]), + ]; const turnInput = { appThread: input.appThread, threadId: input.run.threadId, diff --git a/apps/server/src/orchestration-v2/ThreadForkService.test.ts b/apps/server/src/orchestration-v2/ThreadForkService.test.ts index f9f6ff25b2f0..24ca3dc683ed 100644 --- a/apps/server/src/orchestration-v2/ThreadForkService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadForkService.test.ts @@ -152,6 +152,7 @@ it.effect("resets a fork's pin, snooze, settle and delegation state but keeps it historyOrigin: "v1_import", linkOrigin, tags: ["private"], + specialist: { key: "dev-ui", label: "Dev UI", instructions: "Owns the web client." }, }; const sourceRun = makeSourceRun("completed"); const result = yield* planFork(sourceRun, { @@ -167,6 +168,7 @@ it.effect("resets a fork's pin, snooze, settle and delegation state but keeps it assert.isNull(result.targetThread.activeOrderKey); assert.isNull(result.targetThread.autoSettleDisabledAt); assert.isUndefined(result.targetThread.delegatedFrom); + assert.isUndefined(result.targetThread.specialist); assert.equal(result.targetThread.historyOrigin, "v1_import"); assert.deepEqual(result.targetThread.linkOrigin, linkOrigin); assert.deepEqual(result.targetThread.tags, ["private"]); diff --git a/apps/server/src/orchestration-v2/ThreadForkService.ts b/apps/server/src/orchestration-v2/ThreadForkService.ts index 1dc6d1eaf4a5..7f28e4a18648 100644 --- a/apps/server/src/orchestration-v2/ThreadForkService.ts +++ b/apps/server/src/orchestration-v2/ThreadForkService.ts @@ -100,7 +100,12 @@ export const layer: Layer.Layer = Layer.succeed( }); } // The remote parent delegated the source thread, not its forks. - const { delegatedFrom: _delegatedFrom, ...inherited } = input.sourceProjection.thread; + // Specialist ownership is project-unique; forks must not inherit the key. + const { + delegatedFrom: _delegatedFrom, + specialist: _specialist, + ...inherited + } = input.sourceProjection.thread; const targetThread: OrchestrationV2AppThread = { ...inherited, // PR context follows the conversation; watching remains the source thread's responsibility. diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index f64a95f2a8ea..5c42d86be489 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3013,6 +3013,46 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { DateTime.toEpochMillis(untaggedShell.updatedAt), ); + yield* orchestrator.dispatch({ + type: "thread.specialist.set", + commandId: CommandId.make("runtime-layer-lifecycle-specialist-bind"), + threadId, + specialist: { key: "dev-ui", label: "Dev UI", instructions: "Owns the web client." }, + }); + assert.deepEqual(shellOf(yield* orchestrator.getShellSnapshot()).specialist, { + key: "dev-ui", + label: "Dev UI", + instructions: "Owns the web client.", + }); + const specialistShell = shellOf(yield* orchestrator.getShellSnapshot()); + assert.equal( + DateTime.toEpochMillis(specialistShell.updatedAt), + DateTime.toEpochMillis(untaggedShell.updatedAt), + ); + const otherThreadId = ThreadId.make("runtime-layer-lifecycle-specialist-other"); + yield* orchestrator.dispatch({ + ...create, + commandId: CommandId.make("runtime-layer-lifecycle-specialist-other-create"), + threadId: otherThreadId, + title: "Other lifecycle thread", + }); + const specialistConflict = yield* orchestrator + .dispatch({ + type: "thread.specialist.set", + commandId: CommandId.make("runtime-layer-lifecycle-specialist-conflict"), + threadId: otherThreadId, + specialist: { key: "dev-ui", label: "Dev UI" }, + }) + .pipe(Effect.flip); + assert.instanceOf(specialistConflict, Orchestrator.OrchestratorDispatchError); + yield* orchestrator.dispatch({ + type: "thread.specialist.set", + commandId: CommandId.make("runtime-layer-lifecycle-specialist-clear"), + threadId, + specialist: null, + }); + assert.isUndefined(shellOf(yield* orchestrator.getShellSnapshot()).specialist); + const preAutoProjection = yield* orchestrator.getThreadProjection(threadId); const staleAutoSettle = yield* orchestrator .dispatch({ diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index b413c0aefa25..a2f227d6b204 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -152,6 +152,7 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray ) : null} + {density === "full" && !props.draftId && visible.role ? ( + + ) : null} + {density === "full" && !props.draftId && visible.automations ? ( ({ + specialist: undefined as ThreadSpecialist | undefined, + patches: [] as Array, +})); + +vi.mock(import("../../state/entities"), () => ({ + useThreadProjection: (() => + state.specialist === undefined + ? { projection: { thread: { specialist: undefined } } } + : { projection: { thread: { specialist: state.specialist } } }) as never, +})); + +vi.mock(import("../../state/use-orchestration-command"), () => ({ + useOrchestrationCommand: (() => + async (value: { readonly input: { readonly specialist: ThreadSpecialist | null } }) => { + state.patches.push(value.input.specialist); + return AsyncResult.success(undefined); + }) as never, +})); + +vi.mock(import("../../state/threads"), async (original) => { + const actual = await original(); + return { + ...actual, + threadEnvironment: { + ...actual.threadEnvironment, + setSpecialist: { label: "set-specialist", run: async () => undefined } as never, + }, + }; +}); + +import { render } from "../../../test/render"; +import { ToastProvider } from "../ui/toast"; +import { ThreadSpecialistLoadoutPanel } from "./ThreadSpecialistLoadoutPanel"; + +const environmentId = EnvironmentId.make("environment"); +const threadId = ThreadId.make("specialist"); + +const bound: ThreadSpecialist = { + key: "vault", + label: "Vault", + skills: ["vault-user"], + mcpServers: { + vault: { type: "stdio", command: "vault-mcp", args: ["serve"] }, + }, +}; + +function setup(specialist?: ThreadSpecialist) { + state.specialist = specialist; + state.patches = []; +} + +const panel = ( + + + +); + +it("hides while the role has no skills or MCP servers", async () => { + setup({ key: "vault", label: "Vault" }); + await using screen = await render(panel); + expect(screen.container.querySelector("#thread-details-role-heading")).toBeNull(); +}); + +it("lists role skills and MCP with a From role source line", async () => { + setup(bound); + await using screen = await render(panel); + expect(page.getByText("vault-user")).toBeTruthy(); + expect(screen.container.textContent).toContain("From role · @vault"); + expect(screen.container.textContent).toContain("stdio"); + expect(page.getByRole("button", { name: "Remove skill vault-user" })).toBeTruthy(); + expect(page.getByRole("button", { name: "Remove MCP server vault" })).toBeTruthy(); +}); + +it("removes a skill from the specialist binding", async () => { + setup(bound); + await using _screen = await render(panel); + await userEvent.click(page.getByRole("button", { name: "Remove skill vault-user" })); + expect(state.patches).toEqual([ + { + key: "vault", + label: "Vault", + mcpServers: { + vault: { type: "stdio", command: "vault-mcp", args: ["serve"] }, + }, + }, + ]); +}); + +it("removes an MCP server from the specialist binding", async () => { + setup(bound); + await using _screen = await render(panel); + await userEvent.click(page.getByRole("button", { name: "Remove MCP server vault" })); + expect(state.patches).toEqual([ + { + key: "vault", + label: "Vault", + skills: ["vault-user"], + }, + ]); +}); diff --git a/apps/web/src/components/chat/ThreadSpecialistLoadoutPanel.tsx b/apps/web/src/components/chat/ThreadSpecialistLoadoutPanel.tsx new file mode 100644 index 000000000000..61fe0c686b6a --- /dev/null +++ b/apps/web/src/components/chat/ThreadSpecialistLoadoutPanel.tsx @@ -0,0 +1,171 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + type EnvironmentId, + type ThreadId, + type ThreadSpecialist, + type ThreadSpecialistMcpServer, + specialistMcpServerEntries, + withoutSpecialistMcpServer, + withoutSpecialistSkill, +} from "@t3tools/contracts"; +import { BookOpenIcon, PlugIcon, XIcon } from "lucide-react"; +import { useState, type ReactNode } from "react"; + +import { cn } from "../../lib/utils"; +import { useThreadProjection } from "../../state/entities"; +import { threadEnvironment } from "../../state/threads"; +import { useOrchestrationCommand } from "../../state/use-orchestration-command"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { ThreadDetailsControl } from "./ThreadDetailsControl"; +import { ThreadDetailsSection } from "./ThreadDetailsSection"; +import { THREAD_DETAILS_PANEL_ICON_CLASS } from "./threadDetailsPanelStyles"; + +interface ThreadSpecialistLoadoutPanelProps { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; +} + +const roleSource = (specialist: ThreadSpecialist) => `From role · @${specialist.key}`; + +const mcpSourceDetail = (server: ThreadSpecialistMcpServer) => + server.type === "http" ? server.url : [server.command, ...(server.args ?? [])].join(" "); + +/** Role-scoped skills and MCP from the durable specialist binding. Hidden while unbound or empty. */ +export function ThreadSpecialistLoadoutPanel(props: ThreadSpecialistLoadoutPanelProps) { + const projection = useThreadProjection(scopeThreadRef(props.environmentId, props.threadId)); + const specialist = projection?.projection.thread.specialist; + const skills = specialist?.skills ?? []; + const mcpEntries = specialistMcpServerEntries(specialist); + const setSpecialist = useOrchestrationCommand(threadEnvironment.setSpecialist, { + label: "thread role loadout", + }); + const [busyKey, setBusyKey] = useState(null); + + if (specialist === undefined || (skills.length === 0 && mcpEntries.length === 0)) { + return null; + } + + const reportFailure = (title: string, error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: error instanceof Error ? error.message : String(error), + }), + ); + }; + + const patch = async (next: ThreadSpecialist, busy: string) => { + if (busyKey !== null) return; + setBusyKey(busy); + const result = await setSpecialist({ + environmentId: props.environmentId, + input: { threadId: props.threadId, specialist: next }, + }); + setBusyKey(null); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + reportFailure("Could not update role tools", squashAtomCommandFailure(result)); + } + }; + + return ( + +
    + {skills.map((skill) => { + const busy = `skill:${skill}`; + return ( + } + title={skill} + source={roleSource(specialist)} + detail="Skill attached to this specialist role" + removeLabel={`Remove skill ${skill}`} + busy={busyKey === busy} + onRemove={() => void patch(withoutSpecialistSkill(specialist, skill), busy)} + /> + ); + })} + {mcpEntries.map(([name, server]) => { + const busy = `mcp:${name}`; + const detail = mcpSourceDetail(server); + return ( + } + title={name} + source={`${roleSource(specialist)} · ${server.type}`} + detail={detail} + removeLabel={`Remove MCP server ${name}`} + busy={busyKey === busy} + onRemove={() => void patch(withoutSpecialistMcpServer(specialist, name), busy)} + /> + ); + })} +
+
+ ); +} + +function LoadoutRow(props: { + readonly icon: ReactNode; + readonly title: string; + readonly source: string; + readonly detail: string; + readonly removeLabel: string; + readonly busy: boolean; + readonly onRemove: () => void; +}) { + return ( +
  • + + } + > + {props.icon} + + + {props.title} + + + {props.source} + + + + {props.detail} + +
    + + + } + > + + + Remove from role + +
    +
  • + ); +} diff --git a/apps/web/src/components/chat/threadDetailsItems.ts b/apps/web/src/components/chat/threadDetailsItems.ts index e6f94cc4e11c..946b0ba6b900 100644 --- a/apps/web/src/components/chat/threadDetailsItems.ts +++ b/apps/web/src/components/chat/threadDetailsItems.ts @@ -9,6 +9,7 @@ export const THREAD_DETAILS_ITEMS = [ { id: "gitActions", label: "Git actions", defaultVisible: true }, { id: "changes", label: "Changes", defaultVisible: true }, { id: "servers", label: "Servers", defaultVisible: true }, + { id: "role", label: "Role tools", defaultVisible: true }, { id: "automations", label: "Automations", defaultVisible: true }, { id: "lineage", label: "Lineage", defaultVisible: true }, { id: "sideChats", label: "Side chats", defaultVisible: true }, diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 05e898420047..518d5b3929f5 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -1059,10 +1059,21 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP )} dir={textDirection(thread.title)} > + {thread.source.specialist !== undefined ? ( + + @{thread.source.specialist.key} + + ) : null} {thread.title} ); - const accessibleTitle = isRenaming ? null : {thread.title}; + const accessibleTitle = isRenaming ? null : ( + + {thread.source.specialist !== undefined + ? `${thread.source.specialist.label} (@${thread.source.specialist.key}): ${thread.title}` + : thread.title} + + ); const prBadgeShape = supportsMultiplePullRequests ? resolveThreadPullRequestBadge(thread.pullRequests) diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 894a961e018b..59bf0b6001d1 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -25,6 +25,7 @@ import { type RuntimeRequestId, type ThreadId, type ThreadEnvMode, + type ThreadSpecialist, type UploadChatAttachment, type GuestMessageId, } from "@t3tools/contracts"; @@ -115,6 +116,10 @@ export interface ReorderActiveThreadInput extends ThreadCommandInput { readonly orderKey: string; } +export interface SetThreadSpecialistInput extends ThreadCommandInput { + readonly specialist: ThreadSpecialist | null; +} + export interface SnoozeThreadInput extends ThreadCommandInput { readonly snoozedUntil: string; } @@ -594,6 +599,17 @@ export const reorderActiveThread = Effect.fn("EnvironmentCommands.reorderActiveT }); }); +export const setThreadSpecialist = Effect.fn("EnvironmentCommands.setThreadSpecialist")(function* ( + input: SetThreadSpecialistInput, +) { + return yield* dispatch({ + type: "thread.specialist.set", + commandId: yield* allocateCommandId(input), + threadId: input.threadId, + specialist: input.specialist, + }); +}); + export const unpinThread = Effect.fn("EnvironmentCommands.unpinThread")(function* ( input: UnpinThreadInput, ) { diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.ts b/packages/client-runtime/src/state/orchestrationV2Projection.ts index d9368ef4058a..ee2bb5445dc4 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.ts @@ -293,9 +293,10 @@ export function applyOrchestrationV2ProjectionEvent( case "thread.provider-switched": case "thread.guest-share-updated": return { ...base, thread: event.payload }; - // Visited tracking and tags are not activity: skip the updatedAt bump. + // Visited tracking, tags, and specialist bindings are not activity: skip the updatedAt bump. case "thread.visited": case "thread.tags-updated": + case "thread.specialist-updated": case "thread.marked-unread": return { ...projection, thread: event.payload }; case "run.created": diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 3761d0fa6e71..43c7aca5d40a 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -42,6 +42,7 @@ import { revertThreadCheckpoint, setThreadInteractionMode, setThreadRuntimeMode, + setThreadSpecialist, pinThread, reorderPinnedThread, reorderActiveThread, @@ -204,6 +205,12 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + setSpecialist: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:set-specialist", + execute: setThreadSpecialist, + scheduler, + concurrency, + }), reorderActive: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:reorder-active", execute: reorderActiveThread, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 8dd47df33a21..9952145f61e2 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -48,6 +48,7 @@ export * from "./threadMetadataMcp.ts"; export * from "./threadPullRequest.ts"; export * from "./threadSearch.ts"; export * from "./threadTags.ts"; +export * from "./threadSpecialist.ts"; export * from "./threadTitle.ts"; export * from "./t3ProjectFile.ts"; export * from "./editor.ts"; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 4cb4984b74ba..3a3d85a5d31b 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -45,6 +45,7 @@ import { AuthMcpClientAccess } from "./auth.ts"; import { ChatAttachment } from "./chatAttachment.ts"; import { ThreadGuestShare } from "./guestShare.ts"; import { StoredThreadTags, ThreadTag } from "./threadTags.ts"; +import { ThreadSpecialist } from "./threadSpecialist.ts"; import { OrchestrationSearchThreadsInput, OrchestrationSearchThreadsResult, @@ -495,6 +496,11 @@ export const OrchestrationV2AppThread = Schema.Struct({ activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), /** Absent on threads written before tags existed; read as empty. */ tags: Schema.optional(StoredThreadTags), + /** + * Durable specialist ownership for this listed thread. Absent when unbound. + * Keys are unique per project among non-deleted threads. + */ + specialist: Schema.optional(ThreadSpecialist), lastVisitedAt: Schema.NullOr(Schema.DateTimeUtc).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), @@ -1933,6 +1939,7 @@ export const OrchestrationV2DomainEvent = Schema.Union([ "thread.pin-reordered", "thread.active-reordered", "thread.tags-updated", + "thread.specialist-updated", "thread.visited", "thread.marked-unread", "thread.metadata-updated", @@ -2189,6 +2196,8 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), /** Absent on threads written before tags existed; read as empty. */ tags: Schema.optional(StoredThreadTags), + /** Absent when the thread is not a durable specialist. */ + specialist: Schema.optional(ThreadSpecialist), lastVisitedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), opens: Schema.optional(LenientThreadOpens), titleRegeneration: Schema.optional( @@ -2830,6 +2839,7 @@ export const OrchestrationV2DomainEventJson = Schema.Union([ "thread.pin-reordered", "thread.active-reordered", "thread.tags-updated", + "thread.specialist-updated", "thread.visited", "thread.marked-unread", "thread.pull-request-synced", @@ -3079,6 +3089,13 @@ export const OrchestrationV2Command = Schema.Union([ tag: ThreadTag, tagged: Schema.Boolean, }), + Schema.Struct({ + type: Schema.Literal("thread.specialist.set"), + commandId: CommandId, + threadId: ThreadId, + /** Bind a specialist, or `null` to clear. */ + specialist: Schema.NullOr(ThreadSpecialist), + }), Schema.Struct({ type: Schema.Literal("thread.pin.reorder"), commandId: CommandId, diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index d1bb9961a0c6..c120723568db 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -35,6 +35,14 @@ import { import { secretRequestPageUrl } from "./secretRequest.ts"; import { hasOnlyPairedSurrogates } from "./threadMetadataMcp.ts"; import { ThreadTitleRegeneration } from "./threadTitle.ts"; +import { + ThreadSpecialist, + ThreadSpecialistInstructions, + ThreadSpecialistKey, + ThreadSpecialistLabel, + ThreadSpecialistMcpServers, + ThreadSpecialistSkillNames, +} from "./threadSpecialist.ts"; import { OrchestrationV2Actor, OrchestrationV2CreationSource, @@ -335,6 +343,64 @@ export const OrchestratorMcpEnvironmentTarget = Schema.optional( }), ); +/** Resume or create a project-scoped durable specialist thread (OpenRig-style named role). */ +export const OrchestratorMcpFindOrOpenSpecialistInput = Schema.Struct({ + projectId: OrchestratorMcpProjectTarget, + key: ThreadSpecialistKey.annotate({ + description: "Stable slug, e.g. `dev-ui`. Unique per project among non-deleted threads.", + }), + label: Schema.optional( + ThreadSpecialistLabel.annotate({ + description: "Display name. Default: the key. Used when creating or rebinding.", + }), + ), + instructions: Schema.optional( + ThreadSpecialistInstructions.annotate({ + description: "Domain brief stored on the binding. Applied when creating or rebinding.", + }), + ), + skills: Schema.optional( + ThreadSpecialistSkillNames.annotate({ + description: + "Skill names attached to this role. Agents in the role get them in their loadout automatically.", + }), + ), + mcpServers: Schema.optional( + ThreadSpecialistMcpServers.annotate({ + description: + "Extra MCP servers merged into the provider session for this role (http or stdio).", + }), + ), + title: Schema.optional( + OrchestratorMcpTitle.annotate({ + description: "Title for a newly created specialist thread. Default: the label.", + }), + ), + prompt: Schema.optional( + OrchestratorMcpPrompt.annotate({ + description: "Optional first message when creating, or follow-up when resuming.", + }), + ), + target: Schema.optional(OrchestratorMcpTarget), + runtimeMode: Schema.optional(OrchestratorMcpRuntimeMode), + interactionMode: Schema.optional(OrchestratorMcpInteractionMode), + clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), +}); +export type OrchestratorMcpFindOrOpenSpecialistInput = + typeof OrchestratorMcpFindOrOpenSpecialistInput.Type; + +export const OrchestratorMcpFindOrOpenSpecialistResult = Schema.Struct({ + threadId: ThreadId, + link: Schema.optional(Schema.String), + created: Schema.Boolean, + specialist: ThreadSpecialist, + runId: Schema.NullOr(RunId), + status: OrchestratorMcpThreadStatus, + title: Schema.String, +}); +export type OrchestratorMcpFindOrOpenSpecialistResult = + typeof OrchestratorMcpFindOrOpenSpecialistResult.Type; + export const OrchestratorMcpThreadListInput = Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, projectId: OrchestratorMcpProjectTarget, @@ -343,6 +409,11 @@ export const OrchestratorMcpThreadListInput = Schema.Struct({ Schema.Array(OrchestratorMcpThreadStatus).check(Schema.isMaxLength(10)), ), titleContains: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(256))), + specialistKey: Schema.optional( + ThreadSpecialistKey.annotate({ + description: "Only threads bound to this durable specialist key.", + }), + ), settled: Schema.optional(Schema.Boolean), snoozed: Schema.optional(Schema.Boolean), includeSubagents: Schema.optional(Schema.Boolean), @@ -381,6 +452,7 @@ export const OrchestratorMcpThreadListItem = Schema.Struct({ snoozedUntil: Schema.NullOr(IsoDateTime), parentThreadId: Schema.NullOr(ThreadId), relationshipToParent: Schema.NullOr(Schema.Literals(["fork", "subagent"])), + specialist: Schema.optional(ThreadSpecialist), itemCount: NonNegativeInt, createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -459,6 +531,7 @@ export const OrchestratorMcpThreadDetail = Schema.Struct({ settledAt: Schema.NullOr(IsoDateTime), snoozed: Schema.Boolean, snoozedUntil: Schema.NullOr(IsoDateTime), + specialist: Schema.optional(ThreadSpecialist), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); diff --git a/packages/contracts/src/threadSpecialist.test.ts b/packages/contracts/src/threadSpecialist.test.ts new file mode 100644 index 000000000000..40c03e356051 --- /dev/null +++ b/packages/contracts/src/threadSpecialist.test.ts @@ -0,0 +1,259 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import { + formatSpecialistRoleLoadout, + resolveSpecialistBinding, + specialistBindingsEqual, + specialistMcpServerEntries, + ThreadSpecialist, + ThreadSpecialistKey, + withoutSpecialistMcpServer, + withoutSpecialistSkill, +} from "./threadSpecialist.ts"; + +const decodeKey = Schema.decodeUnknownSync(ThreadSpecialistKey); +const decodeSpecialist = Schema.decodeUnknownSync(ThreadSpecialist); + +describe("ThreadSpecialistKey", () => { + it("accepts lowercase slugs", () => { + assert.equal(decodeKey("dev-ui"), "dev-ui"); + assert.equal(decodeKey("factory"), "factory"); + assert.equal(decodeKey("a"), "a"); + }); + + it("rejects uppercase, spaces, and leading hyphens", () => { + assert.throws(() => decodeKey("Dev-UI")); + assert.throws(() => decodeKey("dev ui")); + assert.throws(() => decodeKey("-dev")); + assert.throws(() => decodeKey("1dev")); + }); +}); + +describe("ThreadSpecialist", () => { + it("requires key and label", () => { + assert.deepEqual( + decodeSpecialist({ + key: "dev-ui", + label: "Dev UI", + instructions: "Owns the web client.", + }), + { + key: "dev-ui", + label: "Dev UI", + instructions: "Owns the web client.", + }, + ); + assert.deepEqual(decodeSpecialist({ key: "dev-ui", label: "Dev UI" }), { + key: "dev-ui", + label: "Dev UI", + }); + }); + + it("accepts role-scoped skills and MCP servers", () => { + assert.deepEqual( + decodeSpecialist({ + key: "vault", + label: "Vault", + skills: ["vault-user", "systematic-debugging"], + mcpServers: { + vault: { + type: "stdio", + command: "vault-mcp", + args: ["serve"], + env: { VAULT_ADDR: "https://vault.example" }, + }, + docs: { + type: "http", + url: "https://docs.example/mcp", + headers: { Authorization: "Bearer token" }, + }, + }, + }), + { + key: "vault", + label: "Vault", + skills: ["vault-user", "systematic-debugging"], + mcpServers: { + vault: { + type: "stdio", + command: "vault-mcp", + args: ["serve"], + env: { VAULT_ADDR: "https://vault.example" }, + }, + docs: { + type: "http", + url: "https://docs.example/mcp", + headers: { Authorization: "Bearer token" }, + }, + }, + }, + ); + }); + + it("rejects reserved t3-code MCP server names", () => { + assert.throws(() => + decodeSpecialist({ + key: "vault", + label: "Vault", + mcpServers: { + "t3-code": { type: "http", url: "http://127.0.0.1/mcp" }, + }, + }), + ); + }); +}); + +describe("resolveSpecialistBinding", () => { + it("keeps prior skills and MCP when rebind omits them", () => { + const prior = decodeSpecialist({ + key: "vault", + label: "Vault", + instructions: "old", + skills: ["vault-user"], + mcpServers: { vault: { type: "stdio", command: "vault-mcp" } }, + }); + assert.deepEqual( + resolveSpecialistBinding({ + key: "vault", + label: "Vault Ops", + prior, + }), + { + key: "vault", + label: "Vault Ops", + instructions: "old", + skills: ["vault-user"], + mcpServers: { vault: { type: "stdio", command: "vault-mcp" } }, + }, + ); + }); + + it("replaces skills and MCP when provided", () => { + const prior = decodeSpecialist({ + key: "vault", + label: "Vault", + skills: ["vault-user"], + mcpServers: { vault: { type: "stdio", command: "vault-mcp" } }, + }); + assert.deepEqual( + resolveSpecialistBinding({ + key: "vault", + skills: ["new-skill"], + mcpServers: { docs: { type: "http", url: "https://docs.example/mcp" } }, + prior, + }), + { + key: "vault", + label: "Vault", + skills: ["new-skill"], + mcpServers: { docs: { type: "http", url: "https://docs.example/mcp" } }, + }, + ); + }); +}); + +describe("specialistBindingsEqual", () => { + it("treats skill order as insignificant", () => { + const left = decodeSpecialist({ + key: "vault", + label: "Vault", + skills: ["b", "a"], + }); + const right = decodeSpecialist({ + key: "vault", + label: "Vault", + skills: ["a", "b"], + }); + assert.isTrue(specialistBindingsEqual(left, right)); + }); +}); + +describe("formatSpecialistRoleLoadout", () => { + it("returns undefined without brief or skills", () => { + assert.isUndefined( + formatSpecialistRoleLoadout(decodeSpecialist({ key: "vault", label: "Vault" })), + ); + }); + + it("formats brief and skills for prompt injection", () => { + const text = formatSpecialistRoleLoadout( + decodeSpecialist({ + key: "vault", + label: "Vault", + instructions: "Own Vault ops.", + skills: ["vault-user"], + }), + ); + assert.isTrue(text?.includes("Vault (`vault`)")); + assert.isTrue(text?.includes("Own Vault ops.")); + assert.isTrue(text?.includes("`vault-user`")); + }); +}); + +describe("specialistMcpServerEntries", () => { + it("returns sorted entries", () => { + assert.deepEqual( + specialistMcpServerEntries( + decodeSpecialist({ + key: "vault", + label: "Vault", + mcpServers: { + zebra: { type: "http", url: "https://z.example/mcp" }, + alpha: { type: "stdio", command: "alpha-mcp" }, + }, + }), + ), + [ + ["alpha", { type: "stdio", command: "alpha-mcp" }], + ["zebra", { type: "http", url: "https://z.example/mcp" }], + ], + ); + }); +}); + +describe("withoutSpecialistSkill", () => { + it("removes one skill and clears the field when empty", () => { + const specialist = decodeSpecialist({ + key: "vault", + label: "Vault", + skills: ["vault-user", "debug"], + }); + assert.deepEqual(withoutSpecialistSkill(specialist, "debug"), { + key: "vault", + label: "Vault", + skills: ["vault-user"], + }); + assert.deepEqual(withoutSpecialistSkill(specialist, "vault-user").skills, ["debug"]); + assert.deepEqual( + withoutSpecialistSkill(withoutSpecialistSkill(specialist, "debug"), "vault-user"), + { + key: "vault", + label: "Vault", + }, + ); + }); +}); + +describe("withoutSpecialistMcpServer", () => { + it("removes one server and clears the field when empty", () => { + const specialist = decodeSpecialist({ + key: "vault", + label: "Vault", + mcpServers: { + vault: { type: "stdio", command: "vault-mcp" }, + docs: { type: "http", url: "https://docs.example/mcp" }, + }, + }); + assert.deepEqual(withoutSpecialistMcpServer(specialist, "docs").mcpServers, { + vault: { type: "stdio", command: "vault-mcp" }, + }); + assert.deepEqual( + withoutSpecialistMcpServer(withoutSpecialistMcpServer(specialist, "docs"), "vault"), + { + key: "vault", + label: "Vault", + }, + ); + }); +}); diff --git a/packages/contracts/src/threadSpecialist.ts b/packages/contracts/src/threadSpecialist.ts new file mode 100644 index 000000000000..6cdc79e25e23 --- /dev/null +++ b/packages/contracts/src/threadSpecialist.ts @@ -0,0 +1,217 @@ +import * as Schema from "effect/Schema"; + +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; + +/** + * Stable project-scoped handle for a durable specialist thread, like OpenRig's + * `dev-ui@factory`. Lowercase slug so agents can address it without fuzzy title match. + */ +export const ThreadSpecialistKey = TrimmedNonEmptyString.check( + Schema.isMaxLength(64), + Schema.makeFilter((value) => + /^[a-z][a-z0-9-]{0,62}$/.test(value) + ? true + : "A specialist key must be a lowercase slug (letters, digits, hyphens).", + ), +); +export type ThreadSpecialistKey = typeof ThreadSpecialistKey.Type; + +export const ThreadSpecialistLabel = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); +export type ThreadSpecialistLabel = typeof ThreadSpecialistLabel.Type; + +export const ThreadSpecialistInstructions = TrimmedNonEmptyString.check(Schema.isMaxLength(8_000)); +export type ThreadSpecialistInstructions = typeof ThreadSpecialistInstructions.Type; + +/** Skill name from provider discovery that this role should receive automatically. */ +export const ThreadSpecialistSkillName = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); +export type ThreadSpecialistSkillName = typeof ThreadSpecialistSkillName.Type; + +export const ThreadSpecialistSkillNames = Schema.Array(ThreadSpecialistSkillName).check( + Schema.isMaxLength(32), +); +export type ThreadSpecialistSkillNames = typeof ThreadSpecialistSkillNames.Type; + +const ThreadSpecialistMcpServerName = TrimmedNonEmptyString.check( + Schema.isMaxLength(64), + Schema.makeFilter((value) => + value === "t3-code" || value === "t3-code-direct" + ? "t3-code is reserved for T3's orchestrator MCP." + : true, + ), +); + +export const ThreadSpecialistMcpHttpServer = Schema.Struct({ + type: Schema.Literal("http"), + url: TrimmedNonEmptyString.check(Schema.isMaxLength(2_048)), + headers: Schema.optional( + Schema.Record(Schema.String, TrimmedNonEmptyString.check(Schema.isMaxLength(4_096))), + ), +}); +export type ThreadSpecialistMcpHttpServer = typeof ThreadSpecialistMcpHttpServer.Type; + +export const ThreadSpecialistMcpStdioServer = Schema.Struct({ + type: Schema.Literal("stdio"), + command: TrimmedNonEmptyString.check(Schema.isMaxLength(1_024)), + args: Schema.optional(Schema.Array(Schema.String.check(Schema.isMaxLength(1_024)))), + env: Schema.optional( + Schema.Record(Schema.String, TrimmedNonEmptyString.check(Schema.isMaxLength(4_096))), + ), +}); +export type ThreadSpecialistMcpStdioServer = typeof ThreadSpecialistMcpStdioServer.Type; + +export const ThreadSpecialistMcpServer = Schema.Union([ + ThreadSpecialistMcpHttpServer, + ThreadSpecialistMcpStdioServer, +]); +export type ThreadSpecialistMcpServer = typeof ThreadSpecialistMcpServer.Type; + +export const ThreadSpecialistMcpServers = Schema.Record( + ThreadSpecialistMcpServerName, + ThreadSpecialistMcpServer, +).check( + Schema.makeFilter((value) => + Object.keys(value).length <= 16 ? true : "A specialist may declare at most 16 MCP servers.", + ), +); +export type ThreadSpecialistMcpServers = typeof ThreadSpecialistMcpServers.Type; + +export const ThreadSpecialist = Schema.Struct({ + key: ThreadSpecialistKey, + label: ThreadSpecialistLabel, + /** Domain brief injected into the agent turn for this role. */ + instructions: Schema.optional(ThreadSpecialistInstructions), + /** + * Skill names attached to this role (OpenRig-style loadout). Agents in the role + * are told to use them; discovery still comes from the provider skill roots. + */ + skills: Schema.optional(ThreadSpecialistSkillNames), + /** + * Extra MCP servers merged into the provider session for this role, like OpenRig + * projecting role MCP into Claude `.mcp.json` / Codex `config.toml`. + */ + mcpServers: Schema.optional(ThreadSpecialistMcpServers), +}); +export type ThreadSpecialist = typeof ThreadSpecialist.Type; + +const sortedSkillNames = (skills: ThreadSpecialistSkillNames | undefined) => + skills === undefined ? undefined : [...skills].sort((left, right) => left.localeCompare(right)); + +const mcpServersEqual = ( + left: ThreadSpecialistMcpServers | undefined, + right: ThreadSpecialistMcpServers | undefined, +) => JSON.stringify(left ?? null) === JSON.stringify(right ?? null); + +/** True when two bindings would project the same role tools and brief. */ +export const specialistBindingsEqual = ( + left: ThreadSpecialist | undefined, + right: ThreadSpecialist | undefined, +) => { + if (left === right) return true; + if (left === undefined || right === undefined) return false; + return ( + left.key === right.key && + left.label === right.label && + left.instructions === right.instructions && + JSON.stringify(sortedSkillNames(left.skills)) === + JSON.stringify(sortedSkillNames(right.skills)) && + mcpServersEqual(left.mcpServers, right.mcpServers) + ); +}; + +/** Merge create/rebind fields onto a prior specialist, keeping unset fields. */ +export const resolveSpecialistBinding = (input: { + readonly key: ThreadSpecialistKey; + readonly label?: ThreadSpecialistLabel; + readonly instructions?: ThreadSpecialistInstructions; + readonly skills?: ThreadSpecialistSkillNames; + readonly mcpServers?: ThreadSpecialistMcpServers; + readonly prior?: ThreadSpecialist; +}): ThreadSpecialist => { + const label = input.label ?? input.prior?.label ?? input.key; + const instructions = + input.instructions !== undefined + ? input.instructions + : input.prior?.instructions !== undefined + ? input.prior.instructions + : undefined; + const skills = + input.skills !== undefined + ? input.skills + : input.prior?.skills !== undefined + ? input.prior.skills + : undefined; + const mcpServers = + input.mcpServers !== undefined + ? input.mcpServers + : input.prior?.mcpServers !== undefined + ? input.prior.mcpServers + : undefined; + return { + key: input.key, + label, + ...(instructions === undefined ? {} : { instructions }), + ...(skills === undefined ? {} : { skills }), + ...(mcpServers === undefined ? {} : { mcpServers }), + }; +}; + +/** + * Prompt block for role-scoped brief + skills. Empty when the binding carries neither. + * MCP servers are injected into the provider session separately. + */ +export const formatSpecialistRoleLoadout = ( + specialist: ThreadSpecialist | undefined, +): string | undefined => { + if (specialist === undefined) return undefined; + const skills = specialist.skills ?? []; + if (specialist.instructions === undefined && skills.length === 0) return undefined; + const lines = [ + `## T3 Code specialist role: ${specialist.label} (\`${specialist.key}\`)`, + "", + "You are bound to this durable specialist role. Follow its brief and prefer its skills.", + ]; + if (specialist.instructions !== undefined) { + lines.push("", "### Role brief", "", specialist.instructions); + } + if (skills.length > 0) { + lines.push( + "", + "### Role skills", + "", + "These skills belong to this role. Invoke them when they apply:", + ...skills.map((name) => `- \`${name}\``), + ); + } + return lines.join("\n"); +}; + +/** Role MCP entries for provider session merge. Reserved T3 names are already schema-rejected. */ +export const specialistMcpServerEntries = ( + specialist: ThreadSpecialist | undefined, +): ReadonlyArray => { + if (specialist?.mcpServers === undefined) return []; + return Object.entries(specialist.mcpServers).sort(([left], [right]) => left.localeCompare(right)); +}; + +/** Drop one skill from the role loadout; omits `skills` when none remain. */ +export const withoutSpecialistSkill = ( + specialist: ThreadSpecialist, + skillName: string, +): ThreadSpecialist => { + const skills = (specialist.skills ?? []).filter((name) => name !== skillName); + const { skills: _removed, ...rest } = specialist; + return skills.length === 0 ? rest : { ...rest, skills }; +}; + +/** Drop one MCP server from the role loadout; omits `mcpServers` when none remain. */ +export const withoutSpecialistMcpServer = ( + specialist: ThreadSpecialist, + serverName: string, +): ThreadSpecialist => { + if (specialist.mcpServers === undefined || !(serverName in specialist.mcpServers)) { + return specialist; + } + const { [serverName]: _removed, ...remaining } = specialist.mcpServers; + const { mcpServers: _servers, ...rest } = specialist; + return Object.keys(remaining).length === 0 ? rest : { ...rest, mcpServers: remaining }; +}; diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 7ae8c0c43f00..985921733808 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -1,4 +1,4 @@ -import type { OrchestrationV2HistoricalMessage } from "@t3tools/contracts"; +import type { OrchestrationV2HistoricalMessage, ThreadSpecialist } from "@t3tools/contracts"; import { ChatAttachment, CheckpointId, @@ -511,6 +511,8 @@ export interface ProviderAdapterV2EnsureThreadInput { readonly providerSessionId?: ProviderSessionId; readonly existingProviderThread?: OrchestrationV2ProviderThread; readonly forceNewNativeThread?: boolean; + /** Durable specialist binding; providers merge its MCP servers at thread launch. */ + readonly specialist?: ThreadSpecialist; } export interface ProviderAdapterV2TurnInput {